@makerbi/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,185 @@
1
+ // FILE: package-version-status.js
2
+ // Purpose: Reads the installed Remodex package version and caches the latest published npm version.
3
+ // Layer: CLI helper
4
+ // Exports: createBridgePackageVersionStatusReader
5
+ // Depends on: https, ../package.json
6
+
7
+ const https = require("https");
8
+ const { version: installedVersion = "" } = require("../package.json");
9
+
10
+ const DEFAULT_CACHE_TTL_MS = 30 * 60 * 1000;
11
+ const DEFAULT_EMPTY_CACHE_RETRY_MS = 60 * 1000;
12
+ const DEFAULT_INITIAL_FETCH_WAIT_MS = 250;
13
+ const REMODEX_REGISTRY_URL = "https://registry.npmjs.org/remodex/latest";
14
+
15
+ function createBridgePackageVersionStatusReader({
16
+ cacheTtlMs = DEFAULT_CACHE_TTL_MS,
17
+ emptyCacheRetryMs = DEFAULT_EMPTY_CACHE_RETRY_MS,
18
+ initialFetchWaitMs = DEFAULT_INITIAL_FETCH_WAIT_MS,
19
+ registryUrl = REMODEX_REGISTRY_URL,
20
+ fetchLatestPublishedVersionImpl = fetchLatestPublishedVersion,
21
+ } = {}) {
22
+ let cachedLatestVersion = "";
23
+ let lastSuccessfulResolveAt = 0;
24
+ let lastAttemptedAt = 0;
25
+ let inFlightPromise = null;
26
+
27
+ // Shares one cached lookup across repeated Settings/account refreshes without
28
+ // holding the local account status path hostage on a slow npm registry call.
29
+ return async function readBridgePackageVersionStatus() {
30
+ const now = Date.now();
31
+ refreshLatestVersionInBackground({
32
+ now,
33
+ cacheTtlMs,
34
+ emptyCacheRetryMs,
35
+ registryUrl,
36
+ fetchLatestPublishedVersionImpl,
37
+ getCachedLatestVersion: () => cachedLatestVersion,
38
+ getLastSuccessfulResolveAt: () => lastSuccessfulResolveAt,
39
+ getLastAttemptedAt: () => lastAttemptedAt,
40
+ getInFlightPromise: () => inFlightPromise,
41
+ setLastAttemptedAt: (value) => {
42
+ lastAttemptedAt = value;
43
+ },
44
+ setInFlightPromise: (value) => {
45
+ inFlightPromise = value;
46
+ },
47
+ setCachedLatestVersion: (value) => {
48
+ cachedLatestVersion = value;
49
+ },
50
+ setLastSuccessfulResolveAt: (value) => {
51
+ lastSuccessfulResolveAt = value;
52
+ },
53
+ });
54
+
55
+ const reportedLatestVersion = await resolveReportedLatestVersion({
56
+ initialFetchWaitMs,
57
+ getCachedLatestVersion: () => cachedLatestVersion,
58
+ getInFlightPromise: () => inFlightPromise,
59
+ });
60
+
61
+ return {
62
+ bridgeVersion: normalizeVersion(installedVersion) || null,
63
+ bridgeLatestVersion: reportedLatestVersion || null,
64
+ };
65
+ };
66
+ }
67
+
68
+ // Waits briefly on the very first lookup so fast registry responses can populate
69
+ // Settings immediately, while slow/offline requests still fall back to background refresh.
70
+ async function resolveReportedLatestVersion({
71
+ initialFetchWaitMs,
72
+ getCachedLatestVersion,
73
+ getInFlightPromise,
74
+ }) {
75
+ const cachedLatestVersion = getCachedLatestVersion();
76
+ if (cachedLatestVersion) {
77
+ return cachedLatestVersion;
78
+ }
79
+
80
+ const inFlightPromise = getInFlightPromise();
81
+ if (!inFlightPromise || initialFetchWaitMs <= 0) {
82
+ return "";
83
+ }
84
+
85
+ const latestVersion = await Promise.race([
86
+ inFlightPromise.catch(() => ""),
87
+ delay(initialFetchWaitMs).then(() => ""),
88
+ ]);
89
+
90
+ return latestVersion || getCachedLatestVersion();
91
+ }
92
+
93
+ // Refreshes the published version opportunistically while keeping callers fast.
94
+ function refreshLatestVersionInBackground({
95
+ now,
96
+ cacheTtlMs,
97
+ emptyCacheRetryMs,
98
+ registryUrl,
99
+ fetchLatestPublishedVersionImpl,
100
+ getCachedLatestVersion,
101
+ getLastSuccessfulResolveAt,
102
+ getLastAttemptedAt,
103
+ getInFlightPromise,
104
+ setLastAttemptedAt,
105
+ setInFlightPromise,
106
+ setCachedLatestVersion,
107
+ setLastSuccessfulResolveAt,
108
+ }) {
109
+ if (getInFlightPromise()) {
110
+ return;
111
+ }
112
+
113
+ const cachedLatestVersion = getCachedLatestVersion();
114
+ const isCacheFresh = cachedLatestVersion && now - getLastSuccessfulResolveAt() < cacheTtlMs;
115
+ const retryWindowMs = cachedLatestVersion ? cacheTtlMs : emptyCacheRetryMs;
116
+ const recentlyAttempted = now - getLastAttemptedAt() < retryWindowMs;
117
+
118
+ if (isCacheFresh || recentlyAttempted) {
119
+ return;
120
+ }
121
+
122
+ setLastAttemptedAt(now);
123
+ setInFlightPromise(
124
+ fetchLatestPublishedVersionImpl(registryUrl)
125
+ .then((latestVersion) => {
126
+ setCachedLatestVersion(latestVersion);
127
+ setLastSuccessfulResolveAt(Date.now());
128
+ return latestVersion;
129
+ })
130
+ .catch(() => getCachedLatestVersion())
131
+ .finally(() => {
132
+ setInFlightPromise(null);
133
+ })
134
+ );
135
+ }
136
+
137
+ function fetchLatestPublishedVersion(registryUrl) {
138
+ return new Promise((resolve, reject) => {
139
+ const request = https.get(registryUrl, (response) => {
140
+ if (response.statusCode !== 200) {
141
+ response.resume();
142
+ reject(new Error(`Unexpected npm registry status: ${response.statusCode || "unknown"}`));
143
+ return;
144
+ }
145
+
146
+ let raw = "";
147
+ response.setEncoding("utf8");
148
+ response.on("data", (chunk) => {
149
+ raw += chunk;
150
+ });
151
+ response.on("end", () => {
152
+ try {
153
+ const parsed = JSON.parse(raw);
154
+ const latestVersion = normalizeVersion(parsed?.version);
155
+ if (!latestVersion) {
156
+ reject(new Error("npm registry response missing version"));
157
+ return;
158
+ }
159
+ resolve(latestVersion);
160
+ } catch (error) {
161
+ reject(error);
162
+ }
163
+ });
164
+ });
165
+
166
+ request.setTimeout(4_000, () => {
167
+ request.destroy(new Error("npm registry request timed out"));
168
+ });
169
+ request.on("error", reject);
170
+ });
171
+ }
172
+
173
+ function normalizeVersion(value) {
174
+ return typeof value === "string" && value.trim() ? value.trim() : "";
175
+ }
176
+
177
+ function delay(timeoutMs) {
178
+ return new Promise((resolve) => {
179
+ setTimeout(resolve, timeoutMs);
180
+ });
181
+ }
182
+
183
+ module.exports = {
184
+ createBridgePackageVersionStatusReader,
185
+ };
@@ -0,0 +1,4 @@
1
+ {
2
+ "relayUrl": "wss://remodex.vectorvein.com/relay",
3
+ "pushServiceUrl": ""
4
+ }
@@ -0,0 +1,359 @@
1
+ // FILE: project-handler.js
2
+ // Purpose: Serves safe Mac-local project folder discovery and creation requests from the iOS app.
3
+ // Layer: Bridge handler
4
+ // Exports: handleProjectRequest plus testable project filesystem helpers
5
+ // Depends on: fs, os, path
6
+
7
+ const fs = require("fs");
8
+ const os = require("os");
9
+ const path = require("path");
10
+
11
+ const DEFAULT_DIRECTORY_LIMIT = 200;
12
+ const DEFAULT_HIDDEN_DIRECTORY_NAMES = new Set(["Library"]);
13
+
14
+ // ─── ENTRY POINT ─────────────────────────────────────────────
15
+
16
+ function handleProjectRequest(rawMessage, sendResponse) {
17
+ let parsed;
18
+ try {
19
+ parsed = JSON.parse(rawMessage);
20
+ } catch {
21
+ return false;
22
+ }
23
+
24
+ const method = typeof parsed?.method === "string" ? parsed.method.trim() : "";
25
+ if (!method.startsWith("project/")) {
26
+ return false;
27
+ }
28
+
29
+ const id = parsed.id;
30
+ const params = parsed.params || {};
31
+
32
+ handleProjectMethod(method, params)
33
+ .then((result) => {
34
+ sendResponse(JSON.stringify({ id, result }));
35
+ })
36
+ .catch((err) => {
37
+ const errorCode = err.errorCode || "project_error";
38
+ const message = err.userMessage || err.message || "Unknown project folder error";
39
+ sendResponse(
40
+ JSON.stringify({
41
+ id,
42
+ error: {
43
+ code: -32000,
44
+ message,
45
+ data: { errorCode },
46
+ },
47
+ })
48
+ );
49
+ });
50
+
51
+ return true;
52
+ }
53
+
54
+ async function handleProjectMethod(method, params, options = {}) {
55
+ switch (method) {
56
+ case "project/quickLocations":
57
+ return projectQuickLocations(options);
58
+ case "project/listDirectory":
59
+ return projectListDirectory(params, options);
60
+ case "project/validatePath":
61
+ return projectValidatePath(params, options);
62
+ case "project/createDirectory":
63
+ return projectCreateDirectory(params, options);
64
+ default:
65
+ throw projectError("unknown_method", `Unknown project method: ${method}`);
66
+ }
67
+ }
68
+
69
+ // ─── Project Methods ─────────────────────────────────────────
70
+
71
+ async function projectQuickLocations(options = {}) {
72
+ const homeDir = resolveHomeDir(options);
73
+ const candidates = [
74
+ { id: "home", label: "Home", path: homeDir },
75
+ { id: "developer", label: "Developer", path: path.join(homeDir, "Developer") },
76
+ { id: "documents", label: "Documents", path: path.join(homeDir, "Documents") },
77
+ { id: "desktop", label: "Desktop", path: path.join(homeDir, "Desktop") },
78
+ ];
79
+
80
+ const locations = [];
81
+ for (const candidate of candidates) {
82
+ const validated = await validateDirectory(candidate.path, options).catch(() => null);
83
+ if (!validated?.exists || !validated.isDirectory || !validated.isAllowed) {
84
+ continue;
85
+ }
86
+
87
+ locations.push({
88
+ id: candidate.id,
89
+ label: candidate.label,
90
+ path: validated.path,
91
+ });
92
+ }
93
+
94
+ return { locations };
95
+ }
96
+
97
+ async function projectListDirectory(params, options = {}) {
98
+ const requestedPath = readString(params.path) || resolveHomeDir(options);
99
+ const directory = await requireUsableDirectory(requestedPath, options);
100
+ const includeHidden = params.includeHidden === true;
101
+ const limit = normalizeLimit(params.limit);
102
+ const entries = await readDirectoryEntries(directory.path, {
103
+ ...options,
104
+ includeHidden,
105
+ limit,
106
+ });
107
+
108
+ return {
109
+ path: directory.path,
110
+ parentPath: parentPathWithinAllowedRoots(directory.path, options),
111
+ entries,
112
+ };
113
+ }
114
+
115
+ async function projectValidatePath(params, options = {}) {
116
+ const requestedPath = readString(params.path);
117
+ if (!requestedPath) {
118
+ throw projectError("missing_path", "A folder path is required.");
119
+ }
120
+
121
+ return validateDirectory(requestedPath, options);
122
+ }
123
+
124
+ async function projectCreateDirectory(params, options = {}) {
125
+ const parentPath = readString(params.parentPath || params.parent || params.path);
126
+ const rawName = readString(params.name || params.folderName || params.directoryName);
127
+ if (!parentPath) {
128
+ throw projectError("missing_parent_path", "A parent folder path is required.");
129
+ }
130
+ if (!rawName) {
131
+ throw projectError("missing_directory_name", "A new folder name is required.");
132
+ }
133
+
134
+ const parent = await requireUsableDirectory(parentPath, options);
135
+ const name = normalizeNewDirectoryName(rawName);
136
+ const targetPath = path.join(parent.path, name);
137
+ assertPathAllowed(targetPath, options);
138
+
139
+ try {
140
+ await fs.promises.mkdir(targetPath, { recursive: false });
141
+ } catch (error) {
142
+ if (error?.code === "EEXIST") {
143
+ throw projectError("directory_exists", "A folder with that name already exists.");
144
+ }
145
+ throw projectError("create_failed", error?.message || "Unable to create that folder.");
146
+ }
147
+
148
+ const created = await requireUsableDirectory(targetPath, options);
149
+ return {
150
+ path: created.path,
151
+ parentPath: parent.path,
152
+ name: path.basename(created.path),
153
+ };
154
+ }
155
+
156
+ // ─── Filesystem Helpers ──────────────────────────────────────
157
+
158
+ async function readDirectoryEntries(directoryPath, options = {}) {
159
+ let dirents;
160
+ try {
161
+ dirents = await fs.promises.readdir(directoryPath, { withFileTypes: true });
162
+ } catch (error) {
163
+ throw projectError("read_failed", error?.message || "Unable to read that folder.");
164
+ }
165
+
166
+ const entries = [];
167
+ for (const dirent of dirents) {
168
+ if (!options.includeHidden && isHiddenDirectoryName(dirent.name)) {
169
+ continue;
170
+ }
171
+
172
+ const childPath = path.join(directoryPath, dirent.name);
173
+ const directory = await directoryEntryForPath(childPath, dirent, options);
174
+ if (directory) {
175
+ entries.push(directory);
176
+ }
177
+ }
178
+
179
+ return entries
180
+ .sort((left, right) => left.name.localeCompare(right.name, undefined, { sensitivity: "base" }))
181
+ .slice(0, options.limit || DEFAULT_DIRECTORY_LIMIT);
182
+ }
183
+
184
+ async function directoryEntryForPath(candidatePath, dirent, options = {}) {
185
+ if (!dirent.isDirectory() && !dirent.isSymbolicLink()) {
186
+ return null;
187
+ }
188
+
189
+ const validation = await validateDirectory(candidatePath, options).catch(() => null);
190
+ if (!validation?.exists || !validation.isDirectory || !validation.isAllowed) {
191
+ return null;
192
+ }
193
+
194
+ return {
195
+ name: dirent.name,
196
+ path: validation.path,
197
+ isSymlink: dirent.isSymbolicLink(),
198
+ };
199
+ }
200
+
201
+ async function requireUsableDirectory(candidatePath, options = {}) {
202
+ const validation = await validateDirectory(candidatePath, options);
203
+ if (!validation.isAllowed) {
204
+ throw projectError("path_not_allowed", "That folder is outside the allowed local project locations.");
205
+ }
206
+ if (!validation.exists) {
207
+ throw projectError("missing_directory", "That folder does not exist on this Mac.");
208
+ }
209
+ if (!validation.isDirectory) {
210
+ throw projectError("not_directory", "That path is not a folder.");
211
+ }
212
+
213
+ return validation;
214
+ }
215
+
216
+ async function validateDirectory(candidatePath, options = {}) {
217
+ const normalizedPath = normalizeCandidatePath(candidatePath, options);
218
+ const isAllowed = isPathAllowed(normalizedPath, options);
219
+ if (!isAllowed) {
220
+ return {
221
+ path: normalizedPath,
222
+ exists: false,
223
+ isDirectory: false,
224
+ isAllowed: false,
225
+ };
226
+ }
227
+
228
+ try {
229
+ const realPath = await fs.promises.realpath(normalizedPath);
230
+ const stats = await fs.promises.stat(realPath);
231
+ return {
232
+ path: realPath,
233
+ exists: true,
234
+ isDirectory: stats.isDirectory(),
235
+ isAllowed: isPathAllowed(realPath, options),
236
+ };
237
+ } catch {
238
+ return {
239
+ path: normalizedPath,
240
+ exists: false,
241
+ isDirectory: false,
242
+ isAllowed,
243
+ };
244
+ }
245
+ }
246
+
247
+ function parentPathWithinAllowedRoots(candidatePath, options = {}) {
248
+ const parentPath = path.dirname(candidatePath);
249
+ if (!parentPath || parentPath === candidatePath) {
250
+ return null;
251
+ }
252
+
253
+ return isPathAllowed(parentPath, options) ? parentPath : null;
254
+ }
255
+
256
+ function assertPathAllowed(candidatePath, options = {}) {
257
+ if (!isPathAllowed(candidatePath, options)) {
258
+ throw projectError("path_not_allowed", "That folder is outside the allowed local project locations.");
259
+ }
260
+ }
261
+
262
+ function isPathAllowed(candidatePath, options = {}) {
263
+ const normalizedPath = path.resolve(candidatePath);
264
+ return allowedProjectRoots(options).some((rootPath) => samePathOrDescendant(normalizedPath, rootPath));
265
+ }
266
+
267
+ function allowedProjectRoots(options = {}) {
268
+ const roots = Array.isArray(options.allowedRoots) && options.allowedRoots.length
269
+ ? options.allowedRoots
270
+ : [resolveHomeDir(options)];
271
+
272
+ return [...new Set(roots.flatMap((rootPath) => {
273
+ const resolvedRoot = path.resolve(rootPath);
274
+ return [resolvedRoot, realpathSyncIfAvailable(resolvedRoot)].filter(Boolean);
275
+ }))];
276
+ }
277
+
278
+ function samePathOrDescendant(candidatePath, rootPath) {
279
+ const relative = path.relative(rootPath, candidatePath);
280
+ return relative === "" || (!!relative && !relative.startsWith("..") && !path.isAbsolute(relative));
281
+ }
282
+
283
+ function normalizeCandidatePath(candidatePath, options = {}) {
284
+ const rawPath = readString(candidatePath);
285
+ if (!rawPath) {
286
+ throw projectError("missing_path", "A folder path is required.");
287
+ }
288
+
289
+ if (rawPath === "~" || rawPath.startsWith("~/")) {
290
+ return path.resolve(resolveHomeDir(options), rawPath.slice(2));
291
+ }
292
+
293
+ if (!path.isAbsolute(rawPath)) {
294
+ throw projectError("invalid_path", "Use an absolute folder path.");
295
+ }
296
+
297
+ return path.resolve(rawPath);
298
+ }
299
+
300
+ function isHiddenDirectoryName(name) {
301
+ return name.startsWith(".") || DEFAULT_HIDDEN_DIRECTORY_NAMES.has(name);
302
+ }
303
+
304
+ function normalizeNewDirectoryName(rawName) {
305
+ const name = rawName.trim();
306
+ if (!name || name === "." || name === "..") {
307
+ throw projectError("invalid_directory_name", "Use a valid folder name.");
308
+ }
309
+ if (name.includes("/") || name.includes("\\") || name.includes("\0")) {
310
+ throw projectError("invalid_directory_name", "Folder names cannot contain path separators.");
311
+ }
312
+ if (name.length > 120) {
313
+ throw projectError("invalid_directory_name", "Use a shorter folder name.");
314
+ }
315
+
316
+ return name;
317
+ }
318
+
319
+ function normalizeLimit(rawLimit) {
320
+ const numericLimit = Number(rawLimit);
321
+ if (!Number.isFinite(numericLimit) || numericLimit <= 0) {
322
+ return DEFAULT_DIRECTORY_LIMIT;
323
+ }
324
+
325
+ return Math.min(Math.floor(numericLimit), DEFAULT_DIRECTORY_LIMIT);
326
+ }
327
+
328
+ function resolveHomeDir(options = {}) {
329
+ return options.homeDir || os.homedir();
330
+ }
331
+
332
+ function realpathSyncIfAvailable(candidatePath) {
333
+ try {
334
+ return fs.realpathSync(candidatePath);
335
+ } catch {
336
+ return null;
337
+ }
338
+ }
339
+
340
+ function readString(value) {
341
+ return typeof value === "string" && value.trim() ? value.trim() : null;
342
+ }
343
+
344
+ function projectError(errorCode, userMessage) {
345
+ const err = new Error(userMessage);
346
+ err.errorCode = errorCode;
347
+ err.userMessage = userMessage;
348
+ return err;
349
+ }
350
+
351
+ module.exports = {
352
+ handleProjectRequest,
353
+ handleProjectMethod,
354
+ projectQuickLocations,
355
+ projectListDirectory,
356
+ projectValidatePath,
357
+ projectCreateDirectory,
358
+ validateDirectory,
359
+ };
@@ -0,0 +1,147 @@
1
+ // FILE: push-notification-completion-dedupe.js
2
+ // Purpose: Owns duplicate-suppression state for completion pushes emitted by the bridge.
3
+ // Layer: Bridge helper
4
+ // Exports: createPushNotificationCompletionDedupe
5
+ // Depends on: none
6
+
7
+ const DEFAULT_SENT_DEDUPE_TTL_MS = 24 * 60 * 60 * 1000;
8
+ const DEFAULT_STATUS_FALLBACK_TTL_MS = 5_000;
9
+
10
+ function createPushNotificationCompletionDedupe({
11
+ now = () => Date.now(),
12
+ sentDedupeTTLms = DEFAULT_SENT_DEDUPE_TTL_MS,
13
+ statusFallbackTTLms = DEFAULT_STATUS_FALLBACK_TTL_MS,
14
+ } = {}) {
15
+ const sentDedupeKeys = new Map();
16
+ const pendingDedupeKeys = new Set();
17
+ const recentTurnScopedCompletionsByThread = new Map();
18
+
19
+ function clearForNewRun(threadId) {
20
+ if (!readString(threadId)) {
21
+ return;
22
+ }
23
+
24
+ recentTurnScopedCompletionsByThread.delete(threadId);
25
+ }
26
+
27
+ // Thread-level terminal events are only a fallback when we have not already sent a turn-scoped completion.
28
+ function shouldSuppressThreadStatusFallback({ threadId, turnId, result } = {}) {
29
+ if (readString(turnId)) {
30
+ return false;
31
+ }
32
+
33
+ pruneRecentTurnScopedCompletions();
34
+ const previous = recentTurnScopedCompletionsByThread.get(readString(threadId));
35
+ return previous?.result === result;
36
+ }
37
+
38
+ function hasActiveDedupeKey(dedupeKey) {
39
+ const normalizedKey = readString(dedupeKey);
40
+ if (!normalizedKey) {
41
+ return false;
42
+ }
43
+
44
+ pruneSentDedupeKeys();
45
+ return sentDedupeKeys.has(normalizedKey) || pendingDedupeKeys.has(normalizedKey);
46
+ }
47
+
48
+ function beginNotification({ dedupeKey, threadId, turnId, result } = {}) {
49
+ const normalizedKey = readString(dedupeKey);
50
+ if (!normalizedKey) {
51
+ return;
52
+ }
53
+
54
+ pendingDedupeKeys.add(normalizedKey);
55
+ if (readString(turnId)) {
56
+ rememberTurnScopedCompletion(threadId, result);
57
+ }
58
+ }
59
+
60
+ function commitNotification({ dedupeKey, threadId, turnId, result } = {}) {
61
+ const normalizedKey = readString(dedupeKey);
62
+ if (normalizedKey) {
63
+ sentDedupeKeys.set(normalizedKey, now());
64
+ pendingDedupeKeys.delete(normalizedKey);
65
+ }
66
+
67
+ if (readString(turnId)) {
68
+ rememberTurnScopedCompletion(threadId, result);
69
+ }
70
+ }
71
+
72
+ function abortNotification({ dedupeKey, threadId, turnId, result } = {}) {
73
+ const normalizedKey = readString(dedupeKey);
74
+ if (normalizedKey) {
75
+ pendingDedupeKeys.delete(normalizedKey);
76
+ }
77
+
78
+ const normalizedThreadId = readString(threadId);
79
+ if (!readString(turnId) || !normalizedThreadId) {
80
+ return;
81
+ }
82
+
83
+ const previous = recentTurnScopedCompletionsByThread.get(normalizedThreadId);
84
+ if (previous?.result === result) {
85
+ recentTurnScopedCompletionsByThread.delete(normalizedThreadId);
86
+ }
87
+ }
88
+
89
+ // Exposed for focused tests so we can prove dedupe state stays bounded.
90
+ function debugState() {
91
+ pruneSentDedupeKeys();
92
+ pruneRecentTurnScopedCompletions();
93
+ return {
94
+ sentDedupeKeys: sentDedupeKeys.size,
95
+ pendingDedupeKeys: pendingDedupeKeys.size,
96
+ recentThreadFallbacks: recentTurnScopedCompletionsByThread.size,
97
+ };
98
+ }
99
+
100
+ function rememberTurnScopedCompletion(threadId, result) {
101
+ const normalizedThreadId = readString(threadId);
102
+ if (!normalizedThreadId) {
103
+ return;
104
+ }
105
+
106
+ recentTurnScopedCompletionsByThread.set(normalizedThreadId, {
107
+ result,
108
+ timestamp: now(),
109
+ });
110
+ }
111
+
112
+ function pruneSentDedupeKeys() {
113
+ const cutoff = now() - sentDedupeTTLms;
114
+ for (const [dedupeKey, timestamp] of sentDedupeKeys.entries()) {
115
+ if (timestamp < cutoff) {
116
+ sentDedupeKeys.delete(dedupeKey);
117
+ }
118
+ }
119
+ }
120
+
121
+ function pruneRecentTurnScopedCompletions() {
122
+ const cutoff = now() - statusFallbackTTLms;
123
+ for (const [threadId, entry] of recentTurnScopedCompletionsByThread.entries()) {
124
+ if (entry.timestamp < cutoff) {
125
+ recentTurnScopedCompletionsByThread.delete(threadId);
126
+ }
127
+ }
128
+ }
129
+
130
+ return {
131
+ abortNotification,
132
+ beginNotification,
133
+ clearForNewRun,
134
+ commitNotification,
135
+ debugState,
136
+ hasActiveDedupeKey,
137
+ shouldSuppressThreadStatusFallback,
138
+ };
139
+ }
140
+
141
+ function readString(value) {
142
+ return typeof value === "string" && value.trim() ? value.trim() : "";
143
+ }
144
+
145
+ module.exports = {
146
+ createPushNotificationCompletionDedupe,
147
+ };