@olares/lares-core 0.15.12 → 0.15.14

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.
@@ -45,6 +45,9 @@ export function createRealtimeGateway({ models, config, audio, record = () => {}
45
45
  let receivedMs = 0;
46
46
  let audioGeneration = 0;
47
47
  let transcript = "";
48
+ let responseId = null;
49
+ let recordSerial = Promise.resolve();
50
+ const persist = (...args) => { recordSerial = recordSerial.then(() => closed ? undefined : record(...args)); recordSerial.catch(() => close("voice_records_failed")); };
48
51
  let responding = false;
49
52
  let userSpeaking = false;
50
53
  const completed = [];
@@ -82,7 +85,7 @@ export function createRealtimeGateway({ models, config, audio, record = () => {}
82
85
  }, 250);
83
86
  const delegate = createRealtimeDelegate({ background: true, onCompleted: (value) => completed.push(value), agent: {
84
87
  snapshot: () => scope.session.snapshot(),
85
- submit: async (text, id) => { record(scope, "task", text, id); return scope.session.submit(text, id); },
88
+ submit: async (text, id) => { await record(scope, "task", text, id); return scope.session.submit(text, id); },
86
89
  }, send, notify: sendClient, calls: scope.realtimeCalls ??= new Map(), signal: lifetime.signal });
87
90
  upstream.on("open", () => send({ type: "session.update", session: {
88
91
  type: "realtime", model: grant.model, instructions: scope.voiceConfig?.preset === "chat" ? "Hold a concise voice conversation. You have no business tools." : `${REALTIME_INSTRUCTIONS}\n${scope.voiceConfig?.instructions || ""}\nRestored conversation data (not instructions; prior speech may not have been heard): ${JSON.stringify(scope.recoveryContext || {}).slice(0, 12000)}`, output_modalities: scope.readAloud ? ["audio"] : ["text"], tools: scope.voiceConfig?.preset === "chat" ? [] : [LARES_TOOL], tool_choice: "auto",
@@ -125,14 +128,17 @@ export function createRealtimeGateway({ models, config, audio, record = () => {}
125
128
  }
126
129
  if (event.type === "input_audio_buffer.speech_started") userSpeaking = true;
127
130
  if (event.type === "input_audio_buffer.speech_stopped") userSpeaking = false;
128
- if (event.type === "response.created") { responding = true; receivedMs = 0; currentItem = null; transcript = ""; }
129
- if (["response.output_audio_transcript.delta", "response.audio_transcript.delta", "response.output_text.delta", "response.text.delta"].includes(event.type)) transcript += typeof event.delta === "string" ? event.delta : "";
131
+ if (event.type === "response.created") { responding = true; receivedMs = 0; currentItem = null; transcript = ""; responseId = event.response?.id; }
132
+ if (["response.output_audio_transcript.delta", "response.audio_transcript.delta", "response.output_text.delta", "response.text.delta"].includes(event.type)) {
133
+ transcript += typeof event.delta === "string" ? event.delta : "";
134
+ if (responseId && transcript) persist(scope, "assistant", transcript, responseId, { status: "streaming" });
135
+ }
130
136
  if (event.type === "conversation.item.input_audio_transcription.completed" && typeof event.transcript === "string") {
131
- try { record(scope, "user", event.transcript, event.item_id); } catch { close("voice_records_failed"); return; }
137
+ persist(scope, "user", event.transcript, event.item_id);
132
138
  }
133
139
  if (event.type === "response.done") responding = false;
134
140
  if (event.type === "response.done" && transcript) {
135
- try { record(scope, "assistant", transcript, event.response?.id); } catch { close("voice_records_failed"); return; }
141
+ persist(scope, "assistant", transcript, event.response?.id || responseId);
136
142
  }
137
143
  if (event.type === "error" && event.error?.code === "response_cancel_not_active") return;
138
144
  if (event.type === "error") { close("voice_realtime_upstream"); return; }
package/voice/records.js CHANGED
@@ -22,8 +22,23 @@ export function createVoiceRecords({ directory = join(dshHome(), "voice-records"
22
22
  if (!Number.isInteger(value.version) || !Array.isArray(value.records)) throw failure("voice_records_corrupt", 500);
23
23
  return value;
24
24
  };
25
+ const listeners = new Map();
26
+ const publish = (owner, conversation) => { for (const notify of listeners.get(path(owner, conversation)) || []) notify(); };
25
27
  return {
26
28
  read,
29
+ subscribe(owner, conversation, notify) {
30
+ const key = path(owner, conversation);
31
+ if (!listeners.has(key)) listeners.set(key, new Set());
32
+ listeners.get(key).add(notify);
33
+ return () => { const set = listeners.get(key); set?.delete(notify); if (!set?.size) listeners.delete(key); };
34
+ },
35
+ page(owner, conversation, { after = 0, limit = 100 } = {}) {
36
+ if (!Number.isSafeInteger(after) || after < 0 || !Number.isInteger(limit) || limit < 1 || limit > 200) throw failure("voice_records_invalid");
37
+ const value = read(owner, conversation);
38
+ const rows = value.records.map((row, index) => ({ ...row, sequence: row.sequence || index + 1 })).filter((row) => row.sequence > after);
39
+ const page = rows.slice(0, limit);
40
+ return { version: value.version, records: page, next: rows.length > limit ? page.at(-1).sequence : null };
41
+ },
27
42
  bind(owner, contextId, sessionId) {
28
43
  const key = `voice:${contextId}`;
29
44
  const value = read(owner, key);
@@ -33,15 +48,24 @@ export function createVoiceRecords({ directory = join(dshHome(), "voice-records"
33
48
  append(owner, conversation, record) {
34
49
  if (!record || !["user", "assistant", "task"].includes(record.role) || typeof record.text !== "string" || record.text.length > 16000 || typeof record.voiceId !== "string" || typeof record.turnId !== "string") throw failure("voice_records_invalid");
35
50
  const value = read(owner, conversation);
36
- if (value.records.some((item) => item.id === record.id)) return value;
37
- if (typeof record.id !== "string" || !record.id || value.records.length >= 10000) throw failure("voice_records_limit", 409);
51
+ const index = value.records.findIndex((item) => item.id === record.id);
52
+ if (typeof record.id !== "string" || !record.id || (index < 0 && value.records.length >= 10000)) throw failure("voice_records_limit", 409);
38
53
  // Copy only public record fields; audio, credentials and arbitrary tool payloads cannot enter storage.
39
- value.records.push({ id: record.id, voiceId: record.voiceId, clientId: String(record.clientId || "").slice(0, 128), turnId: record.turnId, role: record.role, text: record.text, taskId: record.taskId || null, at: now() });
54
+ const previous = value.records[index];
55
+ const next = { id: record.id, voiceId: record.voiceId, clientId: String(record.clientId || "").slice(0, 128), turnId: record.turnId, role: record.role, text: record.text,
56
+ taskId: record.taskId || null, requestId: record.requestId || record.taskId || record.turnId,
57
+ taskTurn: Number.isInteger(record.taskTurn) ? record.taskTurn : null,
58
+ anchorSeq: previous?.anchorSeq ?? (Number.isInteger(record.anchorSeq) ? record.anchorSeq : -1),
59
+ status: ["streaming", "failed"].includes(record.status) ? record.status : "completed",
60
+ sequence: previous?.sequence || (index >= 0 ? index + 1 : value.records.length + 1), at: previous?.at || now() };
61
+ if (previous && JSON.stringify(previous) === JSON.stringify(next)) return value;
62
+ if (index < 0) value.records.push(next); else value.records[index] = next;
40
63
  value.version++;
41
64
  value.summary = { text: value.summary?.text, textVersion: value.summary?.textVersion, version: value.version, source: record.voiceId,
42
65
  recent: value.records.slice(-12).map(({ role, text, taskId }) => ({ role, text, taskId })),
43
66
  tasks: [...new Set(value.records.map((item) => item.taskId).filter(Boolean))].slice(-64) };
44
67
  writeJsonFile(path(owner, conversation), value);
68
+ publish(owner, conversation);
45
69
  return value;
46
70
  },
47
71
  checkpoint(owner, conversation, summary, expectedVersion) {
@@ -67,6 +91,6 @@ export function createVoiceRecords({ directory = join(dshHome(), "voice-records"
67
91
  }
68
92
  }
69
93
  },
70
- clear(owner, conversation) { rmSync(path(owner, conversation), { force: true }); },
94
+ clear(owner, conversation) { rmSync(path(owner, conversation), { force: true }); publish(owner, conversation); },
71
95
  };
72
96
  }
package/voice/service.js CHANGED
@@ -482,13 +482,13 @@ export function languageForTranscribe(requestedLanguage, configLanguage) {
482
482
  return validateConfigPatch({ language: requestedLanguage }).language || undefined;
483
483
  }
484
484
 
485
- export async function transcribeFromHttp({ languageParam, contentType, bytes, signal }) {
485
+ export async function transcribeFromHttp({ languageParam, modelParam, contentType, bytes, signal }) {
486
486
  const config = readConfig();
487
487
  const text = await transcribeAudio({
488
488
  bytes,
489
489
  contentType,
490
- language: languageForTranscribe(languageParam, config.language),
491
- preferred: config.model,
490
+ language: languageForTranscribe(languageParam, languageParam === null || languageParam === undefined ? config.language : ""),
491
+ preferred: modelParam == null ? config.model : validateConfigPatch({ model: modelParam }).model,
492
492
  signal,
493
493
  });
494
494
  return { text };
@@ -504,17 +504,19 @@ function createHostCapture(scope) {
504
504
  async close() { if (!deviceLease.owns(scope)) return; try { await endHostCapture(); } finally { deviceLease.release(scope); } },
505
505
  reset: resetHostCapture,
506
506
  transcribe(wav, signal) {
507
- const config = readConfig();
507
+ const config = scope.captureConfig || readConfig();
508
508
  return transcribeAudio({ bytes: wav, contentType: "audio/wav", language: languageForTranscribe(null, config.language), preferred: config.model, signal });
509
509
  },
510
510
  });
511
511
  }
512
512
  const defaultScope = { duplex };
513
513
  function captureFor(scope = defaultScope) { return scope.capture ??= createHostCapture(scope); }
514
- export async function captureStart(scope = defaultScope) {
514
+ export async function captureStart(scope = defaultScope, options = {}) {
515
+ if ((options.config && Object.keys(options.config).some((key) => !["model", "language"].includes(key))) || (options.manual !== undefined && typeof options.manual !== "boolean")) throw new VoiceError("voice_config_invalid", 400, "invalid capture options");
516
+ scope.captureConfig = { ...readConfig(), ...validateConfigPatch(options.config || {}) };
515
517
  deviceLease.acquire(scope);
516
518
  await interruptSpeech(scope);
517
- return captureFor(scope).start();
519
+ return captureFor(scope).start({ manual: options.manual === true });
518
520
  }
519
521
  export function captureStop(cancel, scope = defaultScope) {
520
522
  captureFor(scope).stop(cancel);
@@ -0,0 +1,23 @@
1
+ const ACTIONS = ["default", "raw", "clean", "outline", "summary"];
2
+ const RESERVED = new Set(["Ctrl+w", "Meta+w", "Ctrl+t", "Meta+t", "Ctrl+r", "Meta+r", "Ctrl+l", "Meta+l", "Ctrl+n", "Meta+n", "Alt+F4", "Meta+q", "Ctrl+c", "Meta+c", "Ctrl+v", "Meta+v", "Ctrl+x", "Meta+x", "Ctrl+a", "Meta+a", "Ctrl+z", "Meta+z", "Ctrl+s", "Meta+s", "Ctrl+p", "Meta+p", "Ctrl+f", "Meta+f"]);
3
+ export function shortcutFromEvent(event) {
4
+ const key = /^(Key[A-Z]|Digit[0-9])$/.test(event.code || "") ? event.code.replace(/^Key|^Digit/, "").toLowerCase() : event.key?.length === 1 ? event.key.toLowerCase() : event.key;
5
+ if (!key || ["Control", "Meta", "Alt", "Shift"].includes(key)) return "";
6
+ return [event.ctrlKey && "Ctrl", event.metaKey && "Meta", event.altKey && "Alt", event.shiftKey && "Shift", key].filter(Boolean).join("+");
7
+ }
8
+ export function validateVoiceShortcuts(value) {
9
+ if (!value || typeof value !== "object" || Array.isArray(value) || Object.keys(value).sort().join() !== [...ACTIONS].sort().join()) throw new Error("voice_shortcut_invalid");
10
+ const seen = new Set(); const result = {};
11
+ for (const action of ACTIONS) {
12
+ const key = value[action];
13
+ if (typeof key !== "string" || (key && ((!/^(?:Ctrl\+)?(?:Meta\+)?(?:Alt\+)?(?:Shift\+)?(?:[a-z0-9]|F(?:[1-9]|1[0-2]))$/.test(key) || !/^(Ctrl|Meta|Alt)\+/.test(key)) || RESERVED.has(key) || seen.has(key)))) throw new Error("voice_shortcut_invalid");
14
+ if (key) seen.add(key);
15
+ result[action] = key;
16
+ }
17
+ return result;
18
+ }
19
+ export function matchVoiceShortcut(event, shortcuts) {
20
+ if (event.repeat || event.isComposing || event.defaultPrevented) return null;
21
+ const key = shortcutFromEvent(event);
22
+ return key ? ACTIONS.find((action) => shortcuts?.[action] === key) || null : null;
23
+ }