@makerbi/remodex 2.0.1 → 2.3.1
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/package.json +2 -2
- package/src/account-status.js +5 -4
- package/src/bridge.js +1813 -670
- package/src/codex-desktop-refresher.js +35 -7
- package/src/cursor-acp-client.js +242 -0
- package/src/cursor-models.js +134 -0
- package/src/cursor-provider.js +1197 -0
- package/src/desktop-ipc-action-follower.js +2331 -964
- package/src/desktop-ipc-conversation-adapter.js +1132 -0
- package/src/desktop-ipc-conversation-projector.js +1169 -0
- package/src/desktop-ipc-live-owner.js +1790 -0
- package/src/desktop-ipc-owner-transport.js +750 -0
- package/src/desktop-ipc-shared.js +473 -0
- package/src/desktop-ipc-state-patches.js +218 -0
- package/src/opencode-models.js +108 -0
- package/src/opencode-provider.js +1151 -0
- package/src/project-handler.js +50 -7
- package/src/project-registry.js +466 -0
- package/src/push-notification-tracker.js +4 -4
- package/src/rollout-live-mirror.js +930 -218
- package/src/rollout-turn-semantics.js +20 -0
- package/src/runtime-provider-models.js +164 -0
- package/src/runtime-provider-router.js +365 -0
- package/src/scripts/codex-refresh.applescript +26 -15
- package/src/secure-transport.js +204 -9
- package/src/session-jsonl-history.js +429 -39
- package/src/thread-context-handler.js +8 -6
- package/src/thread-runtime-settings-store.js +247 -0
- package/src/voice-audio.js +344 -0
- package/src/voice-handler.js +363 -173
package/src/voice-handler.js
CHANGED
|
@@ -1,15 +1,36 @@
|
|
|
1
1
|
// FILE: voice-handler.js
|
|
2
|
-
// Purpose: Handles bridge-owned voice transcription requests without exposing auth tokens to iPhone.
|
|
2
|
+
// Purpose: Handles bridge-owned voice transcription and prewarm requests without exposing auth tokens to iPhone.
|
|
3
3
|
// Layer: Bridge handler
|
|
4
4
|
// Exports: createVoiceHandler, resolveVoiceAuth
|
|
5
|
-
// Depends on: global fetch/FormData/Blob, local codex app-server auth via sendCodexRequest
|
|
5
|
+
// Depends on: global fetch/FormData/Blob, local codex app-server auth via sendCodexRequest, ./voice-audio
|
|
6
|
+
|
|
7
|
+
const {
|
|
8
|
+
hasConsistentVoiceWavLayout,
|
|
9
|
+
isSupportedVoiceWavFormat,
|
|
10
|
+
readM4AInfo,
|
|
11
|
+
readWavInfo,
|
|
12
|
+
wavDurationMs,
|
|
13
|
+
} = require("./voice-audio");
|
|
6
14
|
|
|
7
|
-
const OPENAI_TRANSCRIPTIONS_URL = "https://api.openai.com/v1/audio/transcriptions";
|
|
8
15
|
const CHATGPT_TRANSCRIPTIONS_URL = "https://chatgpt.com/backend-api/transcribe";
|
|
9
|
-
const DEFAULT_TRANSCRIPTION_MODEL = "gpt-4o-mini-transcribe";
|
|
10
16
|
const MAX_AUDIO_BYTES = 10 * 1024 * 1024;
|
|
11
17
|
const MAX_DURATION_SECONDS = 150;
|
|
12
18
|
const MAX_DURATION_MS = MAX_DURATION_SECONDS * 1_000;
|
|
19
|
+
const DEFAULT_TRANSCRIPTION_TIMEOUT_MS = 175_000;
|
|
20
|
+
const MAX_DURATION_DRIFT_MS = 2_000;
|
|
21
|
+
const AUTH_CACHE_TTL_MS = 60_000;
|
|
22
|
+
const PRECONNECT_MIN_INTERVAL_MS = 2_000;
|
|
23
|
+
const PRECONNECT_TIMEOUT_MS = 10_000;
|
|
24
|
+
// Live endpoint probes confirmed subscription uploads accept AAC m4a alongside WAV.
|
|
25
|
+
const VOICE_AUDIO_FORMATS = ["wav", "m4a"];
|
|
26
|
+
const VOICE_WAV_MIME_TYPE = "audio/wav";
|
|
27
|
+
const VOICE_M4A_MIME_TYPE = "audio/mp4";
|
|
28
|
+
// Cloudflare rejects Node's default fetch identity on chatgpt.com with an HTML 403,
|
|
29
|
+
// which used to fail every bridge upload and force the slow phone-direct fallback.
|
|
30
|
+
// If the accepted identity changes again (symptom: transcription turns slow because
|
|
31
|
+
// every upload falls back to the phone), override it without a release via env.
|
|
32
|
+
const VOICE_UPLOAD_USER_AGENT = process.env.REMODEX_VOICE_UPLOAD_USER_AGENT
|
|
33
|
+
|| "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Safari/605.1.15";
|
|
13
34
|
|
|
14
35
|
function createVoiceHandler({
|
|
15
36
|
sendCodexRequest,
|
|
@@ -17,17 +38,93 @@ function createVoiceHandler({
|
|
|
17
38
|
FormDataImpl = globalThis.FormData,
|
|
18
39
|
BlobImpl = globalThis.Blob,
|
|
19
40
|
logPrefix = "[remodex]",
|
|
20
|
-
|
|
41
|
+
logger = console,
|
|
42
|
+
transcriptionTimeoutMs = DEFAULT_TRANSCRIPTION_TIMEOUT_MS,
|
|
21
43
|
} = {}) {
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
44
|
+
// Keeps a short-lived auth context plus TLS preconnect so the post-recording
|
|
45
|
+
// transcription request skips the token refresh and handshake round trips.
|
|
46
|
+
const warmState = {
|
|
47
|
+
authPromise: null,
|
|
48
|
+
authLoadedAt: 0,
|
|
49
|
+
lastPreconnectAt: 0,
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
function cacheAuthPromise(promise) {
|
|
53
|
+
warmState.authPromise = promise;
|
|
54
|
+
warmState.authLoadedAt = Date.now();
|
|
55
|
+
promise.catch(() => {
|
|
56
|
+
if (warmState.authPromise === promise) {
|
|
57
|
+
warmState.authPromise = null;
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
return promise;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function loadAuth() {
|
|
64
|
+
if (warmState.authPromise && Date.now() - warmState.authLoadedAt < AUTH_CACHE_TTL_MS) {
|
|
65
|
+
return warmState.authPromise;
|
|
66
|
+
}
|
|
67
|
+
return cacheAuthPromise(loadAuthContext(sendCodexRequest, { refreshToken: false }));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function refreshAuth() {
|
|
71
|
+
return cacheAuthPromise(loadAuthContext(sendCodexRequest, { refreshToken: true }));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Opens (or revives) the HTTPS connection to the provider so the upload fetch reuses it.
|
|
75
|
+
function preconnectTranscriptionOrigin() {
|
|
76
|
+
if (typeof fetchImpl !== "function") {
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
const now = Date.now();
|
|
80
|
+
if (now - warmState.lastPreconnectAt < PRECONNECT_MIN_INTERVAL_MS) {
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
warmState.lastPreconnectAt = now;
|
|
84
|
+
|
|
85
|
+
const controller = typeof AbortController === "function" ? new AbortController() : null;
|
|
86
|
+
const timeoutID = controller
|
|
87
|
+
? setTimeout(() => controller.abort(new Error("voice preconnect timed out")), PRECONNECT_TIMEOUT_MS)
|
|
88
|
+
: null;
|
|
89
|
+
timeoutID?.unref?.();
|
|
90
|
+
|
|
91
|
+
Promise.resolve()
|
|
92
|
+
.then(() => fetchImpl(new URL("/", CHATGPT_TRANSCRIPTIONS_URL).toString(), {
|
|
93
|
+
method: "HEAD",
|
|
94
|
+
headers: { "User-Agent": VOICE_UPLOAD_USER_AGENT },
|
|
95
|
+
signal: controller?.signal,
|
|
96
|
+
}))
|
|
97
|
+
.then((response) => {
|
|
98
|
+
response?.body?.cancel?.()?.catch?.(() => {});
|
|
99
|
+
})
|
|
100
|
+
.catch(() => {})
|
|
101
|
+
.finally(() => {
|
|
102
|
+
if (timeoutID) {
|
|
103
|
+
clearTimeout(timeoutID);
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function handlePrewarmRequest(id, sendResponse) {
|
|
109
|
+
preconnectTranscriptionOrigin();
|
|
110
|
+
loadAuth().catch(() => {});
|
|
111
|
+
logVoiceEvent(logger, "log", logPrefix, "prewarm requested");
|
|
112
|
+
if (id != null) {
|
|
113
|
+
sendResponse(JSON.stringify({ id, result: { ok: true, formats: VOICE_AUDIO_FORMATS } }));
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function handleVoiceRequest(rawMessage, sendResponse, parsedMessage = null) {
|
|
118
|
+
const parsed = parsedMessage || parseJsonMessage(rawMessage);
|
|
119
|
+
if (!parsed) {
|
|
27
120
|
return false;
|
|
28
121
|
}
|
|
29
122
|
|
|
30
123
|
const method = typeof parsed?.method === "string" ? parsed.method.trim() : "";
|
|
124
|
+
if (method === "voice/prewarm") {
|
|
125
|
+
handlePrewarmRequest(parsed.id, sendResponse);
|
|
126
|
+
return true;
|
|
127
|
+
}
|
|
31
128
|
if (method !== "voice/transcribe") {
|
|
32
129
|
return false;
|
|
33
130
|
}
|
|
@@ -40,21 +137,25 @@ function createVoiceHandler({
|
|
|
40
137
|
fetchImpl,
|
|
41
138
|
FormDataImpl,
|
|
42
139
|
BlobImpl,
|
|
43
|
-
|
|
140
|
+
logger,
|
|
141
|
+
logPrefix,
|
|
142
|
+
transcriptionTimeoutMs,
|
|
143
|
+
loadAuth,
|
|
144
|
+
refreshAuth,
|
|
44
145
|
})
|
|
45
146
|
.then((result) => {
|
|
46
147
|
sendResponse(JSON.stringify({ id, result }));
|
|
47
148
|
})
|
|
48
149
|
.catch((error) => {
|
|
49
|
-
|
|
150
|
+
logVoiceEvent(logger, "error", logPrefix, "failed", {
|
|
151
|
+
errorCode: error.errorCode || "voice_transcription_failed",
|
|
152
|
+
});
|
|
50
153
|
sendResponse(JSON.stringify({
|
|
51
154
|
id,
|
|
52
155
|
error: {
|
|
53
156
|
code: -32000,
|
|
54
157
|
message: error.userMessage || error.message || "Voice transcription failed.",
|
|
55
|
-
data:
|
|
56
|
-
errorCode: error.errorCode || "voice_transcription_failed",
|
|
57
|
-
},
|
|
158
|
+
data: voiceErrorData(error),
|
|
58
159
|
},
|
|
59
160
|
}));
|
|
60
161
|
});
|
|
@@ -67,12 +168,30 @@ function createVoiceHandler({
|
|
|
67
168
|
};
|
|
68
169
|
}
|
|
69
170
|
|
|
171
|
+
function parseJsonMessage(rawMessage) {
|
|
172
|
+
try {
|
|
173
|
+
return JSON.parse(rawMessage);
|
|
174
|
+
} catch {
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
70
179
|
// ─── Audio validation helpers ───────────────────────────────
|
|
71
180
|
|
|
72
181
|
// Validates iPhone-owned audio input and proxies it to the official transcription endpoint.
|
|
73
182
|
async function transcribeVoice(
|
|
74
183
|
params,
|
|
75
|
-
{
|
|
184
|
+
{
|
|
185
|
+
sendCodexRequest,
|
|
186
|
+
fetchImpl,
|
|
187
|
+
FormDataImpl,
|
|
188
|
+
BlobImpl,
|
|
189
|
+
logger = console,
|
|
190
|
+
logPrefix = "[remodex]",
|
|
191
|
+
transcriptionTimeoutMs = DEFAULT_TRANSCRIPTION_TIMEOUT_MS,
|
|
192
|
+
loadAuth = () => loadAuthContext(sendCodexRequest, { refreshToken: false }),
|
|
193
|
+
refreshAuth = () => loadAuthContext(sendCodexRequest, { refreshToken: true }),
|
|
194
|
+
}
|
|
76
195
|
) {
|
|
77
196
|
if (typeof sendCodexRequest !== "function") {
|
|
78
197
|
throw voiceError("bridge_not_ready", "Voice transcription is not available right now.");
|
|
@@ -82,8 +201,8 @@ async function transcribeVoice(
|
|
|
82
201
|
}
|
|
83
202
|
|
|
84
203
|
const mimeType = readString(params.mimeType);
|
|
85
|
-
if (mimeType
|
|
86
|
-
throw voiceError("unsupported_mime_type", "Only WAV audio is supported for voice transcription.");
|
|
204
|
+
if (!isSupportedVoiceMimeType(mimeType)) {
|
|
205
|
+
throw voiceError("unsupported_mime_type", "Only WAV or M4A audio is supported for voice transcription.");
|
|
87
206
|
}
|
|
88
207
|
|
|
89
208
|
const sampleRateHz = readPositiveNumber(params.sampleRateHz);
|
|
@@ -103,94 +222,156 @@ async function transcribeVoice(
|
|
|
103
222
|
if (audioBuffer.length > MAX_AUDIO_BYTES) {
|
|
104
223
|
throw voiceError("audio_too_large", "Voice messages are limited to 10 MB.");
|
|
105
224
|
}
|
|
106
|
-
const
|
|
107
|
-
|
|
108
|
-
|
|
225
|
+
const audioInfo = readVoiceAudioInfo(audioBuffer, mimeType);
|
|
226
|
+
const actualDurationMs = audioInfo.durationMs;
|
|
227
|
+
if (!Number.isFinite(actualDurationMs) || actualDurationMs <= 0) {
|
|
228
|
+
throw voiceError("invalid_audio", "The recorded audio is not a valid audio file.");
|
|
109
229
|
}
|
|
110
|
-
if (
|
|
111
|
-
|
|
112
|
-
|| wavInfo.sampleRateHz !== 24_000
|
|
113
|
-
|| wavInfo.bitsPerSample !== 16) {
|
|
114
|
-
throw voiceError("unsupported_sample_rate", "Voice transcription requires 24 kHz mono WAV audio.");
|
|
230
|
+
if (actualDurationMs > MAX_DURATION_MS) {
|
|
231
|
+
throw voiceError("duration_too_long", `Voice messages are limited to ${MAX_DURATION_SECONDS} seconds.`);
|
|
115
232
|
}
|
|
233
|
+
if (actualDurationMs > durationMs + MAX_DURATION_DRIFT_MS) {
|
|
234
|
+
throw voiceError("duration_mismatch", "The recorded audio duration did not match the voice request.");
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
logVoiceEvent(logger, "log", logPrefix, "request received", {
|
|
238
|
+
durationMs,
|
|
239
|
+
actualDurationMs: Math.round(actualDurationMs),
|
|
240
|
+
audioBytes: audioBuffer.length,
|
|
241
|
+
});
|
|
116
242
|
|
|
117
|
-
const authContext = await
|
|
243
|
+
const authContext = await loadAuth();
|
|
118
244
|
return requestTranscription({
|
|
119
245
|
authContext,
|
|
120
246
|
audioBuffer,
|
|
121
247
|
mimeType,
|
|
248
|
+
filename: audioInfo.filename,
|
|
122
249
|
fetchImpl,
|
|
123
250
|
FormDataImpl,
|
|
124
251
|
BlobImpl,
|
|
125
|
-
|
|
126
|
-
|
|
252
|
+
refreshAuth,
|
|
253
|
+
logger,
|
|
254
|
+
logPrefix,
|
|
255
|
+
transcriptionTimeoutMs,
|
|
127
256
|
});
|
|
128
257
|
}
|
|
129
258
|
|
|
259
|
+
// Posts the validated clip to the active transcription provider and logs only safe metadata.
|
|
130
260
|
async function requestTranscription({
|
|
131
261
|
authContext,
|
|
132
262
|
audioBuffer,
|
|
133
263
|
mimeType,
|
|
264
|
+
filename = "voice.wav",
|
|
134
265
|
fetchImpl,
|
|
135
266
|
FormDataImpl,
|
|
136
267
|
BlobImpl,
|
|
137
|
-
|
|
138
|
-
|
|
268
|
+
refreshAuth,
|
|
269
|
+
logger = console,
|
|
270
|
+
logPrefix = "[remodex]",
|
|
271
|
+
transcriptionTimeoutMs = DEFAULT_TRANSCRIPTION_TIMEOUT_MS,
|
|
139
272
|
}) {
|
|
140
|
-
const makeAttempt = async (activeAuthContext) => {
|
|
273
|
+
const makeAttempt = async (activeAuthContext, attempt) => {
|
|
274
|
+
logVoiceEvent(logger, "log", logPrefix, "auth selected", {
|
|
275
|
+
attempt,
|
|
276
|
+
source: activeAuthContext.authSource,
|
|
277
|
+
method: activeAuthContext.authMethodClass,
|
|
278
|
+
provider: activeAuthContext.provider,
|
|
279
|
+
});
|
|
280
|
+
|
|
141
281
|
const formData = new FormDataImpl();
|
|
142
|
-
formData.append("file", new BlobImpl([audioBuffer], { type: mimeType }),
|
|
143
|
-
if (!activeAuthContext.isChatGPT) {
|
|
144
|
-
formData.append("model", DEFAULT_TRANSCRIPTION_MODEL);
|
|
145
|
-
}
|
|
282
|
+
formData.append("file", new BlobImpl([audioBuffer], { type: mimeType }), filename);
|
|
146
283
|
|
|
147
284
|
const headers = {
|
|
148
285
|
Authorization: `Bearer ${activeAuthContext.token}`,
|
|
286
|
+
"User-Agent": VOICE_UPLOAD_USER_AGENT,
|
|
149
287
|
};
|
|
150
288
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
289
|
+
const timeoutMs = Number.isFinite(transcriptionTimeoutMs)
|
|
290
|
+
? Math.max(0, Math.floor(transcriptionTimeoutMs))
|
|
291
|
+
: DEFAULT_TRANSCRIPTION_TIMEOUT_MS;
|
|
292
|
+
const controller = typeof AbortController === "function" && timeoutMs > 0
|
|
293
|
+
? new AbortController()
|
|
294
|
+
: null;
|
|
295
|
+
const timeoutID = controller
|
|
296
|
+
? setTimeout(() => {
|
|
297
|
+
controller.abort(createTranscriptionTimeoutError(timeoutMs));
|
|
298
|
+
}, timeoutMs)
|
|
299
|
+
: null;
|
|
300
|
+
timeoutID?.unref?.();
|
|
301
|
+
|
|
302
|
+
let response;
|
|
303
|
+
try {
|
|
304
|
+
response = await fetchImpl(activeAuthContext.transcriptionURL, {
|
|
305
|
+
method: "POST",
|
|
306
|
+
headers,
|
|
307
|
+
body: formData,
|
|
308
|
+
signal: controller?.signal,
|
|
309
|
+
});
|
|
310
|
+
} catch (error) {
|
|
311
|
+
if (isAbortError(error, controller)) {
|
|
312
|
+
logVoiceEvent(logger, "warn", logPrefix, "provider status", {
|
|
313
|
+
attempt,
|
|
314
|
+
provider: activeAuthContext.provider,
|
|
315
|
+
status: "timeout",
|
|
316
|
+
ok: false,
|
|
317
|
+
});
|
|
318
|
+
throw voiceError(
|
|
319
|
+
"transcription_timeout",
|
|
320
|
+
"Voice transcription timed out. Try a shorter clip or retry when the connection is stable.",
|
|
321
|
+
{ provider: activeAuthContext.provider }
|
|
322
|
+
);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
logVoiceEvent(logger, "warn", logPrefix, "provider status", {
|
|
326
|
+
attempt,
|
|
327
|
+
provider: activeAuthContext.provider,
|
|
328
|
+
status: "network_error",
|
|
329
|
+
ok: false,
|
|
330
|
+
});
|
|
331
|
+
throw voiceError(
|
|
332
|
+
"transcription_network_error",
|
|
333
|
+
"Voice transcription could not reach the provider.",
|
|
334
|
+
{ provider: activeAuthContext.provider }
|
|
335
|
+
);
|
|
336
|
+
} finally {
|
|
337
|
+
if (timeoutID) {
|
|
338
|
+
clearTimeout(timeoutID);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
logVoiceEvent(logger, response.ok ? "log" : "warn", logPrefix, "provider status", {
|
|
343
|
+
attempt,
|
|
344
|
+
provider: activeAuthContext.provider,
|
|
345
|
+
status: response.status,
|
|
346
|
+
ok: Boolean(response.ok),
|
|
155
347
|
});
|
|
348
|
+
return response;
|
|
156
349
|
};
|
|
157
350
|
|
|
158
351
|
let activeAuthContext = authContext;
|
|
159
|
-
let
|
|
352
|
+
let attempt = 1;
|
|
353
|
+
let response = await makeAttempt(activeAuthContext, attempt);
|
|
160
354
|
if (response.status === 401 || response.status === 403) {
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
&& activeAuthContext.isChatGPT) {
|
|
166
|
-
const apiKeyContext = loadEnvApiKeyAuthContext(env);
|
|
167
|
-
if (apiKeyContext) {
|
|
168
|
-
activeAuthContext = apiKeyContext;
|
|
169
|
-
response = await makeAttempt(activeAuthContext);
|
|
170
|
-
}
|
|
171
|
-
}
|
|
355
|
+
// First attempt runs on a cached/non-refreshed token, so force a refresh here.
|
|
356
|
+
activeAuthContext = await refreshAuth();
|
|
357
|
+
attempt += 1;
|
|
358
|
+
response = await makeAttempt(activeAuthContext, attempt);
|
|
172
359
|
}
|
|
173
360
|
|
|
174
361
|
if (!response.ok) {
|
|
175
|
-
let errorMessage = `Transcription failed with status ${response.status}.`;
|
|
176
|
-
try {
|
|
177
|
-
const errorPayload = await response.json();
|
|
178
|
-
const providerMessage = readString(errorPayload?.error?.message) || readString(errorPayload?.message);
|
|
179
|
-
if (providerMessage) {
|
|
180
|
-
errorMessage = providerMessage;
|
|
181
|
-
}
|
|
182
|
-
} catch {
|
|
183
|
-
// Keep the generic message when the provider body is empty or non-JSON.
|
|
184
|
-
}
|
|
185
|
-
|
|
186
362
|
if (response.status === 401 || response.status === 403) {
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
363
|
+
throw voiceError(
|
|
364
|
+
"auth_rejected",
|
|
365
|
+
"Your ChatGPT login has expired. Sign in again.",
|
|
366
|
+
{ provider: activeAuthContext.provider }
|
|
367
|
+
);
|
|
191
368
|
}
|
|
192
369
|
|
|
193
|
-
throw voiceError(
|
|
370
|
+
throw voiceError(
|
|
371
|
+
"transcription_failed",
|
|
372
|
+
`Voice transcription failed with provider status ${response.status}.`,
|
|
373
|
+
{ provider: activeAuthContext.provider, status: response.status }
|
|
374
|
+
);
|
|
194
375
|
}
|
|
195
376
|
|
|
196
377
|
const payload = await response.json().catch(() => null);
|
|
@@ -199,56 +380,50 @@ async function requestTranscription({
|
|
|
199
380
|
throw voiceError("transcription_invalid_response", "The transcription response did not include any text.");
|
|
200
381
|
}
|
|
201
382
|
|
|
383
|
+
logVoiceEvent(logger, "log", logPrefix, "success", {
|
|
384
|
+
provider: activeAuthContext.provider,
|
|
385
|
+
status: response.status,
|
|
386
|
+
textLength: text.length,
|
|
387
|
+
});
|
|
388
|
+
|
|
202
389
|
return { text };
|
|
203
390
|
}
|
|
204
391
|
|
|
205
|
-
// Reads the current bridge-owned auth state
|
|
206
|
-
async function loadAuthContext(sendCodexRequest, {
|
|
207
|
-
const authStatus = await readVoiceAuthStatus(sendCodexRequest);
|
|
392
|
+
// Reads the current bridge-owned ChatGPT auth state; refresh is reserved for 401/403 retries.
|
|
393
|
+
async function loadAuthContext(sendCodexRequest, { refreshToken = false } = {}) {
|
|
394
|
+
const authStatus = await readVoiceAuthStatus(sendCodexRequest, { refreshToken });
|
|
208
395
|
|
|
209
396
|
const authMethod = readString(authStatus?.authMethod);
|
|
210
397
|
const token = normalizeBearerToken(authStatus?.authToken);
|
|
211
398
|
const isChatGPT = isChatGPTAuthMethod(authMethod);
|
|
212
399
|
|
|
213
400
|
if (!token) {
|
|
214
|
-
|
|
215
|
-
if (apiKeyContext) {
|
|
216
|
-
return apiKeyContext;
|
|
217
|
-
}
|
|
218
|
-
throw voiceError("not_authenticated", "Sign in with ChatGPT or configure an OpenAI API key before using voice transcription.");
|
|
401
|
+
throw voiceError("not_authenticated", "Sign in with ChatGPT before using voice transcription.");
|
|
219
402
|
}
|
|
220
403
|
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
token,
|
|
224
|
-
isChatGPT,
|
|
225
|
-
transcriptionURL: isChatGPT ? CHATGPT_TRANSCRIPTIONS_URL : OPENAI_TRANSCRIPTIONS_URL,
|
|
226
|
-
};
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
function loadEnvApiKeyAuthContext(env = process.env) {
|
|
230
|
-
const token = normalizeBearerToken(env?.OPENAI_API_KEY);
|
|
231
|
-
if (!token) {
|
|
232
|
-
return null;
|
|
404
|
+
if (!isChatGPT) {
|
|
405
|
+
throw voiceError("not_chatgpt", "Voice transcription requires a ChatGPT account.");
|
|
233
406
|
}
|
|
234
407
|
|
|
235
408
|
return {
|
|
236
|
-
authMethod
|
|
409
|
+
authMethod,
|
|
410
|
+
authSource: "mac_runtime",
|
|
411
|
+
authMethodClass: "chatgpt",
|
|
412
|
+
provider: "chatgpt",
|
|
237
413
|
token,
|
|
238
|
-
|
|
239
|
-
transcriptionURL: OPENAI_TRANSCRIPTIONS_URL,
|
|
414
|
+
transcriptionURL: CHATGPT_TRANSCRIPTIONS_URL,
|
|
240
415
|
};
|
|
241
416
|
}
|
|
242
417
|
|
|
243
|
-
async function readVoiceAuthStatus(sendCodexRequest) {
|
|
418
|
+
async function readVoiceAuthStatus(sendCodexRequest, { refreshToken = true } = {}) {
|
|
244
419
|
try {
|
|
245
420
|
return await sendCodexRequest("getAuthStatus", {
|
|
246
421
|
includeToken: true,
|
|
247
|
-
refreshToken
|
|
422
|
+
refreshToken,
|
|
248
423
|
});
|
|
249
|
-
} catch
|
|
250
|
-
console.error(
|
|
251
|
-
throw voiceError("auth_unavailable", "Could not read
|
|
424
|
+
} catch {
|
|
425
|
+
console.error("[remodex] voice auth: getAuthStatus RPC failed");
|
|
426
|
+
throw voiceError("auth_unavailable", "Could not read ChatGPT auth from the Mac runtime. Is the bridge running?");
|
|
252
427
|
}
|
|
253
428
|
}
|
|
254
429
|
|
|
@@ -258,10 +433,6 @@ function decodeAudioBase64(value) {
|
|
|
258
433
|
throw voiceError("missing_audio", "The voice request did not include any audio.");
|
|
259
434
|
}
|
|
260
435
|
|
|
261
|
-
if (!isLikelyBase64(normalized)) {
|
|
262
|
-
throw voiceError("invalid_audio", "The recorded audio could not be decoded.");
|
|
263
|
-
}
|
|
264
|
-
|
|
265
436
|
const audioBuffer = Buffer.from(normalized, "base64");
|
|
266
437
|
if (!audioBuffer.length) {
|
|
267
438
|
throw voiceError("invalid_audio", "The recorded audio could not be decoded.");
|
|
@@ -271,10 +442,6 @@ function decodeAudioBase64(value) {
|
|
|
271
442
|
throw voiceError("invalid_audio", "The recorded audio could not be decoded.");
|
|
272
443
|
}
|
|
273
444
|
|
|
274
|
-
if (!hasRiffWaveHeader(audioBuffer)) {
|
|
275
|
-
throw voiceError("invalid_audio", "The recorded audio is not a valid WAV file.");
|
|
276
|
-
}
|
|
277
|
-
|
|
278
445
|
return audioBuffer;
|
|
279
446
|
}
|
|
280
447
|
|
|
@@ -283,81 +450,36 @@ function normalizeBase64(value) {
|
|
|
283
450
|
return typeof value === "string" ? value.replace(/\s+/g, "").trim() : "";
|
|
284
451
|
}
|
|
285
452
|
|
|
286
|
-
function
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
}
|
|
453
|
+
function isSupportedVoiceMimeType(mimeType) {
|
|
454
|
+
return mimeType === VOICE_WAV_MIME_TYPE || mimeType === VOICE_M4A_MIME_TYPE;
|
|
455
|
+
}
|
|
290
456
|
|
|
291
|
-
|
|
292
|
-
if (
|
|
293
|
-
const
|
|
294
|
-
if (
|
|
295
|
-
|
|
457
|
+
function readVoiceAudioInfo(buffer, mimeType) {
|
|
458
|
+
if (mimeType === VOICE_WAV_MIME_TYPE) {
|
|
459
|
+
const wavInfo = readWavInfo(buffer);
|
|
460
|
+
if (!wavInfo) {
|
|
461
|
+
throw voiceError("invalid_audio", "The recorded audio is not a valid WAV file.");
|
|
296
462
|
}
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
return false;
|
|
300
|
-
}
|
|
463
|
+
if (!isSupportedVoiceWavFormat(wavInfo)) {
|
|
464
|
+
throw voiceError("unsupported_sample_rate", "Voice transcription requires 24 kHz mono WAV audio.");
|
|
301
465
|
}
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
// Avoid one giant regex: V8 can overflow its stack on multi-MB voice clips.
|
|
305
|
-
const dataEnd = paddingStart === -1 ? value.length : paddingStart;
|
|
306
|
-
for (let i = 0; i < dataEnd; i += 1) {
|
|
307
|
-
const code = value.charCodeAt(i);
|
|
308
|
-
const isUppercase = code >= 65 && code <= 90;
|
|
309
|
-
const isLowercase = code >= 97 && code <= 122;
|
|
310
|
-
const isDigit = code >= 48 && code <= 57;
|
|
311
|
-
if (!isUppercase && !isLowercase && !isDigit && value[i] !== "+" && value[i] !== "/") {
|
|
312
|
-
return false;
|
|
466
|
+
if (!hasConsistentVoiceWavLayout(wavInfo)) {
|
|
467
|
+
throw voiceError("invalid_audio", "The recorded audio is not a valid WAV file.");
|
|
313
468
|
}
|
|
469
|
+
return {
|
|
470
|
+
durationMs: wavDurationMs(wavInfo),
|
|
471
|
+
filename: "voice.wav",
|
|
472
|
+
};
|
|
314
473
|
}
|
|
315
474
|
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
function hasRiffWaveHeader(buffer) {
|
|
320
|
-
return buffer.length >= 44
|
|
321
|
-
&& buffer.toString("ascii", 0, 4) === "RIFF"
|
|
322
|
-
&& buffer.toString("ascii", 8, 12) === "WAVE";
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
// Parses chunked WAV metadata so extra chunks before fmt/data do not break valid clips.
|
|
326
|
-
function readWavInfo(buffer) {
|
|
327
|
-
if (!hasRiffWaveHeader(buffer)) {
|
|
328
|
-
return null;
|
|
475
|
+
const m4aInfo = readM4AInfo(buffer);
|
|
476
|
+
if (!m4aInfo) {
|
|
477
|
+
throw voiceError("invalid_audio", "The recorded audio is not a valid M4A file.");
|
|
329
478
|
}
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
while (offset + 8 <= buffer.length) {
|
|
335
|
-
const chunkId = buffer.toString("ascii", offset, offset + 4);
|
|
336
|
-
const chunkSize = buffer.readUInt32LE(offset + 4);
|
|
337
|
-
const payloadStart = offset + 8;
|
|
338
|
-
const payloadEnd = payloadStart + chunkSize;
|
|
339
|
-
if (payloadEnd > buffer.length) {
|
|
340
|
-
return null;
|
|
341
|
-
}
|
|
342
|
-
|
|
343
|
-
if (chunkId === "fmt ") {
|
|
344
|
-
if (chunkSize < 16) {
|
|
345
|
-
return null;
|
|
346
|
-
}
|
|
347
|
-
info = {
|
|
348
|
-
audioFormat: buffer.readUInt16LE(payloadStart),
|
|
349
|
-
channelCount: buffer.readUInt16LE(payloadStart + 2),
|
|
350
|
-
sampleRateHz: buffer.readUInt32LE(payloadStart + 4),
|
|
351
|
-
bitsPerSample: buffer.readUInt16LE(payloadStart + 14),
|
|
352
|
-
};
|
|
353
|
-
} else if (chunkId === "data") {
|
|
354
|
-
hasData = chunkSize > 0;
|
|
355
|
-
}
|
|
356
|
-
|
|
357
|
-
offset = payloadEnd + (chunkSize % 2);
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
return info && hasData ? info : null;
|
|
479
|
+
return {
|
|
480
|
+
durationMs: m4aInfo.durationMs,
|
|
481
|
+
filename: "voice.m4a",
|
|
482
|
+
};
|
|
361
483
|
}
|
|
362
484
|
|
|
363
485
|
function readString(value) {
|
|
@@ -378,15 +500,83 @@ function isChatGPTAuthMethod(value) {
|
|
|
378
500
|
return normalized.includes("chatgpt");
|
|
379
501
|
}
|
|
380
502
|
|
|
503
|
+
// Formats fixed safe fields only; callers must pass classifications, counts, and statuses.
|
|
504
|
+
function logVoiceEvent(logger, level, logPrefix, event, fields = {}) {
|
|
505
|
+
const writer = resolveLogWriter(logger, level);
|
|
506
|
+
const details = Object.entries(fields)
|
|
507
|
+
.map(([key, value]) => `${key}=${formatLogValue(value)}`)
|
|
508
|
+
.join(" ");
|
|
509
|
+
writer(`${logPrefix} voice transcribe ${event}${details ? ` ${details}` : ""}`);
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function resolveLogWriter(logger, level) {
|
|
513
|
+
if (typeof logger?.[level] === "function") {
|
|
514
|
+
return logger[level].bind(logger);
|
|
515
|
+
}
|
|
516
|
+
if (level === "warn" && typeof logger?.log === "function") {
|
|
517
|
+
return logger.log.bind(logger);
|
|
518
|
+
}
|
|
519
|
+
if (level === "error" && typeof logger?.warn === "function") {
|
|
520
|
+
return logger.warn.bind(logger);
|
|
521
|
+
}
|
|
522
|
+
if (typeof console[level] === "function") {
|
|
523
|
+
return console[level].bind(console);
|
|
524
|
+
}
|
|
525
|
+
return console.log.bind(console);
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
function formatLogValue(value) {
|
|
529
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
530
|
+
return String(value);
|
|
531
|
+
}
|
|
532
|
+
if (typeof value === "boolean") {
|
|
533
|
+
return value ? "true" : "false";
|
|
534
|
+
}
|
|
535
|
+
return String(value).replace(/[^a-zA-Z0-9_.:-]/g, "_");
|
|
536
|
+
}
|
|
537
|
+
|
|
381
538
|
function readPositiveNumber(value) {
|
|
382
539
|
const numericValue = typeof value === "number" ? value : Number(value);
|
|
383
540
|
return Number.isFinite(numericValue) && numericValue >= 0 ? numericValue : 0;
|
|
384
541
|
}
|
|
385
542
|
|
|
386
|
-
function
|
|
543
|
+
function createTranscriptionTimeoutError(timeoutMs) {
|
|
544
|
+
const error = new Error(`Voice transcription timed out after ${timeoutMs}ms`);
|
|
545
|
+
error.name = "AbortError";
|
|
546
|
+
error.code = "voice_transcription_timeout";
|
|
547
|
+
return error;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
function isAbortError(error, controller) {
|
|
551
|
+
return Boolean(controller?.signal?.aborted)
|
|
552
|
+
|| error?.name === "AbortError"
|
|
553
|
+
|| error?.code === "ABORT_ERR"
|
|
554
|
+
|| error?.code === "voice_transcription_timeout";
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
function voiceErrorData(error) {
|
|
558
|
+
const data = {
|
|
559
|
+
errorCode: error.errorCode || "voice_transcription_failed",
|
|
560
|
+
};
|
|
561
|
+
if (error.provider === "chatgpt") {
|
|
562
|
+
data.provider = error.provider;
|
|
563
|
+
}
|
|
564
|
+
if (Number.isInteger(error.status)) {
|
|
565
|
+
data.status = error.status;
|
|
566
|
+
}
|
|
567
|
+
return data;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
function voiceError(errorCode, userMessage, details = {}) {
|
|
387
571
|
const error = new Error(userMessage);
|
|
388
572
|
error.errorCode = errorCode;
|
|
389
573
|
error.userMessage = userMessage;
|
|
574
|
+
if (details.provider === "chatgpt") {
|
|
575
|
+
error.provider = details.provider;
|
|
576
|
+
}
|
|
577
|
+
if (Number.isInteger(details.status)) {
|
|
578
|
+
error.status = details.status;
|
|
579
|
+
}
|
|
390
580
|
return error;
|
|
391
581
|
}
|
|
392
582
|
|