@bitkyc08/opencodex 2.7.35 → 2.7.36

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.
Files changed (80) hide show
  1. package/README.ja.md +1 -1
  2. package/README.ko.md +1 -1
  3. package/README.md +4 -2
  4. package/README.ru.md +1 -1
  5. package/README.zh-CN.md +1 -1
  6. package/bin/ocx.mjs +52 -0
  7. package/gui/dist/assets/index-BpX-hoSd.css +1 -0
  8. package/gui/dist/assets/index-ZmFopEYw.js +52 -0
  9. package/gui/dist/index.html +2 -2
  10. package/package.json +1 -1
  11. package/src/adapters/cursor/cursor-errors.ts +38 -1
  12. package/src/adapters/cursor/discovery.ts +1 -0
  13. package/src/adapters/cursor/effort-map.ts +1 -0
  14. package/src/adapters/cursor/live-models.ts +22 -5
  15. package/src/adapters/cursor/live-transport.ts +82 -7
  16. package/src/adapters/cursor/transport.ts +2 -0
  17. package/src/adapters/cursor.ts +5 -2
  18. package/src/adapters/openai-responses.ts +64 -1
  19. package/src/cli/doctor.ts +10 -0
  20. package/src/cli/help.ts +10 -0
  21. package/src/cli/index.ts +88 -9
  22. package/src/cli/internal-dispatch.ts +20 -0
  23. package/src/cli/status.ts +15 -4
  24. package/src/cli/tray-proxy.ts +52 -0
  25. package/src/codex/auth-api.ts +46 -5
  26. package/src/codex/autostart-health.ts +149 -0
  27. package/src/codex/catalog/aggregation.ts +268 -0
  28. package/src/codex/catalog/bundled.ts +188 -0
  29. package/src/codex/catalog/effort.ts +263 -0
  30. package/src/codex/catalog/metadata.ts +176 -0
  31. package/src/codex/catalog/parsing.ts +399 -0
  32. package/src/codex/catalog/provider-fetch.ts +609 -0
  33. package/src/codex/catalog/sync.ts +540 -0
  34. package/src/codex/catalog.ts +11 -2426
  35. package/src/codex/inject.ts +165 -3
  36. package/src/codex/shim.ts +141 -8
  37. package/src/codex/sync.ts +17 -2
  38. package/src/config.ts +23 -0
  39. package/src/lib/errors.ts +11 -0
  40. package/src/providers/antigravity-models.ts +33 -0
  41. package/src/providers/kiro-models.ts +2 -0
  42. package/src/providers/registry.ts +2 -2
  43. package/src/responses/state.ts +69 -6
  44. package/src/server/auth-cors.ts +3 -0
  45. package/src/server/management/agent-settings-routes.ts +536 -0
  46. package/src/server/management/combo-routes.ts +210 -0
  47. package/src/server/management/config-routes.ts +302 -0
  48. package/src/server/management/context.ts +21 -0
  49. package/src/server/management/logs-usage-routes.ts +176 -0
  50. package/src/server/management/model-routes.ts +253 -0
  51. package/src/server/management/oauth-account-routes.ts +301 -0
  52. package/src/server/management/provider-routes.ts +408 -0
  53. package/src/server/management/shared.ts +186 -0
  54. package/src/server/management-api.ts +23 -1806
  55. package/src/server/responses/collaboration.ts +300 -0
  56. package/src/server/responses/compact.ts +342 -0
  57. package/src/server/responses/core.ts +1498 -0
  58. package/src/server/responses/encrypted-payload.ts +231 -0
  59. package/src/server/responses/fetch-helpers.ts +157 -0
  60. package/src/server/responses.ts +9 -2172
  61. package/src/server/startup-action-control.ts +41 -0
  62. package/src/server/startup-health-cache.ts +100 -0
  63. package/src/server/windows-tray-control.ts +41 -0
  64. package/src/service.ts +171 -19
  65. package/src/tray/assets/opencodex-tray-offline.ico +0 -0
  66. package/src/tray/assets/opencodex-tray-online.ico +0 -0
  67. package/src/tray/assets/opencodex-tray-warning.ico +0 -0
  68. package/src/tray/assets/opencodex-tray.png +0 -0
  69. package/src/tray/windows-tray.ps1 +290 -0
  70. package/src/tray/windows.ts +628 -0
  71. package/src/types.ts +5 -0
  72. package/src/update/index.ts +43 -0
  73. package/src/update/job.ts +46 -0
  74. package/src/update/tray-update-plan.d.mts +18 -0
  75. package/src/update/tray-update-plan.mjs +38 -0
  76. package/src/usage/cost.ts +0 -0
  77. package/src/usage/expected-prices.ts +9 -2
  78. package/src/usage/summary.ts +42 -7
  79. package/gui/dist/assets/index-BunUANVE.js +0 -52
  80. package/gui/dist/assets/index-Sg-7L_oZ.css +0 -1
