conductor-remote 1.127.1 → 1.128.0

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/dist/index.html CHANGED
@@ -25,7 +25,7 @@
25
25
  <title>Conductor Remote</title>
26
26
  <!-- Runs before the module bundle so it can catch a stale shell that fails to boot. -->
27
27
  <script src="/self-heal.js"></script>
28
- <script type="module" crossorigin src="/assets/index-C0ZF9d17.js"></script>
28
+ <script type="module" crossorigin src="/assets/index-BVXbCLBD.js"></script>
29
29
  <link rel="stylesheet" crossorigin href="/assets/index-q-Z5Zz9y.css">
30
30
  <link rel="manifest" href="/manifest.webmanifest"></head>
31
31
  <body>
package/dist/sw.js CHANGED
@@ -1 +1 @@
1
- if(!self.define){let e,s={};const i=(i,n)=>(i=new URL(i+".js",n).href,s[i]||new Promise(s=>{if("document"in self){const e=document.createElement("script");e.src=i,e.onload=s,document.head.appendChild(e)}else e=i,importScripts(i),s()}).then(()=>{let e=s[i];if(!e)throw new Error(`Module ${i} didn’t register its module`);return e}));self.define=(n,r)=>{const l=e||("document"in self?document.currentScript.src:"")||location.href;if(s[l])return;let o={};const a=e=>i(e,l),t={module:{uri:l},exports:o,require:a};s[l]=Promise.all(n.map(e=>t[e]||a(e))).then(e=>(r(...e),o))}}define(["./workbox-dcde9eb3"],function(e){"use strict";importScripts("/push-sw.js"),self.addEventListener("message",e=>{e.data&&"SKIP_WAITING"===e.data.type&&self.skipWaiting()}),e.clientsClaim(),e.precacheAndRoute([{url:"self-heal.js",revision:"49bd63adb25a09341f8d2610e8bd3c76"},{url:"push-sw.js",revision:"4e0b714c0ea53e85c167e6d8f5b767aa"},{url:"index.html",revision:"6aa7324d208f306fe17f522d2bf7c728"},{url:"assets/workbox-window.prod.es5-BBnX5xw4.js",revision:null},{url:"assets/pierre-shiki-wasm-C8ETeZem.js",revision:null},{url:"assets/pierre-light-480U9XYS.js",revision:null},{url:"assets/pierre-dark-CyvmCCZW.js",revision:null},{url:"assets/index-q-Z5Zz9y.css",revision:null},{url:"assets/index-C0ZF9d17.js",revision:null},{url:"assets/PierrePatch-Cf1bAQPR.js",revision:null},{url:"apple-touch-icon.png",revision:"1127bb396b4648add53dce3f22c92aee"},{url:"icon-192.png",revision:"c5e01ac58768627e18ee7b8b6a9239ef"},{url:"icon-512.png",revision:"a40638c55e310312457a621c9a0002c8"},{url:"icon-maskable-512.png",revision:"a9b0d962686287452216492cd2247499"},{url:"icon.svg",revision:"c1aee186821798733dd477e69a0ef243"},{url:"manifest.webmanifest",revision:"cf88fbc5755108a7fe0616fa160a8a15"}],{}),e.cleanupOutdatedCaches(),e.registerRoute(new e.NavigationRoute(e.createHandlerBoundToURL("/index.html"),{denylist:[/^\/api\//]})),e.registerRoute(({url:e})=>e.pathname.startsWith("/assets/diff-syntax/"),new e.CacheFirst({cacheName:"diff-syntax",plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:31536e3})]}),"GET")});
1
+ if(!self.define){let e,s={};const i=(i,n)=>(i=new URL(i+".js",n).href,s[i]||new Promise(s=>{if("document"in self){const e=document.createElement("script");e.src=i,e.onload=s,document.head.appendChild(e)}else e=i,importScripts(i),s()}).then(()=>{let e=s[i];if(!e)throw new Error(`Module ${i} didn’t register its module`);return e}));self.define=(n,r)=>{const l=e||("document"in self?document.currentScript.src:"")||location.href;if(s[l])return;let o={};const t=e=>i(e,l),a={module:{uri:l},exports:o,require:t};s[l]=Promise.all(n.map(e=>a[e]||t(e))).then(e=>(r(...e),o))}}define(["./workbox-dcde9eb3"],function(e){"use strict";importScripts("/push-sw.js"),self.addEventListener("message",e=>{e.data&&"SKIP_WAITING"===e.data.type&&self.skipWaiting()}),e.clientsClaim(),e.precacheAndRoute([{url:"self-heal.js",revision:"49bd63adb25a09341f8d2610e8bd3c76"},{url:"push-sw.js",revision:"4e0b714c0ea53e85c167e6d8f5b767aa"},{url:"index.html",revision:"501e728a1efd2ebf7359c59176f6d3f6"},{url:"assets/workbox-window.prod.es5-BBnX5xw4.js",revision:null},{url:"assets/pierre-shiki-wasm-C8ETeZem.js",revision:null},{url:"assets/pierre-light-480U9XYS.js",revision:null},{url:"assets/pierre-dark-CyvmCCZW.js",revision:null},{url:"assets/index-q-Z5Zz9y.css",revision:null},{url:"assets/index-BVXbCLBD.js",revision:null},{url:"assets/PierrePatch-CmmIAkbQ.js",revision:null},{url:"apple-touch-icon.png",revision:"1127bb396b4648add53dce3f22c92aee"},{url:"icon-192.png",revision:"c5e01ac58768627e18ee7b8b6a9239ef"},{url:"icon-512.png",revision:"a40638c55e310312457a621c9a0002c8"},{url:"icon-maskable-512.png",revision:"a9b0d962686287452216492cd2247499"},{url:"icon.svg",revision:"c1aee186821798733dd477e69a0ef243"},{url:"manifest.webmanifest",revision:"cf88fbc5755108a7fe0616fa160a8a15"}],{}),e.cleanupOutdatedCaches(),e.registerRoute(new e.NavigationRoute(e.createHandlerBoundToURL("/index.html"),{denylist:[/^\/api\//]})),e.registerRoute(({url:e})=>e.pathname.startsWith("/assets/diff-syntax/"),new e.CacheFirst({cacheName:"diff-syntax",plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:31536e3})]}),"GET")});
@@ -2,6 +2,7 @@ import { isRoute, routeParam, routes } from "../../routes.js";
2
2
  import { isOpenAIRealtimeVoice, isVoiceLanguage, isVoiceSpeed, OPENAI_REALTIME_VOICES } from "../../shared.js";
3
3
  import { openAIOriginForSipHost } from "../../voice/config.js";
4
4
  import { parseVoiceCallTarget, readVoiceChatContext, VoiceContextError } from "../../voice/context.js";
5
+ import { parseVoiceDiagnostics } from "../../voice/diagnostic-fields.js";
5
6
  import { MAX_VOICE_SEARCH_CHARS } from "../../voice/history.js";
6
7
  import { mintSipTicket, missingTicketConfig } from "../../voice/ticket.js";
7
8
  import { createWebRtcCall, MAX_SDP_CHARS } from "../../voice/webrtc.js";
@@ -10,6 +11,26 @@ export function createVoiceRoutes(services) {
10
11
  const { json, voiceHistory, voiceConfig, voiceBroker, readBody, reads, voiceSafetyIdentifier } = services;
11
12
  return async (req, res, url) => {
12
13
  const { pathname } = url;
14
+ const diagnosticCall = routeParam(routes.voiceCallDiagnostics, req.method, pathname);
15
+ if (diagnosticCall) {
16
+ if (!voiceHistory.status(diagnosticCall))
17
+ return json(req, res, 404, { error: 'Saved voice call not found' });
18
+ const raw = await readBody(req);
19
+ if (raw.length > 32_000)
20
+ return json(req, res, 413, { error: 'Voice diagnostic batch is too large' });
21
+ let events = null;
22
+ try {
23
+ events = parseVoiceDiagnostics(JSON.parse(raw));
24
+ }
25
+ catch { }
26
+ if (!events)
27
+ return json(req, res, 400, { error: 'Invalid voice diagnostic batch' });
28
+ // Keep each entry below the log ring's per-line cap, even after an offline batch.
29
+ const receivedAt = Date.now();
30
+ for (const event of events)
31
+ console.info(`[voice-diagnostics] ${JSON.stringify({ callId: diagnosticCall, source: 'browser', receivedAt, ...event })}`);
32
+ return json(req, res, 200, { ok: true });
33
+ }
13
34
  const voiceTranscript = routeParam(routes.voiceTranscript, req.method, pathname);
14
35
  if (isRoute(routes.voiceHistory, req.method, pathname) ||
15
36
  isRoute(routes.voiceSearch, req.method, pathname) ||
@@ -60,6 +60,7 @@ export const routes = {
60
60
  voiceCall: flat('POST', '/api/voice/calls'),
61
61
  /** Start the greeting only after the browser has installed OpenAI's SDP answer. */
62
62
  voiceCallReady: param('POST', '/api/voice/calls/:callId/ready'),
63
+ voiceCallDiagnostics: param('POST', '/api/voice/calls/:callId/diagnostics'),
63
64
  /** End one PWA-owned call. SIP calls remain owned by their phone leg. */
64
65
  voiceCallEnd: param('DELETE', '/api/voice/calls/:callId'),
65
66
  /** Durable call history remains readable even when voice is no longer configured. */
@@ -13,6 +13,7 @@
13
13
  * Each of these was a second copy before, and each copy was a way for two screens to
14
14
  * disagree about the same workspace.
15
15
  */
16
+ export { voiceDiagnosticData, voiceRealtimeDiagnostic } from "./voice/diagnostic-fields.js";
16
17
  /** Stable wire values used by request validation and the phone's effort controls. */
17
18
  export const AGENT_EFFORTS = ['none', 'low', 'medium', 'high', 'xhigh', 'max', 'ultracode'];
18
19
  export function isAgentEffort(value) {
@@ -3,6 +3,7 @@ import crypto from 'node:crypto';
3
3
  import fs from 'node:fs';
4
4
  import path from 'node:path';
5
5
  import { DEFAULT_VOICE_SPEED, voiceReasoning } from "./config.js";
6
+ import { voiceRealtimeDiagnostic } from "./diagnostic-fields.js";
6
7
  import { VOICE_INSTRUCTIONS } from "./prompt.js";
7
8
  import { VOICE_TOOL_NAMES } from "./tools.js";
8
9
  import { voiceTranscription } from "./transcription.js";
@@ -371,6 +372,9 @@ export class VoiceBroker {
371
372
  return;
372
373
  }
373
374
  this.deps.history?.record(callId, event);
375
+ const diagnostic = voiceRealtimeDiagnostic(event);
376
+ if (diagnostic)
377
+ this.log(`[voice-diagnostics] ${JSON.stringify({ callId, source: 'server', receivedAt: this.now(), ...diagnostic })}`);
374
378
  const type = event.type;
375
379
  if (type === 'mcp_list_tools.completed') {
376
380
  if (runtime.mode !== 'mcp')
@@ -0,0 +1,113 @@
1
+ const EVENTS = new Set([
2
+ 'capture',
3
+ 'sample',
4
+ 'closed',
5
+ 'visibility',
6
+ 'devicechange',
7
+ 'connection',
8
+ 'microphone.mute',
9
+ 'microphone.unmute',
10
+ 'microphone.ended',
11
+ 'playback.playing',
12
+ 'playback.pause',
13
+ 'playback.waiting',
14
+ 'playback.error',
15
+ 'input_audio_buffer.speech_started',
16
+ 'input_audio_buffer.speech_stopped',
17
+ 'input_audio_buffer.committed',
18
+ 'output_audio_buffer.started',
19
+ 'output_audio_buffer.stopped',
20
+ 'output_audio_buffer.cleared',
21
+ 'conversation.item.truncated',
22
+ 'conversation.item.input_audio_transcription.completed',
23
+ 'conversation.item.input_audio_transcription.failed',
24
+ 'response.created',
25
+ 'response.done'
26
+ ]);
27
+ const BOOLEAN_FIELDS = new Set([
28
+ 'noiseSuppression',
29
+ 'autoGainControl',
30
+ 'supportsEchoCancellation',
31
+ 'supportsNoiseSuppression',
32
+ 'supportsAutoGainControl',
33
+ 'micEnabled',
34
+ 'micMuted',
35
+ 'playbackPaused',
36
+ 'playbackMuted',
37
+ 'hidden'
38
+ ]);
39
+ const NUMBER_FIELDS = new Set([
40
+ 'sampleRate',
41
+ 'sampleSize',
42
+ 'channelCount',
43
+ 'latency',
44
+ 'playbackTime',
45
+ 'playbackVolume',
46
+ 'inputLevel',
47
+ 'inputEnergy',
48
+ 'inputDuration',
49
+ 'outputLevel',
50
+ 'outputEnergy',
51
+ 'outputDuration',
52
+ 'echoReturnLoss',
53
+ 'echoReturnLossEnhancement',
54
+ 'audio_start_ms',
55
+ 'audio_end_ms',
56
+ 'dropped'
57
+ ]);
58
+ function object(value) {
59
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
60
+ }
61
+ export function voiceDiagnosticData(value) {
62
+ const result = {};
63
+ for (const [key, item] of Object.entries(object(value))) {
64
+ if (BOOLEAN_FIELDS.has(key) && (typeof item === 'boolean' || item === null))
65
+ result[key] = item;
66
+ else if (NUMBER_FIELDS.has(key) && (item === null || (typeof item === 'number' && Number.isFinite(item))))
67
+ result[key] = item;
68
+ else if (key === 'echoCancellation' &&
69
+ (item === null || typeof item === 'boolean' || item === 'all' || item === 'remote-only'))
70
+ result[key] = item;
71
+ else if ((key === 'item_id' || key === 'response_id') && typeof item === 'string' && /^[\w-]{1,100}$/.test(item))
72
+ result[key] = item;
73
+ else if (key === 'status' &&
74
+ typeof item === 'string' &&
75
+ ['completed', 'cancelled', 'failed', 'incomplete', 'in_progress'].includes(item))
76
+ result[key] = item;
77
+ else if (key === 'connection' &&
78
+ typeof item === 'string' &&
79
+ ['new', 'connecting', 'connected', 'disconnected', 'failed', 'closed'].includes(item))
80
+ result[key] = item;
81
+ }
82
+ return result;
83
+ }
84
+ export function voiceRealtimeDiagnostic(value) {
85
+ const event = object(value);
86
+ if (typeof event.type !== 'string' ||
87
+ !EVENTS.has(event.type) ||
88
+ (!event.type.includes('_') && !event.type.startsWith('response.')))
89
+ return null;
90
+ const response = object(event.response);
91
+ return {
92
+ type: event.type,
93
+ data: voiceDiagnosticData({ ...event, response_id: event.response_id ?? response.id, status: response.status })
94
+ };
95
+ }
96
+ /** Limits each HTTP batch and strips unknown fields even from authenticated clients. */
97
+ export function parseVoiceDiagnostics(value) {
98
+ const events = object(value).events;
99
+ if (!Array.isArray(events) || !events.length || events.length > 40)
100
+ return null;
101
+ const result = [];
102
+ for (const raw of events) {
103
+ const event = object(raw);
104
+ if (typeof event.type !== 'string' ||
105
+ !EVENTS.has(event.type) ||
106
+ typeof event.atMs !== 'number' ||
107
+ !Number.isFinite(event.atMs) ||
108
+ event.atMs < 0)
109
+ return null;
110
+ result.push({ type: event.type, atMs: Math.round(event.atMs), data: voiceDiagnosticData(event.data) });
111
+ }
112
+ return result;
113
+ }
@@ -347,6 +347,35 @@ Rotating a value through `config set` restarts the managed service. The relay to
347
347
 
348
348
  ## Troubleshooting
349
349
 
350
+ ### Browser echo and interruptions
351
+
352
+ WebRTC calls emit `[voice-diagnostics]` metadata into the relay log. After reproducing,
353
+ use `relay_logs` with `contains: "voice-diagnostics"` (or the call ID from
354
+ `list_voice_calls`). For a managed relay, `file: "relay.log"` also reaches entries
355
+ from before a restart. Inspect promptly: the live log is a bounded ring, not a
356
+ permanent diagnostic archive. Both the PWA and relay must run the updated build.
357
+
358
+ Browser `capture` and five-second `sample` events report actual microphone settings,
359
+ support flags, mute state, playback state and available WebRTC audio levels/energy.
360
+ A null setting means the browser did not report it; `echoCancellation: true` means
361
+ enabled, not proof that all echo was removed. Missing echo-loss statistics mean
362
+ unsupported/unreported, not zero echo. Browser events use monotonic `atMs` since
363
+ diagnostics started for that call; `receivedAt` is the relay's receipt time, which
364
+ may lag after buffering or a network delay. Speech-start, playback-buffer and
365
+ response events carry item/response IDs to correlate with the saved transcript.
366
+ Server events provide a second timeline even when browser uploads fail.
367
+
368
+ Diagnostics never mute the microphone, change speech thresholds or disable
369
+ interruptions. They store no raw audio, captions, device identifiers, SDP or tools.
370
+ Uploads are bounded and best-effort; `dropped` counts discarded metadata events.
371
+ Background suspension, disconnection or process exit can still leave gaps.
372
+
373
+ The user bubbles come from a separate transcription model. The Realtime model
374
+ consumes audio directly, so a wrong caption can accompany a correctly understood
375
+ command; see the [Realtime API reference](https://platform.openai.com/docs/api-reference/realtime).
376
+
377
+ ### Connection and configuration
378
+
350
379
  - **The call rings and then fails with no relay log:** confirm the public endpoint is on 443 and is exactly `/voice`. OpenAI does not deliver this webhook through Funnel 8443 or 10000.
351
380
  - **Twilio gets 403:** check the allowlist, the account's primary auth token, and that `voice.public-url + /twiml` is byte-for-byte the URL configured on the number.
352
381
  - **OpenAI gets 400:** check the webhook signing secret and the Mac's clock.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "conductor-remote",
3
- "version": "1.127.1",
3
+ "version": "1.128.0",
4
4
  "type": "module",
5
5
  "packageManager": "yarn@4.15.0",
6
6
  "description": "Phone control panel for local Conductor agents. Reads ride SQLite + git; prompts ride Conductor's own dispatch path.",