@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.
- package/bin/remodex.js +77 -13
- package/package.json +1 -1
- package/src/account-status.js +7 -1
- package/src/apply-patch-changes.js +185 -0
- package/src/bootstrap-codex-cli.js +1 -1
- package/src/bridge-status.js +3 -2
- package/src/bridge.js +837 -73
- package/src/codex-transport.js +10 -10
- package/src/desktop-handler.js +14 -1
- package/src/desktop-ipc-action-follower.js +129 -0
- package/src/git-handler.js +92 -28
- package/src/index.js +4 -2
- package/src/ios-app-compatibility.js +7 -7
- package/src/macos-launch-agent.js +132 -2
- package/src/project-handler.js +162 -1
- package/src/push-notification-service-client.js +85 -37
- package/src/push-notification-tracker.js +15 -0
- package/src/qr.js +2 -5
- package/src/rollout-live-mirror.js +331 -20
- package/src/rollout-watch.js +5 -1
- package/src/secure-device-state.js +66 -2
- package/src/secure-transport.js +47 -12
- package/src/session-jsonl-history.js +850 -16
- package/src/voice-handler.js +162 -65
- package/src/workspace-handler.js +327 -14
package/src/voice-handler.js
CHANGED
|
@@ -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
|
|
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",
|
|
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
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
-
|
|
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
|
|
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 =
|
|
181
|
-
const isChatGPT = authMethod
|
|
210
|
+
const token = normalizeBearerToken(authStatus?.authToken);
|
|
211
|
+
const isChatGPT = isChatGPTAuthMethod(authMethod);
|
|
182
212
|
|
|
183
213
|
if (!token) {
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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 (!
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
289
|
-
|
|
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 =
|
|
301
|
-
const isChatGPT = authMethod
|
|
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
|
|