@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@unoverse-platform/studio",
3
- "version": "0.1.102",
3
+ "version": "0.1.104",
4
4
  "type": "module",
5
5
  "description": "Unoverse Studio: the local authoring app. Reads your own rx/ folder, no backend.",
6
6
  "scripts": {
@@ -26,7 +26,9 @@
26
26
  "@codemirror/state": "^6.5.2",
27
27
  "@codemirror/theme-one-dark": "^6.1.2",
28
28
  "@codemirror/view": "^6.37.1",
29
- "@modelcontextprotocol/sdk": "^1.25.1",
29
+ "@modelcontextprotocol/client": "^2.2.0",
30
+ "@modelcontextprotocol/ext-apps": "^2.0.3",
31
+ "@modelcontextprotocol/server": "^2.2.0",
30
32
  "@smithy/protocol-http": "^5.5.14",
31
33
  "@smithy/signature-v4": "^5.6.10",
32
34
  "@tailwindcss/vite": "^4.0.0",
@@ -7,7 +7,8 @@
7
7
  * Meta (no manifest). Ported from apps/unoverse/web/studio (kept diffable).
8
8
  */
9
9
  import { useEffect, useMemo, useState, type MouseEvent as ReactMouseEvent } from "react";
10
- import { UnoverseComponent, type ResolvedTheme } from "@unoverse/sdk";
10
+ import type { ResolvedTheme } from "@unoverse/sdk";
11
+ import { UnoverseComponent } from "./react/unoverse";
11
12
  import { client, authedFetch } from "studio-host";
12
13
  import { usePersistentState } from "studio-host";
13
14
  import { NAV, SCREEN, navItem } from "./chrome";
@@ -5,7 +5,8 @@
5
5
  */
6
6
  import { useCallback, useEffect, useMemo, useState } from "react";
7
7
  import { ComponentStore } from "@unoverse/sdk";
8
- import { UnoverseComponent, type ResolvedTheme } from "@unoverse/sdk";
8
+ import type { ResolvedTheme } from "@unoverse/sdk";
9
+ import { UnoverseComponent } from "./react/unoverse";
9
10
  import { client, authedFetch, hasSourceAccess } from "studio-host";
10
11
  import { SourceView } from "./SourceView";
11
12
  import { stateActivation, faceOf, faceAxisOf, switchLayers } from "./states";
@@ -10,8 +10,7 @@
10
10
  * required, from the thing that owns it. Nothing is vendored, nothing is bundled.
11
11
  */
12
12
  import { useEffect, useRef, useState } from "react";
13
- import { Client } from "@modelcontextprotocol/sdk/client/index.js";
14
- import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
13
+ import { Client, StreamableHTTPClientTransport } from "@modelcontextprotocol/client";
15
14
 
16
15
  const APP_UI_URI = "ui://unoverse/app.html";
17
16
 
@@ -15,7 +15,8 @@
15
15
  * NOT TemplatesView.tsx: that name is taken by the Apps screen (the legacy noun).
16
16
  */
17
17
  import { useEffect, useMemo, useState, useSyncExternalStore, useCallback, type MouseEvent as ReactMouseEvent } from "react";
18
- import { ComponentStore, UnoverseComponent, type ResolvedTheme } from "@unoverse/sdk";
18
+ import { ComponentStore, type ResolvedTheme } from "@unoverse/sdk";
19
+ import { UnoverseComponent } from "./react/unoverse";
19
20
  import { client, authedFetch, usePersistentState, useRegistryToggles, ToggleSwitch } from "studio-host";
20
21
  import { NAV, SCREEN, navItem, navSubItem } from "./chrome";
21
22
 
@@ -53,6 +54,8 @@ interface TemplateDef {
53
54
  defaultLayout?: string;
54
55
  /** Seeding map the loader assembles: face → interface refs. */
55
56
  preview?: Record<string, string[]>;
57
+ /** The components it holds, one stand-in per item of its own preview data, per state. */
58
+ heldSeeds?: Record<string, { key: string; component: string; props: Record<string, unknown> }[]>;
56
59
  }
57
60
 
58
61
  const PREVIEW_CHAT = "preview";
@@ -185,6 +188,16 @@ export function TemplatesLibraryView({ theme, org }: { theme: ResolvedTheme; org
185
188
  });
186
189
  const base = current.defaultLayout ?? (current.layouts ? Object.keys(current.layouts)[0] : undefined);
187
190
  if (base) store.mergeComponentState(PREVIEW_CHAT, TEMPLATE_NODE, { state: base });
191
+ // WHAT IT HOLDS (docs/design/templates.md "Holding a component"): each item its own
192
+ // instance under the template's node id, the id the SDK's held slot looks for. With no
193
+ // screen here, an opened one stays in its spot and draws its own page there.
194
+ const held = new Set<string>();
195
+ for (const seed of Object.values(current.heldSeeds ?? {}).flat()) {
196
+ const nodeId = `${TEMPLATE_NODE}:${seed.component}:${seed.key}`;
197
+ if (held.has(nodeId)) continue;
198
+ held.add(nodeId);
199
+ store.apply({ type: "COMPONENT_INIT", chatId: PREVIEW_CHAT, nodeId, component: { type: seed.component, props: seed.props } });
200
+ }
188
201
  // eslint-disable-next-line react-hooks/exhaustive-deps
189
202
  }, [store, current]);
