@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
@@ -47,12 +47,6 @@ export interface ToolBridge {
47
47
  ): Promise<{ content: string; minted: any[]; skills?: Array<{ name: string; title?: string; instructions: string; regionId?: string; regionName?: string | null; ms?: number }> }>;
48
48
  /** Does this exchange end the turn? Depends on which tools were WIRED. */
49
49
  endsTurn(calls: { name: string; resultContent: string }[]): boolean;
50
- /**
51
- * WHAT SORT OF STEP a call to this tool is on the timeline. The bridge minted the tool,
52
- * so the bridge knows whether it came from a task row; the loop only relays the answer
53
- * to the trace. Optional, because a core-tools-only bridge has nothing but tools.
54
- */
55
- kindOf?(toolName: string): "tool" | "task";
56
50
  }
57
51
 
58
52
  /**
@@ -100,8 +94,27 @@ export function recordToolTrace(
100
94
  */
101
95
  kind: "tool" | "skill" | "task" = "tool",
102
96
  ): void {
103
- const executionId = (ctx as any)?.scope?.executionId;
104
- const parentNodeId = (ctx as any)?.scope?.nodeId;
97
+ recordCall({ executionId: (ctx as any)?.scope?.executionId, parentNodeId: (ctx as any)?.scope?.nodeId, toolName, args, result, startTime, success, kind, who: node.type });
98
+ }
99
+
100
+ /**
101
+ * ONE CALL ON THE TIMELINE, by whoever ran it (packages/docs/nodes/mcp-services.md, "Who
102
+ * records a call"): a tool records its own call, nested under the node that called it. The
103
+ * service channel (executor.ts) records a node's methods; the harness records only its own
104
+ * steps through `recordToolTrace`.
105
+ */
106
+ export function recordCall(call: {
107
+ executionId?: string;
108
+ parentNodeId?: string;
109
+ toolName: string;
110
+ args: unknown;
111
+ result: unknown;
112
+ startTime: number;
113
+ success: boolean;
114
+ kind?: "tool" | "skill" | "task";
115
+ who: string;
116
+ }): void {
117
+ const { executionId, parentNodeId, toolName, args, result, startTime, success, kind = "tool" } = call;
105
118
  if (!executionId || !parentNodeId) return;
106
119
 
107
120
  /**
@@ -134,7 +147,7 @@ export function recordToolTrace(
134
147
  duration: endTime - startTime,
135
148
  success,
136
149
  kind,
137
- }).catch((err: any) => console.warn(`[manifests] ${node.type}: could not save MCP trace: ${err?.message ?? err}`));
150
+ }).catch((err: any) => console.warn(`[manifests] ${call.who}: could not save MCP trace: ${err?.message ?? err}`));
138
151
  }
139
152
 
140
153
  export async function runToolLoop(
@@ -339,29 +352,19 @@ export async function runToolLoop(
339
352
 
340
353
  let content: string;
341
354
  /**
342
- * THE TIMELINE BAR, and it is the AGENT's job to record it, not the tool's.
343
- *
344
- * Every legacy agent did this — OpenAIAgent, Grok, Realtime — and the manifest loop
345
- * that replaced them did not, so the nested `findIntent MCP (1 of 5 tools) 1.51s` row
346
- * vanished the day `openai` migrated. It looked like a fault in whichever node was
347
- * being called, which is where I went looking, twice.
348
- *
349
- * `success` is the part that matters most. A tool that fails still returns a string to
350
- * the model (see the catch below), so from the outside a broken tool and an empty
351
- * result are identical: the workflow completes, four green bars, and the model answers
352
- * from its own knowledge. That happened three times in one session before anyone could
353
- * see it. This flag is what turns it red.
355
+ * THE TIMELINE BAR is the TOOL's to record, never the loop's (owner, 2026-10-01;
356
+ * packages/docs/nodes/mcp-services.md, "Who records a call"). Every tool records its
357
+ * own call wherever it runs (a node's method, a row's function, a platform tool), with
358
+ * `success` false on a failure, so a call reads the same from an Agent, a voice session
359
+ * or an outside host. The bar once vanished the day `openai` migrated, because it lived
360
+ * in each Agent family; it lives with the tool now.
354
361
  */
355
- const startedAt = Date.now();
356
- let ok = true;
357
362
  try {
358
363
  content = await bridge.call(call.name, args);
359
364
  } catch (err: any) {
360
365
  // A failed tool is information for the model, not a crash: it can try another.
361
- ok = false;
362
366
  content = JSON.stringify({ error: err?.message ?? String(err) });
363
367
  }
364
- recordToolTrace(node, ctx, call.name, args, content, startedAt, ok, bridge.kindOf?.(call.name) ?? "tool");
365
368
  exchanges.push({ name: call.name, resultContent: content });
366
369
  // The RESULT, and only now that it exists. It is never in the HTTP stream: our loop
367
370
  // produced it, which is why it cannot be a `from: response` row. PARSED for the
@@ -153,6 +153,19 @@ export async function saveMCPTraceToWorkflow(trace: any): Promise<string> {
153
153
  }
154
154
  }