@@ -0,0 +1,399 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { createHash } from "node:crypto";
3
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, realpathSync } from "node:fs";
4
+ import { delimiter, dirname, join, resolve } from "node:path";
5
+ import { atomicWriteFile, expandUserPath, getConfigDir, websocketsEnabled } from "../../config";
6
+ import { CODEX_CONFIG_PATH, CODEX_MODELS_CACHE_PATH, DEFAULT_CATALOG_PATH, readRootTomlString, resolveCodexConfigPath } from "../paths";
7
+ import { clearModelCache, DEFAULT_MODEL_CACHE_TTL_MS, getFreshCached, getStaleCached, isModelsFetchCoolingDown, markModelsFetchFailure, setCached } from "../model-cache";
8
+ import { buildModelsRequest, resolveModelsAuthToken } from "../../oauth";
9
+ import type { OcxConfig, OcxProviderConfig } from "../../types";
10
+ import { modelInList } from "../../types";
11
+ import { CODEX_REASONING_LEVELS, codexEffortRank, configuredReasoningEfforts, modelRecordValue, sanitizeCodexReasoningEfforts } from "../../reasoning-effort";
12
+ import { getJawcodeModelMetadata, getJawcodeModelMetadataCaseInsensitive, listJawcodeModelMetadata, resolveJawcodeProvider } from "../../generated/jawcode-model-metadata";
13
+ import { enrichProviderFromRegistry, shouldCaseFoldMetadataModelId } from "../../providers/derive";
14
+ import { getProviderRegistryEntry } from "../../providers/registry";
15
+ import { applyProviderContextCap, providerContextCap } from "../../providers/context-cap";
16
+ import { encodeRoutedModelId, routedSlug, slugEquals, slugsEquivalent } from "../../providers/slug-codec";
17
+ import { CODEX_GPT5_IDENTITY_LINE } from "../../adapters/identity";
18
+ import { filterCursorConfiguredModelsByLiveDiscovery } from "../../adapters/cursor/discovery";
19
+ import { fetchCursorUsableModels } from "../../adapters/cursor/live-models";
20
+ import { isCanonicalOpenAiForwardProvider, OPENAI_API_PROVIDER_ID, OPENAI_CODEX_PROVIDER_ID } from "../../providers/openai-tiers";
21
+ import {
22
+ COMBO_NAMESPACE,
23
+ comboModelId,
24
+ getCombo,
25
+ listComboIds,
26
+ targetKey,
27
+ } from "../../combos";
28
+ import type { NormalizedComboConfig } from "../../combos/types";
29
+ import { providerDestinationResolvedError } from "../../lib/destination-policy";
30
+ import { redactSecretString } from "../../lib/redact";
31
+ import upstreamModelsSnapshot from "../data/upstream-models.json";
32
+
33
+
34
+ import { NATIVE_OPENAI_CONTEXT_OVERRIDES, SUPPORTED_NATIVE_OPENAI_SLUGS, UPSTREAM_NATIVE_ENTRIES } from "./metadata";
35
+
36
+ export function legacyCatalogBackupPath(): string {
37
+ return join(getConfigDir(), "catalog-backup.json");
38
+ }
39
+
40
+ export function catalogBackupPathFor(catalogPath: string): string {
41
+ const normalized = process.platform === "win32" ? resolve(catalogPath).toLowerCase() : resolve(catalogPath);
42
+ const id = createHash("sha256").update(normalized).digest("hex").slice(0, 16);
43
+ return join(getConfigDir(), `catalog-backup-${id}.json`);
44
+ }
45
+
46
+ export function samePath(a: string, b: string): boolean {
47
+ const left = resolve(a);
48
+ const right = resolve(b);
49
+ return process.platform === "win32" ? left.toLowerCase() === right.toLowerCase() : left === right;
50
+ }
51
+
52
+ export function activeCodexHome(): string | null {
53
+ const raw = process.env.CODEX_HOME?.trim();
54
+ if (!raw) return null;
55
+ const path = resolve(expandUserPath(raw));
56
+ try {
57
+ return realpathSync.native(path);
58
+ } catch {
59
+ return path;
60
+ }
61
+ }
62
+
63
+ export function activeCodexConfigPath(): string {
64
+ const home = activeCodexHome();
65
+ return home ? join(home, "config.toml") : CODEX_CONFIG_PATH;
66
+ }
67
+
68
+ export function activeDefaultCatalogPath(): string {
69
+ const home = activeCodexHome();
70
+ return home ? join(home, "opencodex-catalog.json") : DEFAULT_CATALOG_PATH;
71
+ }
72
+
73
+ export function activeCodexModelsCachePath(): string {
74
+ const home = activeCodexHome();
75
+ return home ? join(home, "models_cache.json") : CODEX_MODELS_CACHE_PATH;
76
+ }
77
+
78
+ export function resolveActiveCodexConfigPath(path: string): string {
79
+ const home = activeCodexHome();
80
+ return home ? resolve(home, path) : resolveCodexConfigPath(path);
81
+ }
82
+
83
+ export function isDefaultCatalogPath(path: string): boolean {
84
+ return samePath(path, activeDefaultCatalogPath());
85
+ }
86
+
87
+ export interface CatalogModel {
88
+ id: string;
89
+ provider: string;
90
+ /** Public Codex-facing slug override (used by combo aliases). */
91
+ alias?: string;
92
+ /**
93
+ * Display-only Codex catalog `display_name` override. Relabels the picker row ONLY — it never
94
+ * affects the routing slug, alias-collision order, native marketing-name precedence, or provider
95
+ * behavior. When unset, the entry falls back to its Codex-facing slug (the historical behavior).
96
+ * Native upstream entries (e.g. gpt-5.6-sol → "GPT-5.6-Sol") come from the pinned snapshot path
97
+ * which carries no CatalogModel, so a configured displayName can never override a native name.
98
+ */
99
+ displayName?: string;
100
+ owned_by?: string;
101
+ reasoningEfforts?: string[];
102
+ defaultReasoningEffort?: string;
103
+ contextWindow?: number;
104
+ maxInputTokens?: number;
105
+ contextCap?: number;
106
+ contextCapped?: boolean;
107
+ inputModalities?: string[];
108
+ /** Provider opted into parallel tool calls (OcxProviderConfig.parallelToolCalls). */
109
+ parallelToolCalls?: boolean;
110
+ /** Whether Codex may send Responses text.verbosity for this routed model. */
111
+ supportsVerbosity?: boolean;
112
+ supportsReasoningSummaries?: boolean;
113
+ }
114
+
115
+ export type RawEntry = Record<string, unknown>;
116
+
117
+ export type RawCatalog = { models?: RawEntry[]; [k: string]: unknown };
118
+
119
+ export const JAWCODE_CATALOG_AUGMENT_PROVIDERS = new Set(["opencode-go"]);
120
+
121
+ export const ROUTED_MODEL_COMPATIBILITY_EXCLUSIONS = new Set([
122
+ // Issue #82: Zen Go /models advertises HY3, but Console Go rejects it as outside the lite list.
123
+ "opencode-go/hy3-preview",
124
+ ]);
125
+
126
+ export function isRoutedModelCompatibilityExcluded(slug: string): boolean {
127
+ return ROUTED_MODEL_COMPATIBILITY_EXCLUSIONS.has(slug);
128
+ }
129
+
130
+ export const MEDIA_GEN_FAMILIES = [
131
+ "dall-e", "dalle", "imagen", "sora", "veo", "flux", "kling",
132
+ "seedance", "hailuo", "stable-diffusion", "sdxl", "midjourney",
133
+ ];
134
+
135
+ export const MEDIA_GEN_ID_RE = new RegExp(
136
+ `(?:^|[/_-])(?:image|video)(?:[/_-]|$)|(?:^|[/_-])(?:${MEDIA_GEN_FAMILIES.join("|")})(?:[/_-]|$|\\d)`,
137
+ "i",
138
+ );
139
+
140
+ export function isMediaGenerationModelId(id: string): boolean {
141
+ return MEDIA_GEN_ID_RE.test(id);
142
+ }
143
+
144
+ export function shouldExposeRoutedModel(model: CatalogModel): boolean {
145
+ if (isRoutedModelCompatibilityExcluded(`${model.provider}/${model.id}`)) return false;
146
+ if (model.provider === "cursor" && model.id === "gemini-3-pro-image-preview") return true;
147
+ return !isMediaGenerationModelId(model.id);
148
+ }
149
+
150
+ export function readCodexCatalogPath(): string {
151
+ try {
152
+ const configPath = activeCodexConfigPath();
153
+ if (existsSync(configPath)) {
154
+ const toml = readFileSync(configPath, "utf-8");
155
+ const path = readRootTomlString(toml, "model_catalog_json");
156
+ if (path) return resolveActiveCodexConfigPath(path);
157
+ }
158
+ } catch { /* ignore */ }
159
+ return activeDefaultCatalogPath();
160
+ }
161
+
162
+ export function parseCatalogJson(raw: string): RawCatalog | null {
163
+ try {
164
+ const cat = JSON.parse(raw);
165
+ return (cat && Array.isArray(cat.models)) ? cat : null;
166
+ } catch { return null; }
167
+ }
168
+
169
+ export function readCatalog(path: string): RawCatalog | null {
170
+ try {
171
+ if (!existsSync(path)) return null;
172
+ return parseCatalogJson(readFileSync(path, "utf-8"));
173
+ } catch { return null; }
174
+ }
175
+
176
+ export function findNativeTemplate(catalog: RawCatalog | null): RawEntry | null {
177
+ return catalog?.models?.find(
178
+ m => typeof m.slug === "string" && !m.slug.includes("/") && "base_instructions" in m,
179
+ ) ?? null;
180
+ }
181
+
182
+ export function normalizeServiceTiers(entry: RawEntry): RawEntry {
183
+ // Codex stores the user-facing config spelling as "fast", but the catalog/request
184
+ // service tier id is "priority" in current codex-rs. Keep legacy catalogs working.
185
+ if (entry.service_tier === "fast") entry.service_tier = "priority";
186
+ if (Array.isArray(entry.service_tiers)) {
187
+ entry.service_tiers = entry.service_tiers.map(tier => {
188
+ if (tier && typeof tier === "object" && "id" in tier && tier.id === "fast") {
189
+ return { ...tier, id: "priority" };
190
+ }
191
+ return tier;
192
+ });
193
+ }
194
+ return entry;
195
+ }
196
+
197
+ export function ensureAutoCompactTokenLimit(entry: RawEntry): RawEntry {
198
+ if (
199
+ typeof entry.context_window === "number"
200
+ && entry.context_window > 0
201
+ && typeof entry.auto_compact_token_limit !== "number"
202
+ ) {
203
+ entry.auto_compact_token_limit = Math.floor(entry.context_window * 0.9);
204
+ }
205
+ return entry;
206
+ }
207
+
208
+ export function isNativeOpenAiEntry(entry: RawEntry): boolean {
209
+ return typeof entry.slug === "string" && !entry.slug.includes("/");
210
+ }
211
+
212
+ export function applyNativeOpenAiContextOverride(entry: RawEntry): void {
213
+ if (!isNativeOpenAiEntry(entry)) return;
214
+ const override = NATIVE_OPENAI_CONTEXT_OVERRIDES[entry.slug as string];
215
+ if (!override) return;
216
+ if (typeof override.contextWindow === "number") {
217
+ entry.context_window = override.contextWindow;
218
+ entry.auto_compact_token_limit = Math.floor(override.contextWindow * 0.9);
219
+ }
220
+ if (typeof override.maxContextWindow === "number") {
221
+ entry.max_context_window = override.maxContextWindow;
222
+ }
223
+ }
224
+
225
+ export function ensureStrictCatalogFields(
226
+ entry: RawEntry,
227
+ options: { preserveExactInputModalities?: boolean; isRouted?: boolean } = {},
228
+ ): RawEntry {
229
+ if (typeof entry.supports_reasoning_summaries !== "boolean") entry.supports_reasoning_summaries = false;
230
+ if (typeof entry.default_reasoning_summary !== "string") entry.default_reasoning_summary = "none";
231
+ if (typeof entry.support_verbosity !== "boolean") entry.support_verbosity = true;
232
+ if (typeof entry.default_verbosity !== "string") entry.default_verbosity = "low";
233
+ if (typeof entry.apply_patch_tool_type !== "string") entry.apply_patch_tool_type = "freeform";
234
+ if (!entry.truncation_policy || typeof entry.truncation_policy !== "object" || Array.isArray(entry.truncation_policy)) {
235
+ entry.truncation_policy = { mode: "tokens", limit: 10000 };
236
+ }
237
+ if (typeof entry.supports_parallel_tool_calls !== "boolean") entry.supports_parallel_tool_calls = true;
238
+ if (typeof entry.supports_image_detail_original !== "boolean") entry.supports_image_detail_original = false;
239
+ if (!Array.isArray(entry.experimental_supported_tools)) entry.experimental_supported_tools = [];
240
+ if (!Array.isArray(entry.input_modalities) && !options.preserveExactInputModalities) {
241
+ entry.input_modalities = ["text"];
242
+ }
243
+ const contextWindow = typeof entry.context_window === "number" && entry.context_window > 0 ? entry.context_window : 128000;
244
+ entry.context_window = contextWindow;
245
+ if (
246
+ typeof entry.max_context_window !== "number"
247
+ || entry.max_context_window <= 0
248
+ || ((options.isRouted === true || !isNativeOpenAiEntry(entry)) && entry.max_context_window > contextWindow)
249
+ ) {
250
+ entry.max_context_window = contextWindow;
251
+ }
252
+ if (typeof entry.effective_context_window_percent !== "number") entry.effective_context_window_percent = 95;
253
+ if (typeof entry.comp_hash !== "string") entry.comp_hash = "opencodex";
254
+ return ensureAutoCompactTokenLimit(entry);
255
+ }
256
+
257
+ export type MultiAgentMode = "v1" | "default" | "v2";
258
+
259
+ export function applyMultiAgentMode(entries: RawEntry[], mode: MultiAgentMode): RawEntry[] {
260
+ if (mode === "default") {
261
+ // Restore upstream defaults: clear any stale forced multi_agent_version and
262
+ // re-apply upstream pins from the snapshot for native entries that have one.
263
+ for (const entry of entries) {
264
+ const slug = typeof entry.slug === "string" ? entry.slug : "";
265
+ const upstream = UPSTREAM_NATIVE_ENTRIES.get(slug);
266
+ const upstreamPin = upstream?.multi_agent_version;
267
+ if (typeof upstreamPin === "string") {
268
+ entry.multi_agent_version = upstreamPin;
269
+ } else {
270
+ delete entry.multi_agent_version;
271
+ }
272
+ }
273
+ return entries;
274
+ }
275
+ for (const entry of entries) {
276
+ entry.multi_agent_version = mode;
277
+ }
278
+ return entries;
279
+ }
280
+
281
+ export function normalizeRoutedCatalogEntry(entry: RawEntry, parallelToolCalls = false): RawEntry {
282
+ delete entry.model_messages;
283
+ delete entry.tool_mode;
284
+ delete entry.multi_agent_version;
285
+ delete entry.use_responses_lite;
286
+ delete entry.supports_websockets;
287
+ delete entry.additional_speed_tiers;
288
+ delete entry.service_tier;
289
+ delete entry.service_tiers;
290
+ delete entry.default_service_tier;
291
+ // Routed rows cloned from native templates must not inherit OpenAI-only summary delivery.
292
+ // Per-model routed opt-ins can be added once provider metadata exposes this capability.
293
+ delete entry.supports_reasoning_summaries;
294
+ const isCursorEntry = typeof entry.slug === "string" && entry.slug.startsWith("cursor/");
295
+ // Routed providers use opencodex sidecars and client-executed tool discovery. The sidecar
296
+ // runs through native gpt-5.4-mini, so image search is available and verbalized for text-only
297
+ // models. EXCEPT cursor: its runTurn transport bypasses the web-search plan entirely and
298
+ // rejects server search queries — advertising the tool would make models call into a void.
299
+ if (isCursorEntry) {
300
+ delete entry.web_search_tool_type;
301
+ entry.supports_search_tool = false;
302
+ } else {
303
+ entry.web_search_tool_type = "text_and_image";
304
+ entry.supports_search_tool = true;
305
+ }
306
+ // Cursor's transport already serializes overlapping tool calls into atomic Responses tool events.
307
+ // Advertising parallel calls lets Codex send the same native capability bit it sends for OpenAI.
308
+ // Opt-in providers (OcxProviderConfig.parallelToolCalls, e.g. xAI) advertise it too: the
309
+ // openai-chat adapter stops forcing parallel_tool_calls:false and the buffered stream parser
310
+ // assembles multi-call turns (devlog/_plan/260709_parallel_tool_calls).
311
+ entry.supports_parallel_tool_calls = isCursorEntry || parallelToolCalls === true;
312
+ return ensureStrictCatalogFields(entry, { isRouted: true });
313
+ }
314
+
315
+ /** Resolve reasoning-summary support from the active Codex catalog for wire sanitization. */
316
+ export function catalogModelSupportsReasoningSummaries(modelId: string): boolean | undefined {
317
+ if (typeof modelId !== "string" || modelId.length === 0) return undefined;
318
+ const catalog = readCatalog(readCodexCatalogPath()) ?? readCatalog(activeCodexModelsCachePath());
319
+ const models = catalog?.models ?? [];
320
+ const exact = models.find(entry => entry.slug === modelId || entry.id === modelId);
321
+ if (typeof exact?.supports_reasoning_summaries === "boolean") {
322
+ return exact.supports_reasoning_summaries;
323
+ }
324
+ const encodedModelId = encodeRoutedModelId(modelId);
325
+ const matches = models.filter(entry => (
326
+ typeof entry.slug === "string"
327
+ && entry.slug.includes("/")
328
+ && entry.slug.slice(entry.slug.indexOf("/") + 1) === encodedModelId
329
+ && typeof entry.supports_reasoning_summaries === "boolean"
330
+ ));
331
+ const values = new Set(matches.map(entry => entry.supports_reasoning_summaries as boolean));
332
+ return values.size === 1 ? values.values().next().value : undefined;
333
+ }
334
+
335
+ export function applyJawcodeCatalogMetadata(entry: RawEntry, provider: string, modelId: string, contextCap?: number): void {
336
+ const jawcodeProvider = resolveJawcodeProvider(provider);
337
+ if (!jawcodeProvider) return;
338
+ const meta = getJawcodeModelMetadata(jawcodeProvider, modelId)
339
+ ?? (shouldCaseFoldMetadataModelId(provider) ? getJawcodeModelMetadataCaseInsensitive(jawcodeProvider, modelId) : undefined);
340
+ if (!meta) return;
341
+ if (typeof meta.contextWindow === "number" && meta.contextWindow > 0) {
342
+ const contextWindow = applyProviderContextCap(meta.contextWindow, contextCap) ?? meta.contextWindow;
343
+ entry.context_window = contextWindow;
344
+ entry.max_context_window = contextWindow;
345
+ entry.auto_compact_token_limit = Math.floor(contextWindow * 0.9);
346
+ }
347
+ if (Array.isArray(meta.input) && meta.input.length > 0) {
348
+ entry.input_modalities = meta.input;
349
+ }
350
+ }
351
+
352
+ export function catalogModelSlug(model: CatalogModel): string {
353
+ return model.alias ?? routedSlug(model.provider, model.id);
354
+ }
355
+
356
+ export function filterSupportedNativeSlugs(models: RawEntry[]): string[] {
357
+ return models
358
+ .filter(m => typeof m.slug === "string" && !(m.slug as string).includes("/") && m.visibility === "list" && SUPPORTED_NATIVE_OPENAI_SLUGS.has(m.slug as string))
359
+ .map(m => m.slug as string);
360
+ }
361
+
362
+ export function readCatalogBackup(catalogPath: string): RawCatalog | null {
363
+ return readCatalog(catalogBackupPathFor(catalogPath))
364
+ ?? (isDefaultCatalogPath(catalogPath) ? readCatalog(legacyCatalogBackupPath()) : null);
365
+ }
366
+
367
+ export function catalogHasRoutedEntries(catalog: RawCatalog | null): boolean {
368
+ return (catalog?.models ?? []).some(m => typeof m.slug === "string" && m.slug.includes("/"));
369
+ }
370
+
371
+ export function writePristineCatalogBackup(backupPath: string, catalogPath: string, catalog: RawCatalog): void {
372
+ if (existsSync(backupPath)) return;
373
+ const onDisk = readCatalog(catalogPath);
374
+ if (onDisk && !catalogHasRoutedEntries(onDisk)) {
375
+ copyFileSync(catalogPath, backupPath);
376
+ return;
377
+ }
378
+ if (!catalogHasRoutedEntries(catalog)) {
379
+ atomicWriteFile(backupPath, JSON.stringify(catalog, null, 2) + "\n");
380
+ }
381
+ }
382
+
383
+ export function ensureCatalogBackup(catalogPath: string, catalog: RawCatalog): void {
384
+ const dir = getConfigDir();
385
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
386
+ writePristineCatalogBackup(catalogBackupPathFor(catalogPath), catalogPath, catalog);
387
+ if (isDefaultCatalogPath(catalogPath)) writePristineCatalogBackup(legacyCatalogBackupPath(), catalogPath, catalog);
388
+ }
389
+
390
+ export function readNativeBaseline(catalogPath: string): Map<string, number> {
391
+ const backup = readCatalogBackup(catalogPath);
392
+ const out = new Map<string, number>();
393
+ for (const e of backup?.models ?? []) {
394
+ if (typeof e.slug === "string" && !e.slug.includes("/") && typeof e.priority === "number") {
395
+ out.set(e.slug, e.priority);
396
+ }
397
+ }
398
+ return out;
399
+ }