190
203
 
@@ -23,7 +23,8 @@ import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from
23
23
  import { useAuth } from "react-oidc-context";
24
24
  import { usePersistentState, useRegistryToggles, ToggleSwitch } from "studio-host";
25
25
  import { ComponentStore } from "@unoverse/sdk";
26
- import { StreamedUnoverseApp, useUnoverseConnection, computeAppWidth, resolveActiveLayout, visible, type ResolvedTheme, type UseVoiceServiceConfig } from "@unoverse/sdk";
26
+ import { computeAppWidth, resolveActiveLayout, visible, type ResolvedTheme } from "@unoverse/sdk";
27
+ import { StreamedUnoverseApp, useUnoverseConnection, type UseVoiceServiceConfig } from "./react/unoverse";
27
28
  import { NativeAppHost } from "./NativeAppHost";
28
29
  import { inArrangement, viewOfSlice } from "./states";
29
30
  import { client, authedFetch, hasAuth, hasSourceAccess, UNOVERSE_BASE, liveUniverseUrl } from "studio-host";
package/screens/index.ts CHANGED
@@ -37,3 +37,7 @@ export { ConfigForm, defaultsFromSchema } from "./ConfigForm";
37
37
  // The source view renders only where the host grants source access, so it is exported
38
38
  // unconditionally and returns null in Canvas (docs/architecture/authoring/LOCAL_STUDIO.md).
39
39
  export { SourceView } from "./SourceView";
