@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
@@ -0,0 +1,142 @@
1
+ import { cpSync, existsSync, mkdirSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { HttpError } from "../tools/http.js";
4
+ import { readJsonFile, writeJsonFile } from "../tools/json-file.js";
5
+ import { ALWAYS_ON_PREFIXES, OFFICIAL_PACKS, packById, packForSkillDir } from "./packs.js";
6
+
7
+ export function packsStatePath(dataDir) {
8
+ return join(dataDir, "skill-packs.json");
9
+ }
10
+
11
+ export function runtimeSkillsDir(dataDir) {
12
+ return join(dataDir, "skills");
13
+ }
14
+
15
+ export function packCacheDir(dataDir, packId) {
16
+ return join(dataDir, "skill-packs", packId);
17
+ }
18
+
19
+ export function defaultCatalogRoot() {
20
+ return process.env.LARES_SKILLS_ROOT?.trim() || join(process.cwd(), "packages", "skills");
21
+ }
22
+
23
+ function directoryNames(root) {
24
+ if (!existsSync(root)) return [];
25
+ return readdirSync(root).filter((name) => {
26
+ try {
27
+ return statSync(join(root, name)).isDirectory();
28
+ } catch {
29
+ return false;
30
+ }
31
+ });
32
+ }
33
+
34
+ export function readEnabledPacks(dataDir) {
35
+ const raw = readJsonFile(packsStatePath(dataDir));
36
+ const listed = Array.isArray(raw?.enabled) ? raw.enabled : [];
37
+ return listed.filter((id) => typeof id === "string" && packById(id));
38
+ }
39
+
40
+ export function writeEnabledPacks(dataDir, enabled) {
41
+ writeJsonFile(packsStatePath(dataDir), { enabled: [...new Set(enabled)] });
42
+ }
43
+
44
+ export function cachedSkillDirs(dataDir, pack) {
45
+ return directoryNames(packCacheDir(dataDir, pack.id)).filter((name) => name.startsWith(pack.prefix));
46
+ }
47
+
48
+ export function packDownloaded(dataDir, pack) {
49
+ return cachedSkillDirs(dataDir, pack).length > 0;
50
+ }
51
+
52
+ function shouldKeepRuntimeDir(name, enabledIds) {
53
+ if (ALWAYS_ON_PREFIXES.some((prefix) => name.startsWith(prefix))) return true;
54
+ const pack = packForSkillDir(name);
55
+ if (!pack) return false;
56
+ return enabledIds.includes(pack.id);
57
+ }
58
+
59
+ /**
60
+ * Image catalog supplies always-on skills (olares-* / lares-*).
61
+ * Optional packs are copied from their download cache only when enabled.
62
+ */
63
+ export function syncRuntimeSkills(catalogRoot, dataDir) {
64
+ const targetDir = runtimeSkillsDir(dataDir);
65
+ mkdirSync(targetDir, { recursive: true });
66
+ const enabled = readEnabledPacks(dataDir);
67
+
68
+ for (const name of directoryNames(targetDir)) {
69
+ if (shouldKeepRuntimeDir(name, enabled)) continue;
70
+ rmSync(join(targetDir, name), { recursive: true, force: true });
71
+ }
72
+
73
+ for (const name of directoryNames(catalogRoot)) {
74
+ if (!ALWAYS_ON_PREFIXES.some((prefix) => name.startsWith(prefix))) continue;
75
+ const to = join(targetDir, name);
76
+ rmSync(to, { recursive: true, force: true });
77
+ cpSync(join(catalogRoot, name), to, { recursive: true });
78
+ }
79
+
80
+ for (const pack of OFFICIAL_PACKS) {
81
+ if (!enabled.includes(pack.id)) continue;
82
+ for (const name of cachedSkillDirs(dataDir, pack)) {
83
+ const to = join(targetDir, name);
84
+ rmSync(to, { recursive: true, force: true });
85
+ cpSync(join(packCacheDir(dataDir, pack.id), name), to, { recursive: true });
86
+ }
87
+ }
88
+ return targetDir;
89
+ }
90
+
91
+ export function writeExportedSkills(cacheDir, files) {
92
+ mkdirSync(cacheDir, { recursive: true });
93
+ for (const name of directoryNames(cacheDir)) {
94
+ rmSync(join(cacheDir, name), { recursive: true, force: true });
95
+ }
96
+ for (const file of files) {
97
+ if (!file?.name || typeof file.body !== "string") continue;
98
+ const dir = join(cacheDir, file.name);
99
+ mkdirSync(dir, { recursive: true });
100
+ writeFileSync(join(dir, "SKILL.md"), file.body);
101
+ }
102
+ }
103
+
104
+ export function listOfficialPacks(dataDir, cliReady = {}) {
105
+ const enabled = readEnabledPacks(dataDir);
106
+ return OFFICIAL_PACKS.map((pack) => {
107
+ const cached = cachedSkillDirs(dataDir, pack);
108
+ const downloaded = cached.length > 0;
109
+ const on = enabled.includes(pack.id);
110
+ return {
111
+ id: pack.id,
112
+ official: true,
113
+ optional: true,
114
+ downloaded,
115
+ enabled: on,
116
+ skillCount: downloaded ? cached.length : 0,
117
+ cliBin: pack.cli?.bin ?? null,
118
+ cliReady: Boolean(cliReady[pack.id]),
119
+ };
120
+ });
121
+ }
122
+
123
+ export function enableOfficialPack(catalogRoot, dataDir, id) {
124
+ const pack = packById(id);
125
+ if (!pack) throw new HttpError("unknown_pack", 404, `unknown skill pack ${id}`);
126
+ if (!packDownloaded(dataDir, pack)) {
127
+ throw new HttpError("pack_not_downloaded", 409, `skill pack ${id} is not downloaded`);
128
+ }
129
+ writeEnabledPacks(dataDir, [...readEnabledPacks(dataDir), pack.id]);
130
+ syncRuntimeSkills(catalogRoot, dataDir);
131
+ return pack;
132
+ }
133
+
134
+ export function disableOfficialPack(catalogRoot, dataDir, id) {
135
+ const pack = packById(id);
136
+ if (!pack) throw new HttpError("unknown_pack", 404, `unknown skill pack ${id}`);
137
+ writeEnabledPacks(dataDir, readEnabledPacks(dataDir).filter((item) => item !== pack.id));
138
+ syncRuntimeSkills(catalogRoot, dataDir);
139
+ return pack;
140
+ }
141
+
142
+ export { packForSkillDir };
@@ -1,3 +1,5 @@
1
+ import { injectHeadScript } from "./head-script.js";
2
+
1
3
  /**
2
4
  * Olares LAN zone is plain http, so browsers withhold `crypto.randomUUID`.
3
5
  * dsh mints host-RPC ids with it; `getRandomValues` stays available.
@@ -16,11 +18,6 @@ export const UUID_SHIM = `(function () {
16
18
  };
17
19
  })();`;
18
20
 
19
- const HEAD_OPEN = /<head[^>]*>/i;
20
-
21
21
  export function injectUuidShim(html) {
22
- if (html.includes("data-lares-uuid-shim")) return html;
23
- const tag = `<script data-lares-uuid-shim>${UUID_SHIM}</script>`;
24
- const head = HEAD_OPEN.exec(html);
25
- return head === null ? `${tag}${html}` : html.replace(HEAD_OPEN, `${head[0]}${tag}`);
22
+ return injectHeadScript(html, { marker: "data-lares-uuid-shim", code: UUID_SHIM });
26
23
  }
@@ -0,0 +1,15 @@
1
+ const HEAD_OPEN = /<head[^>]*>/i;
2
+
3
+ /**
4
+ * Put an inline script at the top of <head> so it runs before the shell bundle.
5
+ * The marker attribute keeps repeated index taps idempotent.
6
+ *
7
+ * @param {string} html
8
+ * @param {{marker: string, code: string}} script
9
+ */
10
+ export function injectHeadScript(html, { marker, code }) {
11
+ if (html.includes(marker)) return html;
12
+ const tag = `<script ${marker}>${code}</script>`;
13
+ const head = HEAD_OPEN.exec(html);
14
+ return head === null ? `${tag}${html}` : html.replace(HEAD_OPEN, `${head[0]}${tag}`);
15
+ }
package/voice/agent.js ADDED
@@ -0,0 +1,50 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
3
+ import { VoiceError } from "../router/stt.js";
4
+
5
+ const DEFAULT_DIR = "/data/voice";
6
+ const WAIT_MS = 30_000;
7
+ const AGENT_STALE_MS = 60_000;
8
+
9
+ async function readJson(path) {
10
+ try {
11
+ return JSON.parse(await readFile(path, "utf8"));
12
+ } catch {
13
+ return null;
14
+ }
15
+ }
16
+
17
+ function agentIsLive(info, now) {
18
+ return Boolean(info && typeof info.at === "number" && now - info.at * 1000 < AGENT_STALE_MS);
19
+ }
20
+
21
+ export function voiceDir(options) {
22
+ return options.dir ?? process.env.LARES_VOICE_BLUETOOTH_DIR ?? DEFAULT_DIR;
23
+ }
24
+
25
+ /** The host agent, or null when this machine has no bluetooth helper running. */
26
+ export async function liveAgent(options = {}) {
27
+ const now = options.now ?? Date.now;
28
+ const info = await readJson(`${voiceDir(options)}/agent.json`);
29
+ return agentIsLive(info, now()) ? info : null;
30
+ }
31
+
32
+ export async function askAgent(op, fields, options) {
33
+ const dir = voiceDir(options);
34
+ const now = options.now ?? Date.now;
35
+ const sleep = options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
36
+ if (!await liveAgent(options)) {
37
+ throw new VoiceError("voice_bluetooth_unavailable", 503, "bluetooth agent is not running");
38
+ }
39
+ const id = options.id ?? randomUUID();
40
+ await mkdir(dir, { recursive: true });
41
+ await rm(`${dir}/result.json`, { force: true });
42
+ await writeFile(`${dir}/request.json`, JSON.stringify({ id, op, ...fields }));
43
+ const deadline = now() + (options.timeoutMs ?? WAIT_MS);
44
+ while (now() < deadline) {
45
+ const result = await readJson(`${dir}/result.json`);
46
+ if (result && result.id === id) return result;
47
+ await sleep(200);
48
+ }
49
+ throw new VoiceError("voice_bluetooth_unavailable", 503, "bluetooth agent did not answer");
50
+ }
@@ -0,0 +1,250 @@
1
+ import { pcmToWav } from "../router/tts.js";
2
+ import { ENDPOINT, createSpeechEndpoint } from "./endpoint.js";
3
+ import { MIN_RECORDING_MS } from "./recorder.js";
4
+
5
+ const FRAME_BYTES = 1024;
6
+ const FRAME_MS = 32;
7
+ const NO_INPUT_MS = 10_000;
8
+ const POLL_MS = 40;
9
+ const CLIENT_LOST_MS = 5000;
10
+ const HOST_RATE = 16_000;
11
+ const PLAYBACK_SETTLE_MS = 320;
12
+ const PLAYBACK_LEADING_MS = 2500;
13
+
14
+ /**
15
+ * The mic still hears the end of playback after the speaker stops. That tail
16
+ * is loud and then quiet, so it would complete the take before anyone speaks.
17
+ * Drop it once the input has gone quiet. Sound that never lets up is the
18
+ * person already talking, and that stays in the take.
19
+ */
20
+ function createPlaybackGate() {
21
+ let residue = false;
22
+ let listening = false;
23
+ let quietMs = 0;
24
+ let loudMs = 0;
25
+ return (audible, rms) => {
26
+ if (listening) return "listen";
27
+ const quiet = !audible || rms < ENDPOINT.floor;
28
+ if (!residue) {
29
+ if (quiet) {
30
+ quietMs += FRAME_MS;
31
+ if (quietMs >= PLAYBACK_SETTLE_MS) listening = true;
32
+ return listening ? "listen" : "wait";
33
+ }
34
+ residue = true;
35
+ quietMs = 0;
36
+ loudMs = FRAME_MS;
37
+ return "wait";
38
+ }
39
+ if (quiet) {
40
+ quietMs += FRAME_MS;
41
+ loudMs = 0;
42
+ if (quietMs >= PLAYBACK_SETTLE_MS) {
43
+ listening = true;
44
+ return "drop";
45
+ }
46
+ return "wait";
47
+ }
48
+ quietMs = 0;
49
+ loudMs += FRAME_MS;
50
+ if (loudMs >= PLAYBACK_LEADING_MS) {
51
+ listening = true;
52
+ return "listen";
53
+ }
54
+ return "wait";
55
+ };
56
+ }
57
+
58
+ /**
59
+ * Loudness of one 16-bit little-endian frame, and whether it is live microphone
60
+ * signal. The headset link opens with a short full-scale burst inside digital
61
+ * silence; a frame that is mostly exact zeros is not the microphone yet.
62
+ */
63
+ export function pcm16Level(bytes) {
64
+ const samples = Math.floor(bytes.length / 2);
65
+ if (!samples) return { rms: 0, audible: false };
66
+ let sum = 0;
67
+ let zeros = 0;
68
+ for (let i = 0; i < samples; i += 1) {
69
+ const sample = bytes.readInt16LE(i * 2);
70
+ if (sample === 0) zeros += 1;
71
+ const unit = sample / 32768;
72
+ sum += unit * unit;
73
+ }
74
+ return { rms: Math.sqrt(sum / samples), audible: zeros * 2 < samples };
75
+ }
76
+
77
+ /**
78
+ * One take recorded on the machine that owns the headset.
79
+ * `skipped` means there is no headset microphone on this machine.
80
+ */
81
+ export function createCaptureSession(io) {
82
+ const sleep = io.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
83
+ const now = io.now ?? Date.now;
84
+ let job = null;
85
+
86
+ function status() {
87
+ if (!job) return { phase: "idle" };
88
+ job.seen = now();
89
+ return {
90
+ phase: job.phase,
91
+ text: job.text ?? "",
92
+ error: job.error ?? "",
93
+ at: job.recordingAt ?? 0,
94
+ };
95
+ }
96
+
97
+ function stop(cancel) {
98
+ if (!job) return;
99
+ job.seen = now();
100
+ if (cancel) {
101
+ job.cancel = true;
102
+ job.controller.abort();
103
+ }
104
+ else job.stop = true;
105
+ }
106
+
107
+ async function start() {
108
+ if (job && (job.phase === "preparing" || job.phase === "recording" || job.phase === "transcribing")) {
109
+ const previous = job;
110
+ previous.cancel = true;
111
+ previous.controller.abort();
112
+ for (let attempts = 0; job === previous && attempts < 25; attempts += 1) {
113
+ await sleep(POLL_MS);
114
+ }
115
+ if (job === previous) throw new Error("voice_failed");
116
+ }
117
+ const run = {
118
+ phase: "preparing",
119
+ text: "",
120
+ error: "",
121
+ recordingAt: 0,
122
+ seen: now(),
123
+ cancel: false,
124
+ stop: false,
125
+ controller: new AbortController(),
126
+ };
127
+ job = run;
128
+ const opened = await io.open();
129
+ if (run.cancel) {
130
+ await io.close?.();
131
+ run.phase = "idle";
132
+ job = null;
133
+ return { cancelled: true };
134
+ }
135
+ if (opened?.skipped) {
136
+ job = null;
137
+ return { skipped: true };
138
+ }
139
+ if (!opened?.ready) {
140
+ run.phase = "error";
141
+ run.error = "voice_bluetooth_not_ready";
142
+ throw new Error("voice_bluetooth_not_ready");
143
+ }
144
+
145
+ let endpoint = createSpeechEndpoint();
146
+ const gate = opened.settle ? createPlaybackGate() : null;
147
+ let offset = Number(opened.offset) || 0;
148
+ let pending = Buffer.alloc(0);
149
+ let pcm = Buffer.alloc(0);
150
+ let utteranceFrom = 0;
151
+ const started = now();
152
+ // `live`: the link delivers samples. Room noise is live too, so only
153
+ // `spoke` (a speech-level frame) keeps the take open past NO_INPUT_MS.
154
+ let live = false;
155
+ let spoke = false;
156
+ run.phase = "recording";
157
+ run.recordingAt = now();
158
+ run.seen = run.recordingAt;
159
+
160
+ void (async () => {
161
+ try {
162
+ while (run === job && run.phase !== "done" && run.phase !== "error" && run.phase !== "idle") {
163
+ if (run.phase === "recording" && now() - run.seen > CLIENT_LOST_MS) run.cancel = true;
164
+ const fresh = await io.read(offset);
165
+ offset += fresh.length;
166
+ if (fresh.length) {
167
+ pcm = Buffer.concat([pcm, fresh]);
168
+ pending = Buffer.concat([pending, fresh]);
169
+ }
170
+ while (pending.length >= FRAME_BYTES) {
171
+ const frame = pending.subarray(0, FRAME_BYTES);
172
+ pending = pending.subarray(FRAME_BYTES);
173
+ const level = pcm16Level(frame);
174
+ if (gate) {
175
+ const verdict = gate(level.audible, level.rms);
176
+ if (verdict === "wait") continue;
177
+ if (verdict === "drop") {
178
+ utteranceFrom = pcm.length - pending.length;
179
+ endpoint = createSpeechEndpoint();
180
+ continue;
181
+ }
182
+ }
183
+ if (level.audible) live = true;
184
+ if (level.audible && level.rms >= ENDPOINT.floor) spoke = true;
185
+ if (live && endpoint.push(1, level.rms, FRAME_MS)) run.stop = true;
186
+ }
187
+ if (run.cancel) {
188
+ await io.close?.();
189
+ run.phase = "idle";
190
+ if (job === run) job = null;
191
+ return;
192
+ }
193
+ if (!spoke && now() - started >= NO_INPUT_MS) {
194
+ await io.close?.();
195
+ if (!live) await io.reset?.();
196
+ run.phase = "error";
197
+ run.error = "voice_no_input";
198
+ return;
199
+ }
200
+ if (run.stop) {
201
+ const audio = pcm.subarray(utteranceFrom);
202
+ const durationMs = (audio.length / 2 / HOST_RATE) * 1000;
203
+ await io.close?.();
204
+ if (durationMs < MIN_RECORDING_MS) {
205
+ run.phase = "error";
206
+ run.error = "voice_too_short";
207
+ return;
208
+ }
209
+ if (!live) {
210
+ await io.reset?.();
211
+ run.phase = "error";
212
+ run.error = "voice_no_input";
213
+ return;
214
+ }
215
+ run.phase = "transcribing";
216
+ try {
217
+ run.text = String(await io.transcribe(
218
+ pcmToWav(audio, HOST_RATE),
219
+ run.controller.signal,
220
+ ) ?? "");
221
+ } catch (err) {
222
+ if (run.cancel || run.controller.signal.aborted) {
223
+ run.phase = "idle";
224
+ if (job === run) job = null;
225
+ return;
226
+ }
227
+ throw err;
228
+ }
229
+ if (run.cancel) {
230
+ run.phase = "idle";
231
+ if (job === run) job = null;
232
+ return;
233
+ }
234
+ run.phase = "done";
235
+ return;
236
+ }
237
+ await sleep(POLL_MS);
238
+ }
239
+ } catch {
240
+ await io.close?.().catch(() => {});
241
+ run.phase = "error";
242
+ run.error = "voice_failed";
243
+ }
244
+ })();
245
+
246
+ return { recording: true, at: run.recordingAt };
247
+ }
248
+
249
+ return { start, stop, status };
250
+ }
package/voice/client.js CHANGED
@@ -20,10 +20,73 @@ export async function loadVoiceSettings(options = {}) {
20
20
  config,
21
21
  status,
22
22
  sttModels: Array.isArray(models.stt) ? models.stt : [],
23
+ ttsModels: Array.isArray(models.tts) ? models.tts : [],
24
+ charts: {
25
+ stt: chartView(models.charts?.stt),
26
+ tts: chartView(models.charts?.tts),
27
+ },
23
28
  };
24
29
  }, options);
