@unoverse-platform/studio 0.1.97 → 0.1.98
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/bin/studio.mjs +2 -2
- package/local/catalog.ts +5 -1
- package/local/nodes.ts +28 -41
- package/local/plugin.ts +71 -13
- package/local/sample-identity/brand.yaml +24 -0
- package/local/sample-identity/organisation.yaml +14 -0
- package/local/sample-identity/purpose.yaml +14 -0
- package/local/sample-identity/story.yaml +18 -0
- package/local/scaffold.mjs +43 -23
- package/local/source.ts +3 -8
- package/package.json +1 -1
- package/screens/AgentsView.tsx +173 -0
- package/screens/ComponentsView.tsx +2 -2
- package/screens/EmptyLibrary.tsx +1 -1
- package/screens/IdentityView.tsx +3 -3
- package/screens/PromptBlocksView.tsx +1 -1
- package/screens/TemplatesLibraryView.tsx +91 -6
- package/screens/TemplatesView.tsx +24 -9
- package/screens/index.ts +2 -0
- package/src/App.tsx +8 -3
- package/src/host.ts +1 -14
- package/vendor/base/src/agent-mcp/discovery.ts +76 -18
- package/vendor/base/src/agent-mcp/handoff.ts +11 -31
- package/vendor/base/src/agent-mcp/index.ts +5 -5
- package/vendor/base/src/agent-mcp/invoke.ts +37 -19
- package/vendor/base/src/agent-mcp/lean.ts +19 -0
- package/vendor/base/src/agent-mcp/memoryContext.ts +15 -9
- package/vendor/base/src/agent-mcp/memoryIngest.ts +8 -3
- package/vendor/base/src/agent-mcp/rowComponents.ts +184 -72
- package/vendor/base/src/agent-mcp/tools.ts +136 -7
- package/vendor/base/src/agent-mcp/types.ts +47 -10
- package/vendor/base/src/definitions/load/compile/component.ts +59 -4
- package/vendor/base/src/definitions/load/compile/sections.ts +7 -1
- package/vendor/base/src/definitions/load/compile/template.ts +19 -15
- package/vendor/base/src/definitions/load/expand/inherit.ts +5 -1
- package/vendor/base/src/definitions/load/expand/referenced.ts +21 -2
- package/vendor/base/src/definitions/load/expand/refs.ts +14 -0
- package/vendor/base/src/definitions/load/expand/walk.ts +88 -38
- package/vendor/base/src/definitions/load/load.ts +37 -28
- package/vendor/base/src/definitions/model.ts +8 -12
- package/vendor/base/src/definitions/registry/agents.ts +111 -0
- package/vendor/base/src/definitions/registry/catalog.ts +28 -12
- package/vendor/base/src/definitions/registry/datasetSql.ts +149 -0
- package/vendor/base/src/definitions/registry/homes.ts +69 -5
- package/vendor/base/src/definitions/registry/objects.ts +413 -0
- package/vendor/base/src/definitions/registry/orgDocuments.ts +69 -29
- package/vendor/base/src/definitions/registry/orgPack.ts +11 -11
- package/vendor/base/src/definitions/registry/pipelines.ts +197 -0
- package/vendor/base/src/definitions/theme.ts +1 -2
- package/vendor/base/src/items/collect.ts +29 -25
- package/vendor/base/src/items/publish.ts +30 -6
- package/vendor/base/src/lint/context.mjs +17 -0
- package/vendor/base/src/lint/design/file.mjs +199 -2
- package/vendor/base/src/lint/design/index.mjs +66 -73
- package/vendor/base/src/lint/design/vocabulary.mjs +5 -2
- package/vendor/base/src/lint/design/walk.mjs +14 -4
- package/vendor/base/src/lint/index.mjs +15 -9
- package/vendor/base/src/lint/nodes/blocks.mjs +6 -2
- package/vendor/base/src/lint/nodes/index.mjs +5 -0
- package/vendor/base/src/lint/nodes/node.mjs +157 -7
- package/vendor/base/src/lint/objects/index.d.mts +32 -0
- package/vendor/base/src/lint/objects/index.mjs +324 -0
- package/vendor/base/src/lint/objects/rules.d.mts +44 -0
- package/vendor/base/src/lint/objects/rules.mjs +446 -0
- package/vendor/base/src/lint/prompts/index.d.mts +34 -0
- package/vendor/base/src/lint/prompts/index.mjs +198 -0
- package/vendor/base/src/manifests/compose.ts +80 -9
- package/vendor/base/src/manifests/executor/callback.ts +15 -4
- package/vendor/base/src/manifests/executor/context.ts +9 -1
- package/vendor/base/src/manifests/executor/memory.ts +83 -3
- package/vendor/base/src/manifests/executor/session.ts +50 -0
- package/vendor/base/src/manifests/executor/toolBridge.ts +114 -49
- package/vendor/base/src/manifests/runtime/auth/aws.ts +14 -1
- package/vendor/base/src/manifests/runtime/channels/final.ts +46 -34
- package/vendor/base/src/manifests/runtime/channels/service.ts +30 -32
- package/vendor/base/src/manifests/runtime/channels/workflow.ts +6 -11
- package/vendor/base/src/manifests/runtime/context.ts +18 -6
- package/vendor/base/src/manifests/runtime/duplex/audioLane.ts +159 -3
- package/vendor/base/src/manifests/runtime/duplex/session.ts +304 -9
- package/vendor/base/src/manifests/runtime/events.ts +39 -3
- package/vendor/base/src/manifests/runtime/http/concurrency.ts +44 -0
- package/vendor/base/src/manifests/runtime/http/request.ts +36 -7
- package/vendor/base/src/manifests/runtime/index.ts +3 -2
- package/vendor/base/src/manifests/runtime/loops/repeat.ts +86 -0
- package/vendor/base/src/manifests/runtime/tools/toolloop.ts +30 -3
- package/vendor/base/src/manifests/runtime/usage.ts +10 -0
- package/vendor/base/src/paths.ts +16 -8
- package/vendor/base/src/platform/audioManager.ts +32 -0
- package/vendor/base/src/platform/executionContext.ts +14 -0
- package/vendor/base/src/platform/liveInput.ts +52 -0
- package/vendor/base/src/plugins/discovery.ts +1 -1
- package/vendor/base/src/template/SafeExpression.ts +6 -0
- package/vendor/base/src/workflow/GetMemoryService.ts +12 -11
- package/vendor/base/src/workflow/PlatformMemoryCore.ts +23 -10
- package/vendor/sdk/appEngine.tsx +30 -11
- package/vendor/sdk/lib/UnoverseComponent.tsx +8 -4
- package/vendor/sdk/lib/app.tsx +70 -95
- package/vendor/sdk/lib/connection.tsx +47 -57
- package/vendor/sdk/lib/core/actions.ts +6 -0
- package/vendor/sdk/lib/core/apps.ts +66 -81
- package/vendor/sdk/lib/core/client.ts +5 -14
- package/vendor/sdk/lib/core/connection.ts +30 -0
- package/vendor/sdk/lib/core/index.ts +2 -2
- package/vendor/sdk/lib/core/store.ts +90 -15
- package/vendor/sdk/lib/core/types.ts +9 -8
- package/vendor/sdk/lib/primitives.tsx +113 -4
- package/vendor/sdk/lib/realtime/useAudioPlayback.ts +8 -1
- package/vendor/sdk/lib/render.tsx +3 -3
- package/vendor/sdk/lib/voice.tsx +27 -13
- package/local/sample-identity/brand/brand.yaml +0 -88
- package/local/sample-identity/brand/manifest.yaml +0 -13
- package/local/sample-identity/organisation/manifest.yaml +0 -15
- package/local/sample-identity/organisation/organisation.yaml +0 -53
- package/local/sample-identity/purpose/manifest.yaml +0 -12
- package/local/sample-identity/purpose/purpose.yaml +0 -46
- package/local/sample-identity/story/manifest.yaml +0 -12
- package/local/sample-identity/story/story.yaml +0 -63
- package/vendor/base/src/definitions/registry/identitySeed.ts +0 -17
|
@@ -40,6 +40,9 @@ export interface UnoverseSessionParams {
|
|
|
40
40
|
conversationId: string;
|
|
41
41
|
/** Groups one request→response turn. Defaults to conversationId if omitted. */
|
|
42
42
|
chatId?: string;
|
|
43
|
+
/** The app this session shows (`<org>/<name>`). The server draws an app that declares places
|
|
44
|
+
* from its screen document (docs/unoverse/design/screen/UNOVERSE_SCREEN.md). */
|
|
45
|
+
appId?: string;
|
|
43
46
|
}
|
|
44
47
|
|
|
45
48
|
export interface UnoverseConnection {
|
|
@@ -63,7 +66,7 @@ export interface UnoverseConnection {
|
|
|
63
66
|
/** Answer a pending ELICITATION with the submitted component state (the native
|
|
64
67
|
* collection path — §3.3). Returns false when no elicitation is pending (the
|
|
65
68
|
* channel then falls back to `sendAction`). */
|
|
66
|
-
resolveElicitation: (state: Record<string, unknown
|
|
69
|
+
resolveElicitation: (state: Record<string, unknown>, action?: "accept" | "cancel") => boolean;
|
|
67
70
|
/** The workflow-selected template (MCP app) name, or null. The channel renders
|
|
68
71
|
* `unoverse://apps/{activeApp}/definition` when set — the official resources/read load. */
|
|
69
72
|
activeApp: string | null;
|
|
@@ -83,7 +86,7 @@ const UNOVERSE_EVENT = "notifications/unoverse/event";
|
|
|
83
86
|
* one visitor); a host with a token is untouched.
|
|
84
87
|
*/
|
|
85
88
|
const GUEST_KEY = "unoverse:guestId";
|
|
86
|
-
function resolveGuestId(): string {
|
|
89
|
+
export function resolveGuestId(): string {
|
|
87
90
|
let id: string | null = null;
|
|
88
91
|
try {
|
|
89
92
|
id = localStorage.getItem(GUEST_KEY);
|
|
@@ -101,19 +104,38 @@ function resolveGuestId(): string {
|
|
|
101
104
|
return id;
|
|
102
105
|
}
|
|
103
106
|
|
|
107
|
+
/**
|
|
108
|
+
* THE ONE IDENTITY RULE for every lane the SDK opens: with a token the host's userId passes
|
|
109
|
+
* through; without one the platform sees a guest and requires a guest-prefixed id, so a
|
|
110
|
+
* host-supplied guest id wins and anything else is replaced by the stored guest id. The data
|
|
111
|
+
* plane and the audio lane both call this, so a page never speaks with two names.
|
|
112
|
+
*/
|
|
113
|
+
export function sessionUserId(userId: string | undefined, getAccessToken: unknown): string {
|
|
114
|
+
return getAccessToken || /^guest[-:]/.test(userId ?? "") ? (userId as string) : resolveGuestId();
|
|
115
|
+
}
|
|
116
|
+
|
|
104
117
|
export function useUnoverseConnection(
|
|
105
118
|
config: UnoverseConnectionConfig,
|
|
106
119
|
session: UnoverseSessionParams,
|
|
107
120
|
store: ComponentStore,
|
|
108
|
-
options: { enabled?: boolean } = {},
|
|
121
|
+
options: { enabled?: boolean; onAppSwitch?: (app: string) => void } = {},
|
|
109
122
|
): UnoverseConnection {
|
|
110
123
|
const enabled = options.enabled ?? true;
|
|
124
|
+
// Read through a ref so a host's inline arrow does not tear down the stream on
|
|
125
|
+
// every render — the connection effect must not depend on the callback's identity.
|
|
126
|
+
const onAppSwitchRef = useRef(options.onAppSwitch);
|
|
127
|
+
onAppSwitchRef.current = options.onAppSwitch;
|
|
128
|
+
const onAppSwitch = (app: string) => onAppSwitchRef.current?.(app);
|
|
129
|
+
// Read through a ref too: the server follows app switches itself, so a new app must not
|
|
130
|
+
// tear the stream down; the next registration simply names it.
|
|
131
|
+
const appIdRef = useRef(session.appId);
|
|
132
|
+
appIdRef.current = session.appId;
|
|
111
133
|
const { streamUrl, getAccessToken } = config;
|
|
112
134
|
const { workflowId, targetTriggerNode, conversationId } = session;
|
|
113
135
|
// No token ⇒ the platform sees a GUEST session and requires a guest-prefixed id —
|
|
114
136
|
// supply one automatically (host-supplied guest ids win; with a token the host's
|
|
115
137
|
// real userId passes through untouched). This is the whole guest story for a host.
|
|
116
|
-
const userId =
|
|
138
|
+
const userId = sessionUserId(session.userId, getAccessToken);
|
|
117
139
|
const chatId = session.chatId ?? conversationId;
|
|
118
140
|
|
|
119
141
|
const [isConnected, setIsConnected] = useState(false);
|
|
@@ -125,7 +147,7 @@ export function useUnoverseConnection(
|
|
|
125
147
|
// The pending elicitation's resolver (one at a time per session) — set while the
|
|
126
148
|
// server is asking this session for structured input; the channel's submit branch
|
|
127
149
|
// answers it via `resolveElicitation`.
|
|
128
|
-
const pendingElicitRef = useRef<((state: Record<string, unknown
|
|
150
|
+
const pendingElicitRef = useRef<((state: Record<string, unknown>, action: "accept" | "cancel") => void) | null>(null);
|
|
129
151
|
|
|
130
152
|
// ---- connection lifecycle (MCP stream) ----
|
|
131
153
|
useEffect(() => {
|
|
@@ -187,8 +209,9 @@ export function useUnoverseConnection(
|
|
|
187
209
|
const requested =
|
|
188
210
|
(req.params as unknown as { requestedSchema?: { properties?: Record<string, unknown> } }).requestedSchema?.properties ?? {};
|
|
189
211
|
return await new Promise((resolve) => {
|
|
190
|
-
pendingElicitRef.current = (state: Record<string, unknown
|
|
212
|
+
pendingElicitRef.current = (state: Record<string, unknown>, action: "accept" | "cancel") => {
|
|
191
213
|
pendingElicitRef.current = null;
|
|
214
|
+
if (action === "cancel") return resolve({ action: "cancel" } as never);
|
|
192
215
|
const content: Record<string, unknown> = {};
|
|
193
216
|
for (const k of Object.keys(requested)) if (state[k] !== undefined) content[k] = state[k];
|
|
194
217
|
resolve({ action: "accept", content } as never);
|
|
@@ -234,6 +257,16 @@ export function useUnoverseConnection(
|
|
|
234
257
|
if (typeof evt.event === "string" && evt.event) emitAnalytics(evt.event, evt.params ?? {});
|
|
235
258
|
return;
|
|
236
259
|
}
|
|
260
|
+
// AN APP SWAP IS THE HOST'S CALL, not the renderer's. Handed straight out, like
|
|
261
|
+
// ANALYTICS_EVENT above and for the same reason: it does not touch the store —
|
|
262
|
+
// the conversation and everything already on screen survive a swap, which is the
|
|
263
|
+
// whole point of swapping rather than starting again. The host re-points its one
|
|
264
|
+
// engine (appEngine.tsx); a host that ignores this simply stays where it is.
|
|
265
|
+
if (evt.type === "APP_SWITCH") {
|
|
266
|
+
const next = (msg as unknown as { app?: unknown }).app;
|
|
267
|
+
if (typeof next === "string" && next) onAppSwitch?.(next);
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
237
270
|
applyServerMessage(store, msg, seenRef.current);
|
|
238
271
|
};
|
|
239
272
|
|
|
@@ -264,7 +297,7 @@ export function useUnoverseConnection(
|
|
|
264
297
|
// stream and an immediate SESSION_READY.
|
|
265
298
|
const bind = await client.callTool({
|
|
266
299
|
name: "register_session",
|
|
267
|
-
arguments: { userId, conversationId, workflowId, targetTriggerNode, chatId },
|
|
300
|
+
arguments: { userId, conversationId, workflowId, targetTriggerNode, chatId, ...(appIdRef.current ? { appId: appIdRef.current } : {}) },
|
|
268
301
|
});
|
|
269
302
|
// A refusal (public-entry gate, requires.role, …) is an isError tool RESULT,
|
|
270
303
|
// not a thrown error — surface it and STOP, never retry: the server has
|
|
@@ -344,60 +377,17 @@ export function useUnoverseConnection(
|
|
|
344
377
|
(text: string) => {
|
|
345
378
|
const message = text.trim();
|
|
346
379
|
if (!message) return;
|
|
347
|
-
/**
|
|
348
|
-
* THE LATCH RIDES THE ORDINARY SEND (docs/MCP_COMPLETE_GUIDE.md §The Component Latch).
|
|
349
|
-
*
|
|
350
|
-
* Handled HERE rather than in a host, because both hosts call this one function and
|
|
351
|
-
* two hosts each deciding "what happens on send" is how the composer regression
|
|
352
|
-
* happened.
|
|
353
|
-
*
|
|
354
|
-
* READ, NOT RE-DERIVED. `app.tsx` runs `resolveLatch` every render and publishes
|
|
355
|
-
* `latched`/`latchKey` into app state for the composer's pill; this reads that same
|
|
356
|
-
* published fact. Deriving it again here is what left the send inert for two weeks:
|
|
357
|
-
* `resolveLatch` needs the app DEFINITION and this file holds a raw MCP `Client` with
|
|
358
|
-
* no definition cache, so the second derivation could never see `def.latches` and
|
|
359
|
-
* silently answered "not latched" on every send.
|
|
360
|
-
*/
|
|
361
|
-
const app = store.getAppState() as Record<string, unknown>;
|
|
362
|
-
const held =
|
|
363
|
-
app.latched === true && typeof app.latchKey === "string" && app.latchKey ? store.split(app.latchKey) : null;
|
|
364
|
-
/**
|
|
365
|
-
* THE RUN REUSES THE LATCHED TURN; the guest's message does not.
|
|
366
|
-
*
|
|
367
|
-
* The user turn is minted normally either way — the guest said something and it
|
|
368
|
-
* belongs in the timeline where they said it. What the latch changes is where the
|
|
369
|
-
* ANSWER opens: `startResponse` and the trigger both take the held component's own
|
|
370
|
-
* chatId, so every render in that run keys `chatId:nodeId` to what is already on
|
|
371
|
-
* screen, and a repeated COMPONENT_INIT on a seen key is already a props merge
|
|
372
|
-
* (applyServerMessage). Hydrate in place, by construction, with no new return path.
|
|
373
|
-
*/
|
|
374
380
|
const turn = mintTurn();
|
|
375
381
|
store.addUserMessage(turn, message); // optimistic user turn
|
|
376
|
-
|
|
382
|
+
// The answer opens under the message that asked for it. What is on screen is the
|
|
383
|
+
// SERVER's fact (the standing set, the open pages); the send carries nothing about it.
|
|
384
|
+
const run = turn;
|
|
377
385
|
// Optimistically open the assistant turn too, so the avatar + thinking dots
|
|
378
386
|
// appear IMMEDIATELY on send instead of waiting for the backend's
|
|
379
387
|
// WORKFLOW_STARTED. startResponse is idempotent — the later WORKFLOW_STARTED
|
|
380
|
-
// (and the first COMPONENT_INIT) reuse this same turn (responseFor by chatId)
|
|
381
|
-
// while latched that turn is the page's own, which is exactly the point.
|
|
388
|
+
// (and the first COMPONENT_INIT) reuse this same turn (responseFor by chatId).
|
|
382
389
|
store.startResponse(run);
|
|
383
|
-
trigger(
|
|
384
|
-
held
|
|
385
|
-
? {
|
|
386
|
-
message,
|
|
387
|
-
// `state` is the component's live slice, so the workflow is told what the page
|
|
388
|
-
// currently holds without a round trip, and it stays right after local
|
|
389
|
-
// `setValue` writes the server never saw. Sent as one `latch` object so an
|
|
390
|
-
// unlatched send is byte-for-byte what it was before.
|
|
391
|
-
latch: {
|
|
392
|
-
chatId: held.chatId,
|
|
393
|
-
nodeId: held.nodeId,
|
|
394
|
-
view: viewOf(store.get(held.chatId, held.nodeId) as Record<string, unknown>) ?? "",
|
|
395
|
-
state: store.get(held.chatId, held.nodeId) ?? {},
|
|
396
|
-
},
|
|
397
|
-
}
|
|
398
|
-
: { message },
|
|
399
|
-
run,
|
|
400
|
-
);
|
|
390
|
+
trigger({ message }, run);
|
|
401
391
|
},
|
|
402
392
|
[mintTurn, store, trigger],
|
|
403
393
|
);
|
|
@@ -421,10 +411,10 @@ export function useUnoverseConnection(
|
|
|
421
411
|
);
|
|
422
412
|
|
|
423
413
|
// Answer a pending elicitation with the submitted component state (native path).
|
|
424
|
-
const resolveElicitation = useCallback((state: Record<string, unknown
|
|
414
|
+
const resolveElicitation = useCallback((state: Record<string, unknown>, action: "accept" | "cancel" = "accept"): boolean => {
|
|
425
415
|
const pending = pendingElicitRef.current;
|
|
426
416
|
if (!pending) return false;
|
|
427
|
-
pending(state);
|
|
417
|
+
pending(state, action);
|
|
428
418
|
return true;
|
|
429
419
|
}, []);
|
|
430
420
|
|
|
@@ -72,6 +72,12 @@ export function dispatchAction(
|
|
|
72
72
|
if (ctx.nodeId && ("state" in patch || "view" in patch || "defaultState" in patch)) {
|
|
73
73
|
const entered = patch.state ?? patch.view ?? patch.defaultState;
|
|
74
74
|
ctx.sendToServer?.("viewEntered", { layout: entered, view: entered });
|
|
75
|
+
} else if (ctx.nodeId && "step" in patch) {
|
|
76
|
+
// A CONFIRMED STEP IS NEWS TOO (SIGNAL.md § 6b): a form's Continue writes the
|
|
77
|
+
// compiler's hidden step key (`STEP` in packages/base compile/component.ts; the
|
|
78
|
+
// author wrote `state`), so the face never changes and no view is entered. Reported
|
|
79
|
+
// as its own message: the server saves the slice and runs no face lifecycle.
|
|
80
|
+
ctx.sendToServer?.("stepEntered", { step: patch.step });
|
|
75
81
|
}
|
|
76
82
|
} else if (spec.type === "setAppValue") {
|
|
77
83
|
// Write the dev's chosen keys into TEMPLATE state — the template-state twin of setValue.
|
|
@@ -48,6 +48,24 @@ export function orderedClaims(def: UnoverseDefinition | null | undefined): strin
|
|
|
48
48
|
* it can never emit invalid CSS (or an invalid native constraint). */
|
|
49
49
|
export const cssWidth = (w: unknown): string | undefined => (typeof w === "string" && /\d/.test(w) ? w : undefined);
|
|
50
50
|
|
|
51
|
+
/**
|
|
52
|
+
* THE ONE WIDTH LAW FOR A PLACE (docs/design/apps, Width). A panel, a slot frame or a
|
|
53
|
+
* mounted template declares `appWidth` once. A NAMED size resolves via theme.appSize and
|
|
54
|
+
* raw CSS passes through: the place is that width, always (`flex: 0 0 auto`). `flex` is
|
|
55
|
+
* the SDK's own word, never a token: "fill the space remaining beside whatever core is
|
|
56
|
+
* open" (`flex: 1 1 0%`). Anything else declares nothing and the place's own style decides.
|
|
57
|
+
*
|
|
58
|
+
* ONE function for every caller, because the slot frame once had its own copy without the
|
|
59
|
+
* `flex` word: beside a full-width app it rendered the task surface in a zero-width frame
|
|
60
|
+
* the moment the org's `flex` token went (SAB's comparison, blank, 2026-09-07).
|
|
61
|
+
*/
|
|
62
|
+
export function placeWidthStyle(declared: unknown, appSize?: Record<string, string>): Record<string, unknown> | null {
|
|
63
|
+
const width = cssWidth(typeof declared === "string" ? (appSize?.[declared] ?? declared) : undefined);
|
|
64
|
+
if (width) return { width, flex: "0 0 auto" };
|
|
65
|
+
if (declared === "flex") return { flex: "1 1 0%", minWidth: 0 };
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
|
|
51
69
|
/** The author's declared prop defaults (def.props[k].default) → a flat data object. Content, not policy. */
|
|
52
70
|
export function propDefaults(props?: Record<string, unknown>): Record<string, unknown> {
|
|
53
71
|
const out: Record<string, unknown> = {};
|
|
@@ -142,6 +160,16 @@ export function computeAppWidth(root: unknown, store: ComponentStore, appSize?:
|
|
|
142
160
|
if (piece) walk(piece);
|
|
143
161
|
return;
|
|
144
162
|
}
|
|
163
|
+
// A PLACE HOLDING SOMETHING sizes the app like an open panel (UNOVERSE_SCREEN.md §2.2).
|
|
164
|
+
if (n.type === "Place") {
|
|
165
|
+
const name = (n as { name?: unknown }).name;
|
|
166
|
+
if (typeof name === "string" && store.placeItems(name).length) {
|
|
167
|
+
const w = rw(n.appWidth);
|
|
168
|
+
if (w) widths.push(w);
|
|
169
|
+
else if (n.appWidth === "flex") flexOpen = true;
|
|
170
|
+
}
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
145
173
|
if (n !== r) {
|
|
146
174
|
if (n.type === "ComponentSlot") {
|
|
147
175
|
const claim = n.select?.where;
|
|
@@ -195,6 +223,22 @@ export function resolveActiveLayout(def: UnoverseDefinition | null | undefined,
|
|
|
195
223
|
return layouts[view] ?? layouts[def.defaultLayout ?? "main"] ?? def.root;
|
|
196
224
|
}
|
|
197
225
|
|
|
226
|
+
/**
|
|
227
|
+
* MOUNT CLAIMS AS ALIASES: a state that mounts a template claims the template's own states
|
|
228
|
+
* (loader-stamped `claims`, UNOVERSE_TEMPLATE_MODEL §12). A face in a claimed state IS a
|
|
229
|
+
* face in the mount state — `grid` under `page` is `page` — for the ladder walk and for
|
|
230
|
+
* rule 4's cancellation alike (the two read faces the same way, or a card wearing `grid`
|
|
231
|
+
* survives a higher state that should have cleared it; observed live 2026-09-06).
|
|
232
|
+
*/
|
|
233
|
+
export function mountAliases(def: UnoverseDefinition | null | undefined, valid: Set<string>): Map<string, string> {
|
|
234
|
+
const aliasOf = new Map<string, string>();
|
|
235
|
+
for (const [sname, entry] of Object.entries((def?.templates ?? {}) as Record<string, { template?: string; claims?: string[] }>)) {
|
|
236
|
+
if (!entry?.template || !valid.has(sname)) continue;
|
|
237
|
+
for (const c of entry.claims ?? []) aliasOf.set(c, sname);
|
|
238
|
+
}
|
|
239
|
+
return aliasOf;
|
|
240
|
+
}
|
|
241
|
+
|
|
198
242
|
/**
|
|
199
243
|
* THE TEMPLATE'S ACTIVE STATE — the ONE derivation every consumer shares.
|
|
200
244
|
*
|
|
@@ -222,11 +266,7 @@ export function resolveActiveView(
|
|
|
222
266
|
// `claims`, UNOVERSE_TEMPLATE_MODEL §12): an instance arriving in any of them is a
|
|
223
267
|
// match FOR THE MOUNT STATE — "the grid holds cards" is what enters `courses`, and
|
|
224
268
|
// the empty grid is what releases it. Same walk, one more spelling of a match.
|
|
225
|
-
const aliasOf =
|
|
226
|
-
for (const [sname, entry] of Object.entries((def?.templates ?? {}) as Record<string, { template?: string; claims?: string[] }>)) {
|
|
227
|
-
if (!entry?.template || !valid.has(sname)) continue;
|
|
228
|
-
for (const c of entry.claims ?? []) aliasOf.set(c, sname);
|
|
229
|
-
}
|
|
269
|
+
const aliasOf = mountAliases(def, valid);
|
|
230
270
|
// A mount-claim match counts only within THE CURRENT DELIVERY (hold: latest):
|
|
231
271
|
// released instances must release the state too, or the app would sit in an
|
|
232
272
|
// arrangement whose template just emptied.
|
|
@@ -266,81 +306,6 @@ export function resolveActiveView(
|
|
|
266
306
|
return { view, byGuest };
|
|
267
307
|
}
|
|
268
308
|
|
|
269
|
-
/**
|
|
270
|
-
* THE LATCH (docs/MCP_COMPLETE_GUIDE.md §The Component Latch) — which component the
|
|
271
|
-
* conversation is currently ADDRESSED AT, or none.
|
|
272
|
-
*
|
|
273
|
-
* DERIVED, never stored, for the same reason `lifecycle` is (STATE_MODEL §3): nothing
|
|
274
|
-
* registers a latch and nothing unregisters one. THE STATE IS WHAT IS LATCHABLE, not the
|
|
275
|
-
* component (moved off the manifest 2026-08-31): an instance holds the latch while it is
|
|
276
|
-
* wearing a state the app declared a `latch` on, and stops the moment it wears one that
|
|
277
|
-
* says nothing — retracted inline, superseded, evicted, all the same answer with no code.
|
|
278
|
-
* There is no set, no stack, and no "latch this one too" — this function returns ONE
|
|
279
|
-
* holder or null, so two latches are not a bug to prevent but a state that cannot be
|
|
280
|
-
* represented.
|
|
281
|
-
*
|
|
282
|
-
* THE HOLDER MUST BE THE STATE ON SCREEN, which is why `active` is a required argument
|
|
283
|
-
* rather than something derived again here. Being ON the ladder is not enough: an app whose
|
|
284
|
-
* ladder is [page, perfect-day] shows `page` the moment a card arrives wearing that face,
|
|
285
|
-
* and the composed page — still live, still ranked, just not showing — went on holding the
|
|
286
|
-
* latch. The guest then reads a pill naming a page that is nowhere on screen and types into
|
|
287
|
-
* it (observed live 2026-08-31: three search cards took the surface and "Your Perfect Day"
|
|
288
|
-
* stayed up over them). A pill is a promise about what the words will reach.
|
|
289
|
-
*
|
|
290
|
-
* SAME WALK AS `resolveActiveView` otherwise: the highest-ranked wins, ties going to the
|
|
291
|
-
* most recent write. So a page retracted to `inline` (superseded by an arrival, released by
|
|
292
|
-
* `cancelBelow`, closed by the guest) silently stops holding it too.
|
|
293
|
-
*
|
|
294
|
-
* Returns the FULL key. `chatId` alone reaches the turn, not the component inside it: a
|
|
295
|
-
* turn carrying two components would hydrate the wrong one.
|
|
296
|
-
*/
|
|
297
|
-
export function resolveLatch(
|
|
298
|
-
def: UnoverseDefinition | null | undefined,
|
|
299
|
-
store: ComponentStore,
|
|
300
|
-
/** The state the app is ACTUALLY showing (`resolveActiveView().view`). Empty = nothing is
|
|
301
|
-
* surfaced, so nothing can be addressed. */
|
|
302
|
-
active: string,
|
|
303
|
-
dismissed?: string,
|
|
304
|
-
): { key: string; chatId: string; nodeId: string; latch: { title: string; icon?: string }; view: string } | null {
|
|
305
|
-
const order = def?.stateOrder?.length ? def.stateOrder : undefined;
|
|
306
|
-
if (!order) return null; // no ladder, no ranking — abstain rather than guess a holder
|
|
307
|
-
const latches = def?.latches;
|
|
308
|
-
if (!latches || !Object.keys(latches).length) return null; // no state is addressable here
|
|
309
|
-
// WHAT IS ON SCREEN, first: an addressable state that is not the active one is not being
|
|
310
|
-
// shown, and a pill over someone else's surface points at nothing the guest can see.
|
|
311
|
-
if (!active || !latches[active]) return null;
|
|
312
|
-
// `key` is the component key this candidate came from, carried so the return can name the
|
|
313
|
-
// FULL key (line 269) rather than re-deriving it from chatId, which reaches the turn and
|
|
314
|
-
// not the component inside it.
|
|
315
|
-
let best:
|
|
316
|
-
| { key: string; chatId: string; nodeId: string; latch: { title: string; icon?: string }; view: string; rank: number; at: number }
|
|
317
|
-
| null = null;
|
|
318
|
-
for (const turn of store.getResponses())
|
|
319
|
-
for (const p of turn.components) {
|
|
320
|
-
// THE ✕ IS THE ONLY EXIT, and this is it: the guest dismissed THIS holder by key, so
|
|
321
|
-
// it is passed over and the walk continues to the next candidate. Keyed rather than a
|
|
322
|
-
// bare flag so the dismissal cannot outlive the thing dismissed — a different
|
|
323
|
-
// component taking the latch is a different key and is not affected.
|
|
324
|
-
if (dismissed && dismissed === p) continue;
|
|
325
|
-
const { chatId, nodeId } = store.split(p);
|
|
326
|
-
const slice = store.get(chatId, nodeId) as Record<string, unknown>;
|
|
327
|
-
const view = viewOf(slice);
|
|
328
|
-
if (!view) continue;
|
|
329
|
-
const rank = order.indexOf(view);
|
|
330
|
-
if (rank < 0) continue; // not on the surface (inline/retracted): holds nothing
|
|
331
|
-
// THE STATE DECIDES, and it must be the state SHOWING. Same lookup for every instance,
|
|
332
|
-
// so nothing has to be told it is addressable and nothing has to be untold — the face
|
|
333
|
-
// it currently wears is the whole question, and a face that is not the active one is
|
|
334
|
-
// not on screen to be addressed.
|
|
335
|
-
if (view !== active) continue;
|
|
336
|
-
const latch = latches[view];
|
|
337
|
-
if (!latch) continue;
|
|
338
|
-
const at = store.touchOf(p);
|
|
339
|
-
if (!best || rank < best.rank || (rank === best.rank && at > best.at))
|
|
340
|
-
best = { key: p, chatId, nodeId, latch, view, rank, at };
|
|
341
|
-
}
|
|
342
|
-
return best ? { key: best.key, chatId: best.chatId, nodeId: best.nodeId, latch: best.latch, view: best.view } : null;
|
|
343
|
-
}
|
|
344
309
|
|
|
345
310
|
/** Resolve a ComponentSlot's `select` against the store → ordered pointers. */
|
|
346
311
|
export function selectPointers(store: ComponentStore, node: UnoverseNode, scope?: Set<string>): string[] {
|
|
@@ -390,8 +355,28 @@ export function selectPointers(store: ComponentStore, node: UnoverseNode, scope?
|
|
|
390
355
|
if (Array.isArray(within)) return within.includes(v);
|
|
391
356
|
return ne !== undefined ? v !== ne : v === eq;
|
|
392
357
|
});
|
|
393
|
-
pointers = [...pointers].sort((a, b) => store.touchOf(b) - store.touchOf(a));
|
|
394
358
|
}
|
|
359
|
+
// THE MOST RECENT WINS (the `limit: 1` law) for EVERY capped select, not only a state
|
|
360
|
+
// select. A slot pinned to a component by `type` with `limit: 1` kept the OLDEST card:
|
|
361
|
+
// the sort lived inside the `where` branch, so a name-pinned cap sliced arrival order
|
|
362
|
+
// (SAB voice, two Account Agent cards, 2026-09-10). A cap means "the newest N"; an
|
|
363
|
+
// uncapped flow keeps arrival order.
|
|
364
|
+
if (sel.where || sel.limit != null) pointers = [...pointers].sort((a, b) => store.touchOf(b) - store.touchOf(a));
|
|
395
365
|
if (sel.limit != null) pointers = pointers.slice(0, sel.limit);
|
|
396
366
|
return pointers;
|
|
397
367
|
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* THE HASH NAMES THE STATE (owner ruling 2026-09-09: "make this the standard way to change
|
|
371
|
+
* states in an app, component or layout"). `unoverse://components/acme/deal#page` is the
|
|
372
|
+
* deal, opened on its page, the way a URL's hash means "this, opened here". Every reader
|
|
373
|
+
* splits the address here: the part before the hash is what the server resolves, the part
|
|
374
|
+
* after is the public `state` the thing opens in. No hash, and the definition's declared
|
|
375
|
+
* initial state stands.
|
|
376
|
+
*/
|
|
377
|
+
export function stateOfUri(address: string): { uri: string; state?: string } {
|
|
378
|
+
const i = address.indexOf("#");
|
|
379
|
+
if (i < 0) return { uri: address };
|
|
380
|
+
const state = address.slice(i + 1);
|
|
381
|
+
return { uri: address.slice(0, i), ...(state ? { state } : {}) };
|
|
382
|
+
}
|
|
@@ -11,6 +11,7 @@ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/
|
|
|
11
11
|
// ChatGPT's widget CSP. See connection.tsx for the full rationale.
|
|
12
12
|
import { CfWorkerJsonSchemaValidator } from "@modelcontextprotocol/sdk/validation/cfworker";
|
|
13
13
|
import type { UnoverseDefinition, ResolvedTheme } from "./types";
|
|
14
|
+
import { stateOfUri } from "./apps";
|
|
14
15
|
|
|
15
16
|
export interface UnoverseClientOptions {
|
|
16
17
|
/**
|
|
@@ -128,21 +129,11 @@ export class UnoverseClient {
|
|
|
128
129
|
return this.withSession(() => this.client.callTool({ name, arguments: args }));
|
|
129
130
|
}
|
|
130
131
|
|
|
131
|
-
/**
|
|
132
|
-
* The cached definition for a URI, or null — SYNCHRONOUS, and it never fetches.
|
|
133
|
-
*
|
|
134
|
-
* For derivations that must answer INSIDE an event, where awaiting is not an option: the
|
|
135
|
-
* latch resolves on send (`sendMessage`), and a send cannot wait on a resources/read.
|
|
136
|
-
* Safe because the only caller asks about the template it is already rendering, which
|
|
137
|
-
* means `readDefinition` resolved before the first frame. Null is a legitimate answer
|
|
138
|
-
* (nothing rendered yet) and every caller must treat it as "no opinion", never as "no".
|
|
139
|
-
*/
|
|
140
|
-
peekDefinition(uri: string): UnoverseDefinition | null {
|
|
141
|
-
return this.cache.get(uri) ?? null;
|
|
142
|
-
}
|
|
143
132
|
|
|
144
|
-
/** resources/read a definition by URI (e.g. unoverse://components/card). Cached.
|
|
145
|
-
|
|
133
|
+
/** resources/read a definition by URI (e.g. unoverse://components/card). Cached. A hash on
|
|
134
|
+
* the address names the state it opens in (`stateOfUri`) and is never sent to the server. */
|
|
135
|
+
async readDefinition(address: string): Promise<UnoverseDefinition> {
|
|
136
|
+
const { uri } = stateOfUri(address);
|
|
146
137
|
const cached = this.cache.get(uri);
|
|
147
138
|
if (cached) return cached;
|
|
148
139
|
const pending = this.inflight.get(uri);
|
|
@@ -137,6 +137,19 @@ export function applyServerMessage(store: ComponentStore, msg: ServerMessage, se
|
|
|
137
137
|
else if (m.state === "ERROR" || m.state === "WORKFLOW_ERROR") store.setLifecycle("error");
|
|
138
138
|
return;
|
|
139
139
|
}
|
|
140
|
+
case "COMPONENT_CLOSE": {
|
|
141
|
+
// A CLOSE, not a placement (RENDER_PIPELINE §3.3): the LLM chose content over an
|
|
142
|
+
// open task (screen_show), and the task closes exactly as the guest's own ✕ closes
|
|
143
|
+
// it — a guest-hand write to `inline`. A plain face write would be refused by the
|
|
144
|
+
// guest-hold shield when the guest had opened the task themselves (observed live
|
|
145
|
+
// 2026-09-07: closed on the server, still on screen).
|
|
146
|
+
// The state to wear rides the message when the sender names one (a step-aside on an app with
|
|
147
|
+
// places, docs/unoverse/design/screen/UNOVERSE_SCREEN.md §2.6); else the universal `inline`.
|
|
148
|
+
const m = msg as { chatId?: string; nodeId?: string; state?: unknown };
|
|
149
|
+
if (typeof m.chatId === "string" && typeof m.nodeId === "string")
|
|
150
|
+
store.mergeComponentState(m.chatId, m.nodeId, { state: typeof m.state === "string" && m.state ? m.state : "inline" });
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
140
153
|
case "COMPONENTS_CLEAR": {
|
|
141
154
|
// Rule 5's empty delivery (STATE_MODEL §5, amended 2026-08-26): a turn that ran
|
|
142
155
|
// with cards enabled and surfaced none owns the screen — earlier-turn components
|
|
@@ -162,6 +175,23 @@ export function applyServerMessage(store: ComponentStore, msg: ServerMessage, se
|
|
|
162
175
|
}
|
|
163
176
|
// No AUDIO_STATE case: voice is a NATIVE SERVICE on the WS audio lane (useVoiceService),
|
|
164
177
|
// NOT the structured MCP stream. Audio-state events never arrive here (UNOVERSE_SPEC §2e-1).
|
|
178
|
+
case "SCREEN_PLACE": {
|
|
179
|
+
// THE SCREEN DOCUMENT (docs/unoverse/design/screen/UNOVERSE_SCREEN.md): one place's full contents,
|
|
180
|
+
// in order. The server decided where everything is; this only records it.
|
|
181
|
+
const m = msg as { place?: unknown; items?: unknown };
|
|
182
|
+
if (typeof m.place !== "string" || !Array.isArray(m.items)) return;
|
|
183
|
+
const pointers: string[] = [];
|
|
184
|
+
const states: Record<string, string> = {};
|
|
185
|
+
for (const x of m.items) {
|
|
186
|
+
const i = x as { chatId?: unknown; nodeId?: unknown; state?: unknown };
|
|
187
|
+
if (typeof i?.chatId !== "string" || typeof i?.nodeId !== "string") continue;
|
|
188
|
+
const p = `${i.chatId}:${i.nodeId}`;
|
|
189
|
+
pointers.push(p);
|
|
190
|
+
if (typeof i.state === "string" && i.state) states[p] = i.state;
|
|
191
|
+
}
|
|
192
|
+
store.setPlace(m.place, pointers, states);
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
165
195
|
case "APP_DATA": {
|
|
166
196
|
// Generic write into TEMPLATE STATE (the template-state twin of COMPONENT_DATA). The
|
|
167
197
|
// PRODUCER names the keys (the Suggestions node sends `{ faqs }`); the template picks
|
|
@@ -35,14 +35,14 @@ export { dispatchAction, resolveValue, type ActionContext } from "./actions";
|
|
|
35
35
|
export {
|
|
36
36
|
collectSurfacedViews,
|
|
37
37
|
computeAppWidth,
|
|
38
|
-
cssWidth,
|
|
38
|
+
cssWidth, placeWidthStyle,
|
|
39
39
|
formatRelative,
|
|
40
40
|
isViewField,
|
|
41
|
+
mountAliases,
|
|
41
42
|
orderedClaims,
|
|
42
43
|
propDefaults,
|
|
43
44
|
resolveActiveLayout,
|
|
44
45
|
resolveActiveView,
|
|
45
|
-
resolveLatch,
|
|
46
46
|
selectPointers,
|
|
47
47
|
viewOf,
|
|
48
48
|
} from "./apps";
|