155
155
 
156
+ /**
157
+ * THE TOOLS A CALLER HOLDS NOW, so each call's row on the trace lists the choice the model
158
+ * really had: the run's starting tools plus every one a search has minted since. Fire and
159
+ * forget, like the trace itself.
160
+ */
161
+ export function saveAvailableTools(executionId: string, nodeId: string, tools: string[]): void {
162
+ void fetch(`${WORKFLOW_SERVICE_URL}/analytics/available-tools`, {
163
+ method: "POST",
164
+ headers: { "Content-Type": "application/json" },
165
+ body: JSON.stringify({ executionId, nodeId, tools }),
166
+ }).catch(() => {});
167
+ }
168
+
156
169
  /**
157
170
  * Persist plugin state to PostgreSQL via workflow-service
158
171
  */
@@ -0,0 +1,363 @@
1
+ /**
2
+ * The native app ENGINE (§6d), plain DOM, surface-agnostic. It:
3
+ * - reads its OWN workflow binding from its manifest (§4b: "App supplies workflow, trigger"),
4
+ * - opens the SDK's native connection (`/stream` register_session → store),
5
+ * - fires the workflow DIRECTLY over that connection with the USER's token (§5a: "direct
6
+ * channel → Gravity backend, never brokered").
7
+ *
8
+ * The server tool only LOADS this app; it never fires or brokers a token.
9
+ */
10
+ import { postToEmbedHost, handAnswerToHost } from "./mcpAppBridge";
11
+ import { mountPageInterfaces, type Binding } from "./pageInterfaces";
12
+ import { UnoverseClient } from "./lib/core/client";
13
+ import { ComponentStore } from "./lib/core/store";
14
+ import { computeAppWidth, resolveActiveLayout } from "./lib/core/apps";
15
+ import { setAnalyticsSink, setAnalyticsContext, setAnalyticsDebug } from "./lib/core/analytics";
16
+ import type { ResolvedTheme, UnoverseDefinition } from "./lib/core/types";
17
+ import type { ActionHandler, ActionMeta } from "./lib/actions";
18
+ import { mountApp, type AppMount } from "./lib/dom/app";
19
+ import { openConnection, type UnoverseConnection } from "./lib/dom/connection";
20
+ import { fetchTheme } from "./lib/dom/theme";
21
+ import { createVoiceService, type VoiceService } from "./lib/voiceService";
22
+
23
+ export interface AppConfig {
24
+ serverUrl: string;
25
+ apiUrl?: string;
26
+ appId: string;
27
+ getAccessToken?: () => Promise<string | null> | string | null;
28
+ userId?: string;
29
+ conversationId: string;
30
+ /** Where this app runs, reported on every analytics event (UNOVERSE_ANALYTICS.md §surfaces). */
31
+ surface?: string;
32
+ /** Log each analytics event as it fires (server-delivered, never a client flag). */
33
+ analyticsDebug?: boolean;
34
+ }
35
+
36
+ export interface AppEngine {
37
+ update(cfg: AppConfig): void;
38
+ destroy(): void;
39
+ }
40
+
41
+ type Manifest = { workflow?: string; trigger?: string; service?: string };
42
+
43
+ /** Mount the named app into `el`. */
44
+ export function mountAppEngine(el: HTMLElement, first: AppConfig): AppEngine {
45
+ const doc = el.ownerDocument;
46
+ let cfg = first;
47
+ // ONE CLIENT PER SERVER, ONE STORE PER CONVERSATION: a conversation swap must not carry the
48
+ // old timeline into another's shell (seen live 2026-08-08).
49
+ let client = new UnoverseClient(`${cfg.serverUrl}/mcp`, { getAccessToken: cfg.getAccessToken as never });
50
+ let storeKey = `${cfg.userId}|${cfg.conversationId}`;
51
+ let store = new ComponentStore();
52
+ // WHICH APP IS RUNNING: the host's config names it, and the SERVER may swap it mid
53
+ // conversation (an app discovered and called IS the surface). A swap resets the binding at
54
+ // once, so no session opens on the old app's trigger against the new app.
55
+ let appId = cfg.appId;
56
+ let binding: Manifest = {};
57
+ let def: UnoverseDefinition | null = null;
58
+ let theme: ResolvedTheme | null = null;
59
+ let inTask = false;
60
+ let alive = true;
61
+ let loadToken = 0;
62
+ let conn: UnoverseConnection | null = null;
63
+ let connKey = "";
64
+ let voice: VoiceService = inertVoice();
65
+ let voiceKey = "";
66
+ let app: AppMount | null = null;
67
+ let page: { destroy(): void } | null = null;
68
+ let lastWidth: string | undefined;
69
+ let stopStore = () => {};
70
+ let stopVoice = () => {};
71
+ let stopConn = () => {};
72
+
73
+ function inertVoice(): VoiceService {
74
+ // No `store`: not a producer, so it writes no callState into ordinary templates.
75
+ return createVoiceService({ apiUrl: "", session: { userId: "", conversationId: "", workflowId: "", targetTriggerNode: "" } });
76
+ }
77
+
78
+ // ANALYTICS (UNOVERSE_ANALYTICS.md): events cross to the HOST REALM, where the page's own tag
79
+ // lives with the visitor's consent; with no parent the post is a silent no-op.
80
+ const analytics = () => {
81
+ setAnalyticsDebug(!!cfg.analyticsDebug);
82
+ setAnalyticsContext({ surface: cfg.surface, conversation_id: cfg.conversationId, user_id: cfg.userId });
83
+ setAnalyticsSink((event) => postToEmbedHost({ type: "unoverse:event", ...event }));
84
+ };
85
+
86
+ // The app's width, reported UP to the embedding host whenever a panel opens or closes; a new
87
+ // app always re-announces (the host resets its frame on a switch).
88
+ const reportWidth = () => {
89
+ const w = computeAppWidth(resolveActiveLayout(def, store), store, theme?.appSize, def);
90
+ if (w && w !== lastWidth) {
91
+ lastWidth = w;
92
+ postToEmbedHost({ type: "unoverse:size", width: w });
93
+ }
94
+ };
95
+
96
+ // STATUS TO THE HOST: ready, connected, the server's refusal, and the last run's failure.
97
+ let lastStatus = "";
98
+ const reportStatus = () => {
99
+ const s = conn?.status() ?? { isConnected: false, isReady: false, refusal: null };
100
+ const msg = { type: "unoverse:status", ready: !!s.isReady, connected: !!s.isConnected, refused: s.refusal ?? undefined, failure: store.getLastFailure() ?? undefined };
101
+ const key = JSON.stringify(msg);
102
+ if (key === lastStatus) return;
103
+ lastStatus = key;
104
+ postToEmbedHost(msg);
105
+ };
106
+
107
+ /** The voice service for this app: real for a `service: "voice"` app, else inert. A call
108
+ * names the workflow it runs; the app lends its binding as the default. */
109
+ const syncVoice = () => {
110
+ const wanted = binding.service === "voice";
111
+ const key = wanted ? JSON.stringify([cfg.apiUrl ?? cfg.serverUrl, cfg.userId, cfg.conversationId, binding.workflow, binding.trigger, storeKey]) : "inert";
112
+ if (key === voiceKey) return;
113
+ voiceKey = key;
114
+ stopVoice();
115
+ voice.destroy();
116
+ voice = wanted
117
+ ? createVoiceService({
118
+ apiUrl: cfg.apiUrl ?? cfg.serverUrl,
119
+ // The audio lane is derived from the client's own server origin, never the host's.
120
+ wsUrl: client.serverOrigin.replace(/^http/, "ws"),
121
+ getAccessToken: cfg.getAccessToken,
122
+ session: { userId: cfg.userId ?? "", conversationId: cfg.conversationId, chatId: cfg.conversationId, workflowId: binding.workflow ?? "", targetTriggerNode: binding.trigger ?? "" },
123
+ // Producer: writes callState/isMuted into TEMPLATE STATE so the voice states switch.
124
+ store,
125
+ })
126
+ : inertVoice();
127
+ stopVoice = voice.subscribe(syncConnection);
128
+ app?.update({ voice });
129
+ mountPage();
130
+ };
131
+
132
+ /**
133
+ * THE CONNECTION, open once the user is known AND there is something to hear from: the app's
134
+ * own workflow, a call, or a task the page is in. Nothing is held open while a visitor reads.
135
+ */
136
+ const syncConnection = () => {
137
+ const enabled = !!cfg.userId && (!!binding.workflow || voice.state.isCallActive || inTask);
138
+ const key = enabled ? JSON.stringify([cfg.serverUrl, binding.workflow, binding.trigger, cfg.userId, cfg.conversationId, storeKey, !!cfg.getAccessToken]) : "";
139
+ if (key === connKey) return;
140
+ connKey = key;
141
+ stopConn();
142
+ conn?.close();
143
+ conn = null;
144
+ if (enabled) {
145
+ conn = openConnection(
146
+ { apiUrl: cfg.apiUrl ?? cfg.serverUrl, streamUrl: cfg.serverUrl, getAccessToken: cfg.getAccessToken as never },
147
+ { workflowId: binding.workflow ?? "", targetTriggerNode: binding.trigger ?? "", userId: cfg.userId ?? "", conversationId: cfg.conversationId, chatId: cfg.conversationId, appId },
148
+ store,
149
+ {
150
+ appId: () => appId,
151
+ onAppSwitch: switchApp,
152
+ // A voice in the running workflow asks the page in: join its call, never start one.
153
+ onJoinCall: (workflowId) => void voice.joinCall(workflowId),
154
+ },
155
+ );
156
+ stopConn = conn.subscribe(reportStatus);
157
+ }
158
+ reportStatus();
159
+ };
160
+
161
+ const switchApp = (next: string) => {
162
+ if (next === appId) return;
163
+ appId = next;
164
+ load();
165
+ };
166
+
167
+ /** Read the app's manifest (its binding, its service) and its definition (its width). */
168
+ const load = () => {
169
+ const token = ++loadToken;
170
+ binding = {};
171
+ def = null;
172
+ lastWidth = undefined;
173
+ syncConnection();
174
+ syncVoice();
175
+ const orgFromId = appId.includes("/") ? appId.split("/")[0] : null;
176
+ if (orgFromId) loadTheme(`${orgFromId}/light`, token);
177
+ client
178
+ .connect()
179
+ .then(() => (client as unknown as { client: { readResource(a: { uri: string }): Promise<{ contents?: { text?: string }[] }> } }).client.readResource({ uri: `unoverse://apps/${appId.toLowerCase()}` }))
180
+ .then((res) => {
181
+ if (!alive || token !== loadToken) return;
182
+ const parsed = res?.contents?.[0]?.text ? JSON.parse(res.contents[0].text) : null;
183
+ const m = parsed?.manifest ?? parsed ?? {};
184
+ const b = m.binding ?? {};
185
+ binding = { workflow: b.workflow, trigger: b.trigger, service: m.service };
186
+ syncVoice();
187
+ syncConnection();
188
+ })
189
+ .catch((e) => console.error("[app] manifest read failed:", e));
190
+ client
191
+ .connect()
192
+ .then(() => client.readDefinition(`unoverse://apps/${appId}/definition`))
193
+ .then((d) => {
194
+ if (!alive || token !== loadToken) return;
195
+ def = d ?? null;
196
+ if (!orgFromId) loadTheme(def?.org ? `${def.org}/light` : "light", token);
197
+ reportWidth();
198
+ })
199
+ .catch((e) => console.error("[app] template read failed:", e));
200
+ mountAppView();
201
+ };
202
+ const loadTheme = (name: string, token: number) =>
203
+ fetchTheme(client, name)
204
+ .then((t) => {
205
+ if (!alive || token !== loadToken) return;
206
+ theme = t;
207
+ reportWidth();
208
+ })
209
+ .catch(() => {});
210
+
211
+ // THE SPEC'S ANSWERS, AND NOTHING ELSE: while the server is ELICITING, a `submit` answers
212
+ // `accept` with its slice and a `cancel` answers `cancel`. Otherwise a submit inside an outside
213
+ // host goes to the host's model; everything else is the data-plane `user_action`.
214
+ const toServer = (t: string, data: Record<string, unknown>, meta?: ActionMeta) => {
215
+ const state = (meta?.state ?? data) as Record<string, unknown>;
216
+ if ((t === "submit" || t === "cancel") && conn?.resolveElicitation(state, t === "cancel" ? "cancel" : "accept")) return;
217
+ if (t === "submit" && handAnswerToHost(state)) return;
218
+ conn?.sendAction(t, {
219
+ componentNodeId: meta?.nodeId,
220
+ componentType: meta?.type,
221
+ // The component's OWN chatId is the TURN id the waiting node execution is keyed on.
222
+ componentChatId: meta?.chatId,
223
+ componentState: state,
224
+ });
225
+ };
226
+
227
+ // A PAGE TEMPLATE'S TASK, called at its own workflow's trigger with the user's token.
228
+ // `metadata.task` names the task its Task Runner loads; the submit carries the answers.
229
+ const startTask = async (b: Binding, task: string, submit?: Record<string, unknown>) => {
230
+ if (!b.workflow) return;
231
+ inTask = true;
232
+ syncConnection();
233
+ const token = cfg.getAccessToken ? await cfg.getAccessToken() : null;
234
+ fetch(`${cfg.apiUrl ?? cfg.serverUrl}/api/workflows/${b.workflow}/execute`, {
235
+ method: "POST",
236
+ headers: { "Content-Type": "application/json", ...(token ? { Authorization: `Bearer ${token}` } : {}) },
237
+ body: JSON.stringify({
238
+ input: {
239
+ message: submit ? "Submit" : "Start",
240
+ chatId: cfg.conversationId,
241
+ conversationId: cfg.conversationId,
242
+ userId: cfg.userId,
243
+ providerId: "gravity-ds",
244
+ metadata: { targetTriggerNode: b.trigger, workflowId: b.workflow, task, ...(submit ? { submit } : {}) },
245
+ },
246
+ conversationId: cfg.conversationId,
247
+ }),
248
+ }).catch((e) => console.error("[unoverse] task start failed", e));
249
+ };
250
+
251
+ const onAction: ActionHandler = (action, data, meta) => {
252
+ const t = typeof action === "string" ? action : (action as { type?: string })?.type;
253
+ const d = (data ?? {}) as Record<string, unknown>;
254
+ if (t === "close") {
255
+ // The app owns its close chrome; the embedding host decides what "close" means.
256
+ postToEmbedHost({ type: "unoverse:close" });
257
+ return;
258
+ }
259
+ if (t === "sendMessage") {
260
+ // A suggestion that NAMES AN APP launches it: read that app's manifest (the binding lives
261
+ // there) and fire its trigger on the bound workflow.
262
+ const launch = typeof d.app === "string" ? d.app : "";
263
+ if (launch) {
264
+ (client as unknown as { client: { readResource(a: { uri: string }): Promise<{ contents?: { text?: string }[] }> } }).client
265
+ .readResource({ uri: `unoverse://apps/${launch.toLowerCase()}` })
266
+ .then((res) => {
267
+ const parsed = res?.contents?.[0]?.text ? JSON.parse(res.contents[0].text) : null;
268
+ const b = parsed?.manifest?.binding ?? parsed?.binding ?? {};
269
+ if (!b.trigger) return console.error("[app] launch failed: no binding on", launch);
270
+ if (b.workflow && binding.workflow && b.workflow !== binding.workflow)
271
+ console.warn("[app] launch:", launch, "binds a different workflow — firing its trigger on the bound workflow");
272
+ // LOADING STATE: the app's declared defaultState pre-opens its surface.
273
+ const declared = parsed?.manifest?.defaultState ?? parsed?.manifest?.mode ?? parsed?.defaultState ?? parsed?.mode;
274
+ if (declared && declared !== "app") store.mergeAppState({ defaultState: declared });
275
+ conn?.trigger({ app: launch, metadata: { targetTriggerNode: b.trigger, ...(def?.org ? { org: def.org } : {}) } });
276
+ })
277
+ .catch((e) => console.error("[app] launch failed:", e));
278
+ return;
279
+ }
280
+ // Composer submit: Enter carries `text`; a send BUTTON carries none, so the shared draft.
281
+ const text = (typeof d.text === "string" && d.text) || store.getDraft();
282
+ if (text.trim()) {
283
+ conn?.sendMessage(text);
284
+ store.setDraft("");
285
+ }
286
+ return;
287
+ }
288
+ // Controlled composer: every keystroke is the shared draft (local, never a round trip).
289
+ if (t === "input") {
290
+ store.setDraft(typeof d.text === "string" ? d.text : "");
291
+ return;
292
+ }
293
+ if (t) toServer(t, d, meta);
294
+ };
295
+
296
+ const mountAppView = () => {
297
+ const uri = `unoverse://apps/${appId}/definition`;
298
+ if (app) return app.update({ uri, voice });
299
+ app = mountApp({ client, store, uri, voice, onAction, doc });
300
+ el.append(...app.nodes);
301
+ };
302
+ // The page's own templates, drawn into its elements by this one runtime.
303
+ const mountPage = () => {
304
+ page?.destroy();
305
+ page = mountPageInterfaces({ org: appId.split("/")[0], client, voice, appStore: store, toServer, startTask });
306
+ };
307
+
308
+ const watchStore = () => {
309
+ stopStore();
310
+ stopStore = store.subscribe(() => {
311
+ reportWidth();
312
+ reportStatus();
313
+ });
314
+ };
315
+
316
+ analytics();
317
+ watchStore();
318
+ load();
319
+
320
+ return {
321
+ update(next) {
322
+ const prev = cfg;
323
+ cfg = next;
324
+ analytics();
325
+ let reset = false;
326
+ if (next.serverUrl !== prev.serverUrl) {
327
+ client = new UnoverseClient(`${next.serverUrl}/mcp`, { getAccessToken: next.getAccessToken as never });
328
+ reset = true;
329
+ }
330
+ const key = `${next.userId}|${next.conversationId}`;
331
+ if (key !== storeKey) {
332
+ storeKey = key;
333
+ store = new ComponentStore();
334
+ watchStore();
335
+ reset = true;
336
+ }
337
+ if (reset) {
338
+ app?.destroy();
339
+ app?.nodes.forEach((n) => n.parentNode?.removeChild(n));
340
+ app = null;
341
+ }
342
+ // The host's own switch WINS and resets: a new config is a new intent.
343
+ if (reset || next.appId !== prev.appId) {
344
+ appId = next.appId;
345
+ load();
346
+ } else {
347
+ syncVoice();
348
+ syncConnection();
349
+ }
350
+ },
351
+ destroy() {
352
+ alive = false;
353
+ stopStore();
354
+ stopVoice();
355
+ stopConn();
356
+ conn?.close();
357
+ voice.destroy();
358
+ page?.destroy();
359
+ app?.destroy();
360
+ setAnalyticsSink(null);
361
+ },
362
+ };
363
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * What a definition's interaction fires, and the handler it reaches. Plain TypeScript: both
3
+ * renderers (the legacy one in render.tsx and the plain-DOM one in ./dom) use it.
4
+ */
5
+ import type { ActionSpec, UnoverseNode } from "./core/types";
6
+
7
+ // An action is a bare string (→ workflow) or a declarative envelope (§2e-3). The
8
+ // binding layer (streamed.tsx) interprets it; primitives just forward node.action.
9
+ /** Instance context riding with a bubbled server action — WHICH component fired and its
10
+ * current slice, so the channel can forward the submitted state (e.g. a wizard's answers)
11
+ * without reaching into the store. Pure transport metadata; the SDK interprets none of it. */
12
+ export interface ActionMeta {
13
+ chatId?: string;
14
+ nodeId?: string;
15
+ /** Which component this instance is — so the server needs no registry to find out. */
16
+ type?: string;
17
+ state?: Record<string, unknown>;
18
+ }
19
+
20
+ export type ActionHandler = (action: string | ActionSpec, data: Record<string, unknown>, meta?: ActionMeta) => void;
21
+
22
+ /**
23
+ * A leaf's action, with the node's declared `analytics` attached (UNOVERSE_ANALYTICS.md).
24
+ *
25
+ * Authors write `analytics:` beside `action:` on the node, where the data it reports is
26
+ * already in scope. The two travel together from here so `dispatchAction` remains the ONE
27
+ * place an interaction is understood: leaves stay dumb, and no leaf can forget to report.
28
+ * A node with no `analytics` block passes its action through untouched, unallocated.
29
+ */
30
+ export function nodeAction(node: UnoverseNode, fallback: string): string | ActionSpec {
31
+ const action = node.action ?? fallback;
32
+ if (!node.analytics?.event) return action;
33
+ return typeof action === "string"
34
+ ? { type: action, analytics: node.analytics }
35
+ : { ...action, analytics: node.analytics };
36
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Citations in answers (docs/unoverse/data/objects/citations-in-answers.md): the text rules
3
+ * and the pop-up placement. Plain TypeScript, shared by both renderers.
4
+ */
5
+
6
+ /**
7
+ * A CITATION STILL ARRIVING is held back (citations-in-answers.md): while an answer streams, the
8
+ * tail from an open `[1](cite:` to its closing `)` would draw as a long line of raw link and then
9
+ * collapse into its marker, jolting the text once per citation. Only the tail is touched, and only
10
+ * a numbered label (a citation's), so finished text and web links draw exactly as written.
11
+ */
12
+ const OPEN_CITATION = /\[\d{0,3}(?:\](?:\((?:c(?:i(?:t(?:e(?::[^)\n]*)?)?)?)?)?)?)?$/;
13
+ export function holdOpenCitation(text: string): string {
14
+ return text.replace(OPEN_CITATION, "");
15
+ }
16
+
17
+ /**
18
+ * A CITATION'S QUOTE STAYS ONE CELL (citations-in-answers.md, 2026-09-15): a quote of a table row
19
+ * carried its pipes ("Total | 10") into the link's title, and inside an answer's own table the
20
+ * pipe split the row and left the marker raw. Before the markdown is parsed, a pipe inside a
21
+ * `cite:` link's title becomes a broken bar and a line break a space; the words still read.
22
+ */
23
+ const CITATION_TITLE = /(\]\(cite:[^)\s]*\s+")([^"]*)("\))/g;
24
+ export function keepCitationsWhole(text: string): string {
25
+ return text.replace(CITATION_TITLE, (_, open: string, quote: string, close: string) => `${open}${quote.replace(/\|/g, "\u00a6").replace(/\s*\n\s*/g, " ")}${close}`);
26
+ }
27
+
28
+ /**
29
+ * WHERE A POP-UP FITS (citations-in-answers.md): the horizontal shift that keeps a box of this
30
+ * width inside the window from an anchor at `left`, and whether it opens above the anchor
31
+ * because below there is no room and above there is. A margin keeps it off the very edge.
32
+ */
33
+ export function fitPopover(anchor: { left: number; top: number; bottom: number }, box: { width: number; height: number }, view: { width: number; height: number }, margin = 8) {
34
+ const over = anchor.left + box.width - (view.width - margin);
35
+ const shift = Math.max(over > 0 ? -over : 0, margin - anchor.left);
36
+ const above = anchor.bottom + box.height + margin > view.height && anchor.top - box.height - margin >= 0;
37
+ return { shift, above };
38
+ }
39
+
40
+ /** A `cite:` link's parts: `cite:<document>?part=&page=&doc=`, its title the quoted words. */
41
+ export function citationOf(href: string, title?: string) {
42
+ try {
43
+ const u = new URL(href);
44
+ return { document: decodeURIComponent(u.pathname), part: u.searchParams.get("part") ?? "", page: u.searchParams.get("page") ?? "", doc: u.searchParams.get("doc") ?? "", quote: title ?? "" };
45
+ } catch {
46
+ return null;
47
+ }
48
+ }
@@ -308,6 +308,24 @@ export function resolveActiveView(
308
308
 
309
309
 
310
310
  /** Resolve a ComponentSlot's `select` against the store → ordered pointers. */
311
+ /**
312
+ * THE INSTANCE A HELD SLOT DRAWS (docs/design/templates.md "Holding a component"). A template
313
+ * holds a component inside an `Each`: each item is its own instance, keyed by the item's id
314
+ * under the template's own node id, the same id the server sent it under
315
+ * (server runtime/screen/held.ts `heldNodeId`). Nothing when it has not arrived, or when its own
316
+ * state has lifted it into a place: it is drawn there, not here.
317
+ */
318
+ export function heldPointer(store: ComponentStore, node: UnoverseNode, item: Record<string, unknown>, host: { chatId: string; nodeId: string }): string | undefined {
319
+ const type = (node as { hold?: { type?: unknown } }).hold?.type;
320
+ if (typeof type !== "string") return undefined;
321
+ const id = item.universal_id ?? item.ref ?? item.id ?? item.index;
322
+ if (typeof id !== "string" && typeof id !== "number") return undefined;
323
+ const nodeId = `${host.nodeId}:${type}:${String(id)}`;
324
+ if (!store.getType(host.chatId, nodeId)) return undefined;
325
+ const pointer = `${host.chatId}:${nodeId}`;
326
+ return store.isPlaced(pointer) ? undefined : pointer;
327
+ }
328
+
311
329
  export function selectPointers(store: ComponentStore, node: UnoverseNode, scope?: Set<string>): string[] {
312
330
  const sel = (node.select ?? {}) as { from?: string; type?: string; where?: { field?: string; eq?: unknown; ne?: unknown }; limit?: number };
313
331
  // A SCOPED selection (the mount's hold: latest) spans ALL turns and the SCOPE is
@@ -5,11 +5,11 @@
5
5
  * (resources/read), with a per-request bearer for secured servers. Caches both.
6
6
  * No UI framework here.
7
7
  */
8
- import { Client } from "@modelcontextprotocol/sdk/client/index.js";
9
- import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
8
+ import { CfWorkerJsonSchemaValidator } from "@modelcontextprotocol/client/validators/cf-worker";
9
+ import { Client, StreamableHTTPClientTransport } from "@modelcontextprotocol/client";
10
+
10
11
  // CSP-safe (no `new Function`) validator — the default Ajv provider's eval is blocked by
11
12
  // ChatGPT's widget CSP. See connection.tsx for the full rationale.
12
- import { CfWorkerJsonSchemaValidator } from "@modelcontextprotocol/sdk/validation/cfworker";
13
13
  import type { UnoverseDefinition, ResolvedTheme } from "./types";
14
14
  import { stateOfUri } from "./apps";
15
15
 
@@ -395,6 +395,12 @@ export class ComponentStore {
395
395
  placedState(pointer: Pointer): string | undefined {
396
396
  return this.placed.get(pointer);
397
397
  }
398
+ /** Whether the screen has put this interface in any place. A held interface its own state
399
+ * lifted into a place is drawn there, never in its template too (one instance, one place). */
400
+ isPlaced(pointer: Pointer): boolean {
401
+ for (const items of this.places.values()) if (items.includes(pointer)) return true;
402
+ return false;
403
+ }
398
404
  /** What one place holds, in the order the screen document gives. */
399
405
  placeItems(place: string): readonly Pointer[] {
400
406
  return this.places.get(place) ?? EMPTY_POINTERS;