@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.
@@ -1,160 +0,0 @@
1
- import { randomUUID } from "node:crypto";
2
- import { WebSocketServer } from "ws";
3
- import { verifyVoicePrincipal, authorizeVoicePreset } from "./external.js";
4
- import { createSpeechEndpoint, frameRms } from "./endpoint.js";
5
- import { pcmSamples } from "./realtime-audio.js";
6
-
7
- export const COMPOSITE_CAPABILITIES = Object.freeze({ protocol: "olares-voice-ws-v1", transport: "websocket", input: "pcm16-mono-24000", output: "pcm16-mono-24000", turnDetection: ["manual", "server_vad"], contextResume: true, requiresPlaybackAck: true, clientTools: false, truncation: false, webrtc: false, sip: false, usage: "child-models" });
8
-
9
- /** Minimal declared Realtime subset. It never accepts arbitrary client tools or business bindings. */
10
- export function createCompositeGateway({ service, config, transcribe, synthesize, secret, grants = {}, hostOwner = "", enabled = false }) {
11
- const server = new WebSocketServer({ noServer: true, maxPayload: 96 * 1024, perMessageDeflate: false });
12
- const clients = new Map();
13
- const nonces = new Map();
14
- return {
15
- capabilities: () => ({ ...COMPOSITE_CAPABILITIES, enabled: enabled && typeof secret === "string" && secret.length >= 32 }),
16
- upgrade(req, socket, head) {
17
- let identity;
18
- let grant;
19
- try {
20
- if (!enabled) throw new Error("voice_external_disabled");
21
- identity = verifyVoicePrincipal(req.headers, secret);
22
- const model = new URL(req.url, "http://voice.invalid").searchParams.get("model");
23
- grant = authorizeVoicePreset(identity, grants, model);
24
- if (["assistant", "direct"].includes(grant.preset) && grant.owner !== hostOwner) throw new Error("voice_external_owner_mismatch");
25
- const dependencies = config();
26
- if ([dependencies.model, dependencies.chatModel, dependencies.ttsModel, dependencies.realtimeModel].some((id) => id === model || id?.endsWith(`/${model}`))) throw new Error("voice_recursive_model");
27
- for (const [nonce, expires] of nonces) if (expires < Date.now()) nonces.delete(nonce);
28
- if (nonces.has(identity.nonce) || nonces.size >= 4096 || clients.size >= 64) throw new Error("voice_external_limit");
29
- nonces.set(identity.nonce, Date.now() + 120000);
30
- } catch {
31
- socket.end("HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n"); return;
32
- }
33
- server.handleUpgrade(req, socket, head, (client) => {
34
- clients.set(client, () => close());
35
- const scope = { id: randomUUID(), owner: grant.owner, authorizedBinding: grant, device: "device" };
36
- let chunks = [], bytes = 0, automatic = false, closed = false, busy = false, generation = 0, seconds = 0;
37
- let endpoint = createSpeechEndpoint({ silenceMs: config().endpointSilenceMs || 500 });
38
- let lastReply = null;
39
- let awaitingPlayback = false;
40
- let audioOutput = true;
41
- let responseId = null;
42
- let speaking = false;
43
- let configuring = false;
44
- let submittedTurns = 0;
45
- let alive = true;
46
- const send = (event) => {
47
- if (client.readyState !== 1) return;
48
- if (client.bufferedAmount > 1024 * 1024) { close(); return; }
49
- client.send(JSON.stringify({ event_id: randomUUID(), ...event }));
50
- };
51
- const reset = () => { chunks = []; bytes = 0; speaking = false; endpoint = createSpeechEndpoint({ silenceMs: config().endpointSilenceMs || 500 }); };
52
- const done = (id, status = "completed") => send({ type: "response.done", response: { id, status, output: [] }, voice_usage: { input_audio_seconds: seconds, billing: "child-models" } });
53
- const emit = async (text, id, epoch) => {
54
- responseId = id;
55
- send({ type: "response.created", response: { id, status: "in_progress" } });
56
- send({ type: "response.output_audio_transcript.delta", response_id: id, delta: text });
57
- if (!audioOutput) { done(id); return; }
58
- scope.outputAbort = new AbortController();
59
- const output = async (audio) => {
60
- if (closed || epoch !== generation) return;
61
- awaitingPlayback = true;
62
- for (let offset = 0; offset < audio.length; offset += 24000) send({ type: "response.output_audio.delta", response_id: id, item_id: id, delta: audio.subarray(offset, offset + 24000).toString("base64") });
63
- };
64
- const audio = await synthesize(text, scope, output);
65
- if (closed || epoch !== generation) return;
66
- if (audio) await output(audio);
67
- send({ type: "response.output_audio.done", response_id: id });
68
- done(id);
69
- };
70
- const commit = async () => {
71
- if (!bytes || busy) return;
72
- const input = Buffer.concat(chunks); reset(); busy = true;
73
- const epoch = generation;
74
- const id = randomUUID(); submittedTurns++;
75
- try {
76
- let text = await transcribe(input, scope);
77
- if (grant.preset === "dictation") text = await service.clean(scope, text);
78
- if (closed || epoch !== generation || !text.trim()) return;
79
- send({ type: "conversation.item.input_audio_transcription.completed", item_id: id, content_index: 0, transcript: text });
80
- if (grant.preset === "dictation") return;
81
- const state = await service.prompt(scope, { text, requestId: id });
82
- if (closed || epoch !== generation) return;
83
- if (state.utterance?.text && !state.utterance.silent) { lastReply = state.utterance.key; await emit(state.utterance.text, id, epoch); }
84
- } catch (cause) { if (!closed && epoch === generation) send({ type: "error", error: { code: cause.code || "voice_failed", message: "Voice operation failed" } }); }
85
- finally { busy = false; }
86
- };
87
- const close = () => {
88
- if (closed) return;
89
- closed = true; scope.outputAbort?.abort(); generation++; reset(); clearInterval(heartbeat); clearInterval(results); clearTimeout(idle);
90
- clients.delete(client); client.close(); void service.close(scope).catch(() => {});
91
- };
92
- let idle;
93
- const touch = () => { clearTimeout(idle); idle = setTimeout(close, config().idleTimeoutMs || 300000); };
94
- touch();
95
- const heartbeat = setInterval(() => { if (!alive) close(); else { alive = false; client.ping(); } }, 15000);
96
- const results = setInterval(() => {
97
- if (!scope.active || busy || speaking || awaitingPlayback) return;
98
- try {
99
- const state = service.state(scope);
100
- if (!state.utterance?.text || state.utterance.key === lastReply || state.utterance.silent) return;
101
- lastReply = state.utterance.key; busy = true;
102
- void emit(state.utterance.text, randomUUID(), generation).catch(() => close()).finally(() => { busy = false; });
103
- } catch { close(); }
104
- }, 250);
105
- client.on("pong", () => { alive = true; });
106
- client.on("close", close); client.on("error", close);
107
- void service.open(scope, { preset: grant.preset, sessionId: grant.sessionId, readAloud: false }).then(() => {
108
- send({ type: "session.created", session: { id: scope.id, voice_context_id: scope.contextId, model: identity.model, type: "realtime", capabilities: COMPOSITE_CAPABILITIES } });
109
- client.on("message", async (raw) => {
110
- try {
111
- if (configuring) throw new Error("voice_not_ready");
112
- const event = JSON.parse(raw.toString());
113
- if (event.type === "session.update") {
114
- touch();
115
- const session = event.session || {};
116
- if (session.type && session.type !== "realtime") throw new Error("voice_session_forbidden");
117
- if (session.audio?.input && Object.keys(session.audio.input).some((key) => !["format", "turn_detection"].includes(key))) throw new Error("voice_audio_unsupported");
118
- if (session.audio?.output && Object.keys(session.audio.output).some((key) => key !== "format")) throw new Error("voice_audio_unsupported");
119
- if (Object.keys(session).some((key) => !["type", "model", "audio", "output_modalities", "voice_context_id"].includes(key)) || (session.model && session.model !== identity.model)) throw new Error("voice_session_forbidden");
120
- if (session.voice_context_id && session.voice_context_id !== scope.contextId) {
121
- if (submittedTurns || bytes || busy) throw new Error("voice_context_locked");
122
- configuring = true;
123
- try {
124
- await service.close(scope);
125
- await service.open(scope, { preset: grant.preset, sessionId: grant.sessionId, contextId: session.voice_context_id, readAloud: false });
126
- } finally { configuring = false; }
127
- }
128
- const detection = session.audio?.input?.turn_detection;
129
- if (detection && (detection.type !== "server_vad" || Object.keys(detection).some((key) => key !== "type"))) throw new Error("voice_turn_detection_unsupported");
130
- for (const format of [session.audio?.input?.format, session.audio?.output?.format].filter(Boolean)) if (format.type !== "audio/pcm" || format.rate !== 24000) throw new Error("voice_audio_format");
131
- if (session.output_modalities && (!Array.isArray(session.output_modalities) || session.output_modalities.length !== 1 || !["audio", "text"].includes(session.output_modalities[0]))) throw new Error("voice_output_unsupported");
132
- audioOutput = !session.output_modalities || session.output_modalities[0] === "audio";
133
- automatic = detection?.type === "server_vad";
134
- send({ type: "session.updated", session: { id: scope.id, voice_context_id: scope.contextId, model: identity.model, type: "realtime", capabilities: COMPOSITE_CAPABILITIES } });
135
- } else if (event.type === "input_audio_buffer.append") {
136
- if (awaitingPlayback) throw new Error("voice_waiting_for_playback");
137
- if (busy) throw new Error("voice_turn_busy");
138
- if (typeof event.audio !== "string" || event.audio.length > 64000 || !/^[A-Za-z0-9+/]*={0,2}$/.test(event.audio)) throw new Error("voice_audio_format");
139
- const chunk = Buffer.from(event.audio, "base64");
140
- if (chunk.length % 2 || bytes + chunk.length > 2880000) throw new Error("voice_audio_limit");
141
- chunks.push(chunk); bytes += chunk.length; seconds += chunk.length / 48000;
142
- const samples = pcmSamples(chunk);
143
- if (frameRms(samples) >= 0.004) touch();
144
- if (frameRms(samples) >= 0.004 && !speaking) { speaking = true; scope.voiceInputActive = true; send({ type: "input_audio_buffer.speech_started" }); }
145
- if (automatic && endpoint.push(1, frameRms(samples), chunk.length / 48)) {
146
- scope.voiceInputActive = false; send({ type: "input_audio_buffer.speech_stopped" }); void commit();
147
- }
148
- } else if (event.type === "input_audio_buffer.commit") { scope.voiceInputActive = false; void commit(); }
149
- else if (event.type === "input_audio_buffer.clear") { reset(); scope.voiceInputActive = false; send({ type: "input_audio_buffer.cleared" }); }
150
- else if (event.type === "voice.playback.completed") { awaitingPlayback = false; }
151
- else if (event.type === "response.cancel") { scope.outputAbort?.abort(); generation++; reset(); awaitingPlayback = false; done(responseId || randomUUID(), "cancelled"); }
152
- else throw new Error("voice_event_unsupported");
153
- } catch (cause) { send({ type: "error", error: { code: cause.message, message: "Unsupported or invalid voice event" } }); }
154
- });
155
- }).catch(close);
156
- });
157
- },
158
- close() { for (const close of clients.values()) close(); server.close(); },
159
- };
160
- }
package/voice/external.js DELETED
@@ -1,26 +0,0 @@
1
- import { createHmac, timingSafeEqual } from "node:crypto";
2
-
3
- /** A configured Router signs the resolved principal; caller-supplied identity headers are ignored. */
4
- export function verifyVoicePrincipal(headers, secret, { now = Date.now } = {}) {
5
- if (typeof secret !== "string" || secret.length < 32) throw new Error("voice_external_disabled");
6
- const claim = headers["x-olares-voice-principal"];
7
- const signature = headers["x-olares-voice-signature"];
8
- if (typeof claim !== "string" || claim.length > 4096 || typeof signature !== "string" || !/^[a-f0-9]{64}$/.test(signature)) throw new Error("voice_external_unauthorized");
9
- const expected = createHmac("sha256", secret).update(claim).digest();
10
- if (!timingSafeEqual(expected, Buffer.from(signature, "hex"))) throw new Error("voice_external_unauthorized");
11
- let principal;
12
- try { principal = JSON.parse(Buffer.from(claim, "base64url").toString("utf8")); } catch { throw new Error("voice_external_unauthorized"); }
13
- if (!Number.isInteger(principal.at) || Math.abs(now() / 1000 - principal.at) > 60 || typeof principal.nonce !== "string" || !/^[a-f0-9]{32}$/.test(principal.nonce) || typeof principal.model !== "string" || (!principal.user && !principal.app)) throw new Error("voice_external_unauthorized");
14
- return principal;
15
- }
16
-
17
- export function authorizeVoicePreset(principal, grants, model) {
18
- if (principal.model !== model) throw new Error("voice_external_model_mismatch");
19
- const preset = { "voice-dictation": "dictation", "voice-chat": "chat", "voice-assistant": "assistant", "voice-direct": "direct" }[model];
20
- if (!preset) throw new Error("voice_external_model_unknown");
21
- const owner = principal.user || `app:${principal.app}`;
22
- const grant = grants[owner];
23
- if (!grant?.presets?.includes(preset)) throw new Error("voice_external_forbidden");
24
- if (["assistant", "direct"].includes(preset) && (!principal.user || !grant.lares)) throw new Error("voice_external_forbidden");
25
- return { owner, preset, sessionId: grant.sessionId || null, workspaceId: grant.workspaceId || null, agentPreset: grant.agentPreset || null };
26
- }