@makerbi/remodex 1.5.3 → 1.5.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.
@@ -2,11 +2,12 @@
2
2
  // Purpose: Serves safe Mac-local project folder discovery and creation requests from the iOS app.
3
3
  // Layer: Bridge handler
4
4
  // Exports: handleProjectRequest plus testable project filesystem helpers
5
- // Depends on: fs, os, path
5
+ // Depends on: fs, os, path, ./codex-home
6
6
 
7
7
  const fs = require("fs");
8
8
  const os = require("os");
9
9
  const path = require("path");
10
+ const { resolveCodexHome } = require("./codex-home");
10
11
 
11
12
  const DEFAULT_DIRECTORY_LIMIT = 200;
12
13
  const DEFAULT_DIRECTORY_SEARCH_LIMIT = 80;
@@ -14,6 +15,14 @@ const DEFAULT_DIRECTORY_SEARCH_MAX_DEPTH = 8;
14
15
  const DEFAULT_DIRECTORY_SEARCH_MAX_VISITED = 5000;
15
16
  const DEFAULT_HIDDEN_DIRECTORY_NAMES = new Set(["Library"]);
16
17
 
18
+ // Rootless chat slug rules mirror Codex Desktop's `~/Documents/Codex/<DATE>/<slug>`
19
+ // convention so iOS-created Quick Chats land in the same bucket Desktop classifies
20
+ // as projectless.
21
+ const ROOTLESS_CHAT_SLUG_MAX_TOKENS = 6;
22
+ const ROOTLESS_CHAT_SLUG_MAX_LENGTH = 60;
23
+ const ROOTLESS_CHAT_SLUG_FALLBACK = "new-chat";
24
+ const ROOTLESS_CHAT_DEDUP_LIMIT = 50;
25
+
17
26
  // ─── ENTRY POINT ─────────────────────────────────────────────
18
27
 