40
+ // The React faces of the plain SDK (the SDK has no React): one component, an app, the live
41
+ // connection and the voice service, for the React hosts that share these screens.
42
+ export { UnoverseComponent, StreamedUnoverseApp, useUnoverseConnection, useVoiceService } from "./react/unoverse";
43
+ export type { UnoverseComponentProps, StreamedUnoverseAppProps, UnoverseConnection, UseVoiceServiceConfig, VoiceService } from "./react/unoverse";
@@ -0,0 +1,281 @@
1
+ /**
2
+ * THE REACT ADAPTER for the Unoverse SDK, for the React apps that host it (Studio, the canvas).
3
+ *
4
+ * The SDK has no React (apps/unoverse/web/sdk/CLAUDE.md): it draws with `mountComponent`,
5
+ * `mountApp`, `openConnection` and `createVoiceService`. These four names give a React tree the
6
+ * same props it always passed, each a thin mount of the plain API into one element. Nothing here
7
+ * draws or decides: the SDK does (docs/unoverse/design/sdk/UNOVERSE_SDK_NO_REACT.md §6 (a)).
8
+ */
9
+ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore, type ReactNode } from "react";
10
+ import { createPortal } from "react-dom";
11
+ import {
12
+ mountComponent,
13
+ mountApp,
14
+ openConnection,
15
+ createVoiceService,
16
+ sessionUserId,
17
+ type ActionHandler,
18
+ type AppMount,
19
+ type ComponentMount,
20
+ type ComponentStore,
21
+ type ResolvedTheme,
22
+ type UnoverseClient,
23
+ type UnoverseConnection as PlainConnection,
24
+ type UnoverseConnectionConfig,
25
+ type UnoverseSessionParams,
26
+ type VoiceService as PlainVoiceService,
27
+ type VoiceServiceConfig,
28
+ type VoiceServiceState,
29
+ } from "@unoverse/sdk";
30
+
31
+ /** One element the plain mount draws into; `display: contents` keeps it out of layout. */
32
+ function useHost() {
33
+ const ref = useRef<HTMLDivElement>(null);
34
+ return { ref, el: () => ref.current };
35
+ }
36
+
37
+ export interface UnoverseComponentProps {
38
+ client: UnoverseClient;
39
+ uri?: string;
40
+ data?: Record<string, unknown>;
41
+ store?: ComponentStore;
42
+ chatId?: string;
43
+ nodeId?: string;
44
+ extraData?: Record<string, unknown>;
45
+ onAction?: ActionHandler;
46
+ theme?: ResolvedTheme;
47
+ isolate?: boolean;
48
+ fill?: boolean;
49
+ /** What a `Place` in the definition draws (React content, drawn into the place). */
50
+ place?: (name: string) => ReactNode;
51
+ appStore?: ComponentStore;
52
+ }
53
+
54
+ /** React content the plain SDK draws into an element it is handed (a place, a chrome). */
55
+ function usePortals() {
56
+ const holders = useRef(new Map<string, { el: HTMLDivElement; node: ReactNode; sig: string }>());
57
+ const [, redraw] = useState(0);
58
+ /** The element for `key`, with `node` to draw in it; redraws only when `sig` changes. */
59
+ const hold = useCallback((key: string, node: ReactNode, sig: string) => {
60
+ let h = holders.current.get(key);
61
+ if (!h) {
62
+ h = { el: document.createElement("div"), node, sig };
63
+ h.el.style.display = "contents";
64
+ holders.current.set(key, h);
65
+ queueMicrotask(() => redraw((n) => n + 1));
66
+ } else {
67
+ h.node = node;
68
+ if (h.sig !== sig) {
69
+ h.sig = sig;
70
+ queueMicrotask(() => redraw((n) => n + 1));
71
+ }
72
+ }
73
+ return h.el;
74
+ }, []);
75
+ const portals = [...holders.current.entries()].map(([key, h]) => createPortal(h.node, h.el, key));
76
+ return { hold, portals };
77
+ }
78
+
79
+ export function UnoverseComponent(props: UnoverseComponentProps) {
80
+ const host = useHost();
81
+ const mount = useRef<ComponentMount | null>(null);
82
+ const { hold, portals } = usePortals();
83
+ const latest = useRef(props);
84
+ latest.current = props;
85
+ const options = () => {
86
+ const p = latest.current;
87
+ return {
88
+ client: p.client as never,
89
+ uri: p.uri,
90
+ data: p.data,
91
+ store: p.store,
92
+ chatId: p.chatId,
93
+ nodeId: p.nodeId,
94
+ extraData: p.extraData,
95
+ onAction: p.onAction,
96
+ theme: p.theme,
97
+ isolate: p.isolate,
98
+ fill: p.fill,
99
+ appStore: p.appStore,
100
+ place: p.place ? (name: string) => [hold(`place:${name}`, latest.current.place?.(name), name)] : undefined,
101
+ };
102
+ };
103
+ useLayoutEffect(() => {
104
+ const m = mountComponent(options());
105
+ mount.current = m;
106
+ host.el()?.append(...m.nodes);
107
+ return () => {
108
+ m.destroy();
109
+ m.nodes.forEach((n) => n.parentNode?.removeChild(n));
110
+ mount.current = null;
111
+ };
112
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- one mount; props flow through update
113
+ }, []);
114
+ useLayoutEffect(() => {
115
+ mount.current?.update(options());
116
+ });
117
+ return (
118
+ <div ref={host.ref} style={{ display: "contents" }}>
119
+ {portals}
120
+ </div>
121
+ );
122
+ }
123
+
124
+ export interface StreamedUnoverseAppProps {
125
+ client: UnoverseClient;
126
+ store: ComponentStore;
127
+ uri: string;
128
+ onAction?: ActionHandler;
129
+ theme?: ResolvedTheme;
130
+ props?: Record<string, unknown>;
131
+ isolate?: boolean;
132
+ /** DEV CHROME ON A CONTAINER: React content drawn above a Container's content. */
133
+ templateChrome?: (info: { name: string; states: string[]; active?: string; set: (state: string) => void }) => ReactNode;
134
+ /** The voice service config, for a `service: "voice"` app (the SDK starts it). */
135
+ voice?: VoiceServiceConfig;
136
+ /** The voice service, when the host already runs one. */
137
+ voiceService?: Pick<PlainVoiceService, "startCall" | "endCall" | "toggleMute">;
138
+ }
139
+
140
+ export function StreamedUnoverseApp(props: StreamedUnoverseAppProps) {
141
+ const host = useHost();
142
+ const mount = useRef<AppMount | null>(null);
143
+ const { hold, portals } = usePortals();
144
+ const latest = useRef(props);
145
+ latest.current = props;
146
+ // The app's own voice service, when given a config and no service.
147
+ const own = useMemo(
148
+ () => (props.voice && !props.voiceService ? createVoiceService({ ...props.voice, wsUrl: props.client.serverOrigin.replace(/^http/, "ws") }) : null),
149
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- one per config identity
150
+ [props.voice, props.voiceService, props.client],
151
+ );
152
+ useEffect(() => () => own?.destroy(), [own]);
153
+ const options = () => {
154
+ const p = latest.current;
155
+ return {
156
+ client: p.client as never,
157
+ store: p.store,
158
+ uri: p.uri,
159
+ onAction: p.onAction,
160
+ theme: p.theme,
161
+ props: p.props,
162
+ isolate: p.isolate,
163
+ voice: p.voiceService ?? own ?? undefined,
164
+ templateChrome: p.templateChrome
165
+ ? (info: { name: string; states: string[]; active?: string; set: (state: string) => void }) =>
166
+ hold(`chrome:${info.name}`, latest.current.templateChrome?.(info), JSON.stringify([info.states, info.active]))
167
+ : undefined,
168
+ doc: document,
169
+ };
170
+ };
171
+ useLayoutEffect(() => {
172
+ const m = mountApp(options());
173
+ mount.current = m;
174
+ host.el()?.append(...m.nodes);
175
+ return () => {
176
+ m.destroy();
177
+ m.nodes.forEach((n) => n.parentNode?.removeChild(n));
178
+ mount.current = null;
179
+ };
180
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- one mount; props flow through update
181
+ }, []);
182
+ useLayoutEffect(() => {
183
+ mount.current?.update(options());
184
+ });
185
+ return (
186
+ <div ref={host.ref} style={{ display: "contents" }}>
187
+ {portals}
188
+ </div>
189
+ );
190
+ }
191
+
192
+ export interface UnoverseConnection {
193
+ isConnected: boolean;
194
+ isReady: boolean;
195
+ refusal: string | null;
196
+ sendMessage: (text: string) => void;
197
+ trigger: (input?: Record<string, unknown>) => void;
198
+ sendAction: (action: string, data: Record<string, unknown>) => void;
199
+ resolveElicitation: (state: Record<string, unknown>, action?: "accept" | "cancel") => boolean;
200
+ /** The workflow-selected template (MCP app) name, or null. */
201
+ activeApp: string | null;
202
+ }
203
+
204
+ const IDLE = { isConnected: false, isReady: false, refusal: null };
205
+
206
+ /** The live connection, opened for a React host and re-rendered on its status. */
207
+ export function useUnoverseConnection(
208
+ config: UnoverseConnectionConfig,
209
+ session: UnoverseSessionParams,
210
+ store: ComponentStore,
211
+ options: { enabled?: boolean; onAppSwitch?: (app: string) => void; onJoinCall?: (workflowId?: string) => void } = {},
212
+ ): UnoverseConnection {
213
+ const enabled = options.enabled ?? true;
214
+ // Read through refs, so a host's inline arrow or a new app never tears the stream down.
215
+ const callbacks = useRef(options);
216
+ callbacks.current = options;
217
+ const appId = useRef(session.appId);
218
+ appId.current = session.appId;
219
+ const userId = sessionUserId(session.userId, config.getAccessToken);
220
+ const chatId = session.chatId ?? session.conversationId;
221
+ const [conn, setConn] = useState<PlainConnection | null>(null);
222
+ useEffect(() => {
223
+ if (!enabled) return; // mock mode: no live stream
224
+ const c = openConnection(config, session, store, {
225
+ onAppSwitch: (app) => callbacks.current.onAppSwitch?.(app),
226
+ onJoinCall: (w) => callbacks.current.onJoinCall?.(w),
227
+ appId: () => appId.current,
228
+ });
229
+ setConn(c);
230
+ return () => {
231
+ c.close();
232
+ setConn(null);
233
+ };
234
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- keyed on the fields the connection reads
235
+ }, [enabled, config.streamUrl, session.workflowId, session.targetTriggerNode, userId, session.conversationId, chatId, config.getAccessToken, store]);
236
+ const status = useSyncExternalStore(
237
+ useCallback((cb: () => void) => (conn ? conn.subscribe(cb) : () => {}), [conn]),
238
+ useCallback(() => conn?.status() ?? IDLE, [conn]),
239
+ );
240
+ const activeApp = useSyncExternalStore(
241
+ useCallback((cb: () => void) => store.subscribe(cb), [store]),
242
+ useCallback(() => store.getActiveApp().name, [store]),
243
+ );
244
+ // Without a connection (mock mode, or before it opens) a send still shows the person's turn
245
+ // and the thinking state; the run itself is dropped, loudly.
246
+ const sendMessage = useCallback(
247
+ (text: string) => {
248
+ if (conn) return conn.sendMessage(text);
249
+ const message = text.trim();
250
+ if (!message) return;
251
+ const turn = `${chatId}:t${Date.now().toString(36)}${Math.floor(Math.random() * 1296).toString(36)}`;
252
+ store.addUserMessage(turn, message);
253
+ store.startResponse(turn);
254
+ console.error("[unoverse] trigger dropped — MCP stream not connected yet");
255
+ },
256
+ [conn, chatId, store],
257
+ );
258
+ const trigger = useCallback((input?: Record<string, unknown>) => (conn ? conn.trigger(input) : console.error("[unoverse] trigger dropped — MCP stream not connected yet")), [conn]);
259
+ const sendAction = useCallback((action: string, data: Record<string, unknown>) => conn?.sendAction(action, data), [conn]);
260
+ const resolveElicitation = useCallback((state: Record<string, unknown>, action: "accept" | "cancel" = "accept") => conn?.resolveElicitation(state, action) ?? false, [conn]);
261
+ return { ...status, sendMessage, trigger, sendAction, resolveElicitation, activeApp };
262
+ }
263
+
264
+ export type UseVoiceServiceConfig = VoiceServiceConfig;
265
+ export type VoiceService = Omit<PlainVoiceService, "subscribe" | "destroy" | "state"> & { state: VoiceServiceState };
266
+
267
+ /** The voice service, held for a React host and re-rendered on its state. */
268
+ export function useVoiceService(config: UseVoiceServiceConfig): VoiceService {
269
+ const { session } = config;
270
+ const service = useMemo(
271
+ () => createVoiceService(config),
272
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- keyed on what the service reads
273
+ [config.apiUrl, config.wsUrl, config.getAccessToken, config.store, session.userId, session.conversationId, session.chatId, session.workflowId, session.targetTriggerNode],
274
+ );
275
+ useEffect(() => () => service.destroy(), [service]);
276
+ const state = useSyncExternalStore(
277
+ (cb) => service.subscribe(cb),
278
+ () => service.state,
279
+ );
280
+ return { state, startCall: service.startCall, joinCall: service.joinCall, endCall: service.endCall, toggleMute: service.toggleMute, unlock: service.unlock };
281
+ }
@@ -38,7 +38,7 @@ export { parseToolResult, isTurnEndingHandoff } from "./handoff.js";
38
38
  export { rowComponentsFromResults, renderRowComponents, rankedRowIds, componentTypeFromAppUri, onScreenRead, onScreenPlaces, placesOn, placesLine, rememberRows, rowsById, cardIdOf, screenOf, openedOn, type ScreenItem, openTasksOn, onScreenLine, ABOUT_THE_PAGE, ABOUT_THE_OPENED } from "./rowComponents.js";
