@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.
- package/local/sample-app/chat/manifest.yaml +2 -2
- package/local/sample-component/text-chat/components/suggestions.yaml +1 -1
- package/local/sample-component/text-chat/layouts/chat-welcome.yaml +1 -1
- package/local/sample-component/text-chat/text-chat.yaml +3 -0
- package/local/scaffold.d.mts +5 -2
- package/local/scaffold.mjs +68 -1
- package/package.json +4 -2
- package/screens/AtomsView.tsx +2 -1
- package/screens/ComponentsView.tsx +2 -1
- package/screens/NativeAppHost.tsx +1 -2
- package/screens/TemplatesLibraryView.tsx +14 -1
- package/screens/TemplatesView.tsx +2 -1
- package/screens/index.ts +4 -0
- package/screens/react/unoverse.tsx +281 -0
- package/vendor/base/src/agent-mcp/index.ts +1 -1
- package/vendor/base/src/agent-mcp/invoke.ts +6 -2
- package/vendor/base/src/agent-mcp/tools.ts +14 -5
- package/vendor/base/src/agent-mcp/types.ts +6 -0
- package/vendor/base/src/auth/auth.ts +90 -23
- package/vendor/base/src/definitions/load/compile/sections.ts +79 -0
- package/vendor/base/src/definitions/load/compile/template.ts +23 -2
- package/vendor/base/src/definitions/model.ts +9 -0
- package/vendor/base/src/definitions/registry/catalog.ts +6 -7
- package/vendor/base/src/executor.ts +25 -1
- package/vendor/base/src/lint/design/index.mjs +9 -0
- package/vendor/base/src/manifests/executor/toolBridge.ts +25 -19
- package/vendor/base/src/manifests/runtime/channels/final.ts +4 -1
- package/vendor/base/src/manifests/runtime/duplex/session.ts +3 -9
- package/vendor/base/src/manifests/runtime/tools/toolloop.ts +28 -25
- package/vendor/base/src/platform/serviceCalls.ts +13 -0
- package/vendor/sdk/appEngine.ts +363 -0
- package/vendor/sdk/lib/actions.ts +36 -0
- package/vendor/sdk/lib/citations.ts +48 -0
- package/vendor/sdk/lib/core/apps.ts +18 -0
- package/vendor/sdk/lib/core/client.ts +3 -3
- package/vendor/sdk/lib/core/store.ts +6 -0
- package/vendor/sdk/lib/dom/app.ts +546 -0
- package/vendor/sdk/lib/dom/chrome.ts +125 -0
- package/vendor/sdk/lib/dom/component.ts +333 -0
- package/vendor/sdk/lib/dom/connection.ts +309 -0
- package/vendor/sdk/lib/dom/element.ts +69 -0
- package/vendor/sdk/lib/dom/isolate.ts +37 -0
- package/vendor/sdk/lib/dom/markdown.ts +388 -0
- package/vendor/sdk/lib/dom/markdownParse.ts +440 -0
- package/vendor/sdk/lib/dom/mounted.ts +29 -0
- package/vendor/sdk/lib/dom/primitives.ts +689 -0
- package/vendor/sdk/lib/dom/region.ts +195 -0
- package/vendor/sdk/lib/dom/render.ts +177 -0
- package/vendor/sdk/lib/dom/theme.ts +38 -0
- package/vendor/sdk/lib/dom/types.ts +36 -0
- package/vendor/sdk/lib/index.ts +26 -17
- package/vendor/sdk/lib/orb/canvas.ts +139 -0
- package/vendor/sdk/lib/orb/index.ts +8 -18
- package/vendor/sdk/lib/orb/presets.ts +1 -1
- package/vendor/sdk/lib/orb/{types.ts → states.ts} +0 -24
- package/vendor/sdk/lib/realtime/capture.ts +152 -0
- package/vendor/sdk/lib/realtime/playback.ts +129 -0
- package/vendor/sdk/lib/realtime/socket.ts +117 -0
- package/vendor/sdk/lib/style.ts +45 -37
- package/vendor/sdk/lib/voiceService.ts +344 -0
- package/vendor/sdk/main.ts +107 -0
- package/vendor/sdk/mcpAppBridge.ts +125 -102
- package/vendor/sdk/pageInterfaces.ts +452 -0
- package/vendor/sdk/responseView.ts +193 -0
- package/vite.config.ts +3 -3
- package/vendor/sdk/appEngine.tsx +0 -395
- package/vendor/sdk/lib/UnoverseComponent.tsx +0 -325
- package/vendor/sdk/lib/app.tsx +0 -730
- package/vendor/sdk/lib/connection.tsx +0 -433
- package/vendor/sdk/lib/isolate.tsx +0 -70
- package/vendor/sdk/lib/orb/ThinkingOrb.tsx +0 -131
- package/vendor/sdk/lib/orb/theme.ts +0 -83
- package/vendor/sdk/lib/primitives.tsx +0 -943
- package/vendor/sdk/lib/realtime/useAudioCapture.ts +0 -237
- package/vendor/sdk/lib/realtime/useAudioPlayback.ts +0 -157
- package/vendor/sdk/lib/realtime/useRealtimeWebSocket.ts +0 -164
- package/vendor/sdk/lib/render.tsx +0 -244
- package/vendor/sdk/lib/streamed.tsx +0 -30
- package/vendor/sdk/lib/theme.ts +0 -55
- package/vendor/sdk/lib/transition/MountArrangement.tsx +0 -71
- package/vendor/sdk/lib/transition/index.ts +0 -9
- package/vendor/sdk/lib/transition/viewTransition.ts +0 -29
- package/vendor/sdk/lib/voice.tsx +0 -422
- package/vendor/sdk/main.tsx +0 -125
- package/vendor/sdk/pageInterfaces.tsx +0 -388
- package/vendor/sdk/responseView.tsx +0 -82
|
@@ -172,7 +172,12 @@ async function verifyJWT(token: string): Promise<AuthUser | null> {
|
|
|
172
172
|
const aud = payload.aud;
|
|
173
173
|
const clientId = (payload as Record<string, unknown>).client_id;
|
|
174
174
|
const values = [...(Array.isArray(aud) ? aud : aud ? [aud] : []), ...(clientId ? [clientId] : [])].map(String);
|
|
175
|
-
|
|
175
|
+
// A connector's token (ChatGPT, Claude) names OUR MCP ADDRESS as its audience, not our
|
|
176
|
+
// app's client: it registered its own client and asked the provider for a token for
|
|
177
|
+
// `<public url>/mcp/<org>` (RFC 8707). Only our configured public URL counts, so a token
|
|
178
|
+
// minted for anyone else's resource is refused as before. Approved by Gavin 2026-09-30.
|
|
179
|
+
const forOurMcp = Array.isArray(aud) ? aud.some(isOurMcpResource) : typeof aud === "string" && isOurMcpResource(aud);
|
|
180
|
+
if (!values.includes(expected) && !forOurMcp) {
|
|
176
181
|
console.error(
|
|
177
182
|
`[auth] JWT validation failed: audience mismatch (expected ${expected}, token had ${values.join(", ") || "none"})`,
|
|
178
183
|
);
|
|
@@ -187,6 +192,11 @@ async function verifyJWT(token: string): Promise<AuthUser | null> {
|
|
|
187
192
|
raw: payload,
|
|
188
193
|
};
|
|
189
194
|
} catch (error) {
|
|
195
|
+
// An expired token is routine (a held tab, a host replaying an old tool result): one line, no stack.
|
|
196
|
+
if ((error as { code?: string }).code === "ERR_JWT_EXPIRED") {
|
|
197
|
+
console.warn(`[auth] JWT expired (sub ${(error as { payload?: { sub?: string } }).payload?.sub ?? "?"})`);
|
|
198
|
+
return null;
|
|
199
|
+
}
|
|
190
200
|
console.error("[auth] JWT validation failed:", error);
|
|
191
201
|
return null;
|
|
192
202
|
}
|
|
@@ -265,9 +275,44 @@ function resourceBase(host: string): string {
|
|
|
265
275
|
return `${protocol}://${host}`;
|
|
266
276
|
}
|
|
267
277
|
|
|
268
|
-
/**
|
|
269
|
-
|
|
270
|
-
|
|
278
|
+
/**
|
|
279
|
+
* WAS THIS TOKEN ISSUED TO AN OUTSIDE CONNECTOR? (the MCP Action is the front door, 2026-09-30)
|
|
280
|
+
*
|
|
281
|
+
* A connector (ChatGPT, Claude) signs in through the provider and asks for a token FOR OUR MCP
|
|
282
|
+
* ADDRESS, so its `aud` is `<public url>/mcp` or `<public url>/mcp/<org>` (RFC 8707). Our own
|
|
283
|
+
* hosts (Canvas, Studio, the builder) sign in as our app and never carry that audience. The
|
|
284
|
+
* answer decides only WHICH TOOLS the session is offered: a connector gets the MCP Actions and
|
|
285
|
+
* nothing else, signed in or not. It grants nothing. Call it only on a token `authorize` has
|
|
286
|
+
* already accepted; the payload is read, not verified, here.
|
|
287
|
+
*/
|
|
288
|
+
export function isConnectorToken(token: string): boolean {
|
|
289
|
+
try {
|
|
290
|
+
const payload = JSON.parse(Buffer.from(token.split(".")[1] ?? "", "base64url").toString("utf8"));
|
|
291
|
+
const aud: unknown[] = Array.isArray(payload?.aud) ? payload.aud : payload?.aud ? [payload.aud] : [];
|
|
292
|
+
return aud.some(isOurMcpResource);
|
|
293
|
+
} catch {
|
|
294
|
+
return false;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** `<UNOVERSE_PUBLIC_URL>/mcp` or `<UNOVERSE_PUBLIC_URL>/mcp/<org>`: the resource a connector's token is for. */
|
|
299
|
+
function isOurMcpResource(value: unknown): boolean {
|
|
300
|
+
const base = process.env.UNOVERSE_PUBLIC_URL?.replace(/\/$/, "");
|
|
301
|
+
if (!base || typeof value !== "string") return false;
|
|
302
|
+
if (!value.startsWith(base)) return false;
|
|
303
|
+
return /^\/mcp(\/[A-Za-z0-9._-]+)?$/.test(value.slice(base.length));
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* The 401 challenge — points clients at the RFC 9728 protected-resource-metadata.
|
|
308
|
+
*
|
|
309
|
+
* `path` is the MCP door that was called (`/mcp/sab`). The challenge must name THAT door's
|
|
310
|
+
* metadata: a connector follows this header and sends the `resource` it finds there to the
|
|
311
|
+
* provider, which issues a token only for a resource indicator it knows. Pointing every door
|
|
312
|
+
* at the root document made ChatGPT ask for the bare origin and fail at the callback.
|
|
313
|
+
*/
|
|
314
|
+
export function unauthorizedResponse(host: string, path = ""): Response {
|
|
315
|
+
const resourceMetadataUrl = `${resourceBase(host)}/.well-known/oauth-protected-resource${path}`;
|
|
271
316
|
return new Response(
|
|
272
317
|
JSON.stringify({ jsonrpc: "2.0", error: { code: -32001, message: "Unauthorized" }, id: null }),
|
|
273
318
|
{
|
|
@@ -280,22 +325,36 @@ export function unauthorizedResponse(host: string): Response {
|
|
|
280
325
|
);
|
|
281
326
|
}
|
|
282
327
|
|
|
283
|
-
/**
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
328
|
+
/**
|
|
329
|
+
* OAuth Authorization Server Metadata: THE PROVIDER'S OWN DOCUMENT, passed through.
|
|
330
|
+
*
|
|
331
|
+
* This used to be written here, with Auth0's paths (`/authorize?audience=`, `/oauth/token`,
|
|
332
|
+
* `/oidc/register`) on whatever issuer was configured. On WorkOS AuthKit none of those exist
|
|
333
|
+
* (`/oauth2/authorize`, `/oauth2/token`, `/oauth2/register`), and ChatGPT, which reads this
|
|
334
|
+
* copy on our host, was sent to a token address that is not there: sign-in succeeded and the
|
|
335
|
+
* connector failed at the callback (2026-09-30). Cognito has no registration endpoint at all,
|
|
336
|
+
* yet this advertised one. The provider says where its endpoints are, so we repeat what it
|
|
337
|
+
* says and add nothing. RFC 8414 first, OIDC discovery second (Cognito publishes only that).
|
|
338
|
+
* Held for five minutes, so a change made in the provider's dashboard shows up without a
|
|
339
|
+
* restart. Null when the provider cannot be reached: the caller answers 502, never a guess.
|
|
340
|
+
*/
|
|
341
|
+
let asMetadata: { doc: object; at: number } | null = null;
|
|
342
|
+
export async function getOAuthAuthorizationServerMetadata(): Promise<object | null> {
|
|
343
|
+
const base = getIssuerBase();
|
|
344
|
+
if (!base) return null;
|
|
345
|
+
if (asMetadata && Date.now() - asMetadata.at < 5 * 60_000) return asMetadata.doc;
|
|
346
|
+
for (const path of ["/.well-known/oauth-authorization-server", "/.well-known/openid-configuration"]) {
|
|
347
|
+
try {
|
|
348
|
+
const res = await fetch(`${base}${path}`, { signal: AbortSignal.timeout(5000) });
|
|
349
|
+
if (!res.ok) continue;
|
|
350
|
+
const doc = (await res.json()) as object;
|
|
351
|
+
asMetadata = { doc, at: Date.now() };
|
|
352
|
+
return doc;
|
|
353
|
+
} catch {
|
|
354
|
+
// try the next discovery location
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
return null;
|
|
299
358
|
}
|
|
300
359
|
|
|
301
360
|
/**
|
|
@@ -331,10 +390,18 @@ export function getPublicAuthConfig(): object {
|
|
|
331
390
|
};
|
|
332
391
|
}
|
|
333
392
|
|
|
334
|
-
/**
|
|
335
|
-
|
|
393
|
+
/**
|
|
394
|
+
* OAuth 2.0 Protected Resource Metadata (RFC 9728); canonical URL preferred over Host.
|
|
395
|
+
*
|
|
396
|
+
* `path` is the resource the client asked about (RFC 9728 §3.1 inserts it after the
|
|
397
|
+
* well-known prefix: `/.well-known/oauth-protected-resource/mcp/sab` describes `/mcp/sab`).
|
|
398
|
+
* The client sends `resource` back to the provider as the resource indicator, and the
|
|
399
|
+
* provider only issues a token for an indicator it knows, so it must be the address the
|
|
400
|
+
* connector was given, not the bare origin.
|
|
401
|
+
*/
|
|
402
|
+
export function getOAuthProtectedResourceMetadata(host: string, path = ""): object {
|
|
336
403
|
return {
|
|
337
|
-
resource: resourceBase(host)
|
|
404
|
+
resource: `${resourceBase(host)}${path}`,
|
|
338
405
|
authorization_servers: [process.env.AUTH_ISSUER],
|
|
339
406
|
scopes_supported: ["openid", "profile", "email"],
|
|
340
407
|
bearer_methods_supported: ["header"],
|
|
@@ -46,3 +46,82 @@ export function expandTemplateSections(
|
|
|
46
46
|
for (const [k2, v] of Object.entries(n)) o[k2] = expandTemplateSections(v, templateName, out);
|
|
47
47
|
return o;
|
|
48
48
|
}
|
|
49
|
+
|
|
50
|
+
/** One component a template HOLDS: which component, and the path of item arrays it is
|
|
51
|
+
* drawn over (`["courses", "dishes"]` is one instance per dish of every course). */
|
|
52
|
+
export interface HeldSpec {
|
|
53
|
+
component: string;
|
|
54
|
+
items: string[];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** HOLD THE TEMPLATE'S OWN COMPONENTS (docs/design/templates.md "Holding a component"). A
|
|
58
|
+
* `Ref` to a component whose states name places, drawn inside an `Each` over the template's
|
|
59
|
+
* data, is not drawn flat: each item is its own instance of that component, keeping its own
|
|
60
|
+
* state, and the layout keeps a slot for it in its spot. `holds` resolves a ref to the
|
|
61
|
+
* component's name when it is one to hold, and to nothing otherwise, so this stays pure. */
|
|
62
|
+
export function expandHeldRefs(node: unknown, holds: (ref: string) => string | undefined, out: HeldSpec[], scope: string[] = []): unknown {
|
|
63
|
+
if (Array.isArray(node)) return node.map((c) => expandHeldRefs(c, holds, out, scope));
|
|
64
|
+
if (!node || typeof node !== "object") return node;
|
|
65
|
+
const n = node as Record<string, unknown>;
|
|
66
|
+
if (scope.length && n.type === "Ref" && typeof n.ref === "string" && !n.ref.startsWith(PART_PREFIX)) {
|
|
67
|
+
const component = holds(n.ref);
|
|
68
|
+
if (component) {
|
|
69
|
+
// One entry per component and path: a layout drawn in two branches holds it once.
|
|
70
|
+
if (!out.some((h) => h.component === component && h.items.join("/") === scope.join("/"))) out.push({ component, items: [...scope] });
|
|
71
|
+
const { type: _t, ref: _r, ...rest } = n;
|
|
72
|
+
return { type: "ComponentSlot", hold: { type: component, items: [...scope] }, ...rest };
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
// An `Each` over a bound field draws one item of it; an `Each` over literal `items` draws
|
|
76
|
+
// the author's own content, which holds nothing.
|
|
77
|
+
const itemsField = n.type === "Each" ? (n.bind as { items?: unknown } | undefined)?.items : undefined;
|
|
78
|
+
const itemScope = n.type !== "Each" ? scope : typeof itemsField === "string" && !Array.isArray(n.items) ? [...scope, itemsField] : [];
|
|
79
|
+
const o: Record<string, unknown> = {};
|
|
80
|
+
for (const [k, v] of Object.entries(n)) o[k] = expandHeldRefs(v, holds, out, k === "item" ? itemScope : scope);
|
|
81
|
+
return o;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
type Data = Record<string, unknown>;
|
|
85
|
+
|
|
86
|
+
/** A held instance's node id: its template's, the component, and the item's own id. The SDK
|
|
87
|
+
* builds the same id to find it (web/sdk core/apps.ts `heldPointer`), so this is the one place
|
|
88
|
+
* it is spelled on the server side. */
|
|
89
|
+
export function heldNodeId(host: string, component: string, key: string): string {
|
|
90
|
+
return `${host}:${component}:${key}`;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** An item's own id: the row's id when it carries one, else its position in its own list,
|
|
94
|
+
* which is the `index` the client's `Each` gives it. */
|
|
95
|
+
function keyOfItem(item: Data, index: number): string {
|
|
96
|
+
const id = item.universal_id ?? item.ref ?? item.id;
|
|
97
|
+
return typeof id === "string" || typeof id === "number" ? String(id) : String(index);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Every item a held spec is drawn over, walking its path of arrays (`courses` then `dishes`).
|
|
101
|
+
* Undefined when the data does not carry the path's first field: a partial update that leaves
|
|
102
|
+
* the held data alone holds nothing new. */
|
|
103
|
+
function itemsAt(data: Data, path: string[]): { item: Data; index: number }[] | undefined {
|
|
104
|
+
if (!(path[0] in data)) return undefined;
|
|
105
|
+
let level: { item: unknown; index: number }[] = [{ item: data, index: 0 }];
|
|
106
|
+
for (const field of path)
|
|
107
|
+
level = level.flatMap(({ item: o }) => {
|
|
108
|
+
const list = o && typeof o === "object" ? (o as Data)[field] : undefined;
|
|
109
|
+
return Array.isArray(list) ? list.map((item, index) => ({ item, index })) : [];
|
|
110
|
+
});
|
|
111
|
+
return level.filter((i): i is { item: Data; index: number } => !!i.item && typeof i.item === "object" && !Array.isArray(i.item));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** The instances a template holds, from data written to it: one per item of each held spec,
|
|
115
|
+
* each keyed by the item's own id and carrying the item's fields as its props. */
|
|
116
|
+
export function heldInstances(held: HeldSpec[], data: Data): { key: string; component: string; props: Data }[] {
|
|
117
|
+
const seen = new Set<string>();
|
|
118
|
+
const out: { key: string; component: string; props: Data }[] = [];
|
|
119
|
+
for (const spec of held)
|
|
120
|
+
itemsAt(data, spec.items)?.forEach(({ item, index }) => {
|
|
121
|
+
const key = keyOfItem(item, index);
|
|
122
|
+
if (seen.has(`${spec.component}:${key}`)) return;
|
|
123
|
+
seen.add(`${spec.component}:${key}`);
|
|
124
|
+
out.push({ key, component: spec.component, props: { ...item } });
|
|
125
|
+
});
|
|
126
|
+
return out;
|
|
127
|
+
}
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
import type { UnoverseDefinition } from "../../model.js";
|
|
9
9
|
import { loadDefinition } from "../load.js";
|
|
10
10
|
import { composeIncludes } from "../expand/index.js";
|
|
11
|
-
import { expandTemplateSections } from "./sections.js";
|
|
11
|
+
import { expandHeldRefs, expandTemplateSections, heldInstances, type HeldSpec } from "./sections.js";
|
|
12
12
|
import { placesOfStates, pointStepsAtStep, STEP } from "./component.js";
|
|
13
13
|
|
|
14
14
|
/**
|
|
@@ -105,19 +105,37 @@ export function synthesizeTemplateRoot(def: UnoverseDefinition, folderDir?: stri
|
|
|
105
105
|
}
|
|
106
106
|
const layouts: Record<string, unknown> = {};
|
|
107
107
|
const sections: NonNullable<UnoverseDefinition["sections"]> = {};
|
|
108
|
+
const held: Record<string, HeldSpec[]> = {};
|
|
109
|
+
// A component is HELD when its states name places: it can open somewhere of its own.
|
|
110
|
+
const holds = (ref: string): string | undefined => {
|
|
111
|
+
const c = loadDefinition(ref, "component", "expanded", def.org) as { statePlaces?: Record<string, string>; org?: string } | null;
|
|
112
|
+
if (!c?.statePlaces || !Object.keys(c.statePlaces).length) return undefined;
|
|
113
|
+
return ref.includes("/") || !c.org ? ref : `${c.org}/${ref}`;
|
|
114
|
+
};
|
|
108
115
|
const compiledPreview: Record<string, string[]> = {};
|
|
109
116
|
for (const name of shapeNames) {
|
|
110
117
|
const st = (states[name] ?? {}) as Record<string, unknown>;
|
|
111
118
|
if (typeof st.layout !== "string") continue;
|
|
112
119
|
const secs: NonNullable<UnoverseDefinition["sections"]>[string] = [];
|
|
113
120
|
const authored = composeIncludes({ $include: st.layout }, folderDir);
|
|
114
|
-
|
|
121
|
+
const heldHere: HeldSpec[] = [];
|
|
122
|
+
const holding = expandHeldRefs(authored, holds, heldHere);
|
|
123
|
+
layouts[name] = expandTemplateSections(steps.size ? pointStepsAtStep(holding, steps) : holding, def.name, secs);
|
|
115
124
|
sections[name] = secs;
|
|
125
|
+
if (heldHere.length) held[name] = heldHere;
|
|
116
126
|
// MOCK VALUES TRAVEL WITH THE SECTION — a preview host seeds instances from the wire.
|
|
117
127
|
seedSections(secs, def.org, compiledPreview);
|
|
118
128
|
}
|
|
119
129
|
if (!Object.keys(layouts).length) return;
|
|
120
130
|
def.layouts = layouts;
|
|
131
|
+
if (Object.keys(held).length) {
|
|
132
|
+
def.held = held;
|
|
133
|
+
// THE HELD INSTANCES' STAND-INS: one per item of the template's own preview data, so a
|
|
134
|
+
// preview host seeds them the way it seeds parts and the arrangement draws finished.
|
|
135
|
+
const props = (def.props ?? {}) as Record<string, { preview?: unknown; default?: unknown } | null>;
|
|
136
|
+
const mock = Object.fromEntries(Object.entries(props).map(([k, v]) => [k, v?.preview ?? v?.default]));
|
|
137
|
+
def.heldSeeds = Object.fromEntries(Object.entries(held).map(([layout, specs]) => [layout, heldInstances(specs, mock)]));
|
|
138
|
+
}
|
|
121
139
|
def.defaultLayout = shapeNames[0];
|
|
122
140
|
def.stateOrder = shapeNames.slice(1);
|
|
123
141
|
// THE FACE IT ARRIVES WEARING (2026-08-30). A template's states compiled into
|
|
@@ -140,10 +158,13 @@ export function synthesizeTemplateRoot(def: UnoverseDefinition, folderDir?: stri
|
|
|
140
158
|
const names = subs && typeof subs === "object" ? Object.keys(subs) : [];
|
|
141
159
|
// A state that takes the whole screen says so; the SDK takes the browser fullscreen for it.
|
|
142
160
|
const full = (states[n] as { fullscreen?: unknown } | null)?.fullscreen === true ? { fullscreen: true } : {};
|
|
161
|
+
// A state whose steps the arrow keys move says so; no other template answers the keys.
|
|
162
|
+
const arrows = (states[n] as { arrows?: unknown } | null)?.arrows === true ? { arrows: true } : {};
|
|
143
163
|
if (!names.length) return { name: n, ...full };
|
|
144
164
|
return {
|
|
145
165
|
name: n,
|
|
146
166
|
...full,
|
|
167
|
+
...arrows,
|
|
147
168
|
on: STEP,
|
|
148
169
|
initial: names[0],
|
|
149
170
|
states: names.map((sub) => ({ name: sub, ...(typeof subs![sub]?.layout === "string" ? { layout: subs![sub]!.layout as string } : {}) })),
|
|
@@ -38,6 +38,13 @@ export interface UnoverseDefinition {
|
|
|
38
38
|
seeds?: Record<string, Record<string, unknown>>;
|
|
39
39
|
}[]
|
|
40
40
|
>;
|
|
41
|
+
/** TEMPLATES: the components each state's layout HOLDS, one instance per item of the
|
|
42
|
+
* data it is drawn over (docs/design/templates.md "Holding a component"). The wire layout
|
|
43
|
+
* carries a ComponentSlot with `hold` in each one's spot. */
|
|
44
|
+
held?: Record<string, { component: string; items: string[] }[]>;
|
|
45
|
+
/** TEMPLATES: the held instances' preview stand-ins per state, from the template's own prop
|
|
46
|
+
* previews. Studio seeds them under its template instance; never used at runtime. */
|
|
47
|
+
heldSeeds?: Record<string, { key: string; component: string; props: Record<string, unknown> }[]>;
|
|
41
48
|
/** TEMPLATES: Studio preview mocks per slot — interface refs, each rendering its
|
|
42
49
|
* own defaults (the interface-model rule: mocks are the interface's OWN defaults,
|
|
43
50
|
* never authored preview data). From the template's manifest.yaml. */
|
|
@@ -155,6 +162,8 @@ export interface UnoverseDefinition {
|
|
|
155
162
|
states?: { name: string; layout?: string }[];
|
|
156
163
|
/** The state takes the whole screen (the authored `fullscreen: true`). */
|
|
157
164
|
fullscreen?: boolean;
|
|
165
|
+
/** The arrow keys step this state's steps (the authored `arrows: true`). */
|
|
166
|
+
arrows?: boolean;
|
|
158
167
|
}[];
|
|
159
168
|
}
|
|
160
169
|
|
|
@@ -163,13 +163,12 @@ export interface AppManifest {
|
|
|
163
163
|
* (`isPublicEntry`). The org URL picks WHICH SET a connector sees; this picks what is in
|
|
164
164
|
* that set at all. */
|
|
165
165
|
makeMCP?: boolean;
|
|
166
|
-
/**
|
|
167
|
-
*
|
|
168
|
-
*
|
|
169
|
-
* host builds its img-src from that list and blocks everything else
|
|
170
|
-
*
|
|
171
|
-
|
|
172
|
-
assetDomains?: string[];
|
|
166
|
+
/** The outbound hosts this folder may reach (lifecycle-hooks.md): a hook's server-side
|
|
167
|
+
* calls, and the pictures its content loads. The server UNIONS them across the
|
|
168
|
+
* connector's org into the MCP-app shell's CSP (`ui.csp.resourceDomains`), because a
|
|
169
|
+
* sandboxed host builds its img-src from that list and blocks everything else. Bare
|
|
170
|
+
* hosts, the node package grammar: "www.sab.com", "*.datocms-assets.com". */
|
|
171
|
+
allowedHosts?: string[];
|
|
173
172
|
/** Render lifetime (docs/design/04 §Two lifetimes). Default "turn": the instance returns
|
|
174
173
|
* to inline/retires on the next user turn (the universal reset). "conversation" = a
|
|
175
174
|
* DURABLE conversation-scoped surface (a cart, an itinerary, a composed page): keyed by
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import type { NodeDefinition } from "./types.js";
|
|
6
6
|
import { getNode } from "./registry.js";
|
|
7
7
|
import { buildExecutionContext } from "./platform/index.js";
|
|
8
|
+
import { recordCall } from "./manifests/runtime/tools/toolloop.js";
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
11
|
* Create a logger for a node type - outputs structured JSON for Loki/Promtail
|
|
@@ -251,7 +252,30 @@ export async function executeServiceCall(
|
|
|
251
252
|
}
|
|
252
253
|
|
|
253
254
|
const executionContext = buildExecutionContext(nodeType, {}, config, context);
|
|
254
|
-
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* THE METHOD RECORDS ITS OWN CALL (packages/docs/nodes/mcp-services.md, "Who records a call"),
|
|
258
|
+
* nested under the node that called it (`callingNodeId`; `nodeId` is this node's own by now),
|
|
259
|
+
* whoever that is, so the caller
|
|
260
|
+
* records nothing. Only a method the node offers as a tool: `getSchema` is discovery, not a
|
|
261
|
+
* call anybody chose. Taken from the caller's own context, never the fallbacks
|
|
262
|
+
* buildExecutionContext invents, so a call outside a run records nothing.
|
|
263
|
+
*/
|
|
264
|
+
const offered = (Array.isArray(nodeDef.serviceConnectors) ? nodeDef.serviceConnectors : []).some(
|
|
265
|
+
(c: any) => Array.isArray(c?.methods) && c.methods.includes(method),
|
|
266
|
+
);
|
|
267
|
+
const startTime = Date.now();
|
|
268
|
+
const record = (result: unknown, success: boolean) =>
|
|
269
|
+
offered &&
|
|
270
|
+
recordCall({ executionId: context?.executionId, parentNodeId: context?.callingNodeId, toolName: method, args: params, result, startTime, success, who: nodeType });
|
|
271
|
+
try {
|
|
272
|
+
const result = await executor.handleServiceCall(method, params, config, executionContext);
|
|
273
|
+
record(result, true);
|
|
274
|
+
return result;
|
|
275
|
+
} catch (err: any) {
|
|
276
|
+
record({ error: err?.message ?? String(err) }, false);
|
|
277
|
+
throw err;
|
|
278
|
+
}
|
|
255
279
|
}
|
|
256
280
|
|
|
257
281
|
/**
|
|
@@ -924,6 +924,15 @@ function componentNamesForFile(file) {
|
|
|
924
924
|
const org = projectSegs.join("/");
|
|
925
925
|
const cached = componentNamesCache.get(org);
|
|
926
926
|
if (cached !== undefined) return cached;
|
|
927
|
+
// A PARTIAL SET IS WORSE THAN NO SET (the app-sizes trap below, same cause). A developer's
|
|
928
|
+
// project has no design system on disk, so DS falls back to DESIGN_ROOT and only the org's
|
|
929
|
+
// own names would be left: every marketplace name a preview uses (`card`) then reads as
|
|
930
|
+
// unknown, and a fresh Studio workspace failed lint on its own sample. Unverifiable here,
|
|
931
|
+
// so not asserted.
|
|
932
|
+
if (DS === DESIGN_ROOT) {
|
|
933
|
+
componentNamesCache.set(org, null);
|
|
934
|
+
return null;
|
|
935
|
+
}
|
|
927
936
|
const dirs = [
|
|
928
937
|
join(DS, "components"),
|
|
929
938
|
join(DESIGN_ROOT, ...projectSegs, "components"),
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
*/
|
|
12
12
|
import type { ToolBridge } from "../runtime/index.js";
|
|
13
13
|
import { engineUrl } from "./context.js";
|
|
14
|
+
import { saveAvailableTools } from "../../platform/serviceCalls.js";
|
|
14
15
|
|
|
15
16
|
/** The screen's verbs on an app with places (docs/unoverse/design/screen/UNOVERSE_SCREEN.md §3.3). */
|
|
16
17
|
const SCREEN_VERBS = {
|
|
@@ -60,6 +61,13 @@ export async function toolBridgeFor(executionContext: any): Promise<ToolBridge |
|
|
|
60
61
|
chatId: pub?.chatId,
|
|
61
62
|
accessToken: executionContext?.auth?.accessToken,
|
|
62
63
|
};
|
|
64
|
+
/**
|
|
65
|
+
* THE CALLER, for a tool the MODEL calls: each records its own call on the trace under this
|
|
66
|
+
* run and this node (packages/docs/nodes/mcp-services.md, "Who records a call"). Kept apart
|
|
67
|
+
* from `identity`, which also travels on calls no model made.
|
|
68
|
+
*/
|
|
69
|
+
const caller = { executionId: executionContext?.executionId, nodeId: executionContext?.nodeId };
|
|
70
|
+
const called = { ...identity, trace: caller };
|
|
63
71
|
|
|
64
72
|
/**
|
|
65
73
|
* Discovered PATH-B component apps, by tool name.
|
|
@@ -83,11 +91,6 @@ export async function toolBridgeFor(executionContext: any): Promise<ToolBridge |
|
|
|
83
91
|
*/
|
|
84
92
|
const discoveredApps = new Set<string>();
|
|
85
93
|
|
|
86
|
-
/**
|
|
87
|
-
* TOOLS MINTED FROM A TASK ROW, by minted name. A call to one of these is a task step on
|
|
88
|
-
* the execution timeline, not a bare tool call; `kindOf` answers from this set.
|
|
89
|
-
*/
|
|
90
|
-
const taskTools = new Set<string>();
|
|
91
94
|
/**
|
|
92
95
|
* TOOLS WHOSE CALL HANDS THE TURN OFF: a workflow-bound app fired through the service
|
|
93
96
|
* lane (`workflow@trigger`, fire-and-forget). Only these can end the loop; a component
|
|
@@ -120,7 +123,11 @@ export async function toolBridgeFor(executionContext: any): Promise<ToolBridge |
|
|
|
120
123
|
appInvokers.set(name, async (input) => {
|
|
121
124
|
const { client, close } = await harness.connectPlatformMcp({ accessToken: identity.accessToken });
|
|
122
125
|
try {
|
|
123
|
-
const r = (await client.callTool({
|
|
126
|
+
const r = (await client.callTool({
|
|
127
|
+
name,
|
|
128
|
+
arguments: { ...((input ?? {}) as Record<string, unknown>), userId: identity.userId, conversationId: identity.conversationId, chatId: identity.chatId },
|
|
129
|
+
...(caller.executionId && caller.nodeId ? { _meta: { [harness.TRACE_META]: caller } } : {}),
|
|
130
|
+
})) as { content?: Array<{ type: string; text?: string }> };
|
|
124
131
|
return r.content?.find((c) => c.type === "text")?.text ?? "";
|
|
125
132
|
} finally {
|
|
126
133
|
await close();
|
|
@@ -150,7 +157,7 @@ export async function toolBridgeFor(executionContext: any): Promise<ToolBridge |
|
|
|
150
157
|
schema: { methods: {} },
|
|
151
158
|
};
|
|
152
159
|
const def = await harness.resolveToolDefNative(mcp, { accessToken: identity.accessToken });
|
|
153
|
-
appInvokers.set(def.name, harness.componentAppInvoker(mcp,
|
|
160
|
+
appInvokers.set(def.name, harness.componentAppInvoker(mcp, called));
|
|
154
161
|
discoveredApps.add(mcp.component);
|
|
155
162
|
preMinted.push({
|
|
156
163
|
...def,
|
|
@@ -171,7 +178,7 @@ export async function toolBridgeFor(executionContext: any): Promise<ToolBridge |
|
|
|
171
178
|
|
|
172
179
|
return {
|
|
173
180
|
async discover() {
|
|
174
|
-
|
|
181
|
+
const tools = [
|
|
175
182
|
...Object.entries<any>(schema.methods).map(([name, m]) => ({
|
|
176
183
|
name,
|
|
177
184
|
description: m.description || `Execute ${name}`,
|
|
@@ -179,6 +186,9 @@ export async function toolBridgeFor(executionContext: any): Promise<ToolBridge |
|
|
|
179
186
|
})),
|
|
180
187
|
...preMinted,
|
|
181
188
|
];
|
|
189
|
+
// Everything the model starts with, the screen and open pages included.
|
|
190
|
+
if (caller.executionId && caller.nodeId) saveAvailableTools(caller.executionId, caller.nodeId, tools.map((t) => t.name));
|
|
191
|
+
return tools;
|
|
182
192
|
},
|
|
183
193
|
|
|
184
194
|
async call(name, args) {
|
|
@@ -220,9 +230,8 @@ export async function toolBridgeFor(executionContext: any): Promise<ToolBridge |
|
|
|
220
230
|
// carry no schema copy. For a BRIEFED component that schema is the instruction
|
|
221
231
|
// channel, so the model hydrates real fields instead of following prose.
|
|
222
232
|
const def = await harness.resolveToolDefNative(mcp, { accessToken: identity.accessToken });
|
|
223
|
-
const invoke = harness.componentAppInvoker(mcp,
|
|
233
|
+
const invoke = harness.componentAppInvoker(mcp, called);
|
|
224
234
|
appInvokers.set(def.name, invoke);
|
|
225
|
-
if (mcp.task) taskTools.add(def.name);
|
|
226
235
|
|
|
227
236
|
/**
|
|
228
237
|
* A TASK OPENS WHEN THE LLM CALLS IT (UNOVERSE_RENDER_PIPELINE §4, locked
|
|
@@ -255,7 +264,7 @@ export async function toolBridgeFor(executionContext: any): Promise<ToolBridge |
|
|
|
255
264
|
const def = harness.toolDefFromDiscoveredMCP(mcp);
|
|
256
265
|
// A WORKFLOW TASK (2026-09-06) is called NATIVELY: the held call runs the Agent
|
|
257
266
|
// and returns its result. Registered by the minted name, like a component app.
|
|
258
|
-
if (harness.isWorkflowTask(mcp)) appInvokers.set(def.name, harness.workflowTaskInvoker(mcp,
|
|
267
|
+
if (harness.isWorkflowTask(mcp)) appInvokers.set(def.name, harness.workflowTaskInvoker(mcp, called));
|
|
259
268
|
else if (mcp.workflowId) {
|
|
260
269
|
// A WORKFLOW-BOUND APP (path A) OPENS, then ends the turn. It used to only end
|
|
261
270
|
// the turn: nothing registered an invoker, so the model's call fell through to
|
|
@@ -263,11 +272,10 @@ export async function toolBridgeFor(executionContext: any): Promise<ToolBridge |
|
|
|
263
272
|
// success and the screen stayed blank (SAB, 2026-09-07). Both, in this order —
|
|
264
273
|
// the app tool renders the surface and fires no workflow itself (server
|
|
265
274
|
// mcp/server/tools.ts), so the handoff that follows is not a second run.
|
|
266
|
-
appInvokers.set(def.name, harness.workflowAppInvoker(mcp,
|
|
275
|
+
appInvokers.set(def.name, harness.workflowAppInvoker(mcp, called));
|
|
267
276
|
handoffTools.add(def.name);
|
|
268
277
|
}
|
|
269
|
-
|
|
270
|
-
minted.push({ name: def.name, description: def.description, parameters: def.parameters, ...(def.outputSchema ? { outputSchema: def.outputSchema } : {}) });
|
|
278
|
+
minted.push({ name: def.name, description: def.description, parameters: def.parameters, ...(def.outputSchema ? { outputSchema: def.outputSchema } : {}) });
|
|
271
279
|
}
|
|
272
280
|
|
|
273
281
|
/**
|
|
@@ -280,7 +288,7 @@ export async function toolBridgeFor(executionContext: any): Promise<ToolBridge |
|
|
|
280
288
|
*/
|
|
281
289
|
for (const fn of harness.parseOfferedFunctions(toolName, resultContent)) {
|
|
282
290
|
const def = harness.objectFunctionToolDef(fn);
|
|
283
|
-
appInvokers.set(def.name, harness.objectFunctionInvoker(fn, engineUrl()));
|
|
291
|
+
appInvokers.set(def.name, harness.objectFunctionInvoker(fn, engineUrl(), caller));
|
|
284
292
|
minted.push({ name: def.name, description: def.description, parameters: def.parameters });
|
|
285
293
|
}
|
|
286
294
|
|
|
@@ -291,15 +299,13 @@ export async function toolBridgeFor(executionContext: any): Promise<ToolBridge |
|
|
|
291
299
|
*/
|
|
292
300
|
const skills = harness.parseRegionSkills(resultContent);
|
|
293
301
|
|
|
302
|
+
// The model's choice has grown: each call from here records the tools it really had.
|
|
303
|
+
if (minted.length && caller.executionId && caller.nodeId) saveAvailableTools(caller.executionId, caller.nodeId, minted.map((m) => m.name));
|
|
294
304
|
return { content, minted, skills };
|
|
295
305
|
},
|
|
296
306
|
|
|
297
307
|
endsTurn(calls) {
|
|
298
308
|
return harness.isTurnEndingHandoff(calls, handoffTools);
|
|
299
309
|
},
|
|
300
|
-
|
|
301
|
-
kindOf(toolName) {
|
|
302
|
-
return taskTools.has(toolName) ? "task" : "tool";
|
|
303
|
-
},
|
|
304
310
|
};
|
|
305
311
|
}
|
|
@@ -148,7 +148,10 @@ export async function runFinal(
|
|
|
148
148
|
const res = await sendRequest(node, call, ctx, node.type);
|
|
149
149
|
const transport = call.transport;
|
|
150
150
|
|
|
151
|
-
|
|
151
|
+
// Every SETTLED transport readSettled knows, not only json and text: a node may end on a
|
|
152
|
+
// file download (binary) or an xml body just as it may on a json one. binary as the final
|
|
153
|
+
// call threw here while working everywhere else (VeoVideo's download, 2026-09-29).
|
|
154
|
+
if (transport === "json" || transport === "text" || transport === "binary" || transport === "xml") {
|
|
152
155
|
const payload = await readSettled(res, transport, call.encoding);
|
|
153
156
|
await assertOk(node, call, payload, node.type);
|
|
154
157
|
// Token usage: a settled vendor body carries it inline (Chat Completions puts it
|
|
@@ -68,7 +68,7 @@ const IDLE_TIMEOUT_MS = 30_000;
|
|
|
68
68
|
const SILENCE_TIMEOUT_MS = 60_000;
|
|
69
69
|
const MAX_SESSION_MS = 30 * 60_000;
|
|
70
70
|
|
|
71
|
-
import { offerDiscovered, parseMaybeJson,
|
|
71
|
+
import { offerDiscovered, parseMaybeJson, type ToolBridge } from "../tools/toolloop.js";
|
|
72
72
|
import { applyInForce, renderSkillsText, type InForceSkill } from "../tools/regionSkills.js";
|
|
73
73
|
import { registerLiveInput, liveInputKey } from "../../../platform/liveInput.js";
|
|
74
74
|
|
|
@@ -750,10 +750,6 @@ export async function runDuplexSession(opts: {
|
|
|
750
750
|
// A lookup takes 8-9s, and silence on a call reads as a dropped line. The client,
|
|
751
751
|
// the lookup-indicator and the docs all had this wired already; nothing ever sent it.
|
|
752
752
|
sendState("tool", "TOOL_USE", { toolName: name });
|
|
753
|
-
// Started BEFORE the call, so the timeline bar measures the wait the caller
|
|
754
|
-
// actually sat through, not the bookkeeping after it.
|
|
755
|
-
const toolStart = Date.now();
|
|
756
|
-
let toolOk = true;
|
|
757
753
|
try {
|
|
758
754
|
const args = JSON.parse(rawArgs || "{}");
|
|
759
755
|
/**
|
|
@@ -825,12 +821,10 @@ export async function runDuplexSession(opts: {
|
|
|
825
821
|
} catch (err: any) {
|
|
826
822
|
// Reported TO THE MODEL rather than thrown. A failed tool on a live call should let
|
|
827
823
|
// the assistant say it could not do the thing, not drop the conversation.
|
|
828
|
-
toolOk = false;
|
|
829
824
|
output = `Tool "${name}" failed: ${err?.message ?? err}`;
|
|
830
825
|
}
|
|
831
|
-
// The
|
|
832
|
-
//
|
|
833
|
-
recordToolTrace(node, ctx, name, parseMaybeJson(rawArgs), output, toolStart, toolOk, tools.kindOf?.(name) ?? "tool");
|
|
826
|
+
// The timeline row is the tool's own (packages/docs/nodes/mcp-services.md, "Who records
|
|
827
|
+
// a call"), so a voice Agent's calls read the same as a text Agent's.
|
|
834
828
|
// After the catch, so a tool that THREW still stops the dots. Spinning forever is
|
|
835
829
|
// worse than no indicator: it promises something still coming.
|
|
836
830
|
sendState("tool", "TOOL_USE_COMPLETED");
|