19
28
  function handleProjectRequest(rawMessage, sendResponse) {
@@ -58,6 +67,8 @@ async function handleProjectMethod(method, params, options = {}) {
58
67
  switch (method) {
59
68
  case "project/quickLocations":
60
69
  return projectQuickLocations(options);
70
+ case "project/projectlessRoots":
71
+ return projectProjectlessRoots(options);
61
72
  case "project/listDirectory":
62
73
  return projectListDirectory(params, options);
63
74
  case "project/searchDirectories":
@@ -66,6 +77,8 @@ async function handleProjectMethod(method, params, options = {}) {
66
77
  return projectValidatePath(params, options);
67
78
  case "project/createDirectory":
68
79
  return projectCreateDirectory(params, options);
80
+ case "project/createRootlessChatRoot":
81
+ return projectCreateRootlessChatRoot(params, options);
69
82
  default:
70
83
  throw projectError("unknown_method", `Unknown project method: ${method}`);
71
84
  }
@@ -99,6 +112,24 @@ async function projectQuickLocations(options = {}) {
99
112
  return { locations };
100
113
  }
101
114
 
115
+ async function projectProjectlessRoots(options = {}) {
116
+ const homeDir = resolveHomeDir(options);
117
+ const codexHome = path.resolve(readString(options.codexHome) || resolveCodexHome());
118
+ const documentedThreadsRoot = path.join(codexHome, "threads");
119
+ const desktopDocumentsRoot = path.join(homeDir, "Documents", "Codex");
120
+ const roots = uniqueExistingOrCandidatePaths([
121
+ documentedThreadsRoot,
122
+ desktopDocumentsRoot,
123
+ ]);
124
+
125
+ return {
126
+ codexHome,
127
+ roots,
128
+ documentedThreadsRoot,
129
+ desktopDocumentsRoot,
130
+ };
131
+ }
132
+
102
133
  async function projectListDirectory(params, options = {}) {
103
134
  const requestedPath = readString(params.path) || resolveHomeDir(options);
104
135
  const directory = await requireUsableDirectory(requestedPath, options);
@@ -184,6 +215,116 @@ async function projectCreateDirectory(params, options = {}) {
184
215
  };
185
216
  }
186
217
 
218
+ // Mirrors the Codex Desktop "Chats" convention by materializing a dated rootless
219
+ // chat folder under `~/Documents/Codex/<DATE>/<slug>` (or its Windows equivalent
220
+ // `%USERPROFILE%\Documents\Codex\<DATE>\<slug>`) before the iOS client issues
221
+ // `thread/start`. Without an explicit cwd the app-server falls back to its own
222
+ // process working directory (often the user's home), which would otherwise show
223
+ // up in the sidebar as a project named after the user account.
224
+ async function projectCreateRootlessChatRoot(params = {}, options = {}) {
225
+ const homeDir = resolveHomeDir(options);
226
+ const desktopDocumentsRoot = path.join(homeDir, "Documents", "Codex");
227
+ const dateFolder = readString(params.dateFolder) || formatRootlessChatDate(new Date());
228
+ if (!isISODateFolderName(dateFolder)) {
229
+ throw projectError("invalid_date_folder", "The chat date folder must be in YYYY-MM-DD format.");
230
+ }
231
+
232
+ const slugBase = rootlessChatSlugFromPromptHint(params.promptHint);
233
+ const dateRootPath = path.join(desktopDocumentsRoot, dateFolder);
234
+
235
+ try {
236
+ await fs.promises.mkdir(dateRootPath, { recursive: true });
237
+ } catch (error) {
238
+ throw projectError("create_failed", error?.message || "Unable to prepare the Codex chats folder.");
239
+ }
240
+
241
+ const targetPath = await reserveUniqueRootlessChatPath(dateRootPath, slugBase);
242
+ try {
243
+ await fs.promises.mkdir(targetPath, { recursive: false });
244
+ } catch (error) {
245
+ if (error?.code !== "EEXIST") {
246
+ throw projectError("create_failed", error?.message || "Unable to create the rootless chat folder.");
247
+ }
248
+ }
249
+
250
+ const resolvedPath = await safeRealpath(targetPath);
251
+ return {
252
+ path: resolvedPath,
253
+ parentPath: dateRootPath,
254
+ name: path.basename(resolvedPath),
255
+ slug: slugBase,
256
+ dateFolder,
257
+ root: desktopDocumentsRoot,
258
+ };
259
+ }
260
+
261
+ // Codex Desktop generates kebab-case slugs from the first words of the prompt
262
+ // (e.g. "mi-dici-la-pwd-corrente" from "mi dici la pwd corrente?"). We mirror
263
+ // the same shape so that side-by-side users with the desktop app see a
264
+ // consistent chat folder naming scheme.
265
+ function rootlessChatSlugFromPromptHint(rawPromptHint) {
266
+ const hint = typeof rawPromptHint === "string" ? rawPromptHint.normalize("NFKD") : "";
267
+ const sanitized = hint
268
+ .toLowerCase()
269
+ .replace(/[^a-z0-9]+/gu, " ")
270
+ .trim();
271
+ if (!sanitized) {
272
+ return ROOTLESS_CHAT_SLUG_FALLBACK;
273
+ }
274
+
275
+ const tokens = sanitized
276
+ .split(/\s+/)
277
+ .filter(Boolean)
278
+ .slice(0, ROOTLESS_CHAT_SLUG_MAX_TOKENS);
279
+ if (!tokens.length) {
280
+ return ROOTLESS_CHAT_SLUG_FALLBACK;
281
+ }
282
+
283
+ let slug = tokens.join("-");
284
+ if (slug.length > ROOTLESS_CHAT_SLUG_MAX_LENGTH) {
285
+ slug = slug.slice(0, ROOTLESS_CHAT_SLUG_MAX_LENGTH).replace(/-+$/g, "");
286
+ }
287
+ return slug || ROOTLESS_CHAT_SLUG_FALLBACK;
288
+ }
289
+
290
+ // Appends "-2", "-3", … so two rapid-fire chats with the same first words
291
+ // keep distinct folders instead of fighting over the same path.
292
+ async function reserveUniqueRootlessChatPath(parentDirectory, slugBase) {
293
+ for (let attempt = 0; attempt < ROOTLESS_CHAT_DEDUP_LIMIT; attempt += 1) {
294
+ const candidateSlug = attempt === 0 ? slugBase : `${slugBase}-${attempt + 1}`;
295
+ const candidatePath = path.join(parentDirectory, candidateSlug);
296
+ try {
297
+ await fs.promises.access(candidatePath, fs.constants.F_OK);
298
+ } catch {
299
+ return candidatePath;
300
+ }
301
+ }
302
+
303
+ return path.join(parentDirectory, `${slugBase}-${Date.now()}`);
304
+ }
305
+
306
+ function formatRootlessChatDate(date) {
307
+ const year = date.getFullYear().toString().padStart(4, "0");
308
+ const month = (date.getMonth() + 1).toString().padStart(2, "0");
309
+ const day = date.getDate().toString().padStart(2, "0");
310
+ return `${year}-${month}-${day}`;
311
+ }
312
+
313
+ function isISODateFolderName(value) {
314
+ if (typeof value !== "string" || value.length !== 10) {
315
+ return false;
316
+ }
317
+ return /^\d{4}-\d{2}-\d{2}$/u.test(value);
318
+ }
319
+
320
+ async function safeRealpath(candidatePath) {
321
+ try {
322
+ return await fs.promises.realpath(candidatePath);
323
+ } catch {
324
+ return path.resolve(candidatePath);
325
+ }
326
+ }
327
+
187
328
  // ─── Filesystem Helpers ──────────────────────────────────────
188
329
 
189
330
  async function readDirectoryEntries(directoryPath, options = {}) {
@@ -462,6 +603,23 @@ function resolveHomeDir(options = {}) {
462
603
  return options.homeDir || os.homedir();
463
604
  }
464
605
 
606
+ function uniqueExistingOrCandidatePaths(paths) {
607
+ const seen = new Set();
608
+ const result = [];
609
+
610
+ for (const candidatePath of paths) {
611
+ const normalizedPath = path.resolve(candidatePath);
612
+ const realPath = realpathSyncIfAvailable(normalizedPath) || normalizedPath;
613
+ if (seen.has(realPath)) {
614
+ continue;
615
+ }
616
+ seen.add(realPath);
617
+ result.push(realPath);
618
+ }
619
+
620
+ return result;
621
+ }
622
+
465
623
  function realpathSyncIfAvailable(candidatePath) {
466
624
  try {
467
625
  return fs.realpathSync(candidatePath);
@@ -485,9 +643,12 @@ module.exports = {
485
643
  handleProjectRequest,
486
644
  handleProjectMethod,
487
645
  projectQuickLocations,
646
+ projectProjectlessRoots,
488
647
  projectListDirectory,
489
648
  projectSearchDirectories,
490
649
  projectValidatePath,
491
650
  projectCreateDirectory,
651
+ projectCreateRootlessChatRoot,
492
652
  validateDirectory,
653
+ rootlessChatSlugFromPromptHint,
493
654
  };
@@ -5,6 +5,8 @@
5
5
  // Depends on: global fetch
6
6
 
7
7
  const DEFAULT_PUSH_SERVICE_TIMEOUT_MS = 10_000;
8
+ const DEFAULT_PUSH_SERVICE_RETRY_LIMIT = 2;
9
+ const DEFAULT_PUSH_SERVICE_RETRY_BASE_DELAY_MS = 500;
8
10
 
9
11
  function createPushNotificationServiceClient({
10
12
  baseUrl = "",
@@ -13,8 +15,16 @@ function createPushNotificationServiceClient({
13
15
  fetchImpl = globalThis.fetch,
14
16
  logPrefix = "[remodex]",
15
17
  requestTimeoutMs = DEFAULT_PUSH_SERVICE_TIMEOUT_MS,
18
+ retryLimit = DEFAULT_PUSH_SERVICE_RETRY_LIMIT,
19
+ retryBaseDelayMs = DEFAULT_PUSH_SERVICE_RETRY_BASE_DELAY_MS,
20
+ sleepImpl = sleep,
16
21
  } = {}) {
17
22
  const normalizedBaseUrl = normalizeBaseUrl(baseUrl);
23
+ const safeRetryLimit = normalizeNonNegativeInteger(retryLimit, DEFAULT_PUSH_SERVICE_RETRY_LIMIT);
24
+ const safeRetryBaseDelayMs = normalizeNonNegativeInteger(
25
+ retryBaseDelayMs,
26
+ DEFAULT_PUSH_SERVICE_RETRY_BASE_DELAY_MS
27
+ );
18
28
 
19
29
  async function registerDevice({
20
30
  deviceToken,
@@ -55,48 +65,70 @@ function createPushNotificationServiceClient({
55
65
  return { ok: false, skipped: true };
56
66
  }
57
67
 
58
- const controller = typeof AbortController === "function" && requestTimeoutMs > 0
59
- ? new AbortController()
60
- : null;
61
- const timeoutID = controller
62
- ? setTimeout(() => {
63
- controller.abort(createTimeoutAbortError(requestTimeoutMs));
64
- }, requestTimeoutMs)
65
- : null;
66
-
67
- let response;
68
- try {
69
- response = await fetchImpl(`${normalizedBaseUrl}${pathname}`, {
70
- method: "POST",
71
- headers: {
72
- "content-type": "application/json",
73
- },
74
- body: JSON.stringify(payload),
75
- signal: controller?.signal,
76
- });
77
- } catch (error) {
78
- if (isAbortError(error)) {
79
- const timeoutError = new Error(`Push service request timed out after ${requestTimeoutMs}ms`);
80
- timeoutError.code = "push_request_timeout";
81
- throw timeoutError;
68
+ const bodyJSON = JSON.stringify(payload);
69
+ let lastError = null;
70
+ for (let attempt = 0; attempt <= safeRetryLimit; attempt += 1) {
71
+ if (attempt > 0) {
72
+ const delayMs = safeRetryBaseDelayMs * Math.pow(2, attempt - 1);
73
+ if (delayMs > 0) {
74
+ await sleepImpl(delayMs);
75
+ }
82
76
  }
83
- throw error;
84
- } finally {
85
- if (timeoutID) {
86
- clearTimeout(timeoutID);
77
+
78
+ const controller = typeof AbortController === "function" && requestTimeoutMs > 0
79
+ ? new AbortController()
80
+ : null;
81
+ const timeoutID = controller
82
+ ? setTimeout(() => {
83
+ controller.abort(createTimeoutAbortError(requestTimeoutMs));
84
+ }, requestTimeoutMs)
85
+ : null;
86
+
87
+ let response;
88
+ try {
89
+ response = await fetchImpl(`${normalizedBaseUrl}${pathname}`, {
90
+ method: "POST",
91
+ headers: {
92
+ "content-type": "application/json",
93
+ },
94
+ body: bodyJSON,
95
+ signal: controller?.signal,
96
+ });
97
+ } catch (error) {
98
+ lastError = error;
99
+ if (isAbortError(error)) {
100
+ continue;
101
+ }
102
+ if (isRetryableNetworkError(error)) {
103
+ continue;
104
+ }
105
+ throw error;
106
+ } finally {
107
+ if (timeoutID) {
108
+ clearTimeout(timeoutID);
109
+ }
110
+ }
111
+
112
+ const responseText = await response.text();
113
+ const parsed = safeParseJSON(responseText);
114
+ if (!response.ok) {
115
+ const message = parsed?.error || parsed?.message || responseText || `HTTP ${response.status}`;
116
+ const error = new Error(message);
117
+ error.status = response.status;
118
+ if (response.status >= 500 && attempt < safeRetryLimit) {
119
+ lastError = error;
120
+ continue;
121
+ }
122
+ throw error;
87
123
  }
88
- }
89
124
 
90
- const responseText = await response.text();
91
- const parsed = safeParseJSON(responseText);
92
- if (!response.ok) {
93
- const message = parsed?.error || parsed?.message || responseText || `HTTP ${response.status}`;
94
- const error = new Error(message);
95
- error.status = response.status;
96
- throw error;
125
+ return parsed ?? { ok: true };
97
126
  }
98
127
 
99
- return parsed ?? { ok: true };
128
+ if (lastError) {
129
+ throw lastError;
130
+ }
131
+ return { ok: false };
100
132
  }
101
133
 
102
134
  return {
@@ -127,6 +159,7 @@ function normalizeBaseUrl(value) {
127
159
  function createTimeoutAbortError(timeoutMs) {
128
160
  const error = new Error(`Push service request timed out after ${timeoutMs}ms`);
129
161
  error.name = "AbortError";
162
+ error.code = "push_request_timeout";
130
163
  return error;
131
164
  }
132
165
 
@@ -134,6 +167,21 @@ function isAbortError(error) {
134
167
  return error?.name === "AbortError" || error?.code === "ABORT_ERR";
135
168
  }
136
169
 
170
+ function isRetryableNetworkError(error) {
171
+ const code = error?.code ?? "";
172
+ return code === "ECONNRESET" || code === "ECONNREFUSED" || code === "ETIMEDOUT"
173
+ || code === "ENETUNREACH" || code === "EHOSTUNREACH" || code === "ENOTFOUND"
174
+ || error?.message?.includes("fetch failed");
175
+ }
176
+
177
+ function normalizeNonNegativeInteger(value, fallback) {
178
+ return Number.isInteger(value) && value >= 0 ? value : fallback;
179
+ }
180
+
181
+ function sleep(delayMs) {
182
+ return new Promise((resolve) => setTimeout(resolve, delayMs));
183
+ }
184
+
137
185
  function safeParseJSON(value) {
138
186
  if (!value || typeof value !== "string") {
139
187
  return null;
@@ -9,6 +9,9 @@ const {
9
9
  } = require("./push-notification-completion-dedupe");
10
10
 
11
11
  const DEFAULT_PREVIEW_MAX_CHARS = 160;
12
+ const MAX_THREAD_TITLE_ENTRIES = 200;
13
+ const MAX_TURN_STATE_ENTRIES = 500;
14
+ const MAX_THREAD_ID_BY_TURN_ENTRIES = 500;
12
15
 
13
16
  function createPushNotificationTracker({
14
17
  sessionId,
@@ -73,6 +76,10 @@ function createPushNotificationTracker({
73
76
  // Remembers thread/turn linkage before the terminal event arrives on a different payload shape.
74
77
  function rememberMessageContext({ threadId, turnId, params, eventObject }) {
75
78
  if (threadId && turnId) {
79
+ if (!threadIdByTurnId.has(turnId) && threadIdByTurnId.size >= MAX_THREAD_ID_BY_TURN_ENTRIES) {
80
+ const oldest = threadIdByTurnId.keys().next().value;
81
+ threadIdByTurnId.delete(oldest);
82
+ }
76
83
  threadIdByTurnId.set(turnId, threadId);
77
84
  ensureTurnState(threadId, turnId);
78
85
  }
@@ -83,6 +90,10 @@ function createPushNotificationTracker({
83
90
 
84
91
  const nextTitle = extractThreadTitle(params, eventObject);
85
92
  if (nextTitle) {
93
+ if (!threadTitleById.has(threadId) && threadTitleById.size >= MAX_THREAD_TITLE_ENTRIES) {
94
+ const oldest = threadTitleById.keys().next().value;
95
+ threadTitleById.delete(oldest);
96
+ }
86
97
  threadTitleById.set(threadId, nextTitle);
87
98
  }
88
99
  }
@@ -225,6 +236,10 @@ function createPushNotificationTracker({
225
236
  function ensureTurnState(threadId, turnId) {
226
237
  const key = turnStateKey(threadId, turnId);
227
238
  if (!turnStateByKey.has(key)) {
239
+ if (turnStateByKey.size >= MAX_TURN_STATE_ENTRIES) {
240
+ const oldest = turnStateByKey.keys().next().value;
241
+ turnStateByKey.delete(oldest);
242
+ }
228
243
  turnStateByKey.set(key, {
229
244
  latestAssistantPreview: "",
230
245
  latestFailurePreview: "",