@unoverse-platform/studio 0.1.103 → 0.1.105

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 (86) hide show
  1. package/local/sample-app/chat/manifest.yaml +2 -2
  2. package/local/sample-component/text-chat/components/suggestions.yaml +1 -1
  3. package/local/sample-component/text-chat/layouts/chat-welcome.yaml +1 -1
  4. package/local/sample-component/text-chat/text-chat.yaml +3 -0
  5. package/local/scaffold.d.mts +5 -2
  6. package/local/scaffold.mjs +68 -1
  7. package/package.json +4 -2
  8. package/screens/AtomsView.tsx +2 -1
  9. package/screens/ComponentsView.tsx +2 -1
  10. package/screens/NativeAppHost.tsx +1 -2
  11. package/screens/TemplatesLibraryView.tsx +14 -1
  12. package/screens/TemplatesView.tsx +2 -1
  13. package/screens/index.ts +4 -0
  14. package/screens/react/unoverse.tsx +281 -0
  15. package/vendor/base/src/agent-mcp/index.ts +1 -1
  16. package/vendor/base/src/agent-mcp/invoke.ts +6 -2
  17. package/vendor/base/src/agent-mcp/tools.ts +14 -5
  18. package/vendor/base/src/agent-mcp/types.ts +6 -0
  19. package/vendor/base/src/auth/auth.ts +90 -23
  20. package/vendor/base/src/definitions/load/compile/sections.ts +79 -0
  21. package/vendor/base/src/definitions/load/compile/template.ts +23 -2
  22. package/vendor/base/src/definitions/model.ts +9 -0
  23. package/vendor/base/src/definitions/registry/catalog.ts +6 -7
  24. package/vendor/base/src/executor.ts +25 -1
  25. package/vendor/base/src/lint/design/index.mjs +9 -0
  26. package/vendor/base/src/manifests/executor/toolBridge.ts +25 -19
  27. package/vendor/base/src/manifests/runtime/channels/final.ts +4 -1
  28. package/vendor/base/src/manifests/runtime/duplex/session.ts +3 -9
  29. package/vendor/base/src/manifests/runtime/tools/toolloop.ts +28 -25
  30. package/vendor/base/src/platform/serviceCalls.ts +13 -0
  31. package/vendor/sdk/appEngine.ts +363 -0
  32. package/vendor/sdk/lib/actions.ts +36 -0
  33. package/vendor/sdk/lib/citations.ts +48 -0
  34. package/vendor/sdk/lib/core/apps.ts +18 -0
  35. package/vendor/sdk/lib/core/client.ts +3 -3
  36. package/vendor/sdk/lib/core/store.ts +6 -0
  37. package/vendor/sdk/lib/dom/app.ts +546 -0
  38. package/vendor/sdk/lib/dom/chrome.ts +125 -0
  39. package/vendor/sdk/lib/dom/component.ts +333 -0
  40. package/vendor/sdk/lib/dom/connection.ts +309 -0
  41. package/vendor/sdk/lib/dom/element.ts +69 -0
  42. package/vendor/sdk/lib/dom/isolate.ts +37 -0
  43. package/vendor/sdk/lib/dom/markdown.ts +388 -0
  44. package/vendor/sdk/lib/dom/markdownParse.ts +440 -0
  45. package/vendor/sdk/lib/dom/mounted.ts +29 -0
  46. package/vendor/sdk/lib/dom/primitives.ts +689 -0
  47. package/vendor/sdk/lib/dom/region.ts +195 -0
  48. package/vendor/sdk/lib/dom/render.ts +177 -0
  49. package/vendor/sdk/lib/dom/theme.ts +38 -0
  50. package/vendor/sdk/lib/dom/types.ts +36 -0
  51. package/vendor/sdk/lib/index.ts +26 -17
  52. package/vendor/sdk/lib/orb/canvas.ts +139 -0
  53. package/vendor/sdk/lib/orb/index.ts +8 -18
  54. package/vendor/sdk/lib/orb/presets.ts +1 -1
  55. package/vendor/sdk/lib/orb/{types.ts → states.ts} +0 -24
  56. package/vendor/sdk/lib/realtime/capture.ts +152 -0
  57. package/vendor/sdk/lib/realtime/playback.ts +129 -0
  58. package/vendor/sdk/lib/realtime/socket.ts +117 -0
  59. package/vendor/sdk/lib/style.ts +45 -37
  60. package/vendor/sdk/lib/voiceService.ts +344 -0
  61. package/vendor/sdk/main.ts +107 -0
  62. package/vendor/sdk/mcpAppBridge.ts +125 -102
  63. package/vendor/sdk/pageInterfaces.ts +452 -0
  64. package/vendor/sdk/responseView.ts +193 -0
  65. package/vite.config.ts +3 -3
  66. package/vendor/sdk/appEngine.tsx +0 -395
  67. package/vendor/sdk/lib/UnoverseComponent.tsx +0 -325
  68. package/vendor/sdk/lib/app.tsx +0 -730
  69. package/vendor/sdk/lib/connection.tsx +0 -433
  70. package/vendor/sdk/lib/isolate.tsx +0 -70
  71. package/vendor/sdk/lib/orb/ThinkingOrb.tsx +0 -131
  72. package/vendor/sdk/lib/orb/theme.ts +0 -83
  73. package/vendor/sdk/lib/primitives.tsx +0 -943
  74. package/vendor/sdk/lib/realtime/useAudioCapture.ts +0 -237
  75. package/vendor/sdk/lib/realtime/useAudioPlayback.ts +0 -157
  76. package/vendor/sdk/lib/realtime/useRealtimeWebSocket.ts +0 -164
  77. package/vendor/sdk/lib/render.tsx +0 -244
  78. package/vendor/sdk/lib/streamed.tsx +0 -30
  79. package/vendor/sdk/lib/theme.ts +0 -55
  80. package/vendor/sdk/lib/transition/MountArrangement.tsx +0 -71
  81. package/vendor/sdk/lib/transition/index.ts +0 -9
  82. package/vendor/sdk/lib/transition/viewTransition.ts +0 -29
  83. package/vendor/sdk/lib/voice.tsx +0 -422
  84. package/vendor/sdk/main.tsx +0 -125
  85. package/vendor/sdk/pageInterfaces.tsx +0 -388
  86. package/vendor/sdk/responseView.tsx +0 -82
