@makerbi/remodex 1.5.4 → 2.0.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.
@@ -1,12 +1,15 @@
1
1
  // FILE: voice-handler.js
2
2
  // Purpose: Handles bridge-owned voice transcription requests without exposing auth tokens to iPhone.
3
3
  // Layer: Bridge handler
4
- // Exports: createVoiceHandler
4
+ // Exports: createVoiceHandler, resolveVoiceAuth
5
5
  // Depends on: global fetch/FormData/Blob, local codex app-server auth via sendCodexRequest
6
6
 
7
+ const OPENAI_TRANSCRIPTIONS_URL = "https://api.openai.com/v1/audio/transcriptions";
7
8
  const CHATGPT_TRANSCRIPTIONS_URL = "https://chatgpt.com/backend-api/transcribe";
9
+ const DEFAULT_TRANSCRIPTION_MODEL = "gpt-4o-mini-transcribe";
8
10
  const MAX_AUDIO_BYTES = 10 * 1024 * 1024;
9
- const MAX_DURATION_MS = 120_000;
11
+ const MAX_DURATION_SECONDS = 150;
12
+ const MAX_DURATION_MS = MAX_DURATION_SECONDS * 1_000;
10
13
 
11
14
  function createVoiceHandler({
12
15
  sendCodexRequest,
@@ -14,6 +17,7 @@ function createVoiceHandler({
14
17
  FormDataImpl = globalThis.FormData,
15
18
  BlobImpl = globalThis.Blob,
16
19
  logPrefix = "[remodex]",
20
+ env = process.env,
17
21
  } = {}) {
18
22
  function handleVoiceRequest(rawMessage, sendResponse) {
19
23
  let parsed;
@@ -36,6 +40,7 @@ function createVoiceHandler({
36
40
  fetchImpl,
37
41
  FormDataImpl,
38
42
  BlobImpl,
43
+ env,
39
44
  })
40
45
  .then((result) => {
41
46
  sendResponse(JSON.stringify({ id, result }));
@@ -67,7 +72,7 @@ function createVoiceHandler({
67
72
  // Validates iPhone-owned audio input and proxies it to the official transcription endpoint.
68
73
  async function transcribeVoice(
69
74
  params,
70
- { sendCodexRequest, fetchImpl, FormDataImpl, BlobImpl }
75
+ { sendCodexRequest, fetchImpl, FormDataImpl, BlobImpl, env = process.env }
71
76
  ) {
72
77
  if (typeof sendCodexRequest !== "function") {
73
78
  throw voiceError("bridge_not_ready", "Voice transcription is not available right now.");
@@ -91,15 +96,25 @@ async function transcribeVoice(
91
96
  throw voiceError("invalid_duration", "Voice messages must include a positive duration.");
92
97
  }
93
98
  if (durationMs > MAX_DURATION_MS) {
94
- throw voiceError("duration_too_long", "Voice messages are limited to 120 seconds.");
99
+ throw voiceError("duration_too_long", `Voice messages are limited to ${MAX_DURATION_SECONDS} seconds.`);
95
100
  }
96
101
 
97
102
  const audioBuffer = decodeAudioBase64(params.audioBase64);
98
103
  if (audioBuffer.length > MAX_AUDIO_BYTES) {
99
104
  throw voiceError("audio_too_large", "Voice messages are limited to 10 MB.");
100
105
  }
106
+ const wavInfo = readWavInfo(audioBuffer);
107
+ if (!wavInfo) {
108
+ throw voiceError("invalid_audio", "The recorded audio is not a valid WAV file.");
109
+ }
110
+ if (wavInfo.audioFormat !== 1
111
+ || wavInfo.channelCount !== 1
112
+ || wavInfo.sampleRateHz !== 24_000
113
+ || wavInfo.bitsPerSample !== 16) {
114
+ throw voiceError("unsupported_sample_rate", "Voice transcription requires 24 kHz mono WAV audio.");
115
+ }
101
116
 
102
- const authContext = await loadAuthContext(sendCodexRequest);
117
+ const authContext = await loadAuthContext(sendCodexRequest, { env });
103
118
  return requestTranscription({
104
119
  authContext,
105
120
  audioBuffer,
@@ -108,6 +123,7 @@ async function transcribeVoice(
108
123
  FormDataImpl,
109
124
  BlobImpl,
110
125
  sendCodexRequest,
126
+ env,
111
127
  });
112
128
  }
113
129
 
@@ -119,10 +135,14 @@ async function requestTranscription({
119
135
  FormDataImpl,
120
136
  BlobImpl,
121
137
  sendCodexRequest,
138
+ env,
122
139
  }) {
123
140
  const makeAttempt = async (activeAuthContext) => {
124
141
  const formData = new FormDataImpl();
125
142
  formData.append("file", new BlobImpl([audioBuffer], { type: mimeType }), "voice.wav");
143
+ if (!activeAuthContext.isChatGPT) {
144
+ formData.append("model", DEFAULT_TRANSCRIPTION_MODEL);
145
+ }
126
146
 
127
147
  const headers = {
128
148
  Authorization: `Bearer ${activeAuthContext.token}`,
@@ -135,10 +155,20 @@ async function requestTranscription({
135
155
  });
136
156
  };
137
157
 
138
- let response = await makeAttempt(authContext);
139
- if (response.status === 401) {
140
- const refreshedAuthContext = await loadAuthContext(sendCodexRequest);
141
- response = await makeAttempt(refreshedAuthContext);
158
+ let activeAuthContext = authContext;
159
+ let response = await makeAttempt(activeAuthContext);
160
+ if (response.status === 401 || response.status === 403) {
161
+ activeAuthContext = await loadAuthContext(sendCodexRequest, { env });
162
+ response = await makeAttempt(activeAuthContext);
163
+ if (!response.ok
164
+ && (response.status === 401 || response.status === 403)
165
+ && activeAuthContext.isChatGPT) {
166
+ const apiKeyContext = loadEnvApiKeyAuthContext(env);
167
+ if (apiKeyContext) {
168
+ activeAuthContext = apiKeyContext;
169
+ response = await makeAttempt(activeAuthContext);
170
+ }
171
+ }
142
172
  }
143
173
 
144
174
  if (!response.ok) {
@@ -154,7 +184,10 @@ async function requestTranscription({
154
184
  }
155
185
 
156
186
  if (response.status === 401 || response.status === 403) {
157
- throw voiceError("not_authenticated", "Your ChatGPT login has expired. Sign in again.");
187
+ const message = activeAuthContext.isChatGPT
188
+ ? "Your ChatGPT login has expired. Sign in again."
189
+ : "Your OpenAI API key was rejected. Update the API key on the Mac, then try again.";
190
+ throw voiceError("auth_rejected", message);
158
191
  }
159
192
 
160
193
  throw voiceError("transcription_failed", errorMessage);
@@ -170,32 +203,55 @@ async function requestTranscription({
170
203
  }
171
204
 
172
205
  // Reads the current bridge-owned auth state from the local codex app-server and refreshes if needed.
173
- async function loadAuthContext(sendCodexRequest) {
174
- const authStatus = await sendCodexRequest("getAuthStatus", {
175
- includeToken: true,
176
- refreshToken: true,
177
- });
206
+ async function loadAuthContext(sendCodexRequest, { env = process.env } = {}) {
207
+ const authStatus = await readVoiceAuthStatus(sendCodexRequest);
178
208
 
179
209
  const authMethod = readString(authStatus?.authMethod);
180
- const token = readString(authStatus?.authToken);
181
- const isChatGPT = authMethod === "chatgpt" || authMethod === "chatgptAuthTokens";
210
+ const token = normalizeBearerToken(authStatus?.authToken);
211
+ const isChatGPT = isChatGPTAuthMethod(authMethod);
182
212
 
183
213
  if (!token) {
184
- throw voiceError("not_authenticated", "Sign in with ChatGPT before using voice transcription.");
185
- }
186
- if (!isChatGPT) {
187
- throw voiceError("not_chatgpt", "Voice transcription requires a ChatGPT account.");
214
+ const apiKeyContext = loadEnvApiKeyAuthContext(env);
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.");
188
219
  }
189
220
 
190
221
  return {
191
222
  authMethod,
192
223
  token,
193
224
  isChatGPT,
194
- transcriptionURL: CHATGPT_TRANSCRIPTIONS_URL,
195
- chatgptAccountId: readChatGPTAccountIdFromToken(token),
225
+ transcriptionURL: isChatGPT ? CHATGPT_TRANSCRIPTIONS_URL : OPENAI_TRANSCRIPTIONS_URL,
196
226
  };
197
227
  }
198
228
 
229
+ function loadEnvApiKeyAuthContext(env = process.env) {
230
+ const token = normalizeBearerToken(env?.OPENAI_API_KEY);
231
+ if (!token) {
232
+ return null;
233
+ }
234
+
235
+ return {
236
+ authMethod: "apiKey",
237
+ token,
238
+ isChatGPT: false,
239
+ transcriptionURL: OPENAI_TRANSCRIPTIONS_URL,
240
+ };
241
+ }
242
+
243
+ async function readVoiceAuthStatus(sendCodexRequest) {
244
+ try {
245
+ return await sendCodexRequest("getAuthStatus", {
246
+ includeToken: true,
247
+ refreshToken: true,
248
+ });
249
+ } catch (err) {
250
+ console.error(`[remodex] voice auth: getAuthStatus RPC failed: ${err.message}`);
251
+ throw voiceError("auth_unavailable", "Could not read OpenAI auth from the Mac runtime. Is the bridge running?");
252
+ }
253
+ }
254
+
199
255
  function decodeAudioBase64(value) {
200
256
  const normalized = normalizeBase64(value);
201
257
  if (!normalized) {
@@ -215,7 +271,7 @@ function decodeAudioBase64(value) {
215
271
  throw voiceError("invalid_audio", "The recorded audio could not be decoded.");
216
272
  }
217
273
 
218
- if (!isLikelyWavBuffer(audioBuffer)) {
274
+ if (!hasRiffWaveHeader(audioBuffer)) {
219
275
  throw voiceError("invalid_audio", "The recorded audio is not a valid WAV file.");
220
276
  }
221
277
 
@@ -228,48 +284,100 @@ function normalizeBase64(value) {
228
284
  }
229
285
 
230
286
  function isLikelyBase64(value) {
231
- return /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value);
287
+ if (typeof value !== "string" || value.length === 0 || value.length % 4 !== 0) {
288
+ return false;
289
+ }
290
+
291
+ const paddingStart = value.indexOf("=");
292
+ if (paddingStart !== -1) {
293
+ const paddingLength = value.length - paddingStart;
294
+ if (paddingLength > 2) {
295
+ return false;
296
+ }
297
+ for (let i = paddingStart; i < value.length; i += 1) {
298
+ if (value[i] !== "=") {
299
+ return false;
300
+ }
301
+ }
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;
313
+ }
314
+ }
315
+
316
+ return true;
232
317
  }
233
318
 
234
- function isLikelyWavBuffer(buffer) {
319
+ function hasRiffWaveHeader(buffer) {
235
320
  return buffer.length >= 44
236
321
  && buffer.toString("ascii", 0, 4) === "RIFF"
237
322
  && buffer.toString("ascii", 8, 12) === "WAVE";
238
323
  }
239
324
 
240
- function readChatGPTAccountIdFromToken(token) {
241
- const payload = decodeJWTPayload(token);
242
- const authClaim = payload?.["https://api.openai.com/auth"];
243
- return readString(
244
- authClaim?.chatgpt_account_id
245
- || authClaim?.chatgptAccountId
246
- || payload?.chatgpt_account_id
247
- || payload?.chatgptAccountId
248
- );
249
- }
250
-
251
- function decodeJWTPayload(token) {
252
- const segments = typeof token === "string" ? token.split(".") : [];
253
- if (segments.length < 2) {
325
+ // Parses chunked WAV metadata so extra chunks before fmt/data do not break valid clips.
326
+ function readWavInfo(buffer) {
327
+ if (!hasRiffWaveHeader(buffer)) {
254
328
  return null;
255
329
  }
256
330
 
257
- const normalized = segments[1]
258
- .replace(/-/g, "+")
259
- .replace(/_/g, "/")
260
- .padEnd(Math.ceil(segments[1].length / 4) * 4, "=");
331
+ let offset = 12;
332
+ let info = null;
333
+ let hasData = false;
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
+ }
261
342
 
262
- try {
263
- return JSON.parse(Buffer.from(normalized, "base64").toString("utf8"));
264
- } catch {
265
- return null;
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);
266
358
  }
359
+
360
+ return info && hasData ? info : null;
267
361
  }
268
362
 
269
363
  function readString(value) {
270
364
  return typeof value === "string" && value.trim() ? value.trim() : null;
271
365
  }
272
366
 
367
+ function normalizeBearerToken(value) {
368
+ const token = readString(value);
369
+ if (!token) {
370
+ return null;
371
+ }
372
+ const match = token.match(/^bearer\s+(.+)$/i);
373
+ return match ? match[1].trim() : token;
374
+ }
375
+
376
+ function isChatGPTAuthMethod(value) {
377
+ const normalized = readString(value)?.toLowerCase().replace(/[^a-z0-9]/g, "") || "";
378
+ return normalized.includes("chatgpt");
379
+ }
380
+
273
381
  function readPositiveNumber(value) {
274
382
  const numericValue = typeof value === "number" ? value : Number(value);
275
383
  return Number.isFinite(numericValue) && numericValue >= 0 ? numericValue : 0;
@@ -282,33 +390,22 @@ function voiceError(errorCode, userMessage) {
282
390
  return error;
283
391
  }
284
392
 
285
- // Returns an ephemeral ChatGPT token so the phone can call the transcription API directly.
286
- // Uses its own token resolution instead of loadAuthContext so errors are specific and actionable.
393
+ // Serves older phone builds that upload directly to ChatGPT with a Mac-owned token.
287
394
  async function resolveVoiceAuth(sendCodexRequest) {
288
- let authStatus;
289
- try {
290
- authStatus = await sendCodexRequest("getAuthStatus", {
291
- includeToken: true,
292
- refreshToken: true,
293
- });
294
- } catch (err) {
295
- console.error(`[remodex] voice/resolveAuth: getAuthStatus RPC failed: ${err.message}`);
296
- throw voiceError("auth_unavailable", "Could not read ChatGPT session from the Mac runtime. Is the bridge running?");
395
+ if (typeof sendCodexRequest !== "function") {
396
+ throw voiceError("bridge_not_ready", "Voice transcription is not available right now.");
297
397
  }
298
398
 
399
+ const authStatus = await readVoiceAuthStatus(sendCodexRequest);
299
400
  const authMethod = readString(authStatus?.authMethod);
300
- const token = readString(authStatus?.authToken);
301
- const isChatGPT = authMethod === "chatgpt" || authMethod === "chatgptAuthTokens";
401
+ const token = normalizeBearerToken(authStatus?.authToken);
402
+ const isChatGPT = isChatGPTAuthMethod(authMethod);
302
403
 
303
- // Check for a usable ChatGPT token first. The runtime may set requiresOpenaiAuth
304
- // even when a valid ChatGPT session is present (the flag is about the runtime's
305
- // preferred auth mode, not whether ChatGPT tokens are actually available).
306
404
  if (isChatGPT && token) {
307
405
  return { token };
308
406
  }
309
407
 
310
408
  if (!token) {
311
- console.error(`[remodex] voice/resolveAuth: no token. authMethod=${authMethod || "none"} requiresOpenaiAuth=${authStatus?.requiresOpenaiAuth}`);
312
409
  throw voiceError("token_missing", "No ChatGPT session token available. Sign in to ChatGPT on the Mac.");
313
410
  }
314
411