@unoverse-platform/studio 0.1.102 → 0.1.104

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 (79) hide show
  1. package/package.json +4 -2
  2. package/screens/AtomsView.tsx +2 -1
  3. package/screens/ComponentsView.tsx +2 -1
  4. package/screens/NativeAppHost.tsx +1 -2
  5. package/screens/TemplatesLibraryView.tsx +14 -1
  6. package/screens/TemplatesView.tsx +2 -1
  7. package/screens/index.ts +4 -0
  8. package/screens/react/unoverse.tsx +281 -0
  9. package/vendor/base/src/agent-mcp/index.ts +1 -1
  10. package/vendor/base/src/agent-mcp/invoke.ts +6 -2
  11. package/vendor/base/src/agent-mcp/tools.ts +14 -5
  12. package/vendor/base/src/agent-mcp/types.ts +6 -0
  13. package/vendor/base/src/auth/auth.ts +90 -23
  14. package/vendor/base/src/definitions/load/compile/sections.ts +79 -0
  15. package/vendor/base/src/definitions/load/compile/template.ts +23 -2
  16. package/vendor/base/src/definitions/model.ts +9 -0
  17. package/vendor/base/src/definitions/registry/catalog.ts +6 -7
  18. package/vendor/base/src/executor.ts +25 -1
  19. package/vendor/base/src/manifests/executor/toolBridge.ts +25 -19
  20. package/vendor/base/src/manifests/runtime/channels/final.ts +4 -1
  21. package/vendor/base/src/manifests/runtime/duplex/session.ts +3 -9
  22. package/vendor/base/src/manifests/runtime/tools/toolloop.ts +28 -25
  23. package/vendor/base/src/platform/serviceCalls.ts +13 -0
  24. package/vendor/sdk/appEngine.ts +363 -0
  25. package/vendor/sdk/lib/actions.ts +36 -0
  26. package/vendor/sdk/lib/citations.ts +48 -0
  27. package/vendor/sdk/lib/core/apps.ts +18 -0
  28. package/vendor/sdk/lib/core/client.ts +3 -3
  29. package/vendor/sdk/lib/core/store.ts +6 -0
  30. package/vendor/sdk/lib/dom/app.ts +546 -0
  31. package/vendor/sdk/lib/dom/chrome.ts +125 -0
  32. package/vendor/sdk/lib/dom/component.ts +333 -0
  33. package/vendor/sdk/lib/dom/connection.ts +309 -0
  34. package/vendor/sdk/lib/dom/element.ts +69 -0
  35. package/vendor/sdk/lib/dom/isolate.ts +37 -0
  36. package/vendor/sdk/lib/dom/markdown.ts +388 -0
  37. package/vendor/sdk/lib/dom/markdownParse.ts +440 -0
  38. package/vendor/sdk/lib/dom/mounted.ts +29 -0
  39. package/vendor/sdk/lib/dom/primitives.ts +689 -0
  40. package/vendor/sdk/lib/dom/region.ts +195 -0
  41. package/vendor/sdk/lib/dom/render.ts +177 -0
  42. package/vendor/sdk/lib/dom/theme.ts +38 -0
  43. package/vendor/sdk/lib/dom/types.ts +36 -0
  44. package/vendor/sdk/lib/index.ts +26 -17
  45. package/vendor/sdk/lib/orb/canvas.ts +139 -0
  46. package/vendor/sdk/lib/orb/index.ts +8 -18
  47. package/vendor/sdk/lib/orb/presets.ts +1 -1
  48. package/vendor/sdk/lib/orb/{types.ts → states.ts} +0 -24
  49. package/vendor/sdk/lib/realtime/capture.ts +152 -0
  50. package/vendor/sdk/lib/realtime/playback.ts +129 -0
  51. package/vendor/sdk/lib/realtime/socket.ts +117 -0
  52. package/vendor/sdk/lib/style.ts +45 -37
  53. package/vendor/sdk/lib/voiceService.ts +344 -0
  54. package/vendor/sdk/main.ts +107 -0
  55. package/vendor/sdk/mcpAppBridge.ts +125 -102
  56. package/vendor/sdk/pageInterfaces.ts +452 -0
  57. package/vendor/sdk/responseView.ts +193 -0
  58. package/vite.config.ts +3 -3
  59. package/vendor/sdk/appEngine.tsx +0 -393
  60. package/vendor/sdk/lib/UnoverseComponent.tsx +0 -325
  61. package/vendor/sdk/lib/app.tsx +0 -730
  62. package/vendor/sdk/lib/connection.tsx +0 -433
  63. package/vendor/sdk/lib/isolate.tsx +0 -70
  64. package/vendor/sdk/lib/orb/ThinkingOrb.tsx +0 -131
  65. package/vendor/sdk/lib/orb/theme.ts +0 -83
  66. package/vendor/sdk/lib/primitives.tsx +0 -943
  67. package/vendor/sdk/lib/realtime/useAudioCapture.ts +0 -237
  68. package/vendor/sdk/lib/realtime/useAudioPlayback.ts +0 -157
  69. package/vendor/sdk/lib/realtime/useRealtimeWebSocket.ts +0 -164
  70. package/vendor/sdk/lib/render.tsx +0 -244
  71. package/vendor/sdk/lib/streamed.tsx +0 -30
  72. package/vendor/sdk/lib/theme.ts +0 -55
  73. package/vendor/sdk/lib/transition/MountArrangement.tsx +0 -71
  74. package/vendor/sdk/lib/transition/index.ts +0 -9
  75. package/vendor/sdk/lib/transition/viewTransition.ts +0 -29
  76. package/vendor/sdk/lib/voice.tsx +0 -422
  77. package/vendor/sdk/main.tsx +0 -125
  78. package/vendor/sdk/pageInterfaces.tsx +0 -383
  79. package/vendor/sdk/responseView.tsx +0 -82
