@unoverse-platform/studio 0.1.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.
Files changed (57) hide show
  1. package/bin/studio.mjs +124 -0
  2. package/index.html +12 -0
  3. package/local/catalog.ts +105 -0
  4. package/local/keys.ts +118 -0
  5. package/local/nodes.ts +77 -0
  6. package/local/plugin.ts +401 -0
  7. package/local/scaffold.d.mts +13 -0
  8. package/local/scaffold.mjs +85 -0
  9. package/package.json +43 -0
  10. package/public/logo.png +0 -0
  11. package/screens/AtomsView.tsx +206 -0
  12. package/screens/ComponentsView.tsx +551 -0
  13. package/screens/ConfigForm.tsx +217 -0
  14. package/screens/DesignSystemView.tsx +324 -0
  15. package/screens/IdentityHeader.tsx +88 -0
  16. package/screens/PromptBlocksView.tsx +137 -0
  17. package/screens/SkillsView.tsx +167 -0
  18. package/screens/TemplatesView.tsx +1005 -0
  19. package/screens/index.ts +26 -0
  20. package/screens/states.ts +44 -0
  21. package/src/App.tsx +252 -0
  22. package/src/ConnectUniverse.tsx +232 -0
  23. package/src/NewProject.tsx +89 -0
  24. package/src/NodeKeys.tsx +98 -0
  25. package/src/NodesView.tsx +443 -0
  26. package/src/PublishDialog.tsx +309 -0
  27. package/src/UniverseSession.tsx +121 -0
  28. package/src/host.ts +165 -0
  29. package/src/index.css +7 -0
  30. package/src/main.tsx +11 -0
  31. package/src/universes.ts +210 -0
  32. package/vendor/sdk/appEngine.tsx +219 -0
  33. package/vendor/sdk/lib/UnoverseComponent.tsx +120 -0
  34. package/vendor/sdk/lib/connection.tsx +302 -0
  35. package/vendor/sdk/lib/core/actions.ts +84 -0
  36. package/vendor/sdk/lib/core/client.ts +134 -0
  37. package/vendor/sdk/lib/core/conditions.ts +43 -0
  38. package/vendor/sdk/lib/core/connection.ts +142 -0
  39. package/vendor/sdk/lib/core/index.ts +32 -0
  40. package/vendor/sdk/lib/core/store.ts +455 -0
  41. package/vendor/sdk/lib/core/types.ts +194 -0
  42. package/vendor/sdk/lib/index.ts +59 -0
  43. package/vendor/sdk/lib/isolate.tsx +70 -0
  44. package/vendor/sdk/lib/primitives.tsx +453 -0
  45. package/vendor/sdk/lib/realtime/audioUtils.ts +40 -0
  46. package/vendor/sdk/lib/realtime/types.ts +45 -0
  47. package/vendor/sdk/lib/realtime/useAudioCapture.ts +237 -0
  48. package/vendor/sdk/lib/realtime/useAudioPlayback.ts +145 -0
  49. package/vendor/sdk/lib/realtime/useRealtimeWebSocket.ts +164 -0
  50. package/vendor/sdk/lib/render.tsx +178 -0
  51. package/vendor/sdk/lib/streamed.tsx +65 -0
  52. package/vendor/sdk/lib/style.ts +227 -0
  53. package/vendor/sdk/lib/template.tsx +521 -0
  54. package/vendor/sdk/lib/theme.ts +55 -0
  55. package/vendor/sdk/lib/voice.tsx +311 -0
  56. package/vendor/sdk/main.tsx +96 -0
  57. package/vite.config.ts +70 -0
