@aion0/forge 0.10.57 → 0.10.64

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/RELEASE_NOTES.md CHANGED
@@ -1,8 +1,8 @@
1
- # Forge v0.10.57
1
+ # Forge v0.10.64
2
2
 
3
3
  Released: 2026-06-10
4
4
 
5
- ## Changes since v0.10.56
5
+ ## Changes since v0.10.63
6
6
 
7
7
 
8
- **Full Changelog**: https://github.com/aiwatching/forge/compare/v0.10.56...v0.10.57
8
+ **Full Changelog**: https://github.com/aiwatching/forge/compare/v0.10.63...v0.10.64
package/app/chat/page.tsx CHANGED
@@ -43,6 +43,17 @@ interface ChatSession extends Session {
43
43
  meta?: { kind?: 'main' | 'temp'; [k: string]: unknown };
44
44
  }
45
45
 
46
+ // One configured API profile, as the header's quick-switcher lists them.
47
+ // Mirrors settings.apiProfiles entries (apiKey is masked by /api/settings —
48
+ // we never read it here, just id/name/provider/model to label the option).
49
+ interface ProfileOption {
50
+ id: string;
51
+ name: string;
52
+ provider: string;
53
+ model: string;
54
+ enabled: boolean;
55
+ }
56
+
46
57
  export default function ChatPage() {
47
58
  const [sessions, setSessions] = useState<ChatSession[]>([]);
48
59
  // Inline rename state — only one row edits at a time. editingId is the
@@ -63,6 +74,13 @@ export default function ChatPage() {
63
74
  const [memory, setMemory] = useState<MemoryStatus | null>(null);
64
75
  const [memoryOpen, setMemoryOpen] = useState(false);
65
76
  const [error, setError] = useState('');
77
+ // Configured API profiles + the header quick-switch dropdown's open state.
78
+ const [profiles, setProfiles] = useState<ProfileOption[]>([]);
79
+ const [switchOpen, setSwitchOpen] = useState(false);
80
+ // The model the backend ACTUALLY served on the last turn (from the
81
+ // provider response, not the model's self-description). Reset on session
82
+ // switch so it never shows a stale value from a different conversation.
83
+ const [servedModel, setServedModel] = useState('');
66
84
 
67
85
  const eventSrcRef = useRef<EventSource | null>(null);
68
86
  const scrollRef = useRef<HTMLDivElement>(null);
@@ -108,9 +126,32 @@ export default function ChatPage() {
108
126
  }, []);
109
127
 
110
128
  useEffect(() => {
129
+ setServedModel(''); // stale across sessions — cleared until next turn reports
111
130
  if (activeId) loadMessages(activeId);
112
131
  }, [activeId, loadMessages]);
113
132
 
133
+ // ─── Load configured API profiles (for the header quick-switcher) ──
134
+ // Served by next-server's /api/settings (same origin) with apiKeys masked.
135
+ useEffect(() => {
136
+ (async () => {
137
+ try {
138
+ const r = await fetch('/api/settings');
139
+ if (!r.ok) return;
140
+ const s = (await r.json()) as { apiProfiles?: Record<string, { name?: string; enabled?: boolean; provider?: string; model?: string }> };
141
+ const list: ProfileOption[] = Object.entries(s.apiProfiles || {})
142
+ .filter(([, p]) => p && p.enabled !== false)
143
+ .map(([id, p]) => ({
144
+ id,
145
+ name: p.name || id,
146
+ provider: p.provider || 'anthropic',
147
+ model: p.model || 'default',
148
+ enabled: p.enabled !== false,
149
+ }));
150
+ setProfiles(list);
151
+ } catch { /* non-fatal — header just falls back to plain text */ }
152
+ })();
153
+ }, []);
154
+
114
155
  // ─── SSE subscription ─────────────────────────────────────
115
156
  useEffect(() => {
116
157
  if (!activeId) return;
@@ -139,6 +180,7 @@ export default function ChatPage() {
139
180
  setStreaming(false);
140
181
  setStopRequested(false);
141
182
  setPartial('');
183
+ if (data.served_model) setServedModel(String(data.served_model));
142
184
  loadMessages(activeId);
143
185
  refreshSessions();
144
186
  } else if (type === 'watch_status') {
@@ -320,6 +362,32 @@ export default function ChatPage() {
320
362
  }
321
363
  }
322
364
 
365
+ // Switch the active session's API profile. session.provider holds the
366
+ // apiProfile id; session.model the (optional) override. We set both to the
367
+ // chosen profile's id + its configured model so the next turn uses it —
368
+ // updateSession merges with ?? semantics, so passing the model explicitly
369
+ // avoids carrying a stale model from the previous profile.
370
+ async function switchProfile(p: ProfileOption) {
371
+ setSwitchOpen(false);
372
+ if (!activeId) return;
373
+ if (activeSession?.provider === p.id) return; // already on it
374
+ try {
375
+ const r = await fetch(`${PROXY}/sessions/${activeId}`, {
376
+ method: 'PATCH',
377
+ headers: { 'content-type': 'application/json' },
378
+ body: JSON.stringify({ provider: p.id, model: p.model }),
379
+ });
380
+ if (!r.ok) {
381
+ const j = await r.json().catch(() => ({}));
382
+ setError(j.error || `switch failed (HTTP ${r.status})`);
383
+ return;
384
+ }
385
+ await refreshSessions();
386
+ } catch (e) {
387
+ setError(e instanceof Error ? e.message : String(e));
388
+ }
389
+ }
390
+
323
391
  async function clearMessages() {
324
392
  if (!activeId) return;
325
393
  if (!confirm('Clear all messages in this session?')) return;
@@ -497,10 +565,74 @@ export default function ChatPage() {
497
565
  'No session'}
498
566
  </div>
499
567
  {activeSession && (
500
- <div className="text-[11px] text-[var(--text-secondary)]">
501
- {activeSession.provider || 'auto'} · {activeSession.model || 'default'}
568
+ <div className="relative inline-block">
569
+ <button
570
+ type="button"
571
+ onClick={() => setSwitchOpen((v) => !v)}
572
+ disabled={profiles.length === 0}
573
+ title={profiles.length ? 'Switch API profile for this conversation' : 'No API profiles configured'}
574
+ className="flex items-center gap-1 text-[11px] text-[var(--text-secondary)] hover:text-[var(--text-primary)] disabled:cursor-default disabled:hover:text-[var(--text-secondary)] transition-colors"
575
+ >
576
+ <span>
577
+ {(() => {
578
+ const cur = profiles.find((p) => p.id === activeSession.provider);
579
+ const label = cur?.name || activeSession.provider || 'auto';
580
+ return `${label} · ${activeSession.model || cur?.model || 'default'}`;
581
+ })()}
582
+ </span>
583
+ {profiles.length > 0 && <span className="opacity-50 text-[9px]">▼</span>}
584
+ </button>
585
+ {switchOpen && profiles.length > 0 && (
586
+ <>
587
+ {/* click-away backdrop */}
588
+ <div className="fixed inset-0 z-10" onClick={() => setSwitchOpen(false)} />
589
+ <div className="absolute left-0 top-full mt-1 z-20 min-w-[200px] max-h-[60vh] overflow-y-auto rounded-md border border-[var(--border)] bg-[var(--bg-secondary)] shadow-lg py-1">
590
+ {profiles.map((p) => {
591
+ const active = p.id === activeSession.provider;
592
+ return (
593
+ <button
594
+ key={p.id}
595
+ type="button"
596
+ onClick={() => switchProfile(p)}
597
+ className={`w-full text-left px-3 py-1.5 text-[11px] hover:bg-[var(--bg-tertiary)] transition-colors ${
598
+ active ? 'text-[var(--accent)]' : 'text-[var(--text-primary)]'
599
+ }`}
600
+ >
601
+ <div className="flex items-center gap-1.5">
602
+ {active && <span className="text-[9px]">✓</span>}
603
+ <span className="font-medium truncate">{p.name}</span>
604
+ </div>
605
+ <div className="text-[10px] text-[var(--text-secondary)] truncate pl-0.5">
606
+ {p.provider} · {p.model}
607
+ </div>
608
+ </button>
609
+ );
610
+ })}
611
+ </div>
612
+ </>
613
+ )}
502
614
  </div>
503
615
  )}
616
+ {servedModel && (() => {
617
+ // The honest backend identity. Tint amber when it differs from
618
+ // the configured model — that means the proxy (litellm) fell
619
+ // back server-side (e.g. qwen → claude), which is the usual
620
+ // cause of "why is everything claude".
621
+ const cur = profiles.find((p) => p.id === activeSession?.provider);
622
+ const requested = activeSession?.model || cur?.model || '';
623
+ const mismatch = requested && servedModel && servedModel !== requested;
624
+ return (
625
+ <div
626
+ className="text-[10px] mt-0.5"
627
+ style={{ color: mismatch ? '#fbbf24' : 'var(--text-secondary)' }}
628
+ title={mismatch
629
+ ? `Backend actually served "${servedModel}" — differs from the requested "${requested}". The proxy fell back server-side.`
630
+ : `Backend-reported model for the last reply (trustworthy — not the model's self-description)`}
631
+ >
632
+ served: {servedModel}{mismatch ? ` ⚠ (≠ ${requested})` : ''}
633
+ </div>
634
+ );
635
+ })()}
504
636
  </div>
505
637
  <button
506
638
  onClick={clearMessages}
@@ -0,0 +1,104 @@
1
+ {
2
+ "$schema": "https://raw.githubusercontent.com/aiwatching/forge-public-info/main/models/registry.schema.json",
3
+ "version": 1,
4
+ "updatedAt": "2026-06-10",
5
+ "note": "Forge consumes this at startup via lib/public-info/fetch.ts (24h cache). 'agents' holds CLI agent types (claude-code, codex, aider); 'providers' holds API providers (anthropic, openai, etc). UI looks up by cliType for CLI profiles and by provider for API profiles.",
6
+ "agents": {
7
+ "claude-code": {
8
+ "displayName": "Claude Code",
9
+ "default": "claude-sonnet-4-6",
10
+ "aliases": [
11
+ { "id": "default", "label": "default (CLI decides)" },
12
+ { "id": "fable", "label": "fable (alias → latest fable)" },
13
+ { "id": "sonnet", "label": "sonnet (alias → latest sonnet)" },
14
+ { "id": "opus", "label": "opus (alias → latest opus)" },
15
+ { "id": "haiku", "label": "haiku (alias → latest haiku)" }
16
+ ],
17
+ "models": [
18
+ { "id": "claude-fable-5", "label": "Fable 5", "tier": "premium", "default": false },
19
+ { "id": "claude-opus-4-8", "label": "Opus 4.8", "tier": "premium", "default": false },
20
+ { "id": "claude-sonnet-4-6", "label": "Sonnet 4.6", "tier": "standard", "default": true },
21
+ { "id": "claude-haiku-4-5-20251001","label": "Haiku 4.5", "tier": "fast", "default": false }
22
+ ]
23
+ },
24
+ "codex": {
25
+ "displayName": "OpenAI Codex",
26
+ "default": "o4-mini",
27
+ "aliases": [
28
+ { "id": "default", "label": "default (CLI decides)" }
29
+ ],
30
+ "models": [
31
+ { "id": "o4-mini", "label": "o4-mini", "tier": "fast", "default": true },
32
+ { "id": "o3-mini", "label": "o3-mini", "tier": "fast", "default": false },
33
+ { "id": "gpt-4.1", "label": "GPT-4.1", "tier": "standard", "default": false }
34
+ ]
35
+ },
36
+ "aider": {
37
+ "displayName": "Aider",
38
+ "default": "default",
39
+ "aliases": [
40
+ { "id": "default", "label": "default (CLI decides)" }
41
+ ],
42
+ "models": []
43
+ }
44
+ },
45
+ "providers": {
46
+ "anthropic": {
47
+ "displayName": "Anthropic API",
48
+ "default": "claude-sonnet-4-6",
49
+ "aliases": [],
50
+ "models": [
51
+ { "id": "claude-fable-5", "label": "Fable 5", "tier": "premium", "default": false },
52
+ { "id": "claude-opus-4-8", "label": "Opus 4.8", "tier": "premium", "default": false },
53
+ { "id": "claude-sonnet-4-6", "label": "Sonnet 4.6", "tier": "standard", "default": true },
54
+ { "id": "claude-haiku-4-5-20251001", "label": "Haiku 4.5", "tier": "fast", "default": false }
55
+ ]
56
+ },
57
+ "openai": {
58
+ "displayName": "OpenAI API",
59
+ "default": "gpt-4.1",
60
+ "aliases": [],
61
+ "models": [
62
+ { "id": "gpt-4.1", "label": "GPT-4.1", "tier": "premium", "default": true },
63
+ { "id": "gpt-4o", "label": "GPT-4o", "tier": "standard", "default": false },
64
+ { "id": "o4-mini", "label": "o4-mini", "tier": "fast", "default": false },
65
+ { "id": "o3-mini", "label": "o3-mini", "tier": "fast", "default": false }
66
+ ]
67
+ },
68
+ "grok": {
69
+ "displayName": "Grok / xAI",
70
+ "default": "grok-4",
71
+ "aliases": [],
72
+ "models": [
73
+ { "id": "grok-4", "label": "Grok 4", "tier": "premium", "default": true },
74
+ { "id": "grok-3", "label": "Grok 3", "tier": "standard", "default": false },
75
+ { "id": "grok-3-mini", "label": "Grok 3 Mini", "tier": "fast", "default": false }
76
+ ]
77
+ },
78
+ "google": {
79
+ "displayName": "Google / Gemini",
80
+ "default": "gemini-2.5-pro",
81
+ "aliases": [],
82
+ "models": [
83
+ { "id": "gemini-2.5-pro", "label": "Gemini 2.5 Pro", "tier": "premium", "default": true },
84
+ { "id": "gemini-2.5-flash", "label": "Gemini 2.5 Flash", "tier": "standard", "default": false },
85
+ { "id": "gemini-2.0-flash", "label": "Gemini 2.0 Flash", "tier": "fast", "default": false }
86
+ ]
87
+ },
88
+ "deepseek": {
89
+ "displayName": "DeepSeek",
90
+ "default": "deepseek-chat",
91
+ "aliases": [],
92
+ "models": [
93
+ { "id": "deepseek-chat", "label": "DeepSeek Chat", "tier": "standard", "default": true },
94
+ { "id": "deepseek-reasoner", "label": "DeepSeek Reasoner", "tier": "premium", "default": false }
95
+ ]
96
+ },
97
+ "litellm": {
98
+ "displayName": "LiteLLM (OpenAI-compatible proxy)",
99
+ "default": "",
100
+ "aliases": [],
101
+ "models": []
102
+ }
103
+ }
104
+ }
@@ -4,74 +4,14 @@
4
4
  * when the network is unreachable, the file is malformed, or the
5
5
  * user is on first-run before the cache is populated.
6
6
  *
7
- * Keep this list reasonably current if the registry is permanently
8
- * unreachable, this is what users see. New models in the wild should
9
- * land in the public-info repo first (no code change required), and
10
- * trickle into this fallback whenever the next forge release happens.
7
+ * This is a build-time SNAPSHOT of forge-public-info/models/registry.json,
8
+ * refreshed automatically by `publish.sh` at each release do NOT hand-edit
9
+ * `known-models.json`. To change models, edit the public-info repo; running
10
+ * installs pick it up within the 24h cache, fresh/offline installs pick it up
11
+ * at the next forge release when the snapshot is re-pulled.
11
12
  */
12
13
 
13
14
  import type { ModelsRegistry } from '../public-info/types';
15
+ import snapshot from './known-models.json';
14
16
 
15
- export const KNOWN_MODELS_FALLBACK: ModelsRegistry = {
16
- version: 1,
17
- updatedAt: '2026-06-10',
18
- note: 'Bundled fallback — actual current list lives in forge-public-info/models/registry.json',
19
- agents: {
20
- 'claude-code': {
21
- displayName: 'Claude Code',
22
- default: 'claude-sonnet-4-6',
23
- aliases: [
24
- { id: 'default', label: 'default (CLI decides)' },
25
- { id: 'fable', label: 'fable (alias)' },
26
- { id: 'sonnet', label: 'sonnet (alias)' },
27
- { id: 'opus', label: 'opus (alias)' },
28
- { id: 'haiku', label: 'haiku (alias)' },
29
- ],
30
- models: [
31
- { id: 'claude-fable-5', label: 'Fable 5', tier: 'premium' },
32
- { id: 'claude-opus-4-8', label: 'Opus 4.8', tier: 'premium' },
33
- { id: 'claude-sonnet-4-6', label: 'Sonnet 4.6', tier: 'standard', default: true },
34
- { id: 'claude-haiku-4-5-20251001', label: 'Haiku 4.5', tier: 'fast' },
35
- ],
36
- },
37
- codex: {
38
- displayName: 'OpenAI Codex',
39
- default: 'o4-mini',
40
- aliases: [{ id: 'default', label: 'default (CLI decides)' }],
41
- models: [
42
- { id: 'o4-mini', label: 'o4-mini', tier: 'fast', default: true },
43
- { id: 'o3-mini', label: 'o3-mini', tier: 'fast' },
44
- { id: 'gpt-4.1', label: 'GPT-4.1', tier: 'standard' },
45
- ],
46
- },
47
- aider: {
48
- displayName: 'Aider',
49
- default: 'default',
50
- aliases: [{ id: 'default', label: 'default (CLI decides)' }],
51
- models: [],
52
- },
53
- },
54
- providers: {
55
- anthropic: {
56
- displayName: 'Anthropic API',
57
- default: 'claude-sonnet-4-6',
58
- aliases: [],
59
- models: [
60
- { id: 'claude-opus-4-8', label: 'Opus 4.8', tier: 'premium' },
61
- { id: 'claude-sonnet-4-6', label: 'Sonnet 4.6', tier: 'standard', default: true },
62
- { id: 'claude-haiku-4-5-20251001', label: 'Haiku 4.5', tier: 'fast' },
63
- ],
64
- },
65
- openai: {
66
- displayName: 'OpenAI API',
67
- default: 'gpt-4.1',
68
- aliases: [],
69
- models: [
70
- { id: 'gpt-4.1', label: 'GPT-4.1', tier: 'premium', default: true },
71
- { id: 'gpt-4o', label: 'GPT-4o', tier: 'standard' },
72
- { id: 'o4-mini', label: 'o4-mini', tier: 'fast' },
73
- { id: 'o3-mini', label: 'o3-mini', tier: 'fast' },
74
- ],
75
- },
76
- },
77
- };
17
+ export const KNOWN_MODELS_FALLBACK: ModelsRegistry = snapshot as ModelsRegistry;
@@ -773,6 +773,7 @@ export async function runTurn(args: RunTurnArgs): Promise<{ ok: boolean; error?:
773
773
 
774
774
  let iter = 0;
775
775
  let lastStop = '';
776
+ let lastServedModelId = '';
776
777
  let assistantBlocksAccum: ContentBlock[] = [];
777
778
 
778
779
  // beginTurn already ran at function entry (see top of runTurn). The
@@ -933,8 +934,9 @@ export async function runTurn(args: RunTurnArgs): Promise<{ ok: boolean; error?:
933
934
  // ── Real usage from the provider (when reported) ──
934
935
  if (result.usage) {
935
936
  const u = result.usage;
936
- console.log(`[chat-tokens] session=${args.sessionId} turn=${iter} REAL in=${u.inputTokens ?? '?'} out=${u.outputTokens ?? '?'} cache_read=${u.cacheReadTokens ?? 0} cache_create=${u.cacheCreationTokens ?? 0} stop=${result.stopReason}`);
937
+ console.log(`[chat-tokens] session=${args.sessionId} turn=${iter} REAL in=${u.inputTokens ?? '?'} out=${u.outputTokens ?? '?'} cache_read=${u.cacheReadTokens ?? 0} cache_create=${u.cacheCreationTokens ?? 0} stop=${result.stopReason} served=${result.servedModelId ?? '?'}`);
937
938
  }
939
+ if (result.servedModelId) lastServedModelId = result.servedModelId;
938
940
 
939
941
  lastStop = result.stopReason;
940
942
  assistantBlocksAccum = result.content;
@@ -1008,7 +1010,7 @@ export async function runTurn(args: RunTurnArgs): Promise<{ ok: boolean; error?:
1008
1010
  cb({ type: 'error', data: { error: `iteration limit (${MAX_ITERATIONS}) exceeded` } });
1009
1011
  }
1010
1012
 
1011
- cb({ type: 'turn_done', data: { iterations: iter, stop_reason: lastStop } });
1013
+ cb({ type: 'turn_done', data: { iterations: iter, stop_reason: lastStop, served_model: lastServedModelId || undefined, requested_model: provider.model, profile: provider.name } });
1012
1014
  return { ok: true };
1013
1015
  } catch (err) {
1014
1016
  let msg = err instanceof Error ? err.message : String(err);
@@ -203,6 +203,10 @@ export const anthropicAdapter: LlmAdapter = {
203
203
  };
204
204
  }
205
205
  } catch {}
206
- return { stopReason: mapStop(finishReason), content, usage };
206
+ // The model the backend ACTUALLY served — may differ from req.model
207
+ // when a litellm/relay proxy falls back (e.g. forti-coder → claude).
208
+ let servedModelId: string | undefined;
209
+ try { servedModelId = (await result.response)?.modelId; } catch {}
210
+ return { stopReason: mapStop(finishReason), content, usage, servedModelId };
207
211
  },
208
212
  };
@@ -123,6 +123,10 @@ export const openaiAdapter: LlmAdapter = {
123
123
  };
124
124
  }
125
125
  } catch {}
