@makerbi/remodex 3.1.0 → 3.3.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.
@@ -0,0 +1,325 @@
1
+ // FILE: thread-activity-store.js
2
+ // Purpose: Owns Activity snapshots, revisions, opt-in delivery, and bounded retention.
3
+ // Layer: CLI helper
4
+ // Exports: createThreadActivityStore
5
+ // Depends on: crypto
6
+
7
+ const { randomUUID } = require("crypto");
8
+
9
+ const ACTIVITY_SCHEMA_VERSION = 1;
10
+ const ACTIVITY_SUBSCRIBE_METHOD = "remodex/activity/subscribe";
11
+ const ACTIVITY_UNSUBSCRIBE_METHOD = "remodex/activity/unsubscribe";
12
+ const ACTIVITY_UPDATED_METHOD = "remodex/activity/updated";
13
+ const DEFAULT_MAX_INACTIVE_ENTRIES = 200;
14
+ const DEFAULT_COALESCE_MS = 225;
15
+
16
+ function createThreadActivityStore({
17
+ sendApplicationResponse,
18
+ createEpoch = randomUUID,
19
+ maxInactiveEntries = DEFAULT_MAX_INACTIVE_ENTRIES,
20
+ coalesceMs = DEFAULT_COALESCE_MS,
21
+ setTimeoutFn = setTimeout,
22
+ clearTimeoutFn = clearTimeout,
23
+ onEvict = () => {},
24
+ } = {}) {
25
+ const epoch = createEpoch();
26
+ const entriesByThreadId = new Map();
27
+ const serializedEntriesByThreadId = new Map();
28
+ const pendingUpsertsByThreadId = new Map();
29
+ const pendingRemovedThreadIds = new Set();
30
+ let revision = 0;
31
+ let deliveredRevision = 0;
32
+ let subscribed = false;
33
+ let flushTimer = null;
34
+ let disposed = false;
35
+
36
+ function handleRequest(message) {
37
+ if (disposed) {
38
+ return false;
39
+ }
40
+ if (message?.method === ACTIVITY_SUBSCRIBE_METHOD) {
41
+ handleSubscribe(message);
42
+ return true;
43
+ }
44
+ if (message?.method === ACTIVITY_UNSUBSCRIBE_METHOD) {
45
+ handleUnsubscribe(message);
46
+ return true;
47
+ }
48
+ return false;
49
+ }
50
+
51
+ function handleSubscribe(message) {
52
+ if (!validRequest(message)) {
53
+ sendInvalidParams(message?.id);
54
+ return;
55
+ }
56
+ clearPendingDelivery();
57
+ sendResponse(message.id, snapshot());
58
+ deliveredRevision = revision;
59
+ subscribed = true;
60
+ }
61
+
62
+ function handleUnsubscribe(message) {
63
+ if (!validRequest(message)) {
64
+ sendInvalidParams(message?.id);
65
+ return;
66
+ }
67
+ resetSubscriber();
68
+ sendResponse(message.id, { schemaVersion: ACTIVITY_SCHEMA_VERSION });
69
+ }
70
+
71
+ function upsert(entry) {
72
+ if (disposed || !validEntry(entry)) {
73
+ return false;
74
+ }
75
+ const previous = entriesByThreadId.get(entry.threadId) || null;
76
+ const serialized = JSON.stringify(entry);
77
+ if (serializedEntriesByThreadId.get(entry.threadId) === serialized) {
78
+ return false;
79
+ }
80
+
81
+ entriesByThreadId.delete(entry.threadId);
82
+ entriesByThreadId.set(entry.threadId, entry);
83
+ serializedEntriesByThreadId.set(entry.threadId, serialized);
84
+ const removedThreadIds = evictInactiveEntries();
85
+ commitChanges({
86
+ upserts: [entry],
87
+ removedThreadIds,
88
+ urgent: isUrgentChange(previous, entry) || removedThreadIds.length > 0,
89
+ });
90
+ return true;
91
+ }
92
+
93
+ function remove(threadId, { source = "" } = {}) {
94
+ const entry = entriesByThreadId.get(threadId);
95
+ if (!entry || (source && entry.source !== source)) {
96
+ return false;
97
+ }
98
+ entriesByThreadId.delete(threadId);
99
+ serializedEntriesByThreadId.delete(threadId);
100
+ commitChanges({ removedThreadIds: [threadId], urgent: true });
101
+ return true;
102
+ }
103
+
104
+ function markSourceStale(source, sourceGeneration = null) {
105
+ const staleEntries = [];
106
+ for (const entry of entriesByThreadId.values()) {
107
+ if (entry.source !== source
108
+ || (sourceGeneration != null && entry.sourceGeneration !== sourceGeneration)
109
+ || entry.freshness === "stale") {
110
+ continue;
111
+ }
112
+ staleEntries.push({ ...entry, freshness: "stale" });
113
+ }
114
+ if (staleEntries.length === 0) {
115
+ return false;
116
+ }
117
+ for (const entry of staleEntries) {
118
+ entriesByThreadId.set(entry.threadId, entry);
119
+ serializedEntriesByThreadId.set(entry.threadId, JSON.stringify(entry));
120
+ }
121
+ commitChanges({ upserts: staleEntries, urgent: true });
122
+ return true;
123
+ }
124
+
125
+ function resetSubscriber() {
126
+ subscribed = false;
127
+ clearPendingDelivery();
128
+ deliveredRevision = revision;
129
+ }
130
+
131
+ function dispose() {
132
+ disposed = true;
133
+ resetSubscriber();
134
+ entriesByThreadId.clear();
135
+ serializedEntriesByThreadId.clear();
136
+ }
137
+
138
+ function snapshot() {
139
+ return {
140
+ schemaVersion: ACTIVITY_SCHEMA_VERSION,
141
+ epoch,
142
+ revision,
143
+ coverage: "observedThreads",
144
+ entries: [...entriesByThreadId.values()]
145
+ .slice()
146
+ .sort((left, right) => left.threadId.localeCompare(right.threadId)),
147
+ };
148
+ }
149
+
150
+ function evictInactiveEntries() {
151
+ let inactiveCount = [...entriesByThreadId.values()].filter(isInactiveEntry).length;
152
+ const removedThreadIds = [];
153
+ if (inactiveCount <= maxInactiveEntries) {
154
+ return removedThreadIds;
155
+ }
156
+ for (const [threadId, entry] of entriesByThreadId) {
157
+ if (!isInactiveEntry(entry)) {
158
+ continue;
159
+ }
160
+ entriesByThreadId.delete(threadId);
161
+ serializedEntriesByThreadId.delete(threadId);
162
+ removedThreadIds.push(threadId);
163
+ onEvict(threadId, entry.source);
164
+ inactiveCount -= 1;
165
+ if (inactiveCount <= maxInactiveEntries) {
166
+ break;
167
+ }
168
+ }
169
+ return removedThreadIds;
170
+ }
171
+
172
+ function commitChanges({ upserts = [], removedThreadIds = [], urgent = false }) {
173
+ revision += 1;
174
+ if (!subscribed) {
175
+ return;
176
+ }
177
+ for (const entry of upserts) {
178
+ pendingRemovedThreadIds.delete(entry.threadId);
179
+ pendingUpsertsByThreadId.set(entry.threadId, entry);
180
+ }
181
+ for (const threadId of removedThreadIds) {
182
+ pendingUpsertsByThreadId.delete(threadId);
183
+ pendingRemovedThreadIds.add(threadId);
184
+ }
185
+ if (urgent) {
186
+ flushPendingDelivery();
187
+ } else {
188
+ scheduleFlush();
189
+ }
190
+ }
191
+
192
+ function scheduleFlush() {
193
+ if (flushTimer) {
194
+ return;
195
+ }
196
+ flushTimer = setTimeoutFn(flushPendingDelivery, coalesceMs);
197
+ flushTimer?.unref?.();
198
+ }
199
+
200
+ function flushPendingDelivery() {
201
+ clearFlushTimer();
202
+ if (!subscribed || !hasPendingDelivery()) {
203
+ return;
204
+ }
205
+ const notification = {
206
+ method: ACTIVITY_UPDATED_METHOD,
207
+ params: {
208
+ schemaVersion: ACTIVITY_SCHEMA_VERSION,
209
+ epoch,
210
+ baseRevision: deliveredRevision,
211
+ revision,
212
+ upserts: [...pendingUpsertsByThreadId.values()]
213
+ .sort((left, right) => left.threadId.localeCompare(right.threadId)),
214
+ removedThreadIds: [...pendingRemovedThreadIds].sort(),
215
+ },
216
+ };
217
+ clearPendingCollections();
218
+ deliveredRevision = revision;
219
+ sendApplicationResponse(JSON.stringify(notification));
220
+ }
221
+
222
+ function clearPendingDelivery() {
223
+ clearFlushTimer();
224
+ clearPendingCollections();
225
+ }
226
+
227
+ function clearPendingCollections() {
228
+ pendingUpsertsByThreadId.clear();
229
+ pendingRemovedThreadIds.clear();
230
+ }
231
+
232
+ function clearFlushTimer() {
233
+ if (!flushTimer) {
234
+ return;
235
+ }
236
+ clearTimeoutFn(flushTimer);
237
+ flushTimer = null;
238
+ }
239
+
240
+ function hasPendingDelivery() {
241
+ return pendingUpsertsByThreadId.size > 0 || pendingRemovedThreadIds.size > 0;
242
+ }
243
+
244
+ function sendResponse(id, result) {
245
+ if (id == null) {
246
+ return;
247
+ }
248
+ sendApplicationResponse(JSON.stringify({ id, result }));
249
+ }
250
+
251
+ function sendInvalidParams(id) {
252
+ if (id == null) {
253
+ return;
254
+ }
255
+ sendApplicationResponse(JSON.stringify({
256
+ id,
257
+ error: {
258
+ code: -32602,
259
+ message: "Activity requests support schemaVersion 1.",
260
+ },
261
+ }));
262
+ }
263
+
264
+ return {
265
+ get(threadId) { return entriesByThreadId.get(threadId) || null; },
266
+ dispose,
267
+ flush: flushPendingDelivery,
268
+ handleRequest,
269
+ markSourceStale,
270
+ remove,
271
+ resetSubscriber,
272
+ snapshot,
273
+ upsert,
274
+ };
275
+ }
276
+
277
+ function validRequest(message) {
278
+ if (message?.id == null) {
279
+ return false;
280
+ }
281
+ const params = message.params;
282
+ if (params == null) {
283
+ return true;
284
+ }
285
+ return typeof params === "object"
286
+ && !Array.isArray(params)
287
+ && (params.schemaVersion == null || params.schemaVersion === ACTIVITY_SCHEMA_VERSION);
288
+ }
289
+
290
+ function validEntry(entry) {
291
+ return entry
292
+ && typeof entry === "object"
293
+ && typeof entry.threadId === "string"
294
+ && entry.threadId.length > 0;
295
+ }
296
+
297
+ function isInactiveEntry(entry) {
298
+ return entry.runtime !== "active"
299
+ && entry.runningWithoutTurnId !== true
300
+ && entry.approvalRequired !== true
301
+ && entry.userInputRequired !== true;
302
+ }
303
+
304
+ function isUrgentChange(previous, next) {
305
+ if (!previous || previous.source !== next.source) {
306
+ return true;
307
+ }
308
+ return previous.runtime !== next.runtime
309
+ || previous.runningWithoutTurnId !== next.runningWithoutTurnId
310
+ || previous.approvalRequestCount !== next.approvalRequestCount
311
+ || previous.approvalRequired !== next.approvalRequired
312
+ || previous.userInputRequestCount !== next.userInputRequestCount
313
+ || previous.userInputRequired !== next.userInputRequired
314
+ || previous.freshness !== next.freshness
315
+ || JSON.stringify(previous.activeTurnIds) !== JSON.stringify(next.activeTurnIds)
316
+ || JSON.stringify(previous.lastOutcome) !== JSON.stringify(next.lastOutcome);
317
+ }
318
+
319
+ module.exports = {
320
+ ACTIVITY_SCHEMA_VERSION,
321
+ ACTIVITY_SUBSCRIBE_METHOD,
322
+ ACTIVITY_UNSUBSCRIBE_METHOD,
323
+ ACTIVITY_UPDATED_METHOD,
324
+ createThreadActivityStore,
325
+ };
@@ -9,7 +9,10 @@ const os = require("os");
9
9
  const path = require("path");
