codex-configurator 0.2.5 → 0.2.7

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/src/constants.js CHANGED
@@ -3,7 +3,7 @@ export const CONTROL_HINT =
3
3
  export const FILE_SWITCH_HINT =
4
4
  '↑/↓ choose • PgUp/PgDn page • Home/End jump • Enter switch file • Esc/Backspace/← cancel';
5
5
  export const EDIT_CONTROL_HINT =
6
- '↑/↓ choose • PgUp/PgDn page • Home/End jump • Enter save • Esc/Backspace/← cancel • Del delete char (text input)';
6
+ '↑/↓ choose • PgUp/PgDn page • Home/End jump • Enter save • Esc/Backspace/← cancel • Del delete char (inline input)';
7
7
  export const FILTER_CONTROL_HINT =
8
8
  'Type filter • Enter/Esc: done • Del/Backspace: delete • Ctrl+U: clear';
9
9
  export const COMMAND_HINT = 'Type : to see commands';
@@ -20,6 +20,13 @@
20
20
  "description": {
21
21
  "description": "Human-facing role documentation used in spawn tool guidance.",
22
22
  "type": "string"
23
+ },
24
+ "nickname_candidates": {
25
+ "description": "Candidate nicknames for agents spawned with this role.",
26
+ "items": {
27
+ "type": "string"
28
+ },
29
+ "type": "array"
23
30
  }
24
31
  },
25
32
  "type": "object"
@@ -313,6 +320,9 @@
313
320
  "apps_mcp_gateway": {
314
321
  "type": "boolean"
315
322
  },
323
+ "artifact": {
324
+ "type": "boolean"
325
+ },
316
326
  "child_agents_md": {
317
327
  "type": "boolean"
318
328
  },
@@ -349,6 +359,15 @@
349
359
  "experimental_windows_sandbox": {
350
360
  "type": "boolean"
351
361
  },
362
+ "fast_mode": {
363
+ "type": "boolean"
364
+ },
365
+ "image_detail_original": {
366
+ "type": "boolean"
367
+ },
368
+ "image_generation": {
369
+ "type": "boolean"
370
+ },
352
371
  "include_apply_patch_tool": {
353
372
  "type": "boolean"
354
373
  },
@@ -370,6 +389,9 @@
370
389
  "personality": {
371
390
  "type": "boolean"
372
391
  },
392
+ "plugins": {
393
+ "type": "boolean"
394
+ },
373
395
  "powershell_utf8": {
374
396
  "type": "boolean"
375
397
  },
@@ -502,6 +524,9 @@
502
524
  "sandbox_mode": {
503
525
  "$ref": "#/definitions/SandboxMode"
504
526
  },
527
+ "service_tier": {
528
+ "$ref": "#/definitions/ServiceTier"
529
+ },
505
530
  "tools_view_image": {
506
531
  "type": "boolean"
507
532
  },
@@ -613,7 +638,19 @@
613
638
  "additionalProperties": false,
614
639
  "description": "Memories settings loaded from config.toml.",