@@ -0,0 +1,344 @@
1
+ /**
2
+ * The Unoverse `voice` NATIVE SERVICE (UNOVERSE_SPEC §2e-1 Tier-3), plain TypeScript.
3
+ *
4
+ * Audio I/O is inherently native (getUserMedia / AudioContext / the /ws/gravity WS lane), so
5
+ * it cannot be expressed as definition data: it is the spec's sanctioned escape hatch, a fixed
6
+ * `{ state, actions }` capability the SDK provides and a template REFERENCES
7
+ * (`service: "voice"`), binding its neutral state and dispatching its actions. The SDK owns NO
8
+ * styles and exposes only NEUTRAL model facts (never UX flags).
9
+ *
10
+ * Transport split (UNOVERSE_MCP_APP_PROTOCOL §5): the live PCM frames AND the synchronized
11
+ * audio-state events both ride the /ws/gravity lane; only component data is on MCP.
12
+ */
13
+ import { RealtimeSocket } from "./realtime/socket";
14
+ import { AudioCapture } from "./realtime/capture";
15
+ import { AudioPlayback } from "./realtime/playback";
16
+ import type { AudioStateEvent } from "./realtime/types";
17
+ import type { ComponentStore } from "./core/store";
18
+ import { sessionUserId } from "./dom/connection";
19
+
20
+ export type VoiceConnectionStatus = "idle" | "connecting" | "connected" | "ended" | "error";
21
+
22
+ export interface VoiceSession {
23
+ userId: string;
24
+ conversationId: string;
25
+ chatId?: string;
26
+ workflowId: string;
27
+ targetTriggerNode: string;
28
+ }
29
+
30
+ export interface VoiceServiceConfig {
31
+ /** Gateway REST base, e.g. http://localhost:4100 (the workflow `…/execute` host). */
32
+ apiUrl: string;
33
+ /** Audio WS base, e.g. ws://localhost:4100. Defaults to `apiUrl` with http→ws. */
34
+ wsUrl?: string;
35
+ /** Fresh access token provider (rides the WS lane + the REST Authorization header). */
36
+ getAccessToken?: () => Promise<string | null> | string | null;
37
+ session: VoiceSession;
38
+ /** The shared store. Given, the service is a PRODUCER (STATE_MODEL §4): every state change
39
+ * is merged into TEMPLATE STATE, so templates read voice like any global key. */
40
+ store?: ComponentStore;
41
+ }
42
+
43
+ /** The single call phase a voice template branches its states on (`callState`). */
44
+ export type VoiceCallState = "idle" | "active" | "speaking" | "listening" | "thinking";
45
+
46
+ /** NEUTRAL voice projections, flat, so they spread into the template scope. */
47
+ export interface VoiceServiceState {
48
+ connectionStatus: VoiceConnectionStatus;
49
+ callState: VoiceCallState;
50
+ isIdle: boolean;
51
+ isCallActive: boolean;
52
+ isConnecting: boolean;
53
+ isConnected: boolean;
54
+ isAssistantSpeaking: boolean;
55
+ isUserSpeaking: boolean;
56
+ /** user speaking AND assistant not: mutual exclusion (the template can't express NOT). */
57
+ showUserSpeaking: boolean;
58
+ isMuted: boolean;
59
+ isLookingUp: boolean;
60
+ lookupLabel: string;
61
+ callDuration: number;
62
+ durationText: string;
63
+ statusText: string;
64
+ error: string | null;
65
+ }
66
+
67
+ export interface StartCall {
68
+ message?: string;
69
+ metadata?: Record<string, unknown>;
70
+ workflowId?: string;
71
+ targetTriggerNode?: string;
72
+ }
73
+
74
+ export interface VoiceService {
75
+ /** The current state, to spread into the template's data scope. */
76
+ readonly state: VoiceServiceState;
77
+ /** Called on every state change; returns the unsubscribe. */
78
+ subscribe(listener: () => void): () => void;
79
+ /** Start a call (or MOVE a live one to another workflow: same line, the server closes the
80
+ * session before it, VOICE_STREAMING_GUIDE.md "One Live Session Per Conversation"). */
81
+ startCall: (start?: StartCall) => Promise<void>;
82
+ /** Join a call already running (a canvas Run, a button, `JOIN_CALL`) without starting it. */
83
+ joinCall: (workflowId?: string) => Promise<void>;
84
+ /** Wake the speaker. Call it inside the click that may start a call. */
85
+ unlock: () => void;
86
+ endCall: () => Promise<void>;
87
+ toggleMute: () => void;
88
+ /** Release the microphone, the speaker and the lane. */
89
+ destroy: () => void;
90
+ }
91
+
92
+ const formatDuration = (s: number) => `${Math.floor(s / 60).toString().padStart(2, "0")}:${(s % 60).toString().padStart(2, "0")}`;
93
+
94
+ export function createVoiceService(config: VoiceServiceConfig): VoiceService {
95
+ const { apiUrl, getAccessToken } = config;
96
+ const wsUrl = config.wsUrl ?? apiUrl.replace(/^http/, "ws");
97
+ // Same identity as the data plane: without a token the audio socket is a guest socket.
98
+ const session = { ...config.session, userId: sessionUserId(config.session.userId, getAccessToken) };
99
+
100
+ let connectionStatus: VoiceConnectionStatus = "idle";
101
+ let isMuted = false;
102
+ let callDuration = 0;
103
+ let error: string | null = null;
104
+ let assistantHint = false;
105
+ let userSpeaking = false;
106
+ let isLookingUp = false;
107
+ let lookupTool: string | null = null;
108
+ let timer: ReturnType<typeof setInterval> | null = null;
109
+ /** The workflow the live call is on, which a hang-up names; null with no call. */
110
+ let callWorkflow: string | null = null;
111
+ const listeners = new Set<() => void>();
112
+ // Set once the speaker and microphone exist (below); `derive` reads them.
113
+ let state!: VoiceServiceState;
114
+
115
+ function derive(): VoiceServiceState {
116
+ const isAssistantSpeaking = assistantHint || playback.isPlaying;
117
+ const isUserSpeaking = userSpeaking || capture.isSpeaking;
118
+ const isCallActive = connectionStatus === "connecting" || connectionStatus === "connected";
119
+ const durationText = formatDuration(callDuration);
120
+ return {
121
+ connectionStatus,
122
+ // The one phase the template branches on. A lookup wins (it has a true start and end,
123
+ // while speaking may never end: GPT-Live sends no end-of-speech), then the agent speaking
124
+ // over the user (barge-in), and all three win over plain "active".
125
+ callState: !isCallActive ? "idle" : isLookingUp ? "thinking" : isAssistantSpeaking ? "speaking" : isUserSpeaking ? "listening" : "active",
126
+ isIdle: !isCallActive,
127
+ isCallActive,
128
+ isConnecting: connectionStatus === "connecting",
129
+ isConnected: connectionStatus === "connected",
130
+ isAssistantSpeaking,
131
+ isUserSpeaking,
132
+ showUserSpeaking: isUserSpeaking && !isAssistantSpeaking,
133
+ isMuted,
134
+ isLookingUp,
135
+ lookupLabel: lookupTool === "findIntent" ? "Searching the knowledge base…" : `Looking up ${lookupTool ?? "information"}…`,
136
+ callDuration,
137
+ durationText,
138
+ statusText:
139
+ connectionStatus === "idle"
140
+ ? "Ready to call"
141
+ : connectionStatus === "connecting"
142
+ ? "Connecting…"
143
+ : connectionStatus === "connected"
144
+ ? durationText
145
+ : connectionStatus === "error"
146
+ ? (error ?? "Connection error")
147
+ : "Call ended",
148
+ error: error ?? capture.error ?? null,
149
+ };
150
+ }
151
+
152
+ /** Recompute; on any change, tell listeners and (PRODUCER) write it into template state. */
153
+ function changed(): void {
154
+ const next = derive();
155
+ const keys = Object.keys(next) as (keyof VoiceServiceState)[];
156
+ if (keys.every((k) => next[k] === state[k])) return;
157
+ state = next;
158
+ config.store?.mergeAppState(state as unknown as Record<string, unknown>);
159
+ listeners.forEach((l) => l());
160
+ }
161
+
162
+ const playback: AudioPlayback = new AudioPlayback(changed);
163
+ // Audio-state events arrive on the WS lane, in lockstep with the frames.
164
+ const onAudioState = ({ state: s, metadata }: AudioStateEvent) => {
165
+ if (s === "SPEECH_STARTED") assistantHint = true;
166
+ else if (s === "SPEECH_ENDED") {
167
+ assistantHint = false;
168
+ playback.markAsLastChunk();
169
+ } else if (s === "USER_SPEECH_STARTED") {
170
+ userSpeaking = true;
171
+ playback.stopAll(); // barge-in: cut assistant playback the moment the user speaks
172
+ } else if (s === "USER_SPEECH_ENDED") userSpeaking = false;
173
+ else if (s === "SESSION_READY") connectionStatus = "connected";
174
+ else if (s === "SESSION_ENDED") {
175
+ // THE SERVER ENDED THE CALL: the microphone stops and the lane closes.
176
+ release();
177
+ connectionStatus = "ended";
178
+ assistantHint = false;
179
+ userSpeaking = false;
180
+ isLookingUp = false;
181
+ lookupTool = null;
182
+ playback.markAsLastChunk();
183
+ } else if (s === "TOOL_USE") {
184
+ isLookingUp = true;
185
+ lookupTool = (metadata?.toolName as string | undefined) ?? null;
186
+ } else if (s === "TOOL_USE_COMPLETED") {
187
+ isLookingUp = false;
188
+ lookupTool = null;
189
+ }
190
+ changed();
191
+ };
192
+ const ws = new RealtimeSocket({
193
+ sessionId: session.conversationId,
194
+ userId: session.userId,
195
+ chatId: session.chatId,
196
+ wsUrl,
197
+ getAccessToken,
198
+ onAudioReceived: (d) => playback.play(d),
199
+ onAudioState,
200
+ });
201
+ const capture: AudioCapture = new AudioCapture((d) => ws.sendAudio(d), changed);
202
+ state = derive();
203
+ // The starting state lands in template state too, as every later one does.
204
+ config.store?.mergeAppState(state as unknown as Record<string, unknown>);
205
+
206
+ const stopTimer = () => {
207
+ if (timer) clearInterval(timer);
208
+ timer = null;
209
+ };
210
+ const startTimer = () => {
211
+ stopTimer();
212
+ timer = setInterval(() => {
213
+ callDuration += 1;
214
+ changed();
215
+ }, 1000);
216
+ };
217
+ function release(): void {
218
+ stopTimer();
219
+ callWorkflow = null;
220
+ void capture.stop();
221
+ ws.disconnect();
222
+ }
223
+
224
+ return {
225
+ get state() {
226
+ return state;
227
+ },
228
+ subscribe(listener) {
229
+ listeners.add(listener);
230
+ return () => void listeners.delete(listener);
231
+ },
232
+ async startCall(start) {
233
+ const workflowId = start?.workflowId || session.workflowId;
234
+ const targetTriggerNode = start?.targetTriggerNode || session.targetTriggerNode;
235
+ if (!workflowId) {
236
+ // NOTHING TO CALL: no workflow was named and the session has none. Never a default.
237
+ error = "No workflow to call";
238
+ connectionStatus = "error";
239
+ return changed();
240
+ }
241
+ // A CALL ALREADY LIVE IS MOVED, not started again: the line and the microphone stay open,
242
+ // only the workflow changes; the old voice is cut at once so two are never heard.
243
+ const moving = callWorkflow !== null;
244
+ if (moving) {
245
+ playback.stopAll();
246
+ assistantHint = false;
247
+ isLookingUp = false;
248
+ lookupTool = null;
249
+ } else {
250
+ connectionStatus = "connecting";
251
+ error = null;
252
+ callDuration = 0;
253
+ }
254
+ changed();
255
+ try {
256
+ // 1. The audio lane first (INIT_SESSION on open), so the server can route audio the
257
+ // moment the voice session is up.
258
+ if (!moving) await ws.connect();
259
+ callWorkflow = workflowId;
260
+ // 2. START_CALL over REST, fire-and-forget; the workflow stays open for the call.
261
+ const token = getAccessToken ? await getAccessToken() : null;
262
+ const headers: Record<string, string> = { "Content-Type": "application/json" };
263
+ if (token) headers["Authorization"] = `Bearer ${token}`;
264
+ const chatId = session.chatId ?? `voice_${session.conversationId}`;
265
+ fetch(`${apiUrl}/api/workflows/${workflowId}/execute`, {
266
+ method: "POST",
267
+ headers,
268
+ body: JSON.stringify({
269
+ input: {
270
+ message: start?.message ?? "Start call",
271
+ chatId,
272
+ conversationId: session.conversationId,
273
+ userId: session.userId,
274
+ providerId: "gravity-voice",
275
+ isAudio: true,
276
+ metadata: { ...(start?.metadata ?? {}), targetTriggerNode, action: "START_CALL", isAction: true, workflowId, continuousStream: true },
277
+ },
278
+ conversationId: session.conversationId,
279
+ }),
280
+ }).catch((e) => console.error("[voice] START_CALL error:", e));
281
+ if (moving) return;
282
+ // 3. Stream the mic.
283
+ await capture.start();
284
+ startTimer();
285
+ connectionStatus = "connected";
286
+ changed();
287
+ } catch (err: unknown) {
288
+ callWorkflow = null;
289
+ error = err instanceof Error ? err.message : "Failed to start call";
290
+ connectionStatus = "error";
291
+ isLookingUp = false;
292
+ lookupTool = null;
293
+ changed();
294
+ }
295
+ },
296
+ // JOIN, NOT START: the workflow already runs; open the lane and the mic, send no execute.
297
+ async joinCall(workflowId) {
298
+ if (callWorkflow !== null) return;
299
+ callWorkflow = workflowId ?? session.workflowId ?? "";
300
+ connectionStatus = "connecting";
301
+ error = null;
302
+ callDuration = 0;
303
+ changed();
304
+ try {
305
+ await ws.connect();
306
+ await capture.start();
307
+ startTimer();
308
+ connectionStatus = "connected";
309
+ } catch (err: unknown) {
310
+ callWorkflow = null;
311
+ error = err instanceof Error ? err.message : "Failed to join call";
312
+ connectionStatus = "error";
313
+ }
314
+ changed();
315
+ },
316
+ async endCall() {
317
+ stopTimer();
318
+ await capture.stop();
319
+ playback.stopAll();
320
+ // END via WS control, NOT a new REST execute (that would start a new workflow).
321
+ ws.sendControl("AUDIO_CONTROL", { command: "stop", workflowId: callWorkflow ?? session.workflowId });
322
+ callWorkflow = null;
323
+ ws.disconnect();
324
+ connectionStatus = "ended";
325
+ isMuted = false;
326
+ isLookingUp = false;
327
+ lookupTool = null;
328
+ changed();
329
+ },
330
+ toggleMute() {
331
+ isMuted = !isMuted;
332
+ capture.setMuted(isMuted);
333
+ changed();
334
+ },
335
+ unlock: () => playback.unlock(),
336
+ destroy() {
337
+ stopTimer();
338
+ void capture.stop();
339
+ ws.disconnect();
340
+ playback.close();
341
+ listeners.clear();
342
+ },
343
+ };
344
+ }
@@ -0,0 +1,107 @@
1
+ /**
2
+ * ⛔ NO REACT ANYWHERE IN THIS SDK (owner, 2026-10-01; web/sdk/CLAUDE.md).
3
+ *
4
+ * The `ui://` app entry (§6d): what a HOST loads (the unoverse server's SAB host in an iframe,
5
+ * or Claude/ChatGPT). Boots the app engine on the LOCAL vendored SDK. The host provides config
6
+ * (server, bearer, session facts) via `window.__UNOVERSE_APP_CONFIG` or a postMessage
7
+ * `{ type: "unoverse:config", config }` (also the token-refresh channel).
8
+ */
9
+ import { mountAppEngine, type AppConfig, type AppEngine } from "./appEngine";
10
+ import { connectMcpAppHost } from "./mcpAppBridge";
11
+ import { mountResponseView, type ResponseView } from "./responseView";
12
+
13
+ type HostConfig = Omit<AppConfig, "getAccessToken"> & { token?: string };
14
+
15
+ const root = () => document.getElementById("root")!;
16
+ let engine: AppEngine | null = null;
17
+ // A task result draws as the response view; one window shows one or the other.
18
+ let response: ResponseView | null = null;
19
+ let current: HostConfig | null = null;
20
+
21
+ // Stable identity: the connection keys its lifecycle on whether a token accessor exists, and
22
+ // the host re-posts config on every iframe load.
23
+ const readToken = () => current?.token ?? null;
24
+
25
+ function render() {
26
+ if (!current) return;
27
+ response?.destroy();
28
+ response = null;
29
+ const cfg: AppConfig = {
30
+ serverUrl: current.serverUrl,
31
+ apiUrl: current.apiUrl,
32
+ appId: current.appId,
33
+ userId: current.userId,
34
+ conversationId: current.conversationId,
35
+ // ANALYTICS (UNOVERSE_ANALYTICS.md): host-supplied. A host that says nothing gets
36
+ // `mcp_app_host`, the one surface with no parent page to report to.
37
+ surface: current.surface ?? "mcp_app_host",
38
+ analyticsDebug: current.analyticsDebug,
39
+ // Only claim auth when the host sent a token: no token ⇒ no accessor ⇒ the SDK mints a
40
+ // `guest-<uuid>` (UNOVERSE_MCP_APP_PROTOCOL §5, docs/auth-security/IDENTITY.md).
41
+ getAccessToken: current.token ? readToken : undefined,
42
+ };
43
+ if (engine) engine.update(cfg);
44
+ else engine = mountAppEngine(root(), cfg);
45
+ }
46
+
47
+ function apply(cfg: HostConfig) {
48
+ current = { ...current, ...cfg };
49
+ // Re-apply on every config so a token appearing/disappearing takes effect; a same-token
50
+ // refresh changes nothing (readToken is stable, values equal).
51
+ render();
52
+ }
53
+
54
+ declare global {
55
+ interface Window {
56
+ __UNOVERSE_APP_CONFIG?: HostConfig;
57
+ }
58
+ }
59
+
60
+ if (window.__UNOVERSE_APP_CONFIG) apply(window.__UNOVERSE_APP_CONFIG);
61
+ window.addEventListener("message", (e) => {
62
+ const msg = e?.data;
63
+ if (msg?.type === "unoverse:config" && msg.config) apply(msg.config);
64
+ });
65
+
66
+ // ── MCP Apps host bootstrap (SEP-1865, the STANDARD door) ──────────────────
67
+ // An MCP Apps host speaks JSON-RPC over postMessage; the boot facts ride the result's `_meta`
68
+ // (`unoverse/serverUrl` + app + token, widget-only, never seen by the model) with
69
+ // `structuredContent` as the copy.
70
+ connectMcpAppHost((r) => {
71
+ const meta = (r._meta ?? {}) as Record<string, unknown>;
72
+ const out = (r.structuredContent ?? {}) as Record<string, unknown>;
73
+ // serverUrl is SERVER-PROVIDED, never guessed: absent, fail loudly and don't mount.
74
+ const serverUrl = (meta["unoverse/serverUrl"] as string) || (out.serverUrl 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
+
79
+ // THE NAMING RULE (UNOVERSE_MCP_APP_PROTOCOL): the RESULT names its interface.
80
+ // `components` → draw exactly those (the response view); `app` (or the app tools' legacy
81
+ // boot fact) → that app mounts. A result naming nothing renders nothing, loudly.
82
+ const iface = out.interface as { kind?: string; components?: Record<string, unknown>[]; app?: string } | undefined;
83
+ if (iface?.kind === "components" && Array.isArray(iface.components)) {
84
+ if (!serverUrl) {
85
+ console.warn("[unoverse:boot] response result carried no serverUrl — cannot resolve definitions");
86
+ return;
87
+ }
88
+ console.log("[unoverse:boot] rendering RESPONSE from MCP host", { serverUrl, components: iface.components.length });
89
+ engine?.destroy();
90
+ engine = null;
91
+ root().replaceChildren();
92
+ // A page opened on an outside host stays live: its `-update` calls push to this page's
93
+ // conversation, and the view hydrates from them (responseView.ts, `live`).
94
+ const live = userId && conversationId ? { userId, conversationId, token } : undefined;
95
+ response?.destroy();
96
+ response = mountResponseView(root(), { serverUrl, events: iface.components, live });
97
+ return;
98
+ }
99
+
100
+ const appId = (iface?.kind === "app" ? iface.app : undefined) || (out.app as string) || (meta["unoverse/app"] as string);
101
+ if (!appId || !serverUrl) {
102
+ console.warn("[unoverse:boot] cannot mount — result named no interface (no components, no app)", { serverUrl, appId, structuredContent: out, metaKeys: Object.keys(meta) });
103
+ return;
104
+ }
105
+ console.log("[unoverse:boot] mounting NAMED app from MCP host", { serverUrl, appId, conversationId, userId, hasToken: Boolean(token) });
106
+ apply({ serverUrl, appId, conversationId, userId, token });
107
+ });