10
10
  const { forEachThreadRowInResponse } = require("./thread-row-enrichment");
11
11
 
12
- const STORE_VERSION = 1;
12
+ const { randomUUID } = require("crypto");
13
+ const { runtimeSettingsPatch, runtimeSettingsFromConversation } = require("./codex-runtime-settings");
14
+
15
+ const STORE_VERSION = 2;
13
16
  const DEFAULT_MAX_THREADS = 500;
14
17
  const DEFAULT_MAX_AGE_MS = 180 * 24 * 60 * 60 * 1_000;
15
18
  const DEFAULT_STORE_DIR = path.join(os.homedir(), ".remodex");
@@ -21,6 +24,8 @@ function createThreadRuntimeSettingsStore({
21
24
  now = () => Date.now(),
22
25
  maxThreads = DEFAULT_MAX_THREADS,
23
26
  maxAgeMs = DEFAULT_MAX_AGE_MS,
27
+ onChange = () => {},
28
+ onError = (error) => console.warn(`[remodex] runtime settings persistence failed: ${error.message}`),
24
29
  } = {}) {
25
30
  let state = readState({ storeFile, fsImpl });
26
31
 
@@ -29,26 +34,24 @@ function createThreadRuntimeSettingsStore({
29
34
  if (!normalizedThreadId) {
30
35
  return null;
31
36
  }
32
- return cloneSettings(state.threads[normalizedThreadId]);
37
+ const settings = state.threads[normalizedThreadId];
38
+ return settings?.confirmed ? cloneSettings(settings) : null;
33
39
  }
34
40
 
35
41
  function commit(threadId, turnParams, { source = "unknown", turnId = "" } = {}) {
36
42
  const normalizedThreadId = normalizeString(threadId);
37
43
  const nextSource = normalizeString(source) || "unknown";
38
- // Runtime choices are intentionally one-way: the phone may configure the
39
- // runtime that executes its turn, while Desktop choices stay local.
40
- if (!normalizedThreadId || nextSource !== "phone") {
44
+ if (!normalizedThreadId || !["phone", "desktop", "runtime"].includes(nextSource)) {
41
45
  return null;
42
46
  }
43
- const previous = state.threads[normalizedThreadId] || null;
44
- const nextValues = runtimeSettingsFromTurnParams(turnParams, previous);
45
- if (!nextValues.model && !nextValues.reasoningEffort && !previous) {
47
+ const previous = get(normalizedThreadId);
48
+ const nextValues = runtimeSettingsFromTurnParams(turnParams, previous, { authoritative: source === "runtime" });
49
+ if (Object.keys(nextValues).length === 0) {
46
50
  return null;
47
51
  }
48
52
 
49
53
  const normalizedTurnId = normalizeString(turnId);
50
54
  if (previous
51
- && previous.turnId === normalizedTurnId
52
55
  && previous.model === nextValues.model
53
56
  && previous.reasoningEffort === nextValues.reasoningEffort
54
57
  && previous.serviceTier === nextValues.serviceTier) {
@@ -56,20 +59,33 @@ function createThreadRuntimeSettingsStore({
56
59
  }
57
60
 
58
61
  const next = {
59
- model: nextValues.model || null,
60
- reasoningEffort: nextValues.reasoningEffort || null,
61
- serviceTier: nextValues.serviceTier,
62
+ ...nextValues,
62
63
  revision: Math.max(0, Number(previous?.revision) || 0) + 1,
63
- updatedAt: now(),
64
+ updatedAt: Math.max(now(), (previous?.updatedAt || 0) + 1),
65
+ epoch: previous?.epoch || randomUUID(),
66
+ confirmed: true,
64
67
  source: nextSource,
65
68
  turnId: normalizedTurnId || null,
66
69
  };
67
- state.threads[normalizedThreadId] = next;
68
- pruneState(state, { now: now(), maxThreads, maxAgeMs });
69
- writeState(state, { storeFile, fsImpl });
70
+ const nextState = { ...state, threads: { ...state.threads, [normalizedThreadId]: next } };
71
+ pruneState(nextState, { now: now(), maxThreads, maxAgeMs });
72
+ writeState(nextState, { storeFile, fsImpl });
73
+ state = nextState;
74
+ onChange(normalizedThreadId, cloneSettings(next));
70
75
  return cloneSettings(next);
71
76
  }
72
77
 
78
+ // Unsolicited owner notifications must not interrupt the live event stream
79
+ // when the local settings cache cannot be written. A later snapshot retries.
80
+ function observe(threadId, settings, source = "runtime") {
81
+ try {
82
+ return commit(threadId, settings, { source });
83
+ } catch (error) {
84
+ onError(error);
85
+ return get(threadId);
86
+ }
87
+ }
88
+
73
89
  function attachToConversation(threadId, conversation) {
74
90
  if (!conversation || typeof conversation !== "object") {
75
91
  return conversation;
@@ -79,34 +95,6 @@ function createThreadRuntimeSettingsStore({
79
95
  return conversation;
80
96
  }
81
97
  conversation.remodexRuntimeSettings = settings;
82
- if (settings.model) {
83
- conversation.latestModel = settings.model;
84
- }
85
- if (settings.reasoningEffort) {
86
- conversation.latestReasoningEffort = settings.reasoningEffort;
87
- }
88
- conversation.latestServiceTier = settings.serviceTier;
89
- conversation.latestThreadSettings = {
90
- ...(conversation.latestThreadSettings && typeof conversation.latestThreadSettings === "object"
91
- ? conversation.latestThreadSettings
92
- : {}),
93
- model: settings.model,
94
- effort: settings.reasoningEffort,
95
- serviceTier: settings.serviceTier,
96
- };
97
- const collaborationSettings = conversation.latestCollaborationMode?.settings;
98
- conversation.latestCollaborationMode = {
99
- mode: conversation.latestCollaborationMode?.mode || "default",
100
- settings: {
101
- ...(collaborationSettings && typeof collaborationSettings === "object"
102
- ? collaborationSettings
103
- : { developer_instructions: null }),
104
- model: settings.model || collaborationSettings?.model || "",
105
- reasoning_effort: settings.reasoningEffort
106
- || collaborationSettings?.reasoning_effort
107
- || null,
108
- },
109
- };
110
98
  return conversation;
111
99
  }
112
100
 
@@ -120,9 +108,12 @@ function createThreadRuntimeSettingsStore({
120
108
  if (!settings || !thread || typeof thread !== "object") {
121
109
  return thread;
122
110
  }
123
- thread.model = settings.model || thread.model || null;
124
- thread.reasoningEffort = settings.reasoningEffort;
125
- thread.serviceTier = settings.serviceTier;
111
+ thread.runtimeSettings = settings;
112
+ // Legacy phone fields remain readable, while the v2 object explicitly
113
+ // represents next-turn choices rather than an executing turn's metadata.
114
+ thread.model ||= settings.model;
115
+ if (Object.hasOwn(settings, "reasoningEffort")) thread.reasoningEffort = settings.reasoningEffort;
116
+ if (Object.hasOwn(settings, "serviceTier")) thread.serviceTier = settings.serviceTier === "priority" ? "fast" : settings.serviceTier;
126
117
  thread.runtimeSettingsRevision = settings.revision;
127
118
  thread.runtimeSettingsUpdatedAt = settings.updatedAt;
128
119
  thread.runtimeSettingsSource = settings.source;
@@ -132,31 +123,25 @@ function createThreadRuntimeSettingsStore({
132
123
  return {
133
124
  get,
134
125
  commit,
126
+ observe,
135
127
  attachToConversation,
136
128
  attachToThread,
137
129
  enrichResponse,
130
+ observeConversation(threadId, conversation) {
131
+ const patch = runtimeSettingsFromConversation(conversation);
132
+ if (!patch.model) return get(threadId);
133
+ const settings = observe(threadId, patch, "desktop");
134
+ attachToConversation(threadId, conversation);
135
+ return settings;
136
+ },
138
137
  };
139
138
  }
140
139
 
141
- function runtimeSettingsFromTurnParams(turnParams, previous = null) {
142
- const params = turnParams && typeof turnParams === "object" ? turnParams : {};
143
- const collaborationSettings = params.collaborationMode?.settings
144
- || params.collaboration_mode?.settings
145
- || {};
146
- const model = normalizeString(params.model)
147
- || normalizeString(collaborationSettings.model)
148
- || normalizeString(previous?.model)
149
- || null;
150
- const reasoningEffort = normalizeString(params.effort)
151
- || normalizeString(params.reasoningEffort)
152
- || normalizeString(collaborationSettings.reasoning_effort)
153
- || normalizeString(collaborationSettings.reasoningEffort)
154
- || normalizeString(previous?.reasoningEffort)
155
- || null;
156
- const serviceTier = normalizeString(params.serviceTier)
157
- || normalizeString(params.service_tier)
158
- || null;
159
- return { model, reasoningEffort, serviceTier };
140
+ function runtimeSettingsFromTurnParams(turnParams, previous = null, options = {}) {
141
+ return {
142
+ ...runtimeSettingsPatch(previous || {}),
143
+ ...runtimeSettingsPatch(turnParams, options),
144
+ };
160
145
  }
161
146
 
162
147
  function readState({ storeFile, fsImpl }) {
@@ -164,7 +149,7 @@ function readState({ storeFile, fsImpl }) {
164
149
  const parsed = JSON.parse(fsImpl.readFileSync(storeFile, "utf8"));
165
150
  return normalizeState(parsed);
166
151
  } catch {
167
- return { version: STORE_VERSION, threads: {} };
152
+ return { version: STORE_VERSION, epoch: randomUUID(), threads: {} };
168
153
  }
169
154
  }
170
155
 
@@ -173,25 +158,26 @@ function normalizeState(rawState) {
173
158
  ? rawState.threads
174
159
  : {};
175
160
  const threads = {};
161
+ const epoch = normalizeString(rawState?.epoch) || randomUUID();
176
162
  for (const [threadId, rawSettings] of Object.entries(rawThreads)) {
177
163
  const normalizedThreadId = normalizeString(threadId);
178
164
  const source = normalizeString(rawSettings?.source) || "unknown";
179
- // Drop records written by older bidirectional builds so a Desktop choice
180
- // cannot be replayed to the phone after upgrading.
181
- if (!normalizedThreadId || !rawSettings || typeof rawSettings !== "object" || source !== "phone") {
165
+ if (!normalizedThreadId || !rawSettings || typeof rawSettings !== "object") {
182
166
  continue;
183
167
  }
184
168
  threads[normalizedThreadId] = {
185
- model: normalizeString(rawSettings.model) || null,
186
- reasoningEffort: normalizeString(rawSettings.reasoningEffort) || null,
187
- serviceTier: normalizeString(rawSettings.serviceTier) || null,
169
+ ...runtimeSettingsFromTurnParams(rawSettings),
170
+ epoch: normalizeString(rawSettings.epoch) || epoch,
171
+ // Preserve old preferences on disk, but require fresh owner evidence
172
+ // before exposing them as confirmed runtime state.
173
+ confirmed: rawState.version === STORE_VERSION && rawSettings.confirmed === true,
188
174
  revision: Math.max(0, Number(rawSettings.revision) || 0),
189
175
  updatedAt: Math.max(0, Number(rawSettings.updatedAt) || 0),
190
176
  source,
191
177
  turnId: normalizeString(rawSettings.turnId) || null,
192
178
  };
193
179
  }
194
- return { version: STORE_VERSION, threads };
180
+ return { version: STORE_VERSION, epoch, threads };
195
181
  }
196
182
 
197
183
  function pruneState(storeState, { now, maxThreads, maxAgeMs }) {