@@ -23,8 +23,8 @@ inputSchema:
23
23
  # components/chat-region. The component defaults to welcome on an empty conversation
24
24
  # and hands over to the timeline the moment messages exist.
25
25
  states:
26
- standard: {}
27
- focused: {}
26
+ standard: { layout: layouts/standard }
27
+ focused: { layout: layouts/focused }
28
28
 
29
29
  preview:
30
30
  focused: [ card ]
@@ -65,7 +65,7 @@ children:
65
65
  align: start
66
66
  gap: "2"
67
67
  width: full
68
- template:
68
+ item:
69
69
  type: Button
70
70
  action:
71
71
  type: sendMessage
@@ -36,7 +36,7 @@ children:
36
36
  gap: "4"
37
37
  width: full
38
38
  padding: [ "2", "0", "2", "0" ]
39
- template:
39
+ item:
40
40
  type: Button
41
41
  action: sendMessage
42
42
  style:
@@ -19,6 +19,7 @@ props:
19
19
  # pin the mood forever.
20
20
  chatState:
21
21
  type: string
22
+ description: "Which face the chat is showing. Empty means its default."
22
23
  input: false
23
24
  default: ""
24
25
  enum: ["", welcome, active]
@@ -26,10 +27,12 @@ props:
26
27
  # platform projects for real).
27
28
  isEmpty:
28
29
  type: boolean
30
+ description: "True before any message exists, so the empty state is drawn."
29
31
  input: false
30
32
  default: true
31
33
  hasMessages:
32
34
  type: boolean
35
+ description: "True once the conversation has started."
33
36
  input: false
34
37
  default: false
35
38
  # THE STATE TREE (State Model v2): ONE public state — the single-state permanent
@@ -6,8 +6,11 @@
6
6
  /** Null when the name is usable, otherwise the reason to show beside the field. */
7
7
  export function checkProjectName(name: string): string | null;
8
8
 
9
- /** Create rx/<name>/ under `home`. Returns the created path. Throws if unusable. */
9
+ /** Create design/<name>/ under `home`. Returns the created path. Throws if unusable. */
10
10
  export function createProject(home: string, name: string): string;
11
11
 