@@ -0,0 +1,311 @@
1
+ /**
2
+ * useVoiceService — the Unoverse `voice` NATIVE SERVICE (UNOVERSE_SPEC §2e-1 Tier-3).
3
+ *
4
+ * Audio I/O is inherently native (getUserMedia / AudioContext / the /ws/gravity WS
5
+ * lane), so it cannot be expressed as definition data — it is the spec's sanctioned
6
+ * escape hatch: a fixed `{ state, actions }` capability the SDK provides and a template
7
+ * REFERENCES (`service: "voice"`), binding its neutral state + dispatching its actions.
8
+ *
9
+ * This composes the ported realtime hooks (capture / playback / the WS audio lane) +
10
+ * the orchestration from the legacy useVoiceCall + useAudioContext into ONE neutral
11
+ * projection. The channel spreads `state` into the template's data scope and routes the
12
+ * voice action names to the actions — see the workbench wiring. The SDK owns NO styles
13
+ * and exposes only NEUTRAL model facts here (never UX flags).
14
+ *
15
+ * Transport split (docs/UNOVERSE_MCP_TEMPLATE_PROTOCOL §5): the live PCM frames AND the
16
+ * synchronized audio-state control events both ride the /ws/gravity WS lane (they stay
17
+ * frame-synchronized); only structured component data is on MCP, untouched by voice.
18
+ */
19
+
20
+ import { useState, useCallback, useEffect, useRef, useMemo } from "react";
21
+ import { useRealtimeWebSocket } from "./realtime/useRealtimeWebSocket";
22
+ import { useAudioCapture } from "./realtime/useAudioCapture";
23
+ import { useAudioPlayback } from "./realtime/useAudioPlayback";
24
+ import type { AudioStateEvent } from "./realtime/types";
25
+ import type { ComponentStore } from "./core";
26
+
27
+ export type VoiceConnectionStatus = "idle" | "connecting" | "connected" | "ended" | "error";
28
+
29
+ export interface VoiceSession {
30
+ userId: string;
31
+ conversationId: string;
32
+ chatId?: string;
33
+ workflowId: string;
34
+ targetTriggerNode: string;
35
+ }
36
+
37
+ export interface UseVoiceServiceConfig {
38
+ /** Gateway REST base, e.g. http://localhost:4100 (the workflow `…/execute` host). */
39
+ apiUrl: string;
40
+ /** Audio WS base, e.g. ws://localhost:4100. Defaults to `apiUrl` with http→ws. */
41
+ wsUrl?: string;
42
+ /** Fresh access token provider (rides the WS lane + the REST Authorization header). */
43
+ getAccessToken?: () => Promise<string | null> | string | null;
44
+ session: VoiceSession;
45
+ /** The shared store. When given, the service acts as a PRODUCER (STATE_MODEL §4):
46
+ * every state change is merged into TEMPLATE STATE, so templates read voice exactly
47
+ * like any global key (`visibleWhen: { field: "callState", … }`) with zero host
48
+ * glue — the voice twin of `lifecycle`/`isThinking`/`isStreaming`. */
49
+ store?: ComponentStore;
50
+ }
51
+
52
+ /** The single call-phase VALUE a voice template branches its states on — the standard voice
53
+ * state vocabulary (`visibleWhen: { field: "callState", eq: … }`). Derived from the neutral
54
+ * facts below so every voice template keys off one field, exactly like `mode` for focus. */
55
+ export type VoiceCallState = "idle" | "active" | "agentSpeaking" | "userSpeaking";
56
+
57
+ /** NEUTRAL voice projections — flat, so the channel can spread them into the template scope. */
58
+ export interface VoiceServiceState {
59
+ connectionStatus: VoiceConnectionStatus;
60
+ /** The single phase the template's states key off (idle | active | agentSpeaking | userSpeaking). */
61
+ callState: VoiceCallState;
62
+ isIdle: boolean;
63
+ isCallActive: boolean;
64
+ isConnecting: boolean;
65
+ isConnected: boolean;
66
+ isAssistantSpeaking: boolean;
67
+ isUserSpeaking: boolean;
68
+ /** user speaking AND assistant not — mutual exclusion (the template can't express NOT). */
69
+ showUserSpeaking: boolean;
70
+ isMuted: boolean;
71
+ isLookingUp: boolean;
72
+ lookupLabel: string;
73
+ callDuration: number;
74
+ durationText: string;
75
+ statusText: string;
76
+ error: string | null;
77
+ }
78
+
79
+ export interface VoiceService {
80
+ /** Spread into the template's data scope (channel overrides → rootData). */
81
+ state: VoiceServiceState;
82
+ startCall: () => Promise<void>;
83
+ endCall: () => Promise<void>;
84
+ toggleMute: () => void;
85
+ }
86
+
87
+ function formatDuration(totalSeconds: number): string {
88
+ const m = Math.floor(totalSeconds / 60)
89
+ .toString()
90
+ .padStart(2, "0");
91
+ const s = (totalSeconds % 60).toString().padStart(2, "0");
92
+ return `${m}:${s}`;
93
+ }
94
+
95
+ export function useVoiceService(config: UseVoiceServiceConfig): VoiceService {
96
+ const { apiUrl, session, getAccessToken } = config;
97
+ const wsUrl = config.wsUrl ?? apiUrl.replace(/^http/, "ws");
98
+
99
+ const [connectionStatus, setConnectionStatus] = useState<VoiceConnectionStatus>("idle");
100
+ const [isMuted, setIsMuted] = useState(false);
101
+ const [callDuration, setCallDuration] = useState(0);
102
+ const [error, setError] = useState<string | null>(null);
103
+ const [assistantHint, setAssistantHint] = useState(false);
104
+ const [userSpeaking, setUserSpeaking] = useState(false);
105
+ const [isLookingUp, setIsLookingUp] = useState(false);
106
+ const [lookupTool, setLookupTool] = useState<string | null>(null);
107
+
108
+ const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
109
+ const sendAudioRef = useRef<(d: ArrayBuffer) => void>(() => {});
110
+
111
+ const playback = useAudioPlayback();
112
+
113
+ // Audio-state events arrive on the WS lane, in lockstep with the frames.
114
+ const handleAudioState = useCallback(
115
+ (event: AudioStateEvent) => {
116
+ const { state, metadata } = event;
117
+ if (state === "SPEECH_STARTED") {
118
+ setAssistantHint(true);
119
+ } else if (state === "SPEECH_ENDED") {
120
+ setAssistantHint(false);
121
+ playback.markAsLastChunk();
122
+ } else if (state === "USER_SPEECH_STARTED") {
123
+ setUserSpeaking(true);
124
+ playback.stopAll(); // barge-in: cut assistant playback the moment the user speaks
125
+ } else if (state === "USER_SPEECH_ENDED") {
126
+ setUserSpeaking(false);
127
+ } else if (state === "SESSION_READY") {
128
+ setConnectionStatus("connected");
129
+ } else if (state === "SESSION_ENDED") {
130
+ setConnectionStatus("ended");
131
+ setAssistantHint(false);
132
+ setUserSpeaking(false);
133
+ playback.markAsLastChunk();
134
+ } else if (state === "TOOL_USE") {
135
+ setIsLookingUp(true);
136
+ setLookupTool((metadata?.toolName as string | undefined) ?? null);
137
+ } else if (state === "TOOL_USE_COMPLETED") {
138
+ setIsLookingUp(false);
139
+ setLookupTool(null);
140
+ }
141
+ },
142
+ [playback],
143
+ );
144
+
145
+ const ws = useRealtimeWebSocket({
146
+ sessionId: session.conversationId,
147
+ userId: session.userId,
148
+ chatId: session.chatId,
149
+ wsUrl,
150
+ getAccessToken,
151
+ onAudioReceived: playback.playAudio,
152
+ onAudioState: handleAudioState,
153
+ });
154
+
155
+ // Capture → WS via a ref so the capture graph isn't rebuilt when sendAudio changes.
156
+ const handleAudioData = useCallback((d: ArrayBuffer) => sendAudioRef.current(d), []);
157
+ useEffect(() => {
158
+ sendAudioRef.current = ws.sendAudio;
159
+ }, [ws.sendAudio]);
160
+
161
+ const capture = useAudioCapture({ onAudioData: handleAudioData });
162
+
163
+ const startCall = useCallback(async () => {
164
+ setConnectionStatus("connecting");
165
+ setError(null);
166
+ setCallDuration(0);
167
+ try {
168
+ // 1. Open the audio lane (sends INIT_SESSION on open) BEFORE the trigger,
169
+ // so the server can route audio the moment the Nova/Grok session is up.
170
+ await ws.connect();
171
+
172
+ // 2. START_CALL via REST — fire-and-forget; the workflow stays open for the call.
173
+ const token = getAccessToken ? await getAccessToken() : null;
174
+ const headers: Record<string, string> = { "Content-Type": "application/json" };
175
+ if (token) headers["Authorization"] = `Bearer ${token}`;
176
+ const chatId = session.chatId ?? `voice_${session.conversationId}`;
177
+ fetch(`${apiUrl}/api/workflows/${session.workflowId}/execute`, {
178
+ method: "POST",
179
+ headers,
180
+ body: JSON.stringify({
181
+ input: {
182
+ message: "Start call",
183
+ chatId,
184
+ conversationId: session.conversationId,
185
+ userId: session.userId,
186
+ providerId: "gravity-voice",
187
+ isAudio: true,
188
+ metadata: {
189
+ targetTriggerNode: session.targetTriggerNode,
190
+ action: "START_CALL",
191
+ isAction: true,
192
+ workflowId: session.workflowId,
193
+ continuousStream: true,
194
+ },
195
+ },
196
+ conversationId: session.conversationId,
197
+ }),
198
+ }).catch((e) => console.error("[voice] START_CALL error:", e));
199
+
200
+ // 3. Start streaming the mic.
201
+ await capture.startCapture();
202
+ timerRef.current = setInterval(() => setCallDuration((p) => p + 1), 1000);
203
+ setConnectionStatus("connected");
204
+ } catch (err: unknown) {
205
+ setError(err instanceof Error ? err.message : "Failed to start call");
206
+ setConnectionStatus("error");
207
+ }
208
+ }, [ws, capture, apiUrl, session, getAccessToken]);
209
+
210
+ const endCall = useCallback(async () => {
211
+ if (timerRef.current) {
212
+ clearInterval(timerRef.current);
213
+ timerRef.current = null;
214
+ }
215
+ await capture.stopCapture();
216
+ playback.stopAll();
217
+ // END via WS control — NOT a new REST execute (that would start a new workflow).
218
+ ws.sendControl("AUDIO_CONTROL", { command: "stop", workflowId: session.workflowId });
219
+ ws.disconnect();
220
+ setConnectionStatus("ended");
221
+ setIsMuted(false);
222
+ }, [capture, playback, ws, session.workflowId]);
223
+
224
+ const toggleMute = useCallback(() => {
225
+ setIsMuted((prev) => {
226
+ const next = !prev;
227
+ capture.setMuted(next);
228
+ return next;
229
+ });
230
+ }, [capture]);
231
+
232
+ // Cleanup on unmount.
233
+ useEffect(() => {
234
+ return () => {
235
+ if (timerRef.current) clearInterval(timerRef.current);
236
+ ws.disconnect();
237
+ };
238
+ // eslint-disable-next-line react-hooks/exhaustive-deps
239
+ }, []);
240
+
241
+ const isAssistantSpeaking = assistantHint || playback.isPlaying;
242
+ const isUserSpeaking = userSpeaking || capture.isSpeaking;
243
+ const isCallActive = connectionStatus === "connecting" || connectionStatus === "connected";
244
+ const lookupLabel =
245
+ lookupTool === "findIntent" ? "Searching the knowledge base…" : `Looking up ${lookupTool ?? "information"}…`;
246
+ const durationText = formatDuration(callDuration);
247
+ const statusText =
248
+ connectionStatus === "idle"
249
+ ? "Ready to call"
250
+ : connectionStatus === "connecting"
251
+ ? "Connecting…"
252
+ : connectionStatus === "connected"
253
+ ? durationText
254
+ : connectionStatus === "error"
255
+ ? (error ?? "Connection error")
256
+ : "Call ended";
257
+
258
+ const state: VoiceServiceState = useMemo(
259
+ () => ({
260
+ connectionStatus,
261
+ // The one phase the template branches on — derived from the neutral facts. Order matters:
262
+ // agent speaking wins over user (barge-in), both win over plain "active".
263
+ callState: (!isCallActive
264
+ ? "idle"
265
+ : isAssistantSpeaking
266
+ ? "agentSpeaking"
267
+ : isUserSpeaking
268
+ ? "userSpeaking"
269
+ : "active") as VoiceCallState,
270
+ isIdle: !isCallActive,
271
+ isCallActive,
272
+ isConnecting: connectionStatus === "connecting",
273
+ isConnected: connectionStatus === "connected",
274
+ isAssistantSpeaking,
275
+ isUserSpeaking,
276
+ showUserSpeaking: isUserSpeaking && !isAssistantSpeaking,
277
+ isMuted,
278
+ isLookingUp,
279
+ lookupLabel,
280
+ callDuration,
281
+ durationText,
282
+ statusText,
283
+ error: error ?? capture.error ?? null,
284
+ }),
285
+ [
286
+ connectionStatus,
287
+ isCallActive,
288
+ isAssistantSpeaking,
289
+ isUserSpeaking,
290
+ isMuted,
291
+ isLookingUp,
292
+ lookupLabel,
293
+ callDuration,
294
+ durationText,
295
+ statusText,
296
+ error,
297
+ capture.error,
298
+ ],
299
+ );
300
+
301
+ // PRODUCER write (STATE_MODEL §4): the service feeds the state, it is not state.
302
+ // Call state lands in TEMPLATE STATE like any dev-named key, so every template —
303
+ // studio, canvas, native — reads voice from the store's global bag with no
304
+ // host-side props spread. Low-frequency (call phases + a 1Hz duration tick).
305
+ const store = config.store;
306
+ useEffect(() => {
307
+ store?.mergeTemplateState(state as unknown as Record<string, unknown>);
308
+ }, [store, state]);
309
+
310
+ return { state, startCall, endCall, toggleMute };
311
+ }
@@ -0,0 +1,96 @@
1
+ /**
2
+ * The `ui://` app entry (§6d) — what a HOST loads (the unoverse server's SAB host in an
3
+ * iframe, or Claude/ChatGPT). Boots the AppEngine on the LOCAL vendored SDK. The host
4
+ * provides config (server, bearer, session facts) via `window.__UNOVERSE_APP_CONFIG` or a
5
+ * postMessage `{ type: "unoverse:config", config }` (also the token-refresh channel).
6
+ */
7
+ import { createRoot, type Root } from "react-dom/client";
8
+ import { AppEngine, type AppConfig } from "./appEngine";
9
+
10
+ type HostConfig = Omit<AppConfig, "getAccessToken"> & { token?: string };
11
+
12
+ let root: Root | null = null;
13
+ let current: HostConfig | null = null;
14
+
15
+ function render() {
16
+ if (!current) return;
17
+ const el = document.getElementById("root")!;
18
+ if (!root) root = createRoot(el);
19
+ root.render(
20
+ <AppEngine
21
+ serverUrl={current.serverUrl}
22
+ apiUrl={current.apiUrl}
23
+ templateId={current.templateId}
24
+ userId={current.userId}
25
+ conversationId={current.conversationId}
26
+ getAccessToken={() => current?.token ?? null}
27
+ />,
28
+ );
29
+ }
30
+
31
+ function apply(cfg: HostConfig) {
32
+ const first = !current;
33
+ current = { ...current, ...cfg };
34
+ if (first) render(); // mount once; token refresh just updates `current` (getAccessToken reads it)
35
+ }
36
+
37
+ declare global {
38
+ interface Window {
39
+ __UNOVERSE_APP_CONFIG?: HostConfig;
40
+ }
41
+ }
42
+
43
+ if (window.__UNOVERSE_APP_CONFIG) apply(window.__UNOVERSE_APP_CONFIG);
44
+ window.addEventListener("message", (e) => {
45
+ const msg = e?.data;
46
+ if (msg?.type === "unoverse:config" && msg.config) apply(msg.config);
47
+ });
48
+
49
+ // ── ChatGPT (OpenAI Apps) bootstrap ────────────────────────────────────────
50
+ // ChatGPT does NOT send our host's `unoverse:config` postMessage; it exposes a
51
+ // `window.openai` bridge. Without this the SDK never receives config → #root stays
52
+ // empty → blank widget (observed live). `toolOutput` = the tool's structuredContent
53
+ // (app id + conversationId); `toolResponseMetadata` = the tool result `_meta`
54
+ // (server-forwarded serverUrl + token + userId — widget-only, never seen by the model).
55
+ // DIAGNOSTIC-FIRST: log the real runtime shape so we wire against ground truth, not the
56
+ // docs' assumed names. `window.openai` may arrive after load, so also listen for its ready
57
+ // event. This branch is inert in our own hosts (no window.openai there).
58
+ function bootstrapFromOpenAI(): boolean {
59
+ const oai = (window as unknown as { openai?: Record<string, unknown> }).openai;
60
+ if (!oai) return false;
61
+ const out = (oai.toolOutput ?? {}) as Record<string, unknown>;
62
+ const meta = (oai.toolResponseMetadata ?? {}) as Record<string, unknown>;
63
+ // eslint-disable-next-line no-console
64
+ console.log("[unoverse:boot] window.openai present", {
65
+ keys: Object.keys(oai),
66
+ toolOutput: out,
67
+ toolResponseMetadataKeys: Object.keys(meta),
68
+ hasToken: Boolean(meta["unoverse/token"]),
69
+ });
70
+ // serverUrl is SERVER-PROVIDED (the tool result's `_meta.unoverse/serverUrl` = the origin
71
+ // that served this bundle). NEVER hardcode/guess it — a baked URL ties the bundle to one
72
+ // environment and hides a missing config. If it's absent, fail loudly and don't mount.
73
+ const serverUrl = meta["unoverse/serverUrl"] as string;
74
+ const templateId = (out.app as string) || (meta["unoverse/app"] as string);
75
+ const conversationId = (out.conversationId as string) || (meta["unoverse/conversationId"] as string);
76
+ const token = (meta["unoverse/token"] as string) || undefined;
77
+ const userId = (meta["unoverse/userId"] as string) || undefined;
78
+ if (!templateId || !serverUrl) {
79
+ // eslint-disable-next-line no-console
80
+ console.warn("[unoverse:boot] cannot mount — missing serverUrl or app id from window.openai", {
81
+ serverUrl,
82
+ templateId,
83
+ toolOutput: out,
84
+ toolResponseMetadataKeys: Object.keys(meta),
85
+ });
86
+ return false;
87
+ }
88
+ // eslint-disable-next-line no-console
89
+ console.log("[unoverse:boot] mounting", { serverUrl, templateId, conversationId, userId, hasToken: Boolean(token) });
90
+ apply({ serverUrl, templateId, conversationId, userId, token });
91
+ return true;
92
+ }
93
+ if (!bootstrapFromOpenAI()) {
94
+ // window.openai can be injected after the bundle loads — retry on its ready signal.
95
+ window.addEventListener("openai:set_globals", () => bootstrapFromOpenAI());
96
+ }
package/vite.config.ts ADDED
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Local Studio — the standalone developer tool (docs/architecture/LOCAL_STUDIO.md).
3
+ *
4
+ * Runs entirely offline against the developer's own project folder. No Postgres, no
5
+ * Redis, no engine, no Docker: the catalog routes are served straight off disk by the
6
+ * local host plugin, using the SAME loaders the universe server uses.
7
+ */
8
+ import { defineConfig } from "vite";
9
+ import react from "@vitejs/plugin-react";
10
+ import tailwindcss from "@tailwindcss/vite";
11
+ import { resolve } from "path";
12
+ import { existsSync } from "fs";
13
+ import { localStudioHost } from "./local/plugin.js";
14
+
15
+ export default defineConfig({
16
+ plugins: [react(), tailwindcss(), localStudioHost()],
17
+ server: {
18
+ // 4108. The ports below it are all taken by the dev stack:
19
+ // 4105 unoverse public 4106 the internal node-runtime listener (UNOVERSE_RUNTIME_PORT)
20
+ // 4107 apps/unoverse-runtime (`PORT || 4107`, main.ts)
21
+ // This was 4107, chosen to dodge 4106 and unaware that unoverse-runtime already held
22
+ // it. Running the full stack put two servers on one port, and Vite silently drifts to
23
+ // the next free one, so Studio moved and nothing said so.
24
+ port: 4108,
25
+ // Fail rather than drift. A tool that quietly answers on a different port than it
26
+ // printed is worse than one that refuses to start.
27
+ strictPort: true,
28
+ open: true,
29
+ fs: {
30
+ // The screens and the SDK are SOURCE outside this app's root, and the local host
31
+ // reads the developer's project. Allow the workspace root above us.
32
+ allow: [resolve(__dirname, "../..")],
33
+ },
34
+ },
35
+ resolve: {
36
+ dedupe: ["react", "react-dom"],
37
+ alias: {
38
+ // The Studio SCREENS, owned once and shared with the Canvas Studio.
39
+ "@unoverse/studio": resolve(__dirname, "screens/index.ts"),
40
+ // The host seam. Canvas aliases this to a universe; here it is the local disk.
41
+ "studio-host": resolve(__dirname, "src/host.ts"),
42
+ // The renderer, vendored source (same convention as Canvas and the old studio).
43
+ // THE VENDORED COPY WHEN THERE IS ONE. The monorepo path below exists only here;
44
+ // an installed Studio has `vendor/sdk`, written by prepack (scripts/vendor-sdk.mjs).
45
+ // Preferring the vendor directory is what makes the published package work at all,
46
+ // and falling back keeps the monorepo editing the real source with HMR.
47
+ "@unoverse/sdk": existsSync(resolve(__dirname, "vendor/sdk/lib/index.ts"))
48
+ ? resolve(__dirname, "vendor/sdk/lib/index.ts")
49
+ : resolve(__dirname, "../unoverse/web/sdk/src/lib/index.ts"),
50
+ },
51
+ },
52
+ ssr: {
53
+ // The local host loads the server's disk loaders through Vite's SSR runner, and two
54
+ // packages here are CommonJS, which the ESM runner cannot evaluate. Externalise them
55
+ // so Node requires them natively instead.
56
+ //
57
+ // plugin-base re-exported by inputs.ts
58
+ // marketplace the DESIGN SYSTEM. Studio is not optional about this: a developer's
59
+ // components are built ON TOP of marketplace atoms and components, so
60
+ // without it every shared reference resolves to nothing and Studio
61
+ // shows holes where the design system should be. It failed with
62
+ // "__dirname is not defined", the CommonJS giveaway.
63
+ external: ["@unoverse-platform/plugin-base", "@unoverse-platform/marketplace"],
64
+ },
65
+ optimizeDeps: {
66
+ // The SDK pulls in the MCP SDK, whose ajv-provider does `import Ajv from "ajv"`.
67
+ // ajv is CJS with no default ESM export, so pre-bundle for the interop shim.
68
+ include: ["ajv", "@modelcontextprotocol/sdk/client/index.js", "@modelcontextprotocol/sdk/client/streamableHttp.js"],
69
+ },
70
+ });