@olares/lares-core 0.15.24 → 0.15.26

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/voice/gpu-mode.js CHANGED
@@ -1,3 +1,5 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import https from "node:https";
1
3
  import { readJsonFile, writeJsonFile } from "../tools/json-file.js";
2
4
  import { dshPluginConfigPath } from "../workspace/home.js";
3
5
  import { VoiceError } from "../router/stt.js";
@@ -119,18 +121,41 @@ function speechDevice(nodes) {
119
121
  ?? null;
120
122
  }
121
123
 
124
+ let seenGpu = undefined;
125
+
126
+ /** Last card `speechGpu` observed. `undefined` means it has not been asked yet. */
127
+ export function noteSpeechGpu(gpu) {
128
+ seenGpu = gpu ?? null;
129
+ }
130
+
122
131
  /**
123
- * The NVIDIA card the speech models are bound to.
124
- * @returns {Promise<{ node: string, device: string, model: string, type: string, memory: number, available: string[], bindings: { app: string, memory: number }[] } | null>}
132
+ * Read-while-writing only while the saved mode asks for it and the card is
133
+ * memory-sliced. Time slicing cannot keep the language model and speech model
134
+ * resident together, so a saved streaming flag does not override that.
125
135
  */
126
- export async function speechGpu(options = {}) {
127
- const result = await runOlaresCli(["settings", "compute", "list", "-o", "json"], options.spawnFn);
128
- if (result.code !== 0 || !Array.isArray(result.json)) {
129
- throw new GpuError(cliProblem(result, "voice_gpu_failed"));
136
+ export function streamingEnabled() {
137
+ return readConfig().streaming === true && seenGpu?.type === "MemorySlice";
138
+ }
139
+
140
+ /** What the reading-mode control needs, without listing every GPU app. */
141
+ export async function readingModePayload(options = {}) {
142
+ let gpu = null;
143
+ let problem = null;
144
+ try {
145
+ gpu = await speechGpu(options);
146
+ } catch (err) {
147
+ problem = problemOf(err);
130
148
  }
131
- const device = speechDevice(result.json);
132
- if (!device) return null;
133
149
  return {
150
+ streaming: readConfig().streaming === true,
151
+ allowed: gpu?.type === "MemorySlice",
152
+ gpu: gpu ? { type: gpu.type, model: gpu.model } : null,
153
+ problem,
154
+ };
155
+ }
156
+
157
+ function gpuFromDevice(device) {
158
+ const gpu = {
134
159
  node: String(device.nodeName ?? ""),
135
160
  device: String(device.id ?? ""),
136
161
  model: String(device.cardModel ?? ""),
@@ -142,6 +167,133 @@ export async function speechGpu(options = {}) {
142
167
  memory: Number(binding?.memory) || 0,
143
168
  })).filter((binding) => binding.app),
144
169
  };
170
+ noteSpeechGpu(gpu);
171
+ return gpu;
172
+ }
173
+
174
+ const SHARE_MODE_PREFIX = "sharemode.gpu.bytetrade.io/";
175
+ const NVIDIA_REGISTER = "hami.io/node-nvidia-register";
176
+
177
+ /**
178
+ * Node annotation `sharemode.gpu.bytetrade.io/<uuid>` uses the platform's
179
+ * legacy codes: 0 exclusive, 1 memory slice, anything else time slice.
180
+ */
181
+ export function supportTypeFromShareMode(mode) {
182
+ if (mode === "0") return "Exclusive";
183
+ if (mode === "1") return "MemorySlice";
184
+ return "TimeSlice";
185
+ }
186
+
187
+ /**
188
+ * The card the speech models use, from the node's share-mode annotation and
189
+ * the GPUBinding objects. Memory in those objects is MiB.
190
+ * @returns {ReturnType<typeof gpuFromDevice> | null}
191
+ */
192
+ export function gpuFromClusterState(nodeList, bindingList) {
193
+ const nodes = Array.isArray(nodeList?.items) ? nodeList.items : [];
194
+ const bindings = Array.isArray(bindingList?.items) ? bindingList.items : [];
195
+ const speechApps = new Set(Object.values(VOICE_CHARTS).map((chart) => chart.app));
196
+ const cards = [];
197
+ for (const node of nodes) {
198
+ const annotations = node?.metadata?.annotations ?? {};
199
+ const register = String(annotations[NVIDIA_REGISTER] ?? "");
200
+ const rows = register.split(":").map((row) => row.split(",")).filter((fields) => fields[0]);
201
+ for (const [key, value] of Object.entries(annotations)) {
202
+ if (!key.startsWith(SHARE_MODE_PREFIX)) continue;
203
+ const device = key.slice(SHARE_MODE_PREFIX.length);
204
+ if (!device) continue;
205
+ const card = rows.find((fields) => fields[0] === device);
206
+ const bound = bindings.filter((item) => item?.spec?.uuid === device);
207
+ cards.push({
208
+ node: String(node?.metadata?.name ?? ""),
209
+ device,
210
+ model: card?.[4] ? String(card[4]) : "",
211
+ type: supportTypeFromShareMode(String(value ?? "").trim()),
212
+ memory: card ? (Number(card[2]) || 0) * MIB : 0,
213
+ available: ["TimeSlice", "MemorySlice", "Exclusive"],
214
+ bindings: bound.map((item) => ({
215
+ app: String(item?.spec?.appName ?? ""),
216
+ memory: (Number(item?.spec?.memory) || 0) * MIB,
217
+ })).filter((binding) => binding.app),
218
+ });
219
+ }
220
+ }
221
+ const chosen = cards.find((card) => card.bindings.some((binding) => speechApps.has(binding.app))) ?? cards[0] ?? null;
222
+ if (!chosen) return null;
223
+ noteSpeechGpu(chosen);
224
+ return chosen;
225
+ }
226
+
227
+ function kubeGet(path) {
228
+ return new Promise((resolve, reject) => {
229
+ Promise.all([
230
+ readFile("/var/run/secrets/kubernetes.io/serviceaccount/token", "utf8"),
231
+ readFile("/var/run/secrets/kubernetes.io/serviceaccount/ca.crt"),
232
+ ]).then(([token, ca]) => {
233
+ const req = https.request({
234
+ hostname: "kubernetes.default.svc",
235
+ path,
236
+ method: "GET",
237
+ ca,
238
+ headers: { Authorization: `Bearer ${token.trim()}` },
239
+ timeout: 8000,
240
+ }, (res) => {
241
+ let body = "";
242
+ res.setEncoding("utf8");
243
+ res.on("data", (chunk) => { body += chunk; });
244
+ res.on("end", () => {
245
+ if (res.statusCode !== 200) {
246
+ reject(new Error(`kubernetes ${path} returned ${res.statusCode}`));
247
+ return;
248
+ }
249
+ try {
250
+ resolve(JSON.parse(body));
251
+ } catch (err) {
252
+ reject(err);
253
+ }
254
+ });
255
+ });
256
+ req.on("error", reject);
257
+ req.on("timeout", () => req.destroy(new Error("kubernetes API timed out")));
258
+ req.end();
259
+ }).catch(reject);
260
+ });
261
+ }
262
+
263
+ async function speechGpuFromCluster(options) {
264
+ const get = options.kubeGet ?? kubeGet;
265
+ const [nodes, bindings] = await Promise.all([
266
+ get("/api/v1/nodes"),
267
+ get("/apis/gpu.bytetrade.io/v1alpha1/gpubindings"),
268
+ ]);
269
+ return gpuFromClusterState(nodes, bindings);
270
+ }
271
+
272
+ /**
273
+ * The NVIDIA card the speech models are bound to.
274
+ * The pod CLI profile is the first source. When that profile is not mounted,
275
+ * the node's share-mode annotation is the same fact the settings page shows.
276
+ * @returns {Promise<{ node: string, device: string, model: string, type: string, memory: number, available: string[], bindings: { app: string, memory: number }[] } | null>}
277
+ */
278
+ export async function speechGpu(options = {}) {
279
+ const result = await runOlaresCli(["settings", "compute", "list", "-o", "json"], options.spawnFn);
280
+ if (result.code === 0 && Array.isArray(result.json)) {
281
+ const device = speechDevice(result.json);
282
+ if (!device) {
283
+ noteSpeechGpu(null);
284
+ return null;
285
+ }
286
+ return gpuFromDevice(device);
287
+ }
288
+ const problem = cliProblem(result, "voice_gpu_failed");
289
+ if (problem.code === "voice_gpu_login") {
290
+ try {
291
+ return await speechGpuFromCluster(options);
292
+ } catch {
293
+ /* The annotation is unreadable; the CLI failure is what we can report. */
294
+ }
295
+ }
296
+ throw new GpuError(problem);
145
297
  }