12
- /** The first project, plus prompts/ and nodes/ beside rx/. Returns the project path. */
12
+ /** The first project, plus the editor settings and the CI template. Returns the project path. */
13
13
  export function createFirstProject(home: string, name: string): string;
14
+
15
+ /** Write .github/workflows/unoverse.yml for `project`, once. Never replaces an existing file. */
16
+ export function writeCiWorkflow(home: string, project: string): void;
@@ -245,12 +245,79 @@ export function writeEditorSettings(home) {
245
245
  if (!existsSync(ext)) writeFileSync(ext, JSON.stringify({ recommendations: ["redhat.vscode-yaml"] }, null, 2) + "\n");
246
246
  }
247
247
 
248
+ /**
249
+ * THE CI TEMPLATE (RELEASE_LIFECYCLE.md §3). The workspace is the client's Git repo, and
250
+ * their pipeline deploys it: pull requests lint, main goes to UAT, a `v*` tag goes to
251
+ * production. Each GitHub environment (`uat`, `production`) holds its own UNOVERSE_TOKEN,
252
+ * so a UAT key can never write to production, and production's environment is where the
253
+ * client adds a required approver. Addresses come from unoverse.yaml `environments:`.
254
+ * Written once; a later run never replaces the team's own edits.
255
+ */
256
+ const CI_WORKFLOW = `# Deploys this workspace. Pull requests lint; main goes to UAT; a v* tag goes to production.
257
+ # Setup: https://docs.unoverse.ai/runbooks/publish-from-github
258
+ name: unoverse
259
+
260
+ on:
261
+ pull_request:
262
+ push:
263
+ branches: [main]
264
+ tags: ["v*"]
265
+
266
+ jobs:
267
+ lint:
268
+ runs-on: ubuntu-latest
269
+ steps:
270
+ - uses: actions/checkout@v4
271
+ - uses: actions/setup-node@v4
272
+ with:
273
+ node-version: 20
274
+ - run: npx --yes unoverse@latest lint PROJECT
275
+
276
+ uat:
277
+ if: github.event_name == 'push' && github.ref == 'refs/heads/main'
278
+ needs: lint
279
+ runs-on: ubuntu-latest
280
+ environment: uat
281
+ steps:
282
+ - uses: actions/checkout@v4
283
+ - uses: actions/setup-node@v4
284
+ with:
285
+ node-version: 20
286
+ - run: npx --yes unoverse@latest deploy studio PROJECT --env uat --yes
287
+ env:
288
+ UNOVERSE_TOKEN: \${{ secrets.UNOVERSE_TOKEN }}
289
+
290
+ production:
291
+ if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')
292
+ needs: lint
293
+ runs-on: ubuntu-latest
294
+ environment: production
295
+ steps:
296
+ - uses: actions/checkout@v4
297
+ - uses: actions/setup-node@v4
298
+ with:
299
+ node-version: 20
300
+ - run: npx --yes unoverse@latest deploy studio PROJECT --env production --yes
301
+ env:
302
+ UNOVERSE_TOKEN: \${{ secrets.UNOVERSE_TOKEN }}
303
+ `;
304
+
305
+ export function writeCiWorkflow(home, project) {
306
+ const dir = join(home, ".github", "workflows");
307
+ const file = join(dir, "unoverse.yml");
308
+ if (existsSync(file)) return;
309
+ mkdirSync(dir, { recursive: true });
310
+ writeFileSync(file, CI_WORKFLOW.replaceAll("PROJECT", project));
311
+ }
312
+
248
313
  export function createFirstProject(home, name) {
249
314
  const root = createProject(home, name);
250
315
  // NOTHING beside the design tree. skills/ and blocks/ moved INTO the org on 2026-08-24
251
316
  // and nodes/ followed on 2026-09-08 (the sample package lands at design/<org>/nodes/),
252
- // so every authored kind sits in one place.
317
+ // so every authored kind sits in one place. The two exceptions are the editor's
318
+ // settings and the CI template, which belong to the repo, not to a project.
253
319
  writeEditorSettings(home);
320
+ writeCiWorkflow(home, name);
254
321
 
255
322
  topUpSamples(home, name);
256
323
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@unoverse-platform/studio",
3
- "version": "0.1.103",
3
+ "version": "0.1.105",
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;