@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.
Files changed (100) hide show
  1. package/README.md +2 -1
  2. package/brand/identity.js +33 -3
  3. package/drive/cat.js +62 -0
  4. package/drive/download.js +3 -3
  5. package/drive/execute.js +8 -2
  6. package/drive/files-path.js +33 -0
  7. package/drive/flowstudio-files.js +87 -0
  8. package/drive/ls.js +3 -3
  9. package/drive/paths.js +23 -13
  10. package/drive/tools.js +4 -2
  11. package/entrance/surface.js +96 -0
  12. package/files/app-link.js +64 -0
  13. package/files/backend-client.js +266 -0
  14. package/files/deliverables.js +55 -1
  15. package/files/intake.js +0 -63
  16. package/files/mention.js +1 -51
  17. package/files/preview-groups.js +71 -3
  18. package/files/preview-local.js +69 -0
  19. package/files/preview-workspace.js +114 -13
  20. package/files/preview.js +171 -16
  21. package/files/produced-gate.js +197 -0
  22. package/files/produced-mentions.js +19 -0
  23. package/files/published-tools.js +69 -0
  24. package/files/published-turn-data.js +84 -0
  25. package/i18n/chat-model.js +5 -5
  26. package/i18n/mcp.js +83 -0
  27. package/i18n/mobile.js +96 -2
  28. package/i18n/preview.js +10 -0
  29. package/i18n/search.js +13 -7
  30. package/i18n/skills.js +31 -0
  31. package/i18n/voice.js +88 -19
  32. package/larepass/chat.js +9 -0
  33. package/larepass/host.js +7 -2
  34. package/larepass/mux.js +210 -2
  35. package/larepass/process-fold.js +239 -0
  36. package/larepass/queue.js +44 -0
  37. package/larepass/rpc.js +50 -3
  38. package/larepass/runtime.js +215 -83
  39. package/larepass/session-cache.js +6 -0
  40. package/larepass/settings.js +15 -7
  41. package/larepass/transcript.js +49 -11
  42. package/log/report.js +46 -0
  43. package/mcp/approval.js +152 -0
  44. package/mcp/client.js +38 -0
  45. package/mcp/config.js +139 -0
  46. package/media/catalog-row.js +244 -0
  47. package/media/generation.js +707 -0
  48. package/media/tool.js +92 -0
  49. package/olares/entrance.js +67 -15
  50. package/olares/identity.js +13 -136
  51. package/olares/session-identity.js +12 -12
  52. package/olares/trusted-host.js +0 -29
  53. package/package.json +49 -4
  54. package/router/catalog-cache.d.ts +1 -1
  55. package/router/catalog-cache.js +25 -14
  56. package/router/catalog-sync.js +8 -3
  57. package/router/catalog.d.ts +6 -1
  58. package/router/catalog.js +18 -2
  59. package/router/gateway.d.ts +9 -1
  60. package/router/gateway.js +40 -4
  61. package/router/gemini-schema.js +125 -0
  62. package/router/generation-guard.js +82 -0
  63. package/router/models.d.ts +6 -0
  64. package/router/models.js +22 -3
  65. package/router/search.js +10 -2
  66. package/router/session-model.js +53 -0
  67. package/router/shim.js +104 -3
  68. package/router/sref.js +126 -0
  69. package/router/stt.js +43 -12
  70. package/router/tts.js +118 -0
  71. package/search/client.js +5 -3
  72. package/search/config.js +53 -39
  73. package/search/menu.js +45 -10
  74. package/skills/client.js +42 -0
  75. package/skills/packs.js +40 -0
  76. package/skills/state.d.ts +22 -0
  77. package/skills/state.js +142 -0
  78. package/tools/crypto-uuid.js +3 -6
  79. package/tools/head-script.js +15 -0
  80. package/voice/agent.js +50 -0
  81. package/voice/capture.js +250 -0
  82. package/voice/client.js +126 -24
  83. package/voice/config.js +11 -1
  84. package/voice/duplex.js +44 -0
  85. package/voice/endpoint.js +57 -0
  86. package/voice/host/bluetooth-ready.py +1196 -0
  87. package/voice/host/install-host.sh +102 -0
  88. package/voice/input.js +4 -0
  89. package/voice/install.js +146 -0
  90. package/voice/languages.js +11 -1
  91. package/voice/link.js +86 -0
  92. package/voice/pcm.js +100 -0
  93. package/voice/playback.js +7 -0
  94. package/voice/problem.js +46 -0
  95. package/voice/recorder.js +79 -4
  96. package/voice/script.js +35 -0
  97. package/voice/service.js +253 -10
  98. package/voice/session.js +128 -0
  99. package/voice/speech.js +93 -0
  100. package/files/preview-cache.js +0 -100