146
298
 
147
299
  /**
@@ -1,6 +1,6 @@
1
1
  import { createVoiceAgentSession } from "./agent-session.js";
2
2
  import { createVoiceRecords } from "./records.js";
3
- import { createVoiceDialogue, cleanDictation } from "./dialogue.js";
3
+ import { createVoiceDialogue, cleanDictation, voiceChat } from "./dialogue.js";
4
4
  import { readConfig, validateConfigPatch } from "./config.js";
5
5
  import { listModels } from "../router/stt.js";
6
6
  import { validatedRealtimeModels } from "./realtime.js";
@@ -24,12 +24,21 @@ export function createVoiceHostService(api, { env = process.env, metric = () =>
24
24
  records.clear(ownerOf(scope), before);
25
25
  records.bind(ownerOf(scope), scope.contextId, keyOf(scope));
26
26
  };
27
+ // Router groups a keyed Realtime session's child requests by its session id (Router ADR-83).
28
+ const chatFor = (scope) => (request) => (chat || voiceChat)({ ...request, ...(scope.routerSessionId ? { headers: { ...request.headers, "x-session-id": scope.routerSessionId } } : {}) });
29
+ const summarize = (scope) => {
30
+ const key = keyOf(scope);
31
+ const checkpointVersion = records.read(ownerOf(scope), key).version;
32
+ scope.summaryTask = Promise.resolve(scope.summaryTask).catch(() => {}).then(() => scope.dialogue.summarize()).then((summary) => records.checkpoint(ownerOf(scope), key, summary, checkpointVersion)).catch((cause) => {
33
+ scope.summaryError = cause.code || cause.message || "voice_summary_failed";
34
+ });
35
+ };
27
36
  const scopes = new Set();
28
37
  const service = {
29
38
  async clean(scope, text, options = {}) {
30
39
  if (typeof text !== "string" || text.length > 16000) throw failure("voice_input_invalid", 400);
31
40
  if (options.config && Object.keys(options.config).some((key) => key !== "dictationProfiles")) throw failure("voice_config_invalid", 400);
32
- return cleanDictation(text, { config: { ...config(), ...validateConfigPatch(options.config || {}) }, owner: scope.owner, chat, preset: options.preset, signal: options.signal });
41
+ return cleanDictation(text, { config: { ...config(), ...validateConfigPatch(options.config || {}) }, owner: scope.owner, chat: chatFor(scope), preset: options.preset, signal: options.signal });
33
42
  },
34
43
  async open(scope, body) {
35
44
  if (scope.active || scope.opening || scope.closing) throw failure("voice_session_active", 409);
@@ -69,7 +78,7 @@ export function createVoiceHostService(api, { env = process.env, metric = () =>
69
78
  scope.active = true; scope.readAloud = body.readAloud === true;
70
79
  scope.voiceState = { version: 0, running: false, utterance: null, error: null };
71
80
  scope.voiceRequests = new Map(); scope.deliveredTasks = new Set(restoredRequests.map((item) => item.requestId));
72
- scope.dialogue = preset === "direct" ? null : createVoiceDialogue({ config: scope.voiceConfig, owner: scope.owner, context: history.summary?.recent || [], summary: history.summary?.text || "", chat,
81
+ scope.dialogue = preset === "direct" ? null : createVoiceDialogue({ config: scope.voiceConfig, owner: scope.owner, context: history.summary?.recent || [], summary: history.summary?.text || "", chat: chatFor(scope),
73
82
  submit: async (text, requestId, operation) => {
74
83
  if (operation === "status") return scope.session.snapshot();
75
84
  const id = `${requestId}:delegate`;
@@ -122,11 +131,7 @@ export function createVoiceHostService(api, { env = process.env, metric = () =>
122
131
  });
123
132
  const source = records.read(ownerOf(scope), keyOf(scope)).records.find((row) => row.id === `${scope.id}:${body.requestId}:user`);
124
133
  append(scope, "assistant", text, body.requestId, source?.taskId || null);
125
- const key = keyOf(scope);
126
- const checkpointVersion = records.read(ownerOf(scope), key).version;
127
- scope.summaryTask = scope.dialogue.summarize().then((summary) => records.checkpoint(ownerOf(scope), key, summary, checkpointVersion)).catch((cause) => {
128
- scope.summaryError = cause.code || cause.message || "voice_summary_failed";
129
- });
134
+ summarize(scope);
130
135
  scope.voiceState.utterance = { key: body.requestId, turn: scope.voiceState.version, step: 0, text, done: true, silent: false };
131
136
  } catch (cause) {
132
137
  scope.voiceState.error = cause.code || cause.message || "voice_failed";
@@ -204,6 +209,33 @@ export function createVoiceHostService(api, { env = process.env, metric = () =>
204
209
  output(scope, value) { if (typeof value !== "boolean") throw failure("voice_output_invalid", 400); scope.readAloud = value; },
205
210
  resume: (scope) => scope.session?.resume(),
206
211
  cancel: (scope) => scope.session?.cancel(),
212
+ instructions(scope, text) {
213
+ if (!scope.dialogue || scope.voiceConfig?.preset !== "chat") throw failure("voice_instructions_unsupported", 400);
214
+ scope.dialogue.setInstructions(text);
215
+ },
216
+ /**
217
+ * Keeps only the heard part of a spoken reply in records and dialogue context.
218
+ * Agent output lives in the dsh log and is not rewritten there.
219
+ */
220
+ truncate(scope, utteranceKey, text) {
221
+ const key = String(utteranceKey || "");
222
+ const turnId = key.startsWith("task:") ? `result:${key.slice(5)}` : key;
223
+ const row = records.read(ownerOf(scope), keyOf(scope)).records.find((item) => item.id === `${scope.id}:${turnId}:assistant`);
224
+ if (!row || row.text === text) return false;
225
+ append(scope, "assistant", text, turnId, row.taskId, { status: "truncated", ...(Number.isInteger(row.taskTurn) ? { taskTurn: row.taskTurn } : {}) });
226
+ if (!key.startsWith("task:") && scope.dialogue?.replaceLastAssistant(text)) summarize(scope);
227
+ return true;
228
+ },
229
+ async withdraw(scope, requestId) {
230
+ if (!scope.session) return "not_cancellable";
231
+ // A delegated assistant task is submitted under its turn id plus ":delegate".
232
+ const ids = [requestId, `${requestId}:delegate`];
233
+ for (const id of ids) {
234
+ const result = await scope.session.withdraw(id);
235
+ if (result !== "not_cancellable") return result;
236
+ }
237
+ return "not_cancellable";
238
+ },
207
239
  async close(scope) {
208
240
  scope.epoch = (scope.epoch || 0) + 1; scope.opening = false; scope.closing = true;
209
241
  scope.active = false; scope.voiceAbort?.abort(); scope.realtimeSocket?.close();
@@ -0,0 +1,20 @@
1
+ /**
2
+ * The voice host publishes its Bearer-key upgrade here so the internal entrance listener
3
+ * (port 8081, `webhook-internal`) can serve `lares-realtime-v1` to Router without a direct
4
+ * Service route, which the per-user namespace's NetworkPolicy refuses.
5
+ */
6
+ let handler = null;
7
+
8
+ export function publishRealtimeUpgrade(next) {
9
+ handler = next;
10
+ return () => { if (handler === next) handler = null; };
11
+ }
12
+
13
+ export function internalRealtimeUpgrade(req, socket, head) {
14
+ const path = new URL(req.url ?? "/", "http://lares").pathname;
15
+ if (path !== "/api/lares/voice/realtime" || !req.headers.authorization || !handler) {
16
+ socket.end("HTTP/1.1 404 Not Found\r\nConnection: close\r\n\r\n");
17
+ return;
18
+ }
19
+ handler(req, socket, head);
20
+ }
@@ -0,0 +1 @@
1
+ export const VOICE_KEY_MODELS = Object.freeze(["voice-dictation", "voice-chat", "voice-assistant", "voice-direct"]);
@@ -0,0 +1,30 @@
1
+ import { VOICE_KEY_MODELS } from "./key-models.js";
2
+
3
+ export { VOICE_KEY_MODELS };
4
+ /** Agent-driving models act as the Lares owner; a new key leaves them out unless chosen. */
5
+ export const VOICE_KEY_AGENT_MODELS = Object.freeze(["voice-assistant", "voice-direct"]);
6
+ export const VOICE_KEY_DEFAULT_MODELS = Object.freeze(VOICE_KEY_MODELS.filter((model) => !VOICE_KEY_AGENT_MODELS.includes(model)));
7
+ const PATH = "/api/lares/voice/keys";
8
+
9
+ /** Browser request port with the `{ ok, status, body }` shape the mobile host request uses. */
10
+ export async function fetchVoiceRequest(path, { method = "GET", body } = {}) {
11
+ const res = await fetch(path, { method, ...(body === undefined ? {} : { headers: { "content-type": "application/json" }, body: JSON.stringify(body) }) });
12
+ let payload = null;
13
+ try { payload = await res.json(); } catch { payload = null; }
14
+ return { ok: res.ok, status: res.status, body: payload };
15
+ }
16
+
17
+ const value = (res) => {
18
+ if (res?.ok) return res.body;
19
+ throw new Error(res?.body?.error?.code || "voice_key_failed");
20
+ };
21
+
22
+ /** Owner voice keys for server-side Realtime callers (docs/realtime-protocol.md). */
23
+ export function createVoiceKeysClient(request = fetchVoiceRequest) {
24
+ return {
25
+ async list() { return value(await request(PATH)).keys || []; },
26
+ /** The returned `key` is shown once; the Host stores only its hash. */
27
+ async issue({ name, models }) { return value(await request(PATH, { method: "POST", body: { name: String(name || "").trim(), models } })); },
28
+ async revoke(id) { return value(await request(PATH, { method: "DELETE", body: { id } })).revoked === true; },
29
+ };
30
+ }
package/voice/keys.js ADDED
@@ -0,0 +1,63 @@
1
+ import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
2
+ import { dirname, join } from "node:path";
3
+ import { readJsonFile, writeJsonFile } from "../tools/json-file.js";
4
+ import { dshPluginConfigPath } from "../workspace/home.js";
5
+ import { VOICE_KEY_MODELS } from "./key-models.js";
6
+
7
+ export { VOICE_KEY_MODELS };
8
+ const KEY = /^lvk_([a-f0-9]{16})_[A-Za-z0-9_-]{43}$/;
9
+ const LIMIT = 32;
10
+
11
+ function invalid(message) {
12
+ throw Object.assign(new Error(message), { code: "voice_key_invalid", status: 400 });
13
+ }
14
+ const digest = (key) => createHash("sha256").update(key).digest();
15
+ const view = ({ hash: _hash, ...record }) => record;
16
+ const optional = (value, name) => {
17
+ if (value == null || value === "") return null;
18
+ if (typeof value !== "string" || value.length > 256 || /[\u0000-\u001f\u007f]/.test(value)) invalid(`invalid ${name}`);
19
+ return value;
20
+ };
21
+
22
+ /**
23
+ * Owner-issued credentials for server-side Realtime callers such as Router.
24
+ * Only hashes are stored; the file is re-read on every check so revocation needs no restart.
25
+ */
26
+ export function createVoiceKeyStore({ path = join(dirname(dshPluginConfigPath("voice-input")), "keys.json"), now = Date.now } = {}) {
27
+ const read = () => {
28
+ const stored = readJsonFile(path);
29
+ return Array.isArray(stored?.keys) ? stored.keys : [];
30
+ };
31
+ return {
32
+ list: () => read().map(view),
33
+ issue(body = {}) {
34
+ const name = typeof body.name === "string" ? body.name.trim() : "";
35
+ if (!name || name.length > 64 || /[\u0000-\u001f\u007f]/.test(name)) invalid("invalid key name");
36
+ const models = Array.isArray(body.models) ? [...new Set(body.models)] : [];
37
+ if (!models.length || models.some((model) => !VOICE_KEY_MODELS.includes(model))) invalid("invalid key models");
38
+ const keys = read();
39
+ if (keys.length >= LIMIT) throw Object.assign(new Error("too many voice keys"), { code: "voice_key_limit", status: 409 });
40
+ const id = randomBytes(8).toString("hex");
41
+ const key = `lvk_${id}_${randomBytes(32).toString("base64url")}`;
42
+ const record = { id, name, models, sessionId: optional(body.sessionId, "sessionId"), workspaceId: optional(body.workspaceId, "workspaceId"), createdAt: new Date(now()).toISOString() };
43
+ writeJsonFile(path, { keys: [...keys, { ...record, hash: digest(key).toString("hex") }] });
44
+ return { ...record, key };
45
+ },
46
+ revoke(id) {
47
+ const keys = read();
48
+ const next = keys.filter((record) => record.id !== id);
49
+ if (next.length === keys.length) return false;
50
+ writeJsonFile(path, { keys: next });
51
+ return true;
52
+ },
53
+ /** @returns {null | { id: string, name: string, models: string[], sessionId: string | null, workspaceId: string | null }} */
54
+ verify(authorization) {
55
+ const key = typeof authorization === "string" && authorization.startsWith("Bearer ") ? authorization.slice(7).trim() : "";
56
+ const match = KEY.exec(key);
57
+ if (!match) return null;
58
+ const record = read().find((value) => value.id === match[1]);
59
+ if (!record || typeof record.hash !== "string" || !/^[a-f0-9]{64}$/.test(record.hash)) return null;
60
+ return timingSafeEqual(digest(key), Buffer.from(record.hash, "hex")) ? view(record) : null;
61
+ },
62
+ };
63
+ }