@deepdream314/remodex 1.3.8

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.
@@ -0,0 +1,57 @@
1
+ // FILE: session-state.js
2
+ // Purpose: Persists the latest active Remodex thread so the user can reopen it on the Mac for handoff.
3
+ // Layer: CLI helper
4
+ // Exports: rememberActiveThread, openLastActiveThread, readLastActiveThread
5
+ // Depends on: fs, os, path, child_process
6
+
7
+ const fs = require("fs");
8
+ const os = require("os");
9
+ const path = require("path");
10
+ const { execFileSync } = require("child_process");
11
+
12
+ const STATE_DIR = path.join(os.homedir(), ".remodex");
13
+ const STATE_FILE = path.join(STATE_DIR, "last-thread.json");
14
+ const DEFAULT_BUNDLE_ID = "com.openai.codex";
15
+
16
+ function rememberActiveThread(threadId, source) {
17
+ if (!threadId || typeof threadId !== "string") {
18
+ return false;
19
+ }
20
+
21
+ const payload = {
22
+ threadId,
23
+ source: source || "unknown",
24
+ updatedAt: new Date().toISOString(),
25
+ };
26
+
27
+ fs.mkdirSync(STATE_DIR, { recursive: true });
28
+ fs.writeFileSync(STATE_FILE, JSON.stringify(payload, null, 2));
29
+ return true;
30
+ }
31
+
32
+ function openLastActiveThread({ bundleId = DEFAULT_BUNDLE_ID } = {}) {
33
+ const state = readState();
34
+ const threadId = state?.threadId;
35
+ if (!threadId) {
36
+ throw new Error("No remembered Remodex thread found yet.");
37
+ }
38
+
39
+ const targetUrl = `codex://threads/${threadId}`;
40
+ execFileSync("open", ["-b", bundleId, targetUrl], { stdio: "ignore" });
41
+ return state;
42
+ }
43
+
44
+ function readState() {
45
+ if (!fs.existsSync(STATE_FILE)) {
46
+ return null;
47
+ }
48
+
49
+ const raw = fs.readFileSync(STATE_FILE, "utf8");
50
+ return JSON.parse(raw);
51
+ }
52
+
53
+ module.exports = {
54
+ rememberActiveThread,
55
+ openLastActiveThread,
56
+ readLastActiveThread: readState,
57
+ };
@@ -0,0 +1,80 @@
1
+ // FILE: thread-context-handler.js
2
+ // Purpose: Serves on-demand thread context-window usage reads from local Codex rollout files.
3
+ // Layer: Bridge handler
4
+ // Exports: handleThreadContextRequest
5
+ // Depends on: ./rollout-watch
6
+
7
+ const { readLatestContextWindowUsage } = require("./rollout-watch");
8
+
9
+ function handleThreadContextRequest(rawMessage, sendResponse) {
10
+ let parsed;
11
+ try {
12
+ parsed = JSON.parse(rawMessage);
13
+ } catch {
14
+ return false;
15
+ }
16
+
17
+ const method = typeof parsed?.method === "string" ? parsed.method.trim() : "";
18
+ if (method !== "thread/contextWindow/read") {
19
+ return false;
20
+ }
21
+
22
+ const id = parsed.id;
23
+ const params = parsed.params || {};
24
+
25
+ handleThreadContextRead(params)
26
+ .then((result) => {
27
+ sendResponse(JSON.stringify({ id, result }));
28
+ })
29
+ .catch((err) => {
30
+ const errorCode = err.errorCode || "thread_context_error";
31
+ const message = err.userMessage || err.message || "Unknown thread context error";
32
+ sendResponse(
33
+ JSON.stringify({
34
+ id,
35
+ error: {
36
+ code: -32000,
37
+ message,
38
+ data: { errorCode },
39
+ },
40
+ })
41
+ );
42
+ });
43
+
44
+ return true;
45
+ }
46
+
47
+ // Reads the newest rollout-backed usage snapshot and returns it in the app-facing shape.
48
+ async function handleThreadContextRead(params) {
49
+ const threadId = readString(params.threadId) || readString(params.thread_id);
50
+ if (!threadId) {
51
+ throw threadContextError("missing_thread_id", "thread/contextWindow/read requires a threadId.");
52
+ }
53
+
54
+ const turnId = readString(params.turnId) || readString(params.turn_id);
55
+ const result = readLatestContextWindowUsage({
56
+ threadId,
57
+ turnId,
58
+ });
59
+
60
+ return {
61
+ threadId,
62
+ usage: result?.usage ?? null,
63
+ rolloutPath: result?.rolloutPath ?? null,
64
+ };
65
+ }
66
+
67
+ function readString(value) {
68
+ return typeof value === "string" && value.trim() ? value.trim() : null;
69
+ }
70
+
71
+ function threadContextError(errorCode, userMessage) {
72
+ const error = new Error(userMessage);
73
+ error.errorCode = errorCode;
74
+ error.userMessage = userMessage;
75
+ return error;
76
+ }
77
+
78
+ module.exports = {
79
+ handleThreadContextRequest,
80
+ };
@@ -0,0 +1,36 @@
1
+ // FILE: utf8-chunk-decoder.js
2
+ // Purpose: Preserves partial multi-byte UTF-8 characters across chunk boundaries.
3
+ // Layer: Bridge helper
4
+ // Exports: createUtf8ChunkDecoder
5
+ // Depends on: node:string_decoder
6
+
7
+ const { StringDecoder } = require("node:string_decoder");
8
+
9
+ function createUtf8ChunkDecoder() {
10
+ const decoder = new StringDecoder("utf8");
11
+
12
+ return {
13
+ write(chunk) {
14
+ if (chunk == null) {
15
+ return "";
16
+ }
17
+ if (typeof chunk === "string") {
18
+ return chunk;
19
+ }
20
+ return decoder.write(chunk);
21
+ },
22
+ end(chunk) {
23
+ if (chunk == null) {
24
+ return decoder.end();
25
+ }
26
+ if (typeof chunk === "string") {
27
+ return chunk + decoder.end();
28
+ }
29
+ return decoder.end(chunk);
30
+ },
31
+ };
32
+ }
33
+
34
+ module.exports = {
35
+ createUtf8ChunkDecoder,
36
+ };
@@ -0,0 +1,321 @@
1
+ // FILE: voice-handler.js
2
+ // Purpose: Handles bridge-owned voice transcription requests without exposing auth tokens to iPhone.
3
+ // Layer: Bridge handler
4
+ // Exports: createVoiceHandler
5
+ // Depends on: global fetch/FormData/Blob, local codex app-server auth via sendCodexRequest
6
+
7
+ const CHATGPT_TRANSCRIPTIONS_URL = "https://chatgpt.com/backend-api/transcribe";
8
+ const MAX_AUDIO_BYTES = 10 * 1024 * 1024;
9
+ const MAX_DURATION_MS = 60_000;
10
+
11
+ function createVoiceHandler({
12
+ sendCodexRequest,
13
+ fetchImpl = globalThis.fetch,
14
+ FormDataImpl = globalThis.FormData,
15
+ BlobImpl = globalThis.Blob,
16
+ logPrefix = "[remodex]",
17
+ } = {}) {
18
+ function handleVoiceRequest(rawMessage, sendResponse) {
19
+ let parsed;
20
+ try {
21
+ parsed = JSON.parse(rawMessage);
22
+ } catch {
23
+ return false;
24
+ }
25
+
26
+ const method = typeof parsed?.method === "string" ? parsed.method.trim() : "";
27
+ if (method !== "voice/transcribe") {
28
+ return false;
29
+ }
30
+
31
+ const id = parsed.id;
32
+ const params = parsed.params || {};
33
+
34
+ transcribeVoice(params, {
35
+ sendCodexRequest,
36
+ fetchImpl,
37
+ FormDataImpl,
38
+ BlobImpl,
39
+ })
40
+ .then((result) => {
41
+ sendResponse(JSON.stringify({ id, result }));
42
+ })
43
+ .catch((error) => {
44
+ console.error(`${logPrefix} voice transcription failed: ${error.message}`);
45
+ sendResponse(JSON.stringify({
46
+ id,
47
+ error: {
48
+ code: -32000,
49
+ message: error.userMessage || error.message || "Voice transcription failed.",
50
+ data: {
51
+ errorCode: error.errorCode || "voice_transcription_failed",
52
+ },
53
+ },
54
+ }));
55
+ });
56
+
57
+ return true;
58
+ }
59
+
60
+ return {
61
+ handleVoiceRequest,
62
+ };
63
+ }
64
+
65
+ // ─── Audio validation helpers ───────────────────────────────
66
+
67
+ // Validates iPhone-owned audio input and proxies it to the official transcription endpoint.
68
+ async function transcribeVoice(
69
+ params,
70
+ { sendCodexRequest, fetchImpl, FormDataImpl, BlobImpl }
71
+ ) {
72
+ if (typeof sendCodexRequest !== "function") {
73
+ throw voiceError("bridge_not_ready", "Voice transcription is not available right now.");
74
+ }
75
+ if (typeof fetchImpl !== "function" || !FormDataImpl || !BlobImpl) {
76
+ throw voiceError("transcription_unavailable", "Voice transcription is unavailable on this bridge.");
77
+ }
78
+
79
+ const mimeType = readString(params.mimeType);
80
+ if (mimeType !== "audio/wav") {
81
+ throw voiceError("unsupported_mime_type", "Only WAV audio is supported for voice transcription.");
82
+ }
83
+
84
+ const sampleRateHz = readPositiveNumber(params.sampleRateHz);
85
+ if (sampleRateHz !== 24_000) {
86
+ throw voiceError("unsupported_sample_rate", "Voice transcription requires 24 kHz mono WAV audio.");
87
+ }
88
+
89
+ const durationMs = readPositiveNumber(params.durationMs);
90
+ if (durationMs <= 0) {
91
+ throw voiceError("invalid_duration", "Voice messages must include a positive duration.");
92
+ }
93
+ if (durationMs > MAX_DURATION_MS) {
94
+ throw voiceError("duration_too_long", "Voice messages are limited to 60 seconds.");
95
+ }
96
+
97
+ const audioBuffer = decodeAudioBase64(params.audioBase64);
98
+ if (audioBuffer.length > MAX_AUDIO_BYTES) {
99
+ throw voiceError("audio_too_large", "Voice messages are limited to 10 MB.");
100
+ }
101
+
102
+ const authContext = await loadAuthContext(sendCodexRequest);
103
+ return requestTranscription({
104
+ authContext,
105
+ audioBuffer,
106
+ mimeType,
107
+ fetchImpl,
108
+ FormDataImpl,
109
+ BlobImpl,
110
+ sendCodexRequest,
111
+ });
112
+ }
113
+
114
+ async function requestTranscription({
115
+ authContext,
116
+ audioBuffer,
117
+ mimeType,
118
+ fetchImpl,
119
+ FormDataImpl,
120
+ BlobImpl,
121
+ sendCodexRequest,
122
+ }) {
123
+ const makeAttempt = async (activeAuthContext) => {
124
+ const formData = new FormDataImpl();
125
+ formData.append("file", new BlobImpl([audioBuffer], { type: mimeType }), "voice.wav");
126
+
127
+ const headers = {
128
+ Authorization: `Bearer ${activeAuthContext.token}`,
129
+ };
130
+
131
+ return fetchImpl(activeAuthContext.transcriptionURL, {
132
+ method: "POST",
133
+ headers,
134
+ body: formData,
135
+ });
136
+ };
137
+
138
+ let response = await makeAttempt(authContext);
139
+ if (response.status === 401) {
140
+ const refreshedAuthContext = await loadAuthContext(sendCodexRequest);
141
+ response = await makeAttempt(refreshedAuthContext);
142
+ }
143
+
144
+ if (!response.ok) {
145
+ let errorMessage = `Transcription failed with status ${response.status}.`;
146
+ try {
147
+ const errorPayload = await response.json();
148
+ const providerMessage = readString(errorPayload?.error?.message) || readString(errorPayload?.message);
149
+ if (providerMessage) {
150
+ errorMessage = providerMessage;
151
+ }
152
+ } catch {
153
+ // Keep the generic message when the provider body is empty or non-JSON.
154
+ }
155
+
156
+ if (response.status === 401 || response.status === 403) {
157
+ throw voiceError("not_authenticated", "Your ChatGPT login has expired. Sign in again.");
158
+ }
159
+
160
+ throw voiceError("transcription_failed", errorMessage);
161
+ }
162
+
163
+ const payload = await response.json().catch(() => null);
164
+ const text = readString(payload?.text) || readString(payload?.transcript);
165
+ if (!text) {
166
+ throw voiceError("transcription_invalid_response", "The transcription response did not include any text.");
167
+ }
168
+
169
+ return { text };
170
+ }
171
+
172
+ // 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
+ });
178
+
179
+ const authMethod = readString(authStatus?.authMethod);
180
+ const token = readString(authStatus?.authToken);
181
+ const isChatGPT = authMethod === "chatgpt" || authMethod === "chatgptAuthTokens";
182
+
183
+ 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.");
188
+ }
189
+
190
+ return {
191
+ authMethod,
192
+ token,
193
+ isChatGPT,
194
+ transcriptionURL: CHATGPT_TRANSCRIPTIONS_URL,
195
+ chatgptAccountId: readChatGPTAccountIdFromToken(token),
196
+ };
197
+ }
198
+
199
+ function decodeAudioBase64(value) {
200
+ const normalized = normalizeBase64(value);
201
+ if (!normalized) {
202
+ throw voiceError("missing_audio", "The voice request did not include any audio.");
203
+ }
204
+
205
+ if (!isLikelyBase64(normalized)) {
206
+ throw voiceError("invalid_audio", "The recorded audio could not be decoded.");
207
+ }
208
+
209
+ const audioBuffer = Buffer.from(normalized, "base64");
210
+ if (!audioBuffer.length) {
211
+ throw voiceError("invalid_audio", "The recorded audio could not be decoded.");
212
+ }
213
+
214
+ if (audioBuffer.toString("base64") !== normalized) {
215
+ throw voiceError("invalid_audio", "The recorded audio could not be decoded.");
216
+ }
217
+
218
+ if (!isLikelyWavBuffer(audioBuffer)) {
219
+ throw voiceError("invalid_audio", "The recorded audio is not a valid WAV file.");
220
+ }
221
+
222
+ return audioBuffer;
223
+ }
224
+
225
+ // Keeps the bridge strict about the payload shape so malformed uploads fail before fetch().
226
+ function normalizeBase64(value) {
227
+ return typeof value === "string" ? value.replace(/\s+/g, "").trim() : "";
228
+ }
229
+
230
+ function isLikelyBase64(value) {
231
+ return /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value);
232
+ }
233
+
234
+ function isLikelyWavBuffer(buffer) {
235
+ return buffer.length >= 44
236
+ && buffer.toString("ascii", 0, 4) === "RIFF"
237
+ && buffer.toString("ascii", 8, 12) === "WAVE";
238
+ }
239
+
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) {
254
+ return null;
255
+ }
256
+
257
+ const normalized = segments[1]
258
+ .replace(/-/g, "+")
259
+ .replace(/_/g, "/")
260
+ .padEnd(Math.ceil(segments[1].length / 4) * 4, "=");
261
+
262
+ try {
263
+ return JSON.parse(Buffer.from(normalized, "base64").toString("utf8"));
264
+ } catch {
265
+ return null;
266
+ }
267
+ }
268
+
269
+ function readString(value) {
270
+ return typeof value === "string" && value.trim() ? value.trim() : null;
271
+ }
272
+
273
+ function readPositiveNumber(value) {
274
+ const numericValue = typeof value === "number" ? value : Number(value);
275
+ return Number.isFinite(numericValue) && numericValue >= 0 ? numericValue : 0;
276
+ }
277
+
278
+ function voiceError(errorCode, userMessage) {
279
+ const error = new Error(userMessage);
280
+ error.errorCode = errorCode;
281
+ error.userMessage = userMessage;
282
+ return error;
283
+ }
284
+
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.
287
+ 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?");
297
+ }
298
+
299
+ const authMethod = readString(authStatus?.authMethod);
300
+ const token = readString(authStatus?.authToken);
301
+ const isChatGPT = authMethod === "chatgpt" || authMethod === "chatgptAuthTokens";
302
+
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
+ if (isChatGPT && token) {
307
+ return { token };
308
+ }
309
+
310
+ if (!token) {
311
+ console.error(`[remodex] voice/resolveAuth: no token. authMethod=${authMethod || "none"} requiresOpenaiAuth=${authStatus?.requiresOpenaiAuth}`);
312
+ throw voiceError("token_missing", "No ChatGPT session token available. Sign in to ChatGPT on the Mac.");
313
+ }
314
+
315
+ throw voiceError("not_chatgpt", "Voice transcription requires a ChatGPT account.");
316
+ }
317
+
318
+ module.exports = {
319
+ createVoiceHandler,
320
+ resolveVoiceAuth,
321
+ };