package/router/sref.js ADDED
@@ -0,0 +1,126 @@
1
+ import { createHash, createHmac, randomBytes } from "node:crypto";
2
+
3
+ /**
4
+ * Encrypted end-user credential for FlowStudio generations.
5
+ *
6
+ * Router forwards none of Lares' request headers to FlowStudio, so FlowStudio
7
+ * only ever sees "the caller is Router". Router does carry request-body fields
8
+ * it does not know into `params`, so the logged-in user rides there as an
9
+ * encrypted `sref` and FlowStudio decrypts it (server/app/model_console/sref.py).
10
+ * Both sides must stay byte-for-byte identical.
11
+ *
12
+ * Format: `1.<base64url(nonce[16] || ciphertext || tag[16])>`
13
+ * - keys: SHA-256(SREF_KEY), split by HMAC into an encryption and a MAC key;
14
+ * - cipher: keystream HMAC-SHA256(enc, nonce || counter) XOR plaintext;
15
+ * - tag: HMAC-SHA256(mac, "sref/v1" || nonce || ciphertext) truncated to 16 bytes;
16
+ * - plaintext: {"u": username, "t": unix seconds}; FlowStudio rejects it after 5 minutes.
17
+ */
18
+ export const SREF_FIELD = "sref";
19
+ export const DEFAULT_SREF_KEY = "gBH4rLCE9by5QfqvIr9Jm2pFtJDxwE5kxlvaJ/0goeg=";
20
+
21
+ const NONCE_BYTES = 16;
22
+ const TAG_BYTES = 16;
23
+
24
+ function keys(secret) {
25
+ const root = createHash("sha256").update(secret, "utf8").digest();
26
+ return {
27
+ enc: createHmac("sha256", root).update("sref/enc").digest(),
28
+ mac: createHmac("sha256", root).update("sref/mac").digest(),
29
+ };
30
+ }
31
+
32
+ function keystream(encKey, nonce, length) {
33
+ const blocks = [];
34
+ let size = 0;
35
+ for (let counter = 0; size < length; counter += 1) {
36
+ const index = Buffer.alloc(4);
37
+ index.writeUInt32BE(counter);
38
+ const block = createHmac("sha256", encKey).update(Buffer.concat([nonce, index])).digest();
39
+ blocks.push(block);
40
+ size += block.length;
41
+ }
42
+ return Buffer.concat(blocks).subarray(0, length);
43
+ }
44
+
45
+ /**
46
+ * @param {string} user
47
+ * @param {string} secret
48
+ * @param {{ now?: number, nonce?: Buffer }} [options] now in milliseconds
49
+ */
50
+ export function encodeSref(user, secret, options = {}) {
51
+ const { enc, mac } = keys(secret);
52
+ const issued = Math.floor((options.now ?? Date.now()) / 1000);
53
+ const plain = Buffer.from(JSON.stringify({ u: user, t: issued }), "utf8");
54
+ const nonce = options.nonce ?? randomBytes(NONCE_BYTES);
55
+ const stream = keystream(enc, nonce, plain.length);
56
+ const cipher = Buffer.alloc(plain.length);
57
+ for (let i = 0; i < plain.length; i += 1) cipher[i] = plain[i] ^ stream[i];
58
+ const tag = createHmac("sha256", mac)
59
+ .update(Buffer.concat([Buffer.from("sref/v1"), nonce, cipher]))
60
+ .digest()
61
+ .subarray(0, TAG_BYTES);
62
+ return `1.${Buffer.concat([nonce, cipher, tag]).toString("base64url")}`;
63
+ }
64
+
65
+ export function srefKey(env = process.env) {
66
+ const configured = env.SREF_KEY;
67
+ if (configured === undefined) return DEFAULT_SREF_KEY;
68
+ return configured.trim();
69
+ }
70
+
71
+ /** A FlowStudio workflow in the Router catalog: `Olares/<workflow uuid>`. */
72
+ export function isFlowstudioModel(model) {
73
+ return /^Olares\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
74
+ String(model ?? "").trim(),
75
+ );
76
+ }
77
+
78
+ const CREATE_ROUTES = /^(images\/generations|images\/edits|videos|music\/generations|generations)\/?$/;
79
+
80
+ /** Whether a POST on this Router suffix creates a media generation. */
81
+ export function isGenerationCreateRoute(suffix) {
82
+ return CREATE_ROUTES.test(String(suffix ?? "").replace(/^\/+/, ""));
83
+ }
84
+
85
+ /**
86
+ * Stamp the encrypted user onto a FlowStudio generation request body.
87
+ * Other providers, or no user / no key, get the body back untouched. A `sref`
88
+ * the caller put there itself is always replaced, so it cannot name someone else.
89
+ *
90
+ * It rides in `flowstudio.params`, which Router forwards to FlowStudio as
91
+ * `params` on every creation route. A top-level field would not survive:
92
+ * Router's canonical `/generations` refuses unknown top-level fields
93
+ * (`json: unknown field "sref"`). FlowStudio pops it before the workflow runs.
94
+ *
95
+ * @param {Record<string, any>} body
96
+ * @param {string} user
97
+ * @param {{ now?: number, nonce?: Buffer }} [options]
98
+ */
99
+ export function withSref(body, user, env = process.env, options = {}) {
100
+ if (body == null || typeof body !== "object" || Array.isArray(body)) return body;
101
+ if (!isFlowstudioModel(body.model)) return body;
102
+ const secret = srefKey(env);
103
+ const name = String(user ?? "").trim();
104
+ if (!secret || !name) return body;
105
+ const token = encodeSref(name, secret, options);
106
+ const { [SREF_FIELD]: _dropped, ...rest } = body;
107
+ const flowstudio = rest.flowstudio && typeof rest.flowstudio === "object" && !Array.isArray(rest.flowstudio)
108
+ ? rest.flowstudio
109
+ : {};
110
+ const params = flowstudio.params && typeof flowstudio.params === "object" && !Array.isArray(flowstudio.params)
111
+ ? flowstudio.params
112
+ : {};
113
+ return { ...rest, flowstudio: { ...flowstudio, params: { ...params, [SREF_FIELD]: token } } };
114
+ }
115
+
116
+ /** Same as `withSref` for a raw JSON request body; non-JSON is returned as is. */
117
+ export function withSrefBuffer(buffer, user, env = process.env, options = {}) {
118
+ let parsed;
119
+ try {
120
+ parsed = JSON.parse(buffer.toString("utf8"));
121
+ } catch {
122
+ return buffer;
123
+ }
124
+ const stamped = withSref(parsed, user, env, options);
125
+ return stamped === parsed ? buffer : Buffer.from(JSON.stringify(stamped), "utf8");
126
+ }
package/router/stt.js CHANGED
@@ -22,17 +22,36 @@ export class VoiceError extends Error {
22
22
  }