39
39
  export { leanToolResultForModel } from "./lean.js";
40
40
  export { toolDefFromDiscoveredMCP, resolveToolDefNative, isBindinglessComponentApp, componentAppInvoker, isWorkflowTask, workflowTaskInvoker, workflowAppInvoker, modelToolName, objectFunctionToolDef, objectFunctionInvoker } from "./tools.js";
41
- export { invokeTaskNative, connectPlatformMcp } from "./invoke.js";
41
+ export { invokeTaskNative, connectPlatformMcp, TRACE_META } from "./invoke.js";
42
42
 
43
43
  export { ingestConversationTurn, type ConversationTurn } from "./memoryIngest.js";
44
44
  export { fetchUserMemoryContext, hydratePromptWithUserMemory } from "./memoryContext.js";
@@ -31,13 +31,16 @@ import type { AppInvocationContext, DiscoveredMCP } from "./types.js";
31
31
  * harness (every agent family) using the app id it parsed at mint time — so it needs NO
32
32
  * server-side (XState) schema cache.
33
33
  */
34
+ /** The `_meta` key a platform call carries its caller under (AppInvocationContext.trace). */
35
+ export const TRACE_META = "unoverse/trace";
36
+
34
37
  /** Open an MCP client on the platform's internal `/mcp` — the ONE wire-connect shared by
35
38
  * every native call this harness makes (app invocation, live schema read). */