615
640
  "properties": {
616
- "max_raw_memories_for_global": {
641
+ "consolidation_model": {
642
+ "description": "Model used for memory consolidation.",
643
+ "type": "string"
644
+ },
645
+ "extract_model": {
646
+ "description": "Model used for thread summarisation.",
647
+ "type": "string"
648
+ },
649
+ "generate_memories": {
650
+ "description": "When `false`, newly created threads are stored with `memory_mode = \"disabled\"` in the state DB.",
651
+ "type": "boolean"
652
+ },
653
+ "max_raw_memories_for_consolidation": {
617
654
  "description": "Maximum number of recent raw memories retained for global consolidation.",
618
655
  "format": "uint",
619
656
  "minimum": 0,
@@ -640,17 +677,25 @@
640
677
  "format": "int64",
641
678
  "type": "integer"
642
679
  },
643
- "phase_1_model": {
644
- "description": "Model used for thread summarisation.",
645
- "type": "string"
680
+ "no_memories_if_mcp_or_web_search": {
681
+ "description": "When `true`, web searches and MCP tool calls mark the thread `memory_mode` as `\"polluted\"`.",
682
+ "type": "boolean"
646
683
  },
647
- "phase_2_model": {
648
- "description": "Model used for memory consolidation.",
649
- "type": "string"
684
+ "use_memories": {
685
+ "description": "When `false`, skip injecting memory usage instructions into developer prompts.",
686
+ "type": "boolean"
650
687
  }
651
688
  },
652
689
  "type": "object"
653
690
  },
691
+ "ModelAvailabilityNuxConfig": {
692
+ "additionalProperties": {
693
+ "format": "uint32",
694
+ "minimum": 0,
695
+ "type": "integer"
696
+ },
697
+ "type": "object"
698
+ },
654
699
  "ModelProviderInfo": {
655
700
  "additionalProperties": false,
656
701
  "description": "Serializable representation of a provider definition.",
@@ -1069,6 +1114,16 @@
1069
1114
  ],
1070
1115
  "type": "string"
1071
1116
  },
1117
+ "PluginConfig": {
1118
+ "additionalProperties": false,
1119
+ "properties": {
1120
+ "enabled": {
1121
+ "default": true,
1122
+ "type": "boolean"
1123
+ }
1124
+ },
1125
+ "type": "object"
1126
+ },
1072
1127
  "ProjectConfig": {
1073
1128
  "additionalProperties": false,
1074
1129
  "properties": {
@@ -1146,6 +1201,10 @@
1146
1201
  },
1147
1202
  "type": "object"
1148
1203
  },
1204
+ "oauth_resource": {
1205
+ "default": null,
1206
+ "type": "string"
1207
+ },
1149
1208
  "required": {
1150
1209
  "default": null,
1151
1210
  "type": "boolean"
@@ -1179,6 +1238,18 @@
1179
1238
  },
1180
1239
  "type": "object"
1181
1240
  },
1241
+ "RealtimeAudioToml": {
1242
+ "additionalProperties": false,
1243
+ "properties": {
1244
+ "microphone": {
1245
+ "type": "string"
1246
+ },
1247
+ "speaker": {
1248
+ "type": "string"
1249
+ }
1250
+ },
1251
+ "type": "object"
1252
+ },
1182
1253
  "ReasoningEffort": {
1183
1254
  "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning",
1184
1255
  "enum": [
@@ -1266,6 +1337,13 @@
1266
1337
  },
1267
1338
  "type": "object"
1268
1339
  },