126
- return { stopReason: mapStop(finishReason), content, usage };
126
+ // The model the backend ACTUALLY served — may differ from req.model
127
+ // when a litellm/relay proxy falls back (e.g. forti-coder → claude).
128
+ let servedModelId: string | undefined;
129
+ try { servedModelId = (await result.response)?.modelId; } catch {}
130
+ return { stopReason: mapStop(finishReason), content, usage, servedModelId };
127
131
  },
128
132
  };
@@ -35,6 +35,12 @@ export interface LlmTurnResult {
35
35
  /** Token usage from the provider, if reported. May be partially-filled
36
36
  * or absent for proxies that don't expose it. */
37
37
  usage?: LlmTurnUsage;
38
+ /** The model id the backend actually served, read from the response
39
+ * (`response.modelId`). For litellm/relay proxies this can DIFFER from
40
+ * the requested model — the proxy may silently fall back (e.g. qwen →
41
+ * claude-sonnet-4-6). This is the only trustworthy identity, since the
42
+ * model's own self-description in the text is often wrong. */
43
+ servedModelId?: string;
38
44
  }
39
45
 
40
46
  export interface LlmRequest {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aion0/forge",
3
- "version": "0.10.57",
3
+ "version": "0.10.64",
4
4
  "description": "Unified AI workflow platform — multi-model task orchestration, persistent sessions, web terminal, remote access",
5
5
  "type": "module",
6
6
  "scripts": {
package/publish.sh CHANGED
@@ -9,6 +9,13 @@
9
9
 
10
10
  set -e
11
11
 
12
+ # Preflight: fail BEFORE any version bump / commit / tag / push if gh is
13
+ # not authenticated. Otherwise the release step 401s after everything else
14
+ # already happened, leaving a tag pushed without a GitHub Release.
15
+ if command -v gh &> /dev/null; then
16
+ gh auth status &> /dev/null || { echo "✗ gh is not authenticated — run 'gh auth login' first (the GitHub Release step would 401)."; exit 1; }
17
+ fi
18
+
12
19
  VERSION_ARG=${1:-patch}
13
20
  CURRENT=$(node -p "require('./package.json').version")
14
21
 
@@ -32,6 +39,23 @@ fi
32
39
  echo "Version: $CURRENT → $NEW_VERSION"
33
40
  echo ""
34
41
 
42
+ # Refresh the bundled model-registry snapshot from public-info.
43
+ # This is the offline/first-run fallback for lib/agents/known-models.ts —
44
+ # pulling it here keeps it from drifting against the live registry.
45
+ REGISTRY_URL="https://raw.githubusercontent.com/aiwatching/forge-public-info/main/models/registry.json"
46
+ SNAPSHOT_PATH="lib/agents/known-models.json"
47
+ echo "Refreshing model snapshot from $REGISTRY_URL ..."
48
+ TMP_SNAPSHOT=$(mktemp)
49
+ if curl -fsSL "$REGISTRY_URL" -o "$TMP_SNAPSHOT" && node -e "JSON.parse(require('fs').readFileSync('$TMP_SNAPSHOT','utf8'))"; then
50
+ mv "$TMP_SNAPSHOT" "$SNAPSHOT_PATH"
51
+ echo "✓ Updated $SNAPSHOT_PATH"
52
+ else
53
+ rm -f "$TMP_SNAPSHOT"
54
+ echo "✗ Failed to fetch/validate model registry — aborting publish (keeping old snapshot)."
55
+ exit 1
56
+ fi
57
+ echo ""
58
+
35
59
  # Update package.json
36
60
  sed -i '' "s/\"version\": \"$CURRENT\"/\"version\": \"$NEW_VERSION\"/" package.json
37
61