23
23
  }
24
24
 
25
+ export function isSttModel(model) {
26
+ if (model?.supportsStt) return true;
27
+ return model?.mode === "audio" && STT_HINTS.test(model?.id ?? "");
28
+ }
29
+
30
+ export function isTtsModel(model) {
31
+ if (model?.supportsTts) return true;
32
+ return model?.mode === "tts";
33
+ }
34
+
25
35
  export function sttModelIds(models) {
26
- return models
27
- .filter((model) => model.mode === "audio" && STT_HINTS.test(model.id))
28
- .map((model) => model.id);
36
+ return models.filter(isSttModel).map((model) => model.id);
29
37
  }
30
38
 
31
- export function pickSttModelId(models, preferred) {
32
- const stt = sttModelIds(models);
39
+ export function ttsModelIds(models) {
40
+ return models.filter(isTtsModel).map((model) => model.id);
41
+ }
42
+
43
+ export function pickListedModelId(ids, preferred) {
33
44
  const want = (preferred ?? "").trim();
34
- if (want && stt.includes(want)) return want;
35
- return stt[0] ?? null;
45
+ if (want && ids.includes(want)) return want;
46
+ return ids[0] ?? null;
47
+ }
48
+
49
+ export function pickSttModelId(models, preferred) {
50
+ return pickListedModelId(sttModelIds(models), preferred);
51
+ }
52
+
53
+ export function pickTtsModelId(models, preferred) {
54
+ return pickListedModelId(ttsModelIds(models), preferred);
36
55
  }
37
56
 
38
57
  export function classifyFailure(status, body) {
@@ -72,9 +91,13 @@ const RESOLVED_TTL_MS = 120_000;
72
91
  /** @returns {Promise<{ id: string, mode: string | null }[]>} */
73
92
  export async function listModels(options) {
74
93
  try {
75
- if (options?.refresh) catalogCache.invalidate();
76
- const { rows } = await catalogCache.get();
77
- return rows.map(({ id, mode }) => ({ id, mode }));
94
+ const { rows } = await catalogCache.get({ refresh: options?.refresh });
95
+ return rows.map(({ id, mode, supportsStt, supportsTts }) => ({
96
+ id,
97
+ mode,
98
+ supportsStt: Boolean(supportsStt),
99
+ supportsTts: Boolean(supportsTts),
100
+ }));
78
101
  } catch (err) {
79
102
  const status = err && typeof err === "object" && "status" in err ? Number(err.status) : 503;
80
103
  throw new VoiceError(
@@ -152,6 +175,8 @@ async function readTranscript(res) {
152
175
  */
153
176
  export async function transcribe(take) {
154
177
  let model = take.model;
178
+ const timeoutMs = Number(take.timeoutMs) > 0 ? Number(take.timeoutMs) : REQUEST_TIMEOUT_MS;
179
+ const retries = take.once ? 0 : RETRY_BACKOFF_MS.length;
155
180
  for (let attempt = 0; ; attempt += 1) {
156
181
  const fields = { model };
157
182
  if (take.language) fields.language = take.language;
@@ -165,11 +190,13 @@ export async function transcribe(take) {
165
190
  /** @type {VoiceError} */
166
191
  let failure;
167
192
  try {
193
+ const timeout = AbortSignal.timeout(timeoutMs);
194
+ const signal = take.signal ? AbortSignal.any([take.signal, timeout]) : timeout;
168
195
  const res = await fetch(`${routerShimBaseUrl()}/audio/transcriptions`, {
169
196
  method: "POST",
170
197
  headers: { "content-type": `multipart/form-data; boundary=${boundary}` },
171
198
  body,
172
- signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
199
+ signal,
173
200
  });
174
201
  if (res.ok) return await readTranscript(res);
175
202
  const text = await res.text();
@@ -177,13 +204,17 @@ export async function transcribe(take) {
177
204
  if (!retryable(res.status)) throw failure;
178
205
  } catch (err) {
179
206
  if (err instanceof VoiceError) throw err;
207
+ if (take.signal?.aborted) {
208
+ throw new VoiceError("voice_cancelled", 499, "transcription cancelled");
209
+ }
180
210
  const timedOut = err?.name === "TimeoutError" || err?.name === "AbortError";
181
211
  failure = timedOut
182
212
  ? new VoiceError("voice_timeout", 504, "transcription timed out")
183
213
  : new VoiceError("voice_failed", 502, `router unreachable: ${err?.message ?? err}`);
184
214
  }
185
215
 
186
- if (attempt >= RETRY_BACKOFF_MS.length) throw failure;
216
+ if (take.signal?.aborted) throw new VoiceError("voice_cancelled", 499, "transcription cancelled");
217
+ if (attempt >= retries) throw failure;
187
218
  await new Promise((resolve) => setTimeout(resolve, RETRY_BACKOFF_MS[attempt]));
188
219
  const refreshed = await resolveSttModel(take.preferred, { refresh: true }).catch(() => null);
189
220
  if (refreshed) model = refreshed;
package/router/tts.js ADDED
@@ -0,0 +1,118 @@
1
+ import { VoiceError } from "./stt.js";
2
+ import { routerShimBaseUrl } from "./gateway.js";
3
+
4
+ const REQUEST_TIMEOUT_MS = 180_000;
5
+
6
+ const PCM_RATE = 24000;
7
+
8
+ /** Kokoro concatenates chunks with no gap. This is the silence we insert between sentences. */
9
+ export function silencePcm(ms, sampleRate = PCM_RATE) {
10
+ const samples = Math.max(0, Math.round((sampleRate * ms) / 1000));
11
+ return Buffer.alloc(samples * 2);
12
+ }
13
+
14
+ /** Wrap 24 kHz mono s16le in a WAV header for a browser that has no host speaker. */
15
+ export function pcmToWav(pcm, sampleRate = PCM_RATE) {
16
+ const body = Buffer.from(pcm);
17
+ const header = Buffer.alloc(44);
18
+ header.write("RIFF", 0);
19
+ header.writeUInt32LE(36 + body.length, 4);
20
+ header.write("WAVE", 8);
21
+ header.write("fmt ", 12);
22
+ header.writeUInt32LE(16, 16);
23
+ header.writeUInt16LE(1, 20);
24
+ header.writeUInt16LE(1, 22);
25
+ header.writeUInt32LE(sampleRate, 24);
26
+ header.writeUInt32LE(sampleRate * 2, 28);
27
+ header.writeUInt16LE(2, 32);
28
+ header.writeUInt16LE(16, 34);
29
+ header.write("data", 36);
30
+ header.writeUInt32LE(body.length, 40);
31
+ return Buffer.concat([header, body]);
32
+ }
33
+
34
+ /**
35
+ * @param {{ text: string, model: string }} request
36
+ * @returns {Promise<Buffer>}
37
+ */
38
+ export async function synthesize(request) {
39
+ const text = request.text.trim();
40
+ if (!text) throw new VoiceError("voice_config_invalid", 400, "nothing to speak");
41
+ let res;
42
+ try {
43
+ res = await fetch(`${routerShimBaseUrl()}/audio/speech`, {
44
+ method: "POST",
45
+ headers: { "content-type": "application/json", accept: "audio/wav,application/octet-stream" },
46
+ body: JSON.stringify({
47
+ model: request.model,
48
+ input: text,
49
+ response_format: "wav",
50
+ }),
51
+ signal: AbortSignal.timeout(request.timeoutMs ?? REQUEST_TIMEOUT_MS),
52
+ });
53
+ } catch (err) {
54
+ const timedOut = err?.name === "TimeoutError" || err?.name === "AbortError";
55
+ throw new VoiceError(
56
+ timedOut ? "voice_timeout" : "voice_failed",
57
+ timedOut ? 504 : 502,
58
+ timedOut ? "speech timed out" : `router unreachable: ${err?.message ?? err}`,
59
+ );
60
+ }
61
+ if (!res.ok) {
62
+ const detail = (await res.text()).slice(0, 400);
63
+ throw new VoiceError(res.status === 404 ? "voice_tts_unavailable" : "voice_failed", res.status, detail);
64
+ }
65
+ return Buffer.from(await res.arrayBuffer());
66
+ }
67
+
68
+ /**
69
+ * Deliver each PCM sentence as Kokoro yields it.
70
+ * A non-stream response comes back as `{ streamed: false, audio }` instead.
71
+ *
72
+ * @param {{ text: string, model: string, signal?: AbortSignal }} request
73
+ * @param {(chunk: Buffer) => Promise<void> | void} onChunk
74
+ */
75
+ export async function synthesizeStream(request, onChunk) {
76
+ const text = request.text.trim();
77
+ if (!text) throw new VoiceError("voice_config_invalid", 400, "nothing to speak");
78
+ let res;
79
+ try {
80
+ const timeout = AbortSignal.timeout(REQUEST_TIMEOUT_MS);
81
+ const signal = request.signal
82
+ ? AbortSignal.any([request.signal, timeout])
83
+ : timeout;
84
+ res = await fetch(`${routerShimBaseUrl()}/audio/speech`, {
85
+ method: "POST",
86
+ headers: { "content-type": "application/json", accept: "audio/l16,audio/wav" },
87
+ body: JSON.stringify({
88
+ model: request.model,
89
+ input: text,
90
+ response_format: "pcm",
91
+ stream: true,
92
+ }),
93
+ signal,
94
+ });
95
+ } catch (err) {
96
+ const timedOut = err?.name === "TimeoutError" || err?.name === "AbortError";
97
+ throw new VoiceError(
98
+ timedOut ? "voice_timeout" : "voice_failed",
99
+ timedOut ? 504 : 502,
100
+ timedOut ? "speech timed out" : `router unreachable: ${err?.message ?? err}`,
101
+ );
102
+ }
103
+ if (!res.ok) {
104
+ const detail = (await res.text()).slice(0, 400);
105
+ throw new VoiceError(res.status === 404 ? "voice_tts_unavailable" : "voice_failed", res.status, detail);
106
+ }
107
+ const type = (res.headers.get("content-type") || "").toLowerCase();
108
+ if (!type.includes("audio/l16")) {
109
+ return { streamed: false, audio: Buffer.from(await res.arrayBuffer()) };
110
+ }
111
+ const reader = res.body.getReader();
112
+ while (true) {
113
+ const { done, value } = await reader.read();
114
+ if (done) break;
115
+ if (value?.byteLength) await onChunk(Buffer.from(value));
116
+ }
117
+ return { streamed: true };
118
+ }
package/search/client.js CHANGED
@@ -9,11 +9,13 @@ export function rememberedSearchSettings() {
9
9
  }
10
10
 
11
11
  export async function loadSearchSettings(options = {}) {
12
- return settings.load(() => getJson("/config"), options);
12
+ const query = options.force ? "?refresh=1" : "";
13
+ return settings.load(() => getJson(`/config${query}`), options);
13
14
  }
14
15
 
15
- export async function saveSearchDefault(id) {
16
- return settings.remember(await postJson("/config/default", { defaultSearchModel: id }));
16
+ /** @param {{ defaultSearchModel: string | null, searchOff: boolean }} selection */
17
+ export async function saveSearchDefault(selection) {
18
+ return settings.remember(await postJson("/config/default", selection));
17
19
  }
18
20
 
19
21
  export async function getJson(path) {
package/search/config.js CHANGED
@@ -3,26 +3,36 @@ import { HttpError } from "../tools/http.js";
3
3
  import { dshPluginConfigPath } from "../workspace/home.js";
4
4
  import { fetchRouterSearchModels } from "../router/search.js";
5
5
 
6
- /** @typedef {{ defaultSearchModel: string | null }} WebSearchConfig */
6
+ /**
7
+ * Three states, because "nobody chose yet" and "the user turned search off"
8
+ * must not collapse into one value: an unset default follows Router, and an
9
+ * automatic default must never reopen search the user closed.
10
+ * @typedef {{ defaultSearchModel: string | null, searchOff: boolean }} WebSearchConfig
11
+ */
7
12
 
8
13
  /** @returns {WebSearchConfig} */
9
14
  function defaults() {
10
- return { defaultSearchModel: null };
15
+ return { defaultSearchModel: null, searchOff: false };
11
16
  }
12
17
 
13
18
  function configPath() {
14
19
  return dshPluginConfigPath("web-search");
15
20
  }
16
21
 
22
+ /** @param {unknown} value */
23
+ function modelId(value) {
24
+ const id = typeof value === "string" ? value.trim() : "";
25
+ if (!id || id.length > 512 || /[\u0000-\u001f\u007f]/.test(id)) return null;
26
+ return id;
27
+ }
28
+
17
29
  /** @param {unknown} raw @returns {WebSearchConfig} */
18
30
  function normalize(raw) {
19
31
  if (!raw || typeof raw !== "object") return defaults();
20
32
  const body = /** @type {Record<string, unknown>} */ (raw);
21
- const value = typeof body.defaultSearchModel === "string" ? body.defaultSearchModel.trim() : "";
22
33
  return {
23
- defaultSearchModel: value && value.length <= 512 && !/[\u0000-\u001f\u007f]/.test(value)
24
- ? value
25
- : null,
34
+ defaultSearchModel: modelId(body.defaultSearchModel),
35
+ searchOff: body.searchOff === true,
26
36
  };
27
37
  }
28
38
 
@@ -43,56 +53,60 @@ function persist(config) {
43
53
  }
44
54
 
45
55
  /**
46
- * @param {string | null} id
56
+ * @param {WebSearchConfig} selection
47
57
  * @param {{ id: string }[]} available
48
58
  */
49
- export function setDefaultSearchModel(id, available) {
50
- if (id === null) {
51
- return persist({ defaultSearchModel: null });
52
- }
53
- const model = typeof id === "string" ? id.trim() : "";
54
- if (
55
- !model
56
- || model.length > 512
57
- || /[\u0000-\u001f\u007f]/.test(model)
58
- || !available.some((item) => item.id === model)
59
- ) {
59
+ export function setSearchSelection(selection, available) {
60
+ if (selection.searchOff) return persist({ defaultSearchModel: null, searchOff: true });
61
+ if (selection.defaultSearchModel === null) return persist(defaults());
62
+ const model = modelId(selection.defaultSearchModel);
63
+ if (!model || !available.some((item) => item.id === model)) {
60
64
  throw new HttpError("not_available", 400, "search service is not available from Router");
61
65
  }
62
- return persist({ defaultSearchModel: model });
66
+ return persist({ defaultSearchModel: model, searchOff: false });
63
67
  }
64
68
 
65
- export async function currentSearchConfig() {
66
- const searchModels = await fetchRouterSearchModels();
67
- return {
68
- defaultSearchModel: readConfig().defaultSearchModel,
69
- searchModels,
70
- };
69
+ /** @param {{ refresh?: boolean }} [options] */
70
+ export async function currentSearchConfig(options = {}) {
71
+ const searchModels = await fetchRouterSearchModels(options);
72
+ return { ...readConfig(), searchModels };
71
73
  }
72
74
 
73
75
  export const LARES_PROVIDER_ID = "lares";
74
- export { SEARCH_NONE, searchDefaultReady, searchMenuValue, searchValueFromMenu } from "./menu.js";
76
+ export {
77
+ SEARCH_OFF,
78
+ SEARCH_ROUTER_DEFAULT,
79
+ searchDefaultReady,
80
+ searchMenuValue,
81
+ searchStatus,
82
+ searchValueFromMenu,
83
+ } from "./menu.js";
75
84
 
85
+ /** The model to send, or null to follow Router's own default-search category. */
76
86
  export function configuredSearchModel() {
77
87
  return readConfig().defaultSearchModel;
78
88
  }
79
89
 
80
- export function defaultSearchModelFromBody(body) {
81
- const id = body?.defaultSearchModel === null ? null : body?.defaultSearchModel;
82
- if (id !== null && typeof id !== "string") {
83
- throw new HttpError("bad_model", 400, "invalid defaultSearchModel");
84
- }
85
- return id;
90
+ export function searchDisabled() {
91
+ return readConfig().searchOff;
86
92
  }
87
93
 
88
- export async function setDefaultSearchFromRequest(id) {
89
- if (id !== null && typeof id !== "string") {
94
+ /** @param {unknown} body @returns {WebSearchConfig} */
95
+ export function searchSelectionFromBody(body) {
96
+ const raw = body && typeof body === "object" ? /** @type {Record<string, unknown>} */ (body) : null;
97
+ if (!raw) throw new HttpError("bad_model", 400, "invalid search selection");
98
+ const asked = raw.defaultSearchModel;
99
+ if (asked !== null && asked !== undefined && typeof asked !== "string") {
90
100
  throw new HttpError("bad_model", 400, "invalid defaultSearchModel");
91
101
  }
102
+ if (raw.searchOff !== undefined && typeof raw.searchOff !== "boolean") {
103
+ throw new HttpError("bad_model", 400, "invalid searchOff");
104
+ }
105
+ return { defaultSearchModel: typeof asked === "string" ? asked : null, searchOff: raw.searchOff === true };
106
+ }
107
+
108
+ /** @param {WebSearchConfig} selection */
109
+ export async function setSearchSelectionFromRequest(selection) {
92
110
  const searchModels = await fetchRouterSearchModels();
93
- const saved = setDefaultSearchModel(id, searchModels);
94
- return {
95
- defaultSearchModel: saved.defaultSearchModel,
96
- searchModels,
97
- };
111
+ return { ...setSearchSelection(selection, searchModels), searchModels };
98
112
  }
package/search/menu.js CHANGED
@@ -1,22 +1,57 @@
1
- export const SEARCH_NONE = "none";
1
+ export const SEARCH_ROUTER_DEFAULT = "router-default";
2
+ export const SEARCH_OFF = "off";
2
3
 
3
- export function searchDefaultReady(models, defaultSearchModel) {
4
- return Array.isArray(models) && models.some((model) => model.id === defaultSearchModel);
4
+ /** @param {{ defaultSearchModel?: string | null, searchOff?: boolean } | null} [config] */
5
+ export function searchMenuValue(config) {
6
+ if (config?.searchOff) return SEARCH_OFF;
7
+ return config?.defaultSearchModel ?? SEARCH_ROUTER_DEFAULT;
5
8
  }
6
9
 
7
- export function searchMenuValue(defaultSearchModel) {
8
- return defaultSearchModel ?? SEARCH_NONE;
10
+ /** @param {string} id */
11
+ export function searchValueFromMenu(id) {
12
+ if (id === SEARCH_OFF) return { defaultSearchModel: null, searchOff: true };
13
+ if (id === SEARCH_ROUTER_DEFAULT) return { defaultSearchModel: null, searchOff: false };
14
+ return { defaultSearchModel: id, searchOff: false };
9
15
  }
10
16
 
11
- export function searchValueFromMenu(id) {
12
- return id === SEARCH_NONE ? null : id;
17
+ export function searchDefaultReady(models, config) {
18
+ return searchStatus(models, config).ready;
19
+ }
20
+
21
+ /**
22
+ * What the settings panel says about web search, decided once for every
23
+ * surface. Following Router is ready as soon as Router serves any search
24
+ * model; a named model is ready only while the catalog still lists it.
25
+ * @param {{ id: string }[]} models
26
+ * @param {{ defaultSearchModel?: string | null, searchOff?: boolean } | null} [config]
27
+ * @returns {{ ready: boolean, key: string, model?: string }}
28
+ */
29
+ export function searchStatus(models, config) {
30
+ if (config?.searchOff) return { ready: false, key: "settings.status.off" };
31
+ const listed = Array.isArray(models) ? models : [];
32
+ const model = config?.defaultSearchModel ?? null;
33
+ if (model === null) {
34
+ return listed.length > 0
35
+ ? { ready: true, key: "settings.status.router" }
36
+ : { ready: false, key: "settings.status.empty" };
37
+ }
38
+ return listed.some((entry) => entry.id === model)
39
+ ? { ready: true, key: "settings.status.ready", model }
40
+ : { ready: false, key: "settings.status.notReady", model };
13
41
  }
14
42
 
15
43
  export function searchModelLabel(model) {
16
44
  return model.name || model.id;
17
45
  }
18
46
 
19
- export function searchSelectorItems(models, { none, empty }) {
20
- if (models.length === 0) return [{ id: SEARCH_NONE, label: empty }];
21
- return [{ id: SEARCH_NONE, label: none }, ...models.map((model) => ({ id: model.id, label: searchModelLabel(model) }))];
47
+ /**
48
+ * Follow-Router first, then what Router serves, with off last. Router serving
49
+ * nothing is reported by the status line, not by hiding the choice.
50
+ */
51
+ export function searchSelectorItems(models, { routerDefault, off }) {
52
+ return [
53
+ { id: SEARCH_ROUTER_DEFAULT, label: routerDefault },
54
+ ...models.map((model) => ({ id: model.id, label: searchModelLabel(model) })),
55
+ { id: SEARCH_OFF, label: off },
56
+ ];
22
57
  }
@@ -0,0 +1,42 @@
1
+ import { createSnapshotStore } from "../tools/async.js";
2
+
3
+ export const API = "/api/lares/skills";
4
+
5
+ const settings = createSnapshotStore();
6
+
7
+ async function readPayload(res, path) {
8
+ const payload = await res.json().catch(() => ({}));
9
+ if (!res.ok) {
10
+ throw new Error(payload?.error?.message ?? payload?.error?.code ?? `${path} → ${res.status}`);
11
+ }
12
+ return payload;
13
+ }
14
+
15
+ async function postAction(path, id) {
16
+ const res = await fetch(`${API}${path}`, {
17
+ method: "POST",
18
+ headers: { "content-type": "application/json" },
19
+ body: JSON.stringify({ id }),
20
+ });
21
+ return settings.remember(await readPayload(res, path));
22
+ }
23
+
24
+ export function rememberedSkillPacks() {
25
+ return settings.peek();
26
+ }
27
+
28
+ export async function loadSkillPacks(options = {}) {
29
+ return settings.load(async () => readPayload(await fetch(API), "/"), options);
30
+ }
31
+
32
+ export function downloadSkillPack(id) {
33
+ return postAction("/download", id);
34
+ }
35
+
36
+ export function enableSkillPack(id) {
37
+ return postAction("/enable", id);
38
+ }
39
+
40
+ export function disableSkillPack(id) {
41
+ return postAction("/disable", id);
42
+ }
@@ -0,0 +1,40 @@
1
+ /** Official optional skill packs. Always-on prefixes are seeded every boot from the image catalog. */
2
+
3
+ export const ALWAYS_ON_PREFIXES = ["olares-", "lares-"];
4
+
5
+ export const OFFICIAL_PACKS = [
6
+ {
7
+ id: "ha",
8
+ prefix: "ha-",
9
+ optional: true,
10
+ cli: {
11
+ package: "@olares/hass-cli",
12
+ version: "0.0.1",
13
+ bin: "hass-cli",
14
+ listArgs: ["skill", "list"],
15
+ showArgs: (name) => ["skill", "show", name],
16
+ },
17
+ },
18
+ ];
19
+
20
+ export function packById(id) {
21
+ return OFFICIAL_PACKS.find((pack) => pack.id === id) ?? null;
22
+ }
23
+
24
+ export function packForSkillDir(name) {
25
+ return OFFICIAL_PACKS.find((pack) => name.startsWith(pack.prefix)) ?? null;
26
+ }
27
+
28
+ export function isAlwaysOnSkillDir(name) {
29
+ return ALWAYS_ON_PREFIXES.some((prefix) => name.startsWith(prefix));
30
+ }
31
+
32
+ /** First column of `hass-cli skill list` (name, then tab/spaces, then description). */
33
+ export function parseSkillNames(listOutput) {
34
+ return String(listOutput ?? "")
35
+ .split(/\r?\n/)
36
+ .map((line) => line.trim())
37
+ .filter(Boolean)
38
+ .map((line) => line.split(/[\t ]+/, 1)[0])
39
+ .filter((name) => name && !name.startsWith("NAME"));
40
+ }
@@ -0,0 +1,22 @@
1
+ export function packsStatePath(dataDir: string): string;
2
+ export function runtimeSkillsDir(dataDir: string): string;
3
+ export function packCacheDir(dataDir: string, packId: string): string;
4
+ export function defaultCatalogRoot(): string;
5
+ export function readEnabledPacks(dataDir: string): string[];
6
+ export function writeEnabledPacks(dataDir: string, enabled: string[]): void;
7
+ export function cachedSkillDirs(dataDir: string, pack: { id: string; prefix: string }): string[];
8
+ export function packDownloaded(dataDir: string, pack: { id: string; prefix: string }): boolean;
9
+ export function syncRuntimeSkills(catalogRoot: string, dataDir: string): string;
10
+ export function writeExportedSkills(cacheDir: string, files: Array<{ name?: string; body?: string }>): void;
11
+ export function listOfficialPacks(dataDir: string, cliReady?: Record<string, boolean>): Array<{
12
+ id: string;
13
+ official: boolean;
14
+ optional: boolean;
15
+ downloaded: boolean;
16
+ enabled: boolean;
17
+ skillCount: number;
18
+ cliBin: string | null;
19
+ cliReady: boolean;
20
+ }>;
21
+ export function enableOfficialPack(catalogRoot: string, dataDir: string, id: string): { id: string };
22
+ export function disableOfficialPack(catalogRoot: string, dataDir: string, id: string): { id: string };