1340
+ "ServiceTier": {
1341
+ "enum": [
1342
+ "fast",
1343
+ "flex"
1344
+ ],
1345
+ "type": "string"
1346
+ },
1269
1347
  "ShellEnvironmentPolicyInherit": {
1270
1348
  "oneOf": [
1271
1349
  {
@@ -1396,6 +1474,15 @@
1396
1474
  "description": "Enable animations (welcome screen, shimmer effects, spinners). Defaults to `true`.",
1397
1475
  "type": "boolean"
1398
1476
  },
1477
+ "model_availability_nux": {
1478
+ "allOf": [
1479
+ {
1480
+ "$ref": "#/definitions/ModelAvailabilityNuxConfig"
1481
+ }
1482
+ ],
1483
+ "default": {},
1484
+ "description": "Startup tooltip availability NUX state persisted by the TUI."
1485
+ },
1399
1486
  "notification_method": {
1400
1487
  "allOf": [
1401
1488
  {
@@ -1540,6 +1627,15 @@
1540
1627
  "default": null,
1541
1628
  "description": "Settings for app-specific controls."
1542
1629
  },
1630
+ "audio": {
1631
+ "allOf": [
1632
+ {
1633
+ "$ref": "#/definitions/RealtimeAudioToml"
1634
+ }
1635
+ ],
1636
+ "default": null,
1637
+ "description": "Machine-local realtime audio device preferences used by realtime voice."
1638
+ },
1543
1639
  "background_terminal_max_timeout": {
1544
1640
  "description": "Maximum poll window for background terminal output (`write_stdin`), in milliseconds. Default: `300000` (5 minutes).",
1545
1641
  "format": "uint64",
@@ -1584,11 +1680,15 @@
1584
1680
  "$ref": "#/definitions/AbsolutePathBuf"
1585
1681
  },
1586
1682
  "experimental_realtime_ws_backend_prompt": {
1587
- "description": "Experimental / do not use. Overrides only the realtime conversation websocket transport backend prompt (the `Op::RealtimeConversation` `/ws` session.create backend_prompt) without changing normal prompts.",
1683
+ "description": "Experimental / do not use. Overrides only the realtime conversation websocket transport instructions (the `Op::RealtimeConversation` `/ws` session.update instructions) without changing normal prompts.",
1588
1684
  "type": "string"
1589
1685
  },
1590
1686
  "experimental_realtime_ws_base_url": {
1591
- "description": "Experimental / do not use. Overrides only the realtime conversation websocket transport base URL (the `Op::RealtimeConversation` `/ws` connection) without changing normal provider HTTP requests.",
1687
+ "description": "Experimental / do not use. Overrides only the realtime conversation websocket transport base URL (the `Op::RealtimeConversation` `/v1/realtime` connection) without changing normal provider HTTP requests.",
1688
+ "type": "string"
1689
+ },
1690
+ "experimental_realtime_ws_model": {
1691
+ "description": "Experimental / do not use. Selects the realtime websocket model/snapshot used for the `Op::RealtimeConversation` connection.",
1592
1692
  "type": "string"
1593
1693
  },
1594
1694
  "experimental_use_freeform_apply_patch": {
@@ -1611,6 +1711,9 @@
1611
1711
  "apps_mcp_gateway": {
1612
1712
  "type": "boolean"
1613
1713
  },
1714
+ "artifact": {
1715
+ "type": "boolean"
1716
+ },
1614
1717
  "child_agents_md": {
1615
1718
  "type": "boolean"
1616
1719
  },
@@ -1647,6 +1750,15 @@
1647
1750
  "experimental_windows_sandbox": {
1648
1751
  "type": "boolean"
1649
1752
  },
1753
+ "fast_mode": {
1754
+ "type": "boolean"
1755
+ },
1756
+ "image_detail_original": {
1757
+ "type": "boolean"
1758
+ },
1759
+ "image_generation": {
1760
+ "type": "boolean"
1761
+ },
1650
1762
  "include_apply_patch_tool": {
1651
1763
  "type": "boolean"
1652
1764
  },
@@ -1668,6 +1780,9 @@
1668
1780
  "personality": {
1669
1781
  "type": "boolean"
1670
1782
  },
1783
+ "plugins": {
1784
+ "type": "boolean"
1785
+ },
1671
1786
  "powershell_utf8": {
1672
1787
  "type": "boolean"
1673
1788
  },
@@ -1965,6 +2080,14 @@
1965
2080
  "plan_mode_reasoning_effort": {
1966
2081
  "$ref": "#/definitions/ReasoningEffort"
1967
2082
  },
2083
+ "plugins": {
2084
+ "additionalProperties": {
2085
+ "$ref": "#/definitions/PluginConfig"
2086
+ },
2087
+ "default": {},
2088
+ "description": "User-level plugin config entries keyed by plugin name.",
2089
+ "type": "object"
2090
+ },
1968
2091
  "profile": {
1969
2092
  "description": "Profile to use from the `profiles` map.",
1970
2093
  "type": "string"
@@ -2024,6 +2147,14 @@
2024
2147
  ],
2025
2148
  "description": "Sandbox configuration to apply if `sandbox` is `WorkspaceWrite`."
2026
2149
  },
2150
+ "service_tier": {
2151
+ "allOf": [
2152
+ {
2153
+ "$ref": "#/definitions/ServiceTier"
2154
+ }
2155
+ ],
2156
+ "description": "Optional explicit service tier preference for new turns (`fast` or `flex`)."
2157
+ },
2027
2158
  "shell_environment_policy": {
2028
2159
  "allOf": [
2029
2160
  {
@@ -0,0 +1,117 @@
1
+ import { getReferenceOptionForPath } from './configReference.js';
2
+
3
+ const STRING_REFERENCE_TYPE_PATTERN = /^string(?:\s|$)/;
4
+ const DIGIT_GROUP_PATTERN = '\\d(?:_?\\d)*';
5
+ const INTEGER_DRAFT_PATTERN = new RegExp(
6
+ `^[+-]?${DIGIT_GROUP_PATTERN}$`,
7
+ );
8
+ const NUMBER_DRAFT_PATTERN = new RegExp(
9
+ `^[+-]?(?:(?:${DIGIT_GROUP_PATTERN}(?:\\.(?:${DIGIT_GROUP_PATTERN})?)?)|(?:\\.${DIGIT_GROUP_PATTERN}))(?:[eE][+-]?${DIGIT_GROUP_PATTERN})?$`,
10
+ );
11
+
12
+ export const getReferenceScalarType = (pathSegments) => {
13
+ const referenceType = String(
14
+ getReferenceOptionForPath(pathSegments)?.type || '',
15
+ ).trim();
16
+
17
+ if (STRING_REFERENCE_TYPE_PATTERN.test(referenceType)) {
18
+ return 'string';
19
+ }
20
+
21
+ if (referenceType === 'integer') {
22
+ return 'integer';
23
+ }
24
+
25
+ if (referenceType === 'number') {
26
+ return 'number';
27
+ }
28
+
29
+ return null;
30
+ };
31
+
32
+ export const getScalarEditType = (pathSegments, value) => {
33
+ const referenceScalarType = getReferenceScalarType(pathSegments);
34
+ if (referenceScalarType) {
35
+ return referenceScalarType;
36
+ }
37
+
38
+ if (typeof value === 'string') {
39
+ return 'string';
40
+ }
41
+
42
+ if (typeof value === 'number') {
43
+ return Number.isInteger(value) ? 'integer' : 'number';
44
+ }
45
+ return null;
46
+ };
47
+
48
+ export const parseScalarDraftValue = (draftValue, scalarType) => {
49
+ const nextDraftValue =
50
+ typeof draftValue === 'string'
51
+ ? draftValue
52
+ : String(draftValue ?? '');
53
+
54
+ if (scalarType === 'string') {
55
+ return {
56
+ ok: true,
57
+ value: nextDraftValue,
58
+ };
59
+ }
60
+
61
+ const normalized = nextDraftValue.trim();
62
+ if (!normalized) {
63
+ return {
64
+ ok: false,
65
+ error: 'Value cannot be empty. Use Del to unset it.',
66
+ };
67
+ }
68
+
69
+ if (scalarType === 'integer') {
70
+ if (!INTEGER_DRAFT_PATTERN.test(normalized)) {
71
+ return {
72
+ ok: false,
73
+ error: 'Value must be a whole number.',
74
+ };
75
+ }
76
+
77
+ const parsed = Number(normalized.replaceAll('_', ''));
78
+ if (!Number.isSafeInteger(parsed)) {
79
+ return {
80
+ ok: false,
81
+ error: 'Value must be a safe integer.',
82
+ };
83
+ }
84
+
85
+ return {
86
+ ok: true,
87
+ value: parsed,
88
+ };
89
+ }
90
+
91
+ if (scalarType === 'number') {
92
+ if (!NUMBER_DRAFT_PATTERN.test(normalized)) {
93
+ return {
94
+ ok: false,
95
+ error: 'Value must be a finite decimal number.',
96
+ };
97
+ }
98
+
99
+ const parsed = Number(normalized.replaceAll('_', ''));
100
+ if (!Number.isFinite(parsed)) {
101
+ return {
102
+ ok: false,
103
+ error: 'Value must be a finite decimal number.',
104
+ };
105
+ }
106
+
107
+ return {
108
+ ok: true,
109
+ value: parsed,
110
+ };
111
+ }
112
+
113
+ return {
114
+ ok: false,
115
+ error: 'Unsupported scalar type.',
116
+ };
117
+ };
@@ -0,0 +1,282 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import {
5
+ getReferenceSchemaRevision,
6
+ setReferenceSchema,
7
+ } from './configReference.js';
8
+ import { logConfiguratorError } from './errorLogger.js';
9
+
10
+ const CONFIG_SCHEMA_URL = 'https://developers.openai.com/codex/config-schema.json';
11
+ const SCHEMA_CACHE_DIRECTORY_NAME = 'codex-configurator-cache';
12
+ const SCHEMA_CACHE_FILE_NAME = 'config-schema.json';
13
+ const SCHEMA_METADATA_FILE_NAME = 'config-schema.meta.json';
14
+ const FETCH_TIMEOUT_MS = 10000;
15
+ const MAX_SCHEMA_BYTES = 512 * 1024;
16
+
17
+ const isObject = (value) =>
18
+ value !== null && typeof value === 'object' && !Array.isArray(value);
19
+
20
+ const normalizeText = (value) => String(value || '').trim();
21
+
22
+ const resolveCodexDirectory = ({ mainConfigPath = '', homeDir = os.homedir() } = {}) => {
23
+ const normalizedPath = normalizeText(mainConfigPath);
24
+ if (!normalizedPath) {
25
+ return path.join(homeDir, '.codex');
26
+ }
27
+
28
+ return path.dirname(path.resolve(normalizedPath));
29
+ };
30
+
31
+ export const resolveSchemaCachePaths = ({ mainConfigPath = '', homeDir = os.homedir() } = {}) => {
32
+ const codexDirectory = resolveCodexDirectory({ mainConfigPath, homeDir });
33
+ const cacheDirectory = path.join(codexDirectory, SCHEMA_CACHE_DIRECTORY_NAME);
34
+ return {
35
+ codexDirectory,
36
+ cacheDirectory,
37
+ schemaPath: path.join(cacheDirectory, SCHEMA_CACHE_FILE_NAME),
38
+ metadataPath: path.join(cacheDirectory, SCHEMA_METADATA_FILE_NAME),
39
+ };
40
+ };
41
+
42
+ const readJsonFile = (targetPath) => {
43
+ try {
44
+ const payload = fs.readFileSync(targetPath, 'utf8');
45
+ return JSON.parse(payload);
46
+ } catch {
47
+ return null;
48
+ }
49
+ };
50
+
51
+ const writeJsonFileAtomic = (targetPath, payload) => {
52
+ const directoryPath = path.dirname(targetPath);
53
+ const fileName = path.basename(targetPath);
54
+ const tempPath = path.join(
55
+ directoryPath,
56
+ `.${fileName}.${process.pid}.${Date.now()}.tmp`
57
+ );
58
+
59
+ if (!fs.existsSync(directoryPath)) {
60
+ fs.mkdirSync(directoryPath, { recursive: true, mode: 0o700 });
61
+ }
62
+
63
+ let fileDescriptor = null;
64
+
65
+ try {
66
+ fileDescriptor = fs.openSync(tempPath, 'wx', 0o600);
67
+ fs.writeFileSync(fileDescriptor, `${JSON.stringify(payload, null, 2)}\n`, 'utf8');
68
+ fs.fsyncSync(fileDescriptor);
69
+ fs.closeSync(fileDescriptor);
70
+ fileDescriptor = null;
71
+ fs.renameSync(tempPath, targetPath);
72
+ } finally {
73
+ if (fileDescriptor !== null) {
74
+ try {
75
+ fs.closeSync(fileDescriptor);
76
+ } catch {}
77
+ }
78
+
79
+ if (fs.existsSync(tempPath)) {
80
+ try {
81
+ fs.unlinkSync(tempPath);
82
+ } catch {}
83
+ }
84
+ }
85
+ };
86
+
87
+ const applySchema = (schema) => {
88
+ const previousRevision = getReferenceSchemaRevision();
89
+ const ok = setReferenceSchema(schema);
90
+ if (!ok) {
91
+ return {
92
+ ok: false,
93
+ changed: false,
94
+ };
95
+ }
96
+
97
+ return {
98
+ ok: true,
99
+ changed: previousRevision !== getReferenceSchemaRevision(),
100
+ };
101
+ };
102
+
103
+ const buildConditionalHeaders = (metadata = {}) => {
104
+ const headers = {
105
+ accept: 'application/json',
106
+ };
107
+ const etag = normalizeText(metadata.etag);
108
+ const lastModified = normalizeText(metadata.lastModified);
109
+
110
+ if (etag) {
111
+ headers['if-none-match'] = etag;
112
+ }
113
+
114
+ if (lastModified) {
115
+ headers['if-modified-since'] = lastModified;
116
+ }
117
+
118
+ return headers;
119
+ };
120
+
121
+ const fetchRemoteSchema = async ({
122
+ fetchImpl = fetch,
123
+ metadata = {},
124
+ }) => {
125
+ const controller = new AbortController();
126
+ const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
127
+
128
+ try {
129
+ const response = await fetchImpl(CONFIG_SCHEMA_URL, {
130
+ headers: buildConditionalHeaders(metadata),
131
+ signal: controller.signal,
132
+ });
133
+
134
+ if (response.status === 304) {
135
+ return {
136
+ status: 'not-modified',
137
+ };
138
+ }
139
+
140
+ if (!response.ok) {
141
+ throw new Error(
142
+ `Schema request failed (${response.status} ${response.statusText})`
143
+ );
144
+ }
145
+
146
+ const contentType = normalizeText(response.headers.get('content-type')).toLowerCase();
147
+ if (!contentType.includes('application/json')) {
148
+ throw new Error('Schema response content-type is not application/json.');
149
+ }
150
+
151
+ const bytes = Buffer.from(await response.arrayBuffer());
152
+ if (bytes.length > MAX_SCHEMA_BYTES) {
153
+ throw new Error(
154
+ `Schema response exceeded max size (${bytes.length} bytes > ${MAX_SCHEMA_BYTES} bytes).`
155
+ );
156
+ }
157
+
158
+ let parsedSchema = null;
159
+ try {
160
+ parsedSchema = JSON.parse(bytes.toString('utf8'));
161
+ } catch (error) {
162
+ throw new Error(
163
+ `Schema response was not valid JSON (${String(error?.message || 'parse failed')}).`
164
+ );
165
+ }
166
+
167
+ return {
168
+ status: 'updated',
169
+ schema: parsedSchema,
170
+ metadata: {
171
+ etag: normalizeText(response.headers.get('etag')) || null,
172
+ lastModified: normalizeText(response.headers.get('last-modified')) || null,
173
+ },
174
+ };
175
+ } finally {
176
+ clearTimeout(timeoutId);
177
+ }
178
+ };
179
+
180
+ export const syncReferenceSchemaAtStartup = async ({
181
+ mainConfigPath = '',
182
+ fetchImpl = fetch,
183
+ onSchemaChange,
184
+ onStatus,
185
+ } = {}) => {
186
+ const cachePaths = resolveSchemaCachePaths({ mainConfigPath });
187
+ const notifySchemaChange = (source) => {
188
+ if (typeof onSchemaChange === 'function') {
189
+ onSchemaChange({ source });
190
+ }
191
+ };
192
+ const updateStatus = (value) => {
193
+ if (typeof onStatus === 'function') {
194
+ onStatus(String(value || '').trim());
195
+ }
196
+ };
197
+
198
+ let source = 'bundled';
199
+ let canReuseCachedValidators = false;
200
+
201
+ try {
202
+ updateStatus('Schema: Loading cache...');
203
+
204
+ const cachedSchema = readJsonFile(cachePaths.schemaPath);
205
+ const metadataPayload = readJsonFile(cachePaths.metadataPath);
206
+ const cachedMetadata = isObject(metadataPayload) ? metadataPayload : {};
207
+
208
+ if (cachedSchema !== null) {
209
+ const cacheApplyResult = applySchema(cachedSchema);
210
+ if (cacheApplyResult.ok) {
211
+ source = 'cache';
212
+ canReuseCachedValidators = true;
213
+ if (cacheApplyResult.changed) {
214
+ notifySchemaChange('cache');
215
+ }
216
+ } else {
217
+ logConfiguratorError('schema.cache.invalid', {
218
+ schemaPath: cachePaths.schemaPath,
219
+ });
220
+ }
221
+ }
222
+
223
+ updateStatus('Schema: Checking upstream...');
224
+
225
+ const fetchResult = await fetchRemoteSchema({
226
+ fetchImpl,
227
+ metadata: canReuseCachedValidators ? cachedMetadata : {},
228
+ });
229
+
230
+ if (fetchResult.status === 'not-modified') {
231
+ return {
232
+ ok: true,
233
+ updated: false,
234
+ source,
235
+ };
236
+ }
237
+
238
+ const remoteApplyResult = applySchema(fetchResult.schema);
239
+ if (!remoteApplyResult.ok) {
240
+ throw new Error('Downloaded schema did not pass local validation.');
241
+ }
242
+ if (remoteApplyResult.changed) {
243
+ notifySchemaChange('remote');
244
+ }
245
+
246
+ updateStatus('Schema: Saving cache...');
247
+
248
+ const mergedMetadata = {
249
+ etag: fetchResult.metadata.etag || normalizeText(cachedMetadata.etag) || null,
250
+ lastModified:
251
+ fetchResult.metadata.lastModified ||
252
+ normalizeText(cachedMetadata.lastModified) ||
253
+ null,
254
+ updatedAt: new Date().toISOString(),
255
+ sourceUrl: CONFIG_SCHEMA_URL,
256
+ };
257
+
258
+ writeJsonFileAtomic(cachePaths.schemaPath, fetchResult.schema);
259
+ writeJsonFileAtomic(cachePaths.metadataPath, mergedMetadata);
260
+
261
+ return {
262
+ ok: true,
263
+ updated: true,
264
+ source: 'remote',
265
+ };
266
+ } catch (error) {
267
+ const message = String(error?.message || 'Unknown schema sync error.');
268
+ logConfiguratorError('schema.sync.failed', {
269
+ error: message,
270
+ source,
271
+ schemaPath: cachePaths.schemaPath,
272
+ metadataPath: cachePaths.metadataPath,
273
+ });
274
+
275
+ return {
276
+ ok: false,
277
+ updated: false,
278
+ source,
279
+ error: message,
280
+ };
281
+ }
282
+ };