25
30
  }
26
31
 
32
+ function chartView(row) {
33
+ const phase = row?.phase;
34
+ return {
35
+ phase: phase === "running" || phase === "stopped" || phase === "busy" || phase === "failed" ? phase : "absent",
36
+ state: typeof row?.state === "string" ? row.state : "",
37
+ };
38
+ }
39
+
40
+ async function postInstall(body) {
41
+ const res = await fetch(`${API}/install`, {
42
+ method: "POST",
43
+ headers: { "content-type": "application/json" },
44
+ body: JSON.stringify(body),
45
+ });
46
+ if (!res.ok) throw new Error(String(res.status));
47
+ return loadVoiceSettings({ force: true });
48
+ }
49
+
50
+ export function installVoiceChart(kind) {
51
+ return postInstall({ kind });
52
+ }
53
+
54
+ export function voiceChartAction(kind, op) {
55
+ return postInstall({ kind, op });
56
+ }
57
+
58
+ export function wavBlob(base64) {
59
+ const raw = atob(String(base64 ?? ""));
60
+ const bytes = new Uint8Array(raw.length);
61
+ for (let i = 0; i < raw.length; i += 1) bytes[i] = raw.charCodeAt(i);
62
+ return new Blob([bytes], { type: "audio/wav" });
63
+ }
64
+
65
+ export async function postSpeak(text) {
66
+ const res = await fetch(`${API}/speak`, {
67
+ method: "POST",
68
+ headers: { "content-type": "application/json" },
69
+ body: JSON.stringify({ text }),
70
+ });
71
+ let body = null;
72
+ try {
73
+ body = await res.json();
74
+ } catch {
75
+ body = null;
76
+ }
77
+ if (!res.ok || typeof body?.audio !== "string") throw new Error(transcribeErrorCode(body));
78
+ return {
79
+ bluetooth: body.bluetooth === "played" ? "played" : "skipped",
80
+ blob: wavBlob(body.audio),
81
+ };
82
+ }
83
+
84
+ export async function postPlaybackStop(signal) {
85
+ const res = await fetch(`${API}/playback/stop`, { method: "POST", signal });
86
+ if (!res.ok) throw new Error(String(res.status));
87
+ return res.json();
88
+ }
89
+
27
90
  export async function saveVoiceSettings(patch) {
28
91
  const res = await fetch(`${API}/config`, {
29
92
  method: "POST",
@@ -34,41 +97,80 @@ export async function saveVoiceSettings(patch) {
34
97
  return loadVoiceSettings({ force: true });
35
98
  }
36
99
 
37
- export class TranscriptQueue {
38
- constructor() {
39
- this.pending = null;
40
- }
100
+ function transcribeErrorCode(payload) {
101
+ const code = payload && typeof payload === "object" ? payload.error?.code : undefined;
102
+ return typeof code === "string" ? code : "voice_failed";
103
+ }
41
104
 
42
- apply(text, ready, draft, setDraft) {
43
- if (!text) return;
44
- if (ready) setDraft(mergeTranscript(draft, text));
45
- else this.pending = text;
105
+ export async function postBluetooth(body, signal) {
106
+ const res = await fetch(`${API}/bluetooth`, {
107
+ method: "POST",
108
+ headers: { "content-type": "application/json" },
109
+ body: JSON.stringify(body ?? {}),
110
+ signal,
111
+ });
112
+ let payload = null;
113
+ try {
114
+ payload = await res.json();
115
+ } catch {
116
+ payload = null;
46
117
  }
118
+ if (!res.ok) throw new Error(transcribeErrorCode(payload));
119
+ return payload;
120
+ }
121
+
122
+ export function scanBluetoothDevices(signal) {
123
+ return postBluetooth({ op: "scan" }, signal);
124
+ }
47
125
 
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));
126
+ export function connectBluetoothDevice(address, signal) {
127
+ return postBluetooth({ op: "connect", address }, signal);
128
+ }
129
+
130
+ async function readVoiceJson(res) {
131
+ let payload = null;
132
+ try {
133
+ payload = await res.json();
134
+ } catch {
135
+ payload = null;
53
136
  }
137
+ if (!res.ok) throw new Error(transcribeErrorCode(payload));
138
+ return payload;
54
139
  }
55
140
 
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}`;
141
+ /** Host recording. `skipped` means the headset microphone is not available. */
142
+ export async function postCaptureStart(signal) {
143
+ return readVoiceJson(await fetch(`${API}/capture`, { method: "POST", signal }));
63
144
  }
64
145
 
65
- function transcribeErrorCode(payload) {
66
- const code = payload && typeof payload === "object" ? payload.error?.code : undefined;
67
- return typeof code === "string" ? code : "voice_failed";
146
+ export async function getCapture(signal) {
147
+ return readVoiceJson(await fetch(`${API}/capture`, { signal }));
148
+ }
149
+
150
+ export async function postCaptureStop(signal) {
151
+ return readVoiceJson(await fetch(`${API}/capture/stop`, { method: "POST", signal }));
152
+ }
153
+
154
+ export async function postCaptureCancel(signal) {
155
+ return readVoiceJson(await fetch(`${API}/capture/cancel`, { method: "POST", signal }));
156
+ }
157
+
158
+ export async function postBluetoothReady(signal) {
159
+ const res = await fetch(`${API}/ready`, { method: "POST", signal });
160
+ if (!res.ok) {
161
+ let payload = null;
162
+ try {
163
+ payload = await res.json();
164
+ } catch {
165
+ /* fall back to the generic code */
166
+ }
167
+ throw new Error(transcribeErrorCode(payload));
168
+ }
169
+ return res.json();
68
170
  }
69
171
 
70
172
  /**
71
- * The only voice call that carries a body, and so the only one that cannot go
173
+ * The only voice call that carries an audio body, and so the only one that cannot go
72
174
  * through `createHostSettings`. Like the upload it must accept the host's
73
175
  * `request` port: a cross-origin host page sends no Olares cookie, so a plain
74
176
  * `fetch` here answers 401 even though `/config`, `/status` and `/models` — which
package/voice/config.js CHANGED
@@ -6,7 +6,9 @@ export { STT_LANGUAGE_CHOICES, VOICE_AUTO, voiceMenuValue, voiceValueFromMenu }
6
6
 
7
7
  const DEFAULTS = {
8
8
  model: "",
9
+ ttsModel: "",
9
10
  language: "",
11
+ speak: false,
10
12
  };
11
13
 
12
14
  const WRITABLE = new Set(Object.keys(DEFAULTS));
@@ -23,9 +25,14 @@ export function validateConfigPatch(raw) {
23
25
  const patch = {};
24
26
  for (const [key, value] of Object.entries(raw)) {
25
27
  if (!WRITABLE.has(key)) invalid(`unknown voice setting: ${key}`);
28
+ if (key === "speak") {
29
+ if (typeof value !== "boolean") invalid("speak must be a boolean");
30
+ patch.speak = value;
31
+ continue;
32
+ }
26
33
  if (typeof value !== "string") invalid(`${key} must be a string`);
27
34
  const normalized = value.trim();
28
- if (key === "model" && !SAFE_MODEL.test(normalized)) invalid("invalid voice model");
35
+ if ((key === "model" || key === "ttsModel") && !SAFE_MODEL.test(normalized)) invalid("invalid voice model");
29
36
  if (key === "language" && !SUPPORTED_LANGUAGES.has(normalized)) invalid("unsupported recognition language");
30
37
  patch[key] = normalized;
31
38
  }
@@ -41,9 +48,12 @@ export function readConfig() {
41
48
  const stored = readJsonFile(configPath());
42
49
  const merged = { ...DEFAULTS };
43
50
  const model = typeof stored?.model === "string" ? stored.model.trim() : "";
51
+ const ttsModel = typeof stored?.ttsModel === "string" ? stored.ttsModel.trim() : "";
44
52
  const language = typeof stored?.language === "string" ? stored.language.trim() : "";
45
53
  if (SAFE_MODEL.test(model)) merged.model = model;
54
+ if (SAFE_MODEL.test(ttsModel)) merged.ttsModel = ttsModel;
46
55
  if (SUPPORTED_LANGUAGES.has(language)) merged.language = language;
56
+ if (stored?.speak === true) merged.speak = true;
47
57
  return merged;
48
58
  }
49
59