36
39
  export async function connectPlatformMcp(
37
40
  ctx: Pick<AppInvocationContext, "accessToken" | "baseUrl">,
38
41
  ): Promise<{ client: any; close: () => Promise<void> }> {
39
- const { Client } = await import("@modelcontextprotocol/sdk/client/index.js");
40
- const { StreamableHTTPClientTransport } = await import("@modelcontextprotocol/sdk/client/streamableHttp.js");
42
+ const { Client } = await import("@modelcontextprotocol/client");
43
+ const { StreamableHTTPClientTransport } = await import("@modelcontextprotocol/client");
41
44
 
42
45
  const base = (ctx.baseUrl || process.env.UNOVERSE_SERVICE_URL || "http://localhost:4106").replace(/\/$/, "");
43
46
  const accessToken = ctx.accessToken;
@@ -94,6 +97,7 @@ export async function invokeTaskNative(
94
97
  // A workflow task's own arguments, as the model wrote them against the task's schema.
95
98
  ...(ctx.args ?? {}),
96
99
  },
100
+ ...(ctx.trace?.executionId && ctx.trace?.nodeId ? { _meta: { [TRACE_META]: ctx.trace } } : {}),
97
101
  },
98
102
  undefined,
99
103
  // A human is completing the wizard, or an Agent is working — lift the SDK's 60s default to 24h.
@@ -123,7 +123,7 @@ export function isWorkflowTask(mcp: DiscoveredMCP): boolean {
123
123
  */
124
124
  export function workflowTaskInvoker(
125
125
  mcp: DiscoveredMCP,
126
- identity: Pick<AppInvocationContext, "conversationId" | "userId" | "chatId" | "accessToken" | "baseUrl">,
126
+ identity: Pick<AppInvocationContext, "conversationId" | "userId" | "chatId" | "accessToken" | "baseUrl" | "trace">,
127
127
  ): (input: unknown) => ReturnType<typeof invokeTaskNative> {
128
128
  const toolId = mcp.taskTool as string;
129
129
  return (input: unknown) => {
@@ -133,6 +133,7 @@ export function workflowTaskInvoker(
133
133
  userId: identity.userId,
134
134
  accessToken: identity.accessToken,
135
135
  chatId: identity.chatId,
136
+ ...(identity.trace ? { trace: { ...identity.trace, kind: "task" } } : {}),
136
137
  ...(identity.baseUrl ? { baseUrl: identity.baseUrl } : {}),
137
138
  message: typeof message === "string" ? message : "",
138
139
  ...(Object.keys(rest).length ? { args: rest } : {}),
@@ -156,7 +157,7 @@ export function workflowTaskInvoker(
156
157
  */
157
158
  export function componentAppInvoker(
158
159
  mcp: DiscoveredMCP,
159
- identity: Pick<AppInvocationContext, "conversationId" | "userId" | "chatId" | "accessToken">,
160
+ identity: Pick<AppInvocationContext, "conversationId" | "userId" | "chatId" | "accessToken" | "trace">,
160
161
  ): (input: unknown) => ReturnType<typeof invokeTaskNative> {
161
162
  const toolId = mcp.component as string;
162
163
  return (input: unknown) => {
@@ -166,6 +167,7 @@ export function componentAppInvoker(
166
167
  userId: identity.userId,
167
168
  accessToken: identity.accessToken,
168
169
  chatId: identity.chatId,
170
+ ...(identity.trace ? { trace: { ...identity.trace, kind: mcp.task ? "task" : "tool" } } : {}),
169
171
  message: typeof message === "string" ? message : "",
170
172
  ...(Object.keys(hydrated).length ? { props: hydrated } : {}),
171
173
  });
@@ -190,7 +192,7 @@ export function componentAppInvoker(
190
192
  */
191
193
  export function workflowAppInvoker(
192
194
  mcp: DiscoveredMCP,
193
- identity: Pick<AppInvocationContext, "conversationId" | "userId" | "chatId" | "accessToken" | "baseUrl">,
195
+ identity: Pick<AppInvocationContext, "conversationId" | "userId" | "chatId" | "accessToken" | "baseUrl" | "trace">,
194
196
  ): (input: unknown) => ReturnType<typeof invokeTaskNative> {
195
197
  const toolId = String(mcp.methodName);
196
198
  return (input: unknown) => {
@@ -200,6 +202,7 @@ export function workflowAppInvoker(
200
202
  userId: identity.userId,
201
203
  accessToken: identity.accessToken,
202
204
  chatId: identity.chatId,
205
+ ...(identity.trace ? { trace: { ...identity.trace, kind: mcp.task ? "task" : "tool" } } : {}),
203
206
  ...(identity.baseUrl ? { baseUrl: identity.baseUrl } : {}),
204
207
  message: typeof message === "string" ? message : "",
205
208
  ...(Object.keys(hydrated).length ? { props: hydrated } : {}),
@@ -242,12 +245,18 @@ export function objectFunctionToolDef(fn: OfferedFunction): AgentToolDef {
242
245
  * THAT row's object (a fund's `documents`, a document's `getDetail`), or answers 404 when the
243
246
  * row's object offers no such function, which comes back to the model as the error it is.
244
247
  */
245
- export function objectFunctionInvoker(fn: OfferedFunction, engineUrl: string): (input: unknown) => Promise<unknown> {
248
+ export function objectFunctionInvoker(
249
+ fn: OfferedFunction,
250
+ engineUrl: string,
251
+ /** Who is asking: the run and the calling node. The function records its own call under them. */
252
+ caller?: { executionId?: string; nodeId?: string },
253
+ ): (input: unknown) => Promise<unknown> {
254
+ const who = caller?.executionId && caller?.nodeId ? `?${new URLSearchParams({ executionId: caller.executionId, caller: caller.nodeId })}` : "";
246
255
  return async (input: unknown) => {
247
256
  const { universal_id, ...params } = (input ?? {}) as Record<string, unknown>;
248
257
  const id = typeof universal_id === "string" ? universal_id.trim() : "";
249
258
  if (!id) return { error: `${fn.name} needs the universal_id of a result you already hold.` };
250
- const res = await fetch(`${engineUrl}/dictionary/function/${encodeURIComponent(fn.name)}/${encodeURIComponent(id)}`, {
259
+ const res = await fetch(`${engineUrl}/dictionary/function/${encodeURIComponent(fn.name)}/${encodeURIComponent(id)}${who}`, {
251
260
  method: "POST",
252
261
  headers: { "content-type": "application/json" },
253
262
  body: JSON.stringify(params),
@@ -105,6 +105,12 @@ export interface AgentToolDef {
105
105
 
106
106
  /** Context a held app call needs to reach the human on the live session. */
107
107
  export interface AppInvocationContext {
108
+ /**
109
+ * WHO IS CALLING, for the tool's own row on the trace (packages/docs/nodes/mcp-services.md,
110
+ * "Who records a call"): the run and the calling node, and whether the call opens a task.
111
+ * Sent as the call's `_meta`, never as an argument; absent for a call no model made.
112
+ */
113
+ trace?: { executionId?: string; nodeId?: string; kind?: "tool" | "task" };
108
114
  /** The caller's stream session — where the wizard renders and elicitation is answered. */
109
115
  conversationId?: string;
110
116
  userId?: string;