@unoverse-platform/studio 0.1.116 → 0.1.117

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 (52) hide show
  1. package/local/scaffold.d.mts +1 -0
  2. package/local/scaffold.mjs +12 -0
  3. package/package.json +1 -1
  4. package/screens/AgentChain.tsx +41 -25
  5. package/screens/AgentsView.tsx +41 -1
  6. package/screens/TemplatesLibraryView.tsx +32 -3
  7. package/screens/TemplatesView.tsx +2 -2
  8. package/screens/index.ts +1 -1
  9. package/src/host.ts +5 -0
  10. package/src/index.css +11 -0
  11. package/vendor/base/src/agent-mcp/discovery.ts +1 -1
  12. package/vendor/base/src/agent-mcp/index.ts +3 -1
  13. package/vendor/base/src/agent-mcp/invoke.ts +62 -18
  14. package/vendor/base/src/agent-mcp/progressive.ts +124 -0
  15. package/vendor/base/src/agent-mcp/tools.ts +1 -1
  16. package/vendor/base/src/definitions/load/expand/inherit.ts +1 -1
  17. package/vendor/base/src/definitions/load/expand/refs.ts +1 -1
  18. package/vendor/base/src/definitions/load/expand/walk.ts +1 -1
  19. package/vendor/base/src/definitions/registry/agentFolders.ts +2 -2
  20. package/vendor/base/src/definitions/registry/catalog.ts +1 -1
  21. package/vendor/base/src/definitions/registry/objects.ts +94 -6
  22. package/vendor/base/src/definitions/registry/pipelines.ts +13 -2
  23. package/vendor/base/src/http/uwsFetchAdapter.ts +39 -3
  24. package/vendor/base/src/items/collect.ts +10 -0
  25. package/vendor/base/src/items/publish.ts +10 -0
  26. package/vendor/base/src/lint/design/index.mjs +4 -1
  27. package/vendor/base/src/lint/design/vocabulary.mjs +1 -1
  28. package/vendor/base/src/lint/objects/index.mjs +1 -1
  29. package/vendor/base/src/lint/objects/rules.d.mts +3 -0
  30. package/vendor/base/src/lint/objects/rules.mjs +93 -5
  31. package/vendor/base/src/manifests/compose.ts +3 -0
  32. package/vendor/base/src/manifests/executor/callback.ts +3 -2
  33. package/vendor/base/src/manifests/executor/memory.ts +27 -0
  34. package/vendor/base/src/manifests/executor/toolBridge.ts +70 -11
  35. package/vendor/base/src/manifests/runtime/auth/oauth.ts +43 -10
  36. package/vendor/base/src/manifests/runtime/events.ts +1 -1
  37. package/vendor/base/src/manifests/runtime/http/request.ts +43 -3
  38. package/vendor/base/src/manifests/runtime/loops/poll.ts +27 -5
  39. package/vendor/base/src/manifests/runtime/tools/toolloop.ts +1 -1
  40. package/vendor/base/src/platform/inbound.ts +67 -0
  41. package/vendor/base/src/platform/tokenCost.ts +27 -8
  42. package/vendor/sdk/lib/core/actions.ts +10 -0
  43. package/vendor/sdk/lib/core/apps.ts +2 -2
  44. package/vendor/sdk/lib/core/client.ts +11 -0
  45. package/vendor/sdk/lib/core/store.ts +1 -1
  46. package/vendor/sdk/lib/core/types.ts +2 -0
  47. package/vendor/sdk/lib/dom/app.ts +3 -2
  48. package/vendor/sdk/lib/dom/component.ts +38 -1
  49. package/vendor/sdk/lib/dom/element.ts +23 -0
  50. package/vendor/sdk/lib/dom/primitives.ts +97 -6
  51. package/vendor/sdk/lib/voiceService.ts +3 -1
  52. package/vendor/sdk/main.ts +1 -1
@@ -13,6 +13,7 @@ export function createProject(home: string, name: string): string;
13
13
  export function createFirstProject(home: string, name: string): string;
14
14
 
15
15
  /** Make `dir` one org's own repo (UNO-88): folders at the root, unoverse.yaml declaring the org, samples, editor globs, the check, Git. */
16
+ export const ORG_FOLDERS: string[];
16
17
  export function createOrgRepo(dir: string, org: string): { state: "committed" | "no-identity" | "inside-repo" | "no-git"; message?: string };
17
18
 
18
19
  /** Make `home` a Git repo on main with a first commit, unless it already is one; never an author it was not given. */
@@ -77,6 +77,9 @@ export function createProject(home, name) {
77
77
  return fillOrgFolder(root);
78
78
  }
79
79
 
80
+ /** The folder of every authored kind, at an org repo's root (packages/docs/onboarding/studio.md). */
81
+ export const ORG_FOLDERS = ["components", "apps", "templates", "atoms", "skills", "blocks", "nodes", "agents", "objects", "pipelines"];
82
+
80
83
  /**
81
84
  * One org's folder, filled: the welcome component, the sample styles, identity, app and
82
85
  * chat component. Shared by the monorepo shape (design/<org>/) and an org repo, whose root
@@ -93,6 +96,15 @@ function fillOrgFolder(root, { baselineOnly = false } = {}) {
93
96
  const dirs = baselineOnly ? ["styles", "identity"] : ["components/welcome", "apps", "styles", "skills", "blocks", "identity"];
94
97
  for (const d of dirs) mkdirSync(join(root, d), { recursive: true });
95
98
  if (!baselineOnly) writeFileSync(join(root, "components/welcome/welcome.yaml"), WELCOME);
99
+ // EVERY KIND'S FOLDER, EMPTY (owner, 2026-10-25). The baseline ruling dropped the SAMPLES; the
100
+ // folders went with them only because they were made beside the samples. A new org shows
101
+ // where each kind lives from the first minute. `.gitkeep` because Git keeps no empty folder.
102
+ if (baselineOnly) {
103
+ for (const d of ORG_FOLDERS) {
104
+ mkdirSync(join(root, d), { recursive: true });
105
+ if (readdirSync(join(root, d)).length === 0) writeFileSync(join(root, d, ".gitkeep"), "");
106
+ }
107
+ }
96
108
 
97
109
  // Worked example styles (sample-styles: brand ingredients + the override rules), so
98
110
  // styles/ is never an empty folder with no hint of the token cascade. Per PROJECT,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@unoverse-platform/studio",
3
- "version": "0.1.116",
3
+ "version": "0.1.117",
4
4
  "type": "module",
5
5
  "description": "Unoverse Studio: the local authoring app. Reads your own rx/ folder, no backend.",
6
6
  "scripts": {
@@ -21,11 +21,11 @@ export interface AgentFolderItem {
21
21
  /** Its picture, an image URL. */
22
22
  avatar?: string;
23
23
  skills: string[];
24
- model?: { use?: string; reasoning?: string; fallback?: string };
24
+ model?: { use?: string; reasoning?: string; fallback?: string; turns?: number };
25
25
  budget?: { perDay?: string; perRun?: string };
26
26
  tasks: string[];
27
27
  access: string[];
28
- triggers: { name: string; node?: string; wakes?: string }[];
28
+ triggers: { name: string; node?: string; wakes?: string; config?: Record<string, unknown> }[];
29
29
  checkIn?: string;
30
30
  memory?: { agent?: string };
31
31
  askWhenUnsure?: string;
@@ -58,7 +58,12 @@ function taskLabel(uri: string, org: string): { name: string; from?: string } {
58
58
  return m[1] === org ? { name: m[2] } : { name: m[2], from: m[1] };
59
59
  }
60
60
 
61
- export function AgentChain({ agent: given }: { agent: AgentFolderItem }) {
61
+ /**
62
+ * `mailbox`: its connected address in this environment (never in the file), drawn on the triggers
63
+ * its mailbox serves; `says`, by trigger, whether each works here today; `onConnect`, when the
64
+ * viewer may connect one, puts Connect there.
65
+ */
66
+ export function AgentChain({ agent: given, mailbox, says, onConnect, connecting }: { agent: AgentFolderItem; mailbox?: string | null; says?: Record<string, string>; onConnect?: () => void; connecting?: boolean }) {
62
67
  // Every list may be absent (a hand-written file, an older server): drawn as empty, never a crash.
63
68
  const agent: AgentFolderItem = {
64
69
  ...given,
@@ -108,13 +113,19 @@ export function AgentChain({ agent: given }: { agent: AgentFolderItem }) {
108
113
  {ways.length === 0 && <div className="text-[12px] text-gray-500">None yet</div>}
109
114
  {ways.map((w) => {
110
115
  const Icon = WAY_ICONS[w.name] ?? Radio;
116
+ const mail = w.node === "MailboxMCP";
111
117
  return (
112
118
  <div key={w.name} className={TILE}>
113
119
  <span className={GLYPH}><Icon size={15} strokeWidth={1.8} /></span>
114
- <div className="min-w-0">
120
+ <div className="min-w-0 flex-1">
115
121
  <div className="truncate text-[13px] font-medium capitalize text-gray-900">{w.name}</div>
116
- <div className="truncate text-[11px] text-gray-500">{w.name === "spatial" ? "found in Spatial" : [w.node, w.wakes ? WAKES[w.wakes] ?? w.wakes : null].filter(Boolean).join(", ")}</div>
122
+ <div className="truncate text-[11px] text-gray-500">{mail && mailbox !== undefined ? mailbox ?? "No mailbox connected" : says?.[w.name] ?? (w.name === "spatial" ? "found in Spatial" : [w.node, w.wakes ? WAKES[w.wakes] ?? w.wakes : null].filter(Boolean).join(", "))}</div>
117
123
  </div>
124
+ {mail && onConnect && (
125
+ <button type="button" disabled={connecting} onClick={onConnect} className="shrink-0 rounded-md border border-gray-300 bg-white px-2 py-1 text-[11px] font-medium text-gray-800 hover:bg-gray-50 disabled:opacity-50">
126
+ {connecting ? "Connecting" : mailbox ? "Change" : "Connect"}
127
+ </button>
128
+ )}
118
129
  </div>
119
130
  );
120
131
  })}
@@ -130,17 +141,21 @@ export function AgentChain({ agent: given }: { agent: AgentFolderItem }) {
130
141
  {agent.role && <div className="text-[12px] text-gray-600">{agent.role}</div>}
131
142
  </div>
132
143
  </div>
133
- <dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 text-[12px]">
134
- <dt className="text-gray-500">Model</dt>
135
- <dd className="truncate font-mono text-gray-800">{agent.model?.use ?? "not set"}</dd>
136
- <dt className="text-gray-500">Budget</dt>
137
- <dd className="text-gray-800">
144
+ <div className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 text-[12px]">
145
+ <div className="text-gray-500">Model</div>
146
+ <div className="truncate font-mono text-gray-800">{agent.model?.use ?? "not set"}</div>
147
+ <div className="text-gray-500">Reasoning</div>
148
+ <div className="text-gray-800">{agent.model?.reasoning ?? "the model's default"}</div>
149
+ <div className="text-gray-500">Turns</div>
150
+ <div className="text-gray-800">{agent.model?.turns ? `${agent.model.turns} a run` : "the model's default"}</div>
151
+ <div className="text-gray-500">Budget</div>
152
+ <div className="text-gray-800">
138
153
  {agent.budget?.perDay ? `${agent.budget.perDay} a day` : "not set"}
139
154
  {agent.budget?.perRun ? `, ${agent.budget.perRun} a run` : ""}
140
- </dd>
141
- <dt className="text-gray-500">Memory</dt>
142
- <dd className="text-gray-800">{agent.memory?.agent === "on" ? "its own" : "off"}</dd>
143
- </dl>
155
+ </div>
156
+ <div className="text-gray-500">Memory</div>
157
+ <div className="text-gray-800">{agent.memory?.agent === "on" ? "its own" : "off"}</div>
158
+ </div>
144
159
  </div>
145
160
 
146
161
  <ChevronRight size={18} className="mt-14 text-gray-300" />
@@ -200,17 +215,18 @@ export function AgentChain({ agent: given }: { agent: AgentFolderItem }) {
200
215
  </div>
201
216
 
202
217
  <div className={LABEL}>Settings</div>
203
- <dl className="mb-5 grid grid-cols-[auto_1fr] gap-x-4 gap-y-1 rounded-lg border border-gray-200 bg-gray-50 p-4 text-[12px] text-gray-800">
204
- {agent.model?.reasoning && (<><dt className="text-gray-500">Reasoning</dt><dd>{agent.model.reasoning}</dd></>)}
205
- {agent.model?.fallback && (<><dt className="text-gray-500">Fallback model</dt><dd className="font-mono">{agent.model.fallback}</dd></>)}
206
- <dt className="text-gray-500">Check-in</dt>
207
- <dd>{agent.checkIn ? `every ${agent.checkIn}` : "only when reached"}</dd>
208
- {agent.askWhenUnsure && (<><dt className="text-gray-500">Asks when unsure</dt><dd>in {agent.askWhenUnsure}</dd></>)}
209
- <dt className="text-gray-500">Email</dt>
210
- <dd>its connected mailbox, set in Signal</dd>
211
- <dt className="text-gray-500">Folder</dt>
212
- <dd className="font-mono">{agent.path}</dd>
213
- </dl>
218
+ <div className="mb-5 grid grid-cols-[auto_1fr] gap-x-4 gap-y-1 rounded-lg border border-gray-200 bg-gray-50 p-4 text-[12px] text-gray-800">
219
+ {agent.model?.reasoning && (<><div className="text-gray-500">Reasoning</div><div>{agent.model.reasoning}</div></>)}
220
+ {agent.model?.turns && (<><div className="text-gray-500">Turns</div><div>{agent.model.turns} a run</div></>)}
221
+ {agent.model?.fallback && (<><div className="text-gray-500">Fallback model</div><div className="font-mono">{agent.model.fallback}</div></>)}
222
+ <div className="text-gray-500">Check-in</div>
223
+ <div>{agent.checkIn ? `every ${agent.checkIn}` : "only when reached"}</div>
224
+ {agent.askWhenUnsure && (<><div className="text-gray-500">Asks when unsure</div><div>in {agent.askWhenUnsure}</div></>)}
225
+ <div className="text-gray-500">Email</div>
226
+ <div>its connected mailbox, set in Signal</div>
227
+ <div className="text-gray-500">Folder</div>
228
+ <div className="font-mono">{agent.path}</div>
229
+ </div>
214
230
  </>
215
231
  );
216
232
  }
@@ -39,6 +39,28 @@ interface AgentItem {
39
39
  }
40
40
 
41
41
  const ACCENT = "#7c3aed";
42
+
43
+ /**
44
+ * COPY FOR CANVAS, AN AGENT WITH A VOICE (AGENTS.md § 5: its `voice` trigger names the node). The
45
+ * canvas's own clipboard payload, as Components writes it: only her face (the voice-chat component,
46
+ * her portrait and name). Clicking it starts her call; she works where she was woken, so her tasks
47
+ * show on that canvas.
48
+ */
49
+ export function agentForCanvas(a: AgentFolderItem) {
50
+ if (!a.triggers.some((t) => t.name === "voice" && t.node)) return null;
51
+ const name = a.title ?? a.name;
52
+ // ONE NODE, ITS FACE. Its voice and its work are its own (engine agentKeys/talk.ts): `agent` is
53
+ // its address, and clicking the face starts its call on this canvas.
54
+ const face = {
55
+ id: `${a.name}-face1`,
56
+ type: "Component",
57
+ position: { x: 0, y: 0 },
58
+ data: { label: name, status: "ready", agent: `unoverse://agents/${a.org}/${a.name}`, config: { component: "voice-chat", portrait: a.avatar ?? "", orbSize: "360", assistantName: name, tagline: a.role ?? "" } },
59
+ style: { width: 520 },
60
+ draggable: true,
61
+ };
62
+ return { type: "gravity-workflow", version: 2, nodes: [face], edges: [] };
63
+ }
42
64
  /** An Agent's mark: a bot's head, antenna up, two eyes. */
43
65
  const AGENT_ICON = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="${ACCENT}" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="9" width="16" height="11" rx="3"/><path d="M12 9V5"/><circle cx="12" cy="4" r="1.2" fill="${ACCENT}"/><circle cx="9" cy="14.5" r="1.3" fill="${ACCENT}" stroke="none"/><circle cx="15" cy="14.5" r="1.3" fill="${ACCENT}" stroke="none"/><path d="M2 13v4M22 13v4"/></svg>`)}`;
44
66
 
@@ -49,6 +71,7 @@ export function AgentsView({ org = "all" }: { org?: string } = {}) {
49
71
  const available = useRegistryToggles("agent");
50
72
  const [selected, setSelected] = usePersistentState<string | null>("wb:agents:selected", null);
51
73
  const [error, setError] = useState<string | null>(null);
74
+ const [copied, setCopied] = useState(false);
52
75
 
53
76
  useEffect(() => {
54
77
  fetchUnoverse(`/dev/agents${org !== "all" ? `?org=${encodeURIComponent(org)}` : ""}`)
@@ -137,7 +160,24 @@ export function AgentsView({ org = "all" }: { org?: string } = {}) {
137
160
  {selected && folder ? (
138
161
  <div className={DOC}>
139
162
  <IdentityHeader
140
- right={<ToggleSwitch on={available.isEnabled(selected)} onClick={() => available.toggle(selected)} label="Available" />}
163
+ right={
164
+ <div className="flex items-center gap-3">
165
+ {agentForCanvas(folder) && (
166
+ <button
167
+ onClick={async () => {
168
+ await navigator.clipboard.writeText(JSON.stringify(agentForCanvas(folder), null, 2));
169
+ setCopied(true);
170
+ setTimeout(() => setCopied(false), 1600);
171
+ }}
172
+ className="rounded border border-gray-300 px-2 py-0.5 text-[11px] font-medium text-gray-600 transition hover:bg-gray-50"
173
+ title="Copy this Agent, with its voice, for a canvas: paste (Cmd+V) on any workflow canvas"
174
+ >
175
+ {copied ? "✓ Copied, paste on canvas" : "⧉ Copy for Canvas"}
176
+ </button>
177
+ )}
178
+ <ToggleSwitch on={available.isEnabled(selected)} onClick={() => available.toggle(selected)} label="Available" />
179
+ </div>
180
+ }
141
181
  logoUrl={folder.avatar ?? AGENT_ICON}
142
182
  accent={ACCENT}
143
183
  name={folder.title ? `${folder.title}${folder.role ? `, ${folder.role}` : ""}` : selected}
@@ -14,7 +14,7 @@
14
14
  *
15
15
  * NOT TemplatesView.tsx: that name is taken by the Apps screen (the legacy noun).
16
16
  */
17
- import { useEffect, useMemo, useState, useSyncExternalStore, useCallback, type MouseEvent as ReactMouseEvent } from "react";
17
+ import { useEffect, useMemo, useRef, useState, useSyncExternalStore, useCallback, type MouseEvent as ReactMouseEvent } from "react";
18
18
  import { ComponentStore, type ResolvedTheme } from "@unoverse/sdk";
19
19
  import { UnoverseComponent } from "./react/unoverse";
20
20
  import { client, authedFetch, usePersistentState, useRegistryToggles, ToggleSwitch } from "studio-host";
@@ -56,6 +56,9 @@ interface TemplateDef {
56
56
  preview?: Record<string, string[]>;
57
57
  /** The components it holds, one stand-in per item of its own preview data, per state. */
58
58
  heldSeeds?: Record<string, { key: string; component: string; props: Record<string, unknown> }[]>;
59
+ /** The template's own props (the lists it holds, a picture a hook fills): their previews seed
60
+ * the instance, so a layout-level Each has items to walk and a held card a spot to draw in. */
61
+ props?: Record<string, { preview?: unknown; default?: unknown } | null>;
59
62
  }
60
63
 
61
64
  const PREVIEW_CHAT = "preview";
@@ -184,7 +187,14 @@ export function TemplatesLibraryView({ theme, org }: { theme: ResolvedTheme; org
184
187
  type: "COMPONENT_INIT",
185
188
  chatId: PREVIEW_CHAT,
186
189
  nodeId: TEMPLATE_NODE,
187
- component: { type: ref, componentUrl: `unoverse://templates/${ref}`, props: {} },
190
+ // ITS OWN PROPS, seeded as a part's are (`preview ?? default`): a template whose layout walks
191
+ // a list it holds drew nothing here, because the instance arrived empty while the held
192
+ // stand-ins beneath it waited for an Each that never ran (observed 2026-10-23).
193
+ component: {
194
+ type: ref,
195
+ componentUrl: `unoverse://templates/${ref}`,
196
+ props: Object.fromEntries(Object.entries(current.props ?? {}).map(([k, v]) => [k, v?.preview ?? v?.default]).filter(([, v]) => v !== undefined)),
197
+ },
188
198
  });
189
199
  const base = current.defaultLayout ?? (current.layouts ? Object.keys(current.layouts)[0] : undefined);
190
200
  if (base) store.mergeComponentState(PREVIEW_CHAT, TEMPLATE_NODE, { state: base });
@@ -246,6 +256,25 @@ export function TemplatesLibraryView({ theme, org }: { theme: ResolvedTheme; org
246
256
  if ((own?.state ?? own?.view ?? own?.defaultState) !== activeState) store.mergeComponentState(PREVIEW_CHAT, TEMPLATE_NODE, { state: activeState });
247
257
  }, [store, holdsItems, activeState]);
248
258
 
259
+ // A VIEW OPENING RUNS THE HOOK SCOPED TO IT (docs/design/lifecycle-hooks.md), as the app's stream
260
+ // would: the template's own fields so far as `props`, the state as the layout entered, and what
261
+ // comes back merged into the instance. Once per view per template; a host with no universe
262
+ // to run it (local Studio) fails quietly and the preview keeps its stand-ins.
263
+ const ranHooks = useRef(new Set<string>());
264
+ useEffect(() => {
265
+ if (!uri || !activeState || !client.runTask) return;
266
+ const key = `${uri}#${activeState}`;
267
+ if (ranHooks.current.has(key)) return;
268
+ ranHooks.current.add(key);
269
+ const own = (store.get(PREVIEW_CHAT, TEMPLATE_NODE) ?? {}) as Record<string, unknown>;
270
+ client
271
+ .runTask(uri, "onEnterView", own, activeState)
272
+ .then((fields) => {
273
+ if (fields && Object.keys(fields).length) store.mergeComponentState(PREVIEW_CHAT, TEMPLATE_NODE, fields);
274
+ })
275
+ .catch((e: unknown) => console.warn(`[studio] ${uri} onEnterView(${activeState}) did not run:`, e));
276
+ }, [store, uri, activeState]);
277
+
249
278
  // GROUPED BY CATEGORY, exactly as Components groups its rail — the shelf is no longer a
250
279
  // flat list the moment small templates land on it (the `Blocks` shelf: pair, feature,
251
280
  // single, rail, stack, statement) and a page has to be found among them. The category is
@@ -480,7 +509,7 @@ export function TemplatesLibraryView({ theme, org }: { theme: ResolvedTheme; org
480
509
  );
481
510
  })()}
482
511
 
483
- <div className="flex-1 overflow-auto p-10">
512
+ <div data-print-stage className="flex-1 overflow-auto p-10">
484
513
  <div className="mx-auto max-w-full transition-[width] duration-200" style={{ width: stageWidth ?? "100%" }}>
485
514
  {uri && <UnoverseComponent key={uri} client={client} store={store} chatId={PREVIEW_CHAT} nodeId={TEMPLATE_NODE} theme={theme} />}
486
515
  </div>
@@ -32,7 +32,7 @@ import { NAV, NAV_SECTION, SCREEN, navItem, navSubItem } from "./chrome";
32
32
 
33
33
  // Per-APP test conversation: each app chats in its own conversation id, so testing
34
34
  // one client's app never inherits another's agent history/memory (the "BPP answers
35
- // about SAB" trap — same workflow, polluted shared context).
35
+ // about a bank" trap — same workflow, polluted shared context).
36
36
  // …and a fresh conversation per PAGE LOAD: the nonce is minted once per module load,
37
37
  // so refreshing the page naturally starts a NEW conversation (fresh agent thread,
38
38
  // fresh durable surfaces) — history never outlives the screen it happened on.
@@ -373,7 +373,7 @@ export function TemplatesView({
373
373
  const base = store.getTimeline().length === 0 ? "isEmpty" : "hasMessages";
374
374
  return states.find((s) => s.when === base)?.name ?? null;
375
375
  }, [storeVersion, store, selectedDef]);
376
- // The preview renders under the SELECTED APP'S ORG theme — a SAB app is SAB-red, a
376
+ // The preview renders under the SELECTED APP'S ORG theme — a a bank app is a bank-red, a
377
377
  // BPP app cobalt, whatever the header org scope shows. The header toggle picks the
378
378
  // light/dark variant; the app's org picks whose brand. Falls back to the app-level
379
379
  // theme until the fetch resolves (or for org-less templates).
package/screens/index.ts CHANGED
@@ -25,7 +25,7 @@ export { TemplatesLibraryView } from "./TemplatesLibraryView";
25
25
  export { DesignSystemView } from "./DesignSystemView";
26
26
  export { SkillsView } from "./SkillsView";
27
27
  // Agents: the one file authored for the map (docs.unoverse.ai/design/tasks), one lane in both Studios.
28
- export { AgentsView } from "./AgentsView";
28
+ export { AgentsView, agentForCanvas } from "./AgentsView";
29
29
  // An Agent drawn off its agent.yaml: Studio's lane, and Signal's Agent page beside its runs.
30
30
  export { AgentChain } from "./AgentChain";
31
31
  export type { AgentFolderItem } from "./AgentChain";
package/src/host.ts CHANGED
@@ -110,6 +110,11 @@ class LocalUnoverseClient {
110
110
  );
111
111
  }
112
112
 
113
+ /** A component's task runs in a universe. A folder runs none, so it fills nothing. */
114
+ async runTask(): Promise<Record<string, unknown>> {
115
+ return {};
116
+ }
117
+
113
118
  /**
114
119
  * Read a definition, and STAMP EVERY NODE WITH ITS PATH.
115
120
  *
package/src/index.css CHANGED
@@ -5,3 +5,14 @@ body,
5
5
  #root {
6
6
  height: 100%;
7
7
  }
8
+
9
+ /* PRINT: only the template stage prints, one 1280x720 page per slide; Studio's chrome stays out.
10
+ The page is the stage's own size, so a stage drawn at Desktop width (1280) prints at scale 1. */
11
+ @media print {
12
+ @page { size: 1280px 720px; margin: 0; }
13
+ html, body, #root, #root * { height: auto !important; min-height: 0 !important; overflow: visible !important; }
14
+ body *:not([data-print-stage]):not([data-print-stage] *):not(:has([data-print-stage])) { display: none !important; }
15
+ :has(> [data-print-stage]), :has([data-print-stage]) { display: block !important; padding: 0 !important; margin: 0 !important; border: 0 !important; }
16
+ [data-print-stage] { padding: 0 !important; }
17
+ [data-print-stage] > div { width: 1280px !important; max-width: none !important; margin: 0 !important; }
18
+ }
@@ -72,7 +72,7 @@ export function parseDiscoveredMCPs(calls: AgentToolExchange[]): DiscoveredMCP[]
72
72
  // A TASK IS A TOOL, DESCRIBED LIKE ONE (owner ruling 2026-09-08). This used to close
73
73
  // with TASK_ETIQUETTE — "calling this runs an Agent and holds until it completes while
74
74
  // the person watches its progress on screen; say in one short sentence what you are
75
- // doing" — and the only thing it told the model to DO was speak. Measured on SAB
75
+ // doing" — and the only thing it told the model to DO was speak. Measured on a bank
76
76
  // (2026-09-08): across four runs of one question the model called once and, three
77
77
  // times, emitted exactly one sentence of intent and ended the turn, with no
78
78
  // function-call events in the stream at all.
@@ -22,6 +22,7 @@
22
22
  * lean projecting a result down to what the model needs
23
23
  * tools minting a model tool from a discovered app
24
24
  * invoke THE ONE WIRE CALL, where our code ends and native MCP begins
25
+ * progressive many tools held behind search_tools and call_tool (MCP Client Best Practices)
25
26
  * memory* conversation ingest and user-memory hydration
26
27
  */
27
28
 
@@ -38,7 +39,8 @@ export { parseToolResult, isTurnEndingHandoff } from "./handoff.js";
38
39
  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
40
  export { leanToolResultForModel } from "./lean.js";
40
41
  export { toolDefFromDiscoveredMCP, resolveToolDefNative, isBindinglessComponentApp, componentAppInvoker, isWorkflowTask, workflowTaskInvoker, workflowAppInvoker, modelToolName, objectFunctionToolDef, objectFunctionInvoker } from "./tools.js";
41
- export { invokeTaskNative, connectPlatformMcp, TRACE_META } from "./invoke.js";
42
+ export { invokeTaskNative, connectPlatformMcp, TRACE_META, readTask, answerTask } from "./invoke.js";
43
+ export { shouldDefer, metaTools, searchAnswer, searchTools, estimateTokens, DEFER_ABOVE_TOKENS, SEARCH_TOOLS, CALL_TOOL, type HeldTool } from "./progressive.js";
42
44
 
43
45
  export { ingestConversationTurn, type ConversationTurn } from "./memoryIngest.js";
44
46
  export { fetchUserMemoryContext, hydratePromptWithUserMemory } from "./memoryContext.js";
@@ -84,20 +84,11 @@ type CallResult = {
84
84
  structuredContent?: Record<string, any>;
85
85
  };
86
86
 
87
- /**
88
- * ONE CALL, FOLLOWED AS AN MCP TASK WHEN IT IS ONE (docs/mcp/MCP_TASKS.md steps 6 and 7). Every
89
- * Task is an MCP task: a workflow, a form, an app that asks. The harness calls as a 2026-07-28
90
- * client that declared the tasks extension, so a Task answers at once with its task and the
91
- * official client (`@modelcontextprotocol/ext-tasks/client`) follows it to its result; a call
92
- * with nothing to wait for answers at once, through the same client. Several calls in one turn
93
- * each follow their own task, side by side.
94
- *
95
- * The one piece of host code the library asks for is `rawDispatch`: it sends one JSON-RPC request
96
- * on the 2026 framing and hands back the response. The host owns request ids, so it adds them.
97
- */
98
- async function callFollowingTasks(toolId: string, args: Record<string, unknown>, ctx: AppInvocationContext): Promise<CallResult> {
87
+ /** A 2026-07-28 client on the platform's /mcp that declared the tasks extension, and the official
88
+ * task session over it: what every native task call below is made on. Close the client after. */
89
+ async function openTaskSession(ctx: Pick<AppInvocationContext, "baseUrl" | "accessToken">) {
99
90
  const { Client, StreamableHTTPClientTransport } = await import("@modelcontextprotocol/client");
100
- const { createTaskSessionFromClient, resultFromTaskOutcome } = await import("@modelcontextprotocol/ext-tasks/client");
91
+ const { createTaskSessionFromClient } = await import("@modelcontextprotocol/ext-tasks/client");
101
92
  const base = (ctx.baseUrl || process.env.UNOVERSE_SERVICE_URL || "http://localhost:4106").replace(/\/$/, "");
102
93
  const auth: Record<string, string> = ctx.accessToken ? { Authorization: `Bearer ${ctx.accessToken}` } : {};
103
94
  const clientInfo = { name: "unoverse-agent-mcp", version: "1.0.0" };
@@ -130,12 +121,29 @@ async function callFollowingTasks(toolId: string, args: Record<string, unknown>,
130
121
  const answers = text.split("\n").filter((l) => l.startsWith("data: ")).map((l) => JSON.parse(l.slice(6)));
131
122
  return answers.filter((m) => m.id === id).pop() ?? answers.pop();
132
123
  };
124
+ const session = createTaskSessionFromClient(client as never, {
125
+ endpointId: `${base}/mcp`,
126
+ rawDispatch: rawDispatch as never,
127
+ v2RequestFraming: { protocolVersion: MODERN, clientInfo, clientCapabilities },
128
+ } as never);
129
+ return { client, session };
130
+ }
131
+
132
+ /**
133
+ * ONE CALL, FOLLOWED AS AN MCP TASK WHEN IT IS ONE (docs/mcp/MCP_TASKS.md steps 6 and 7). Every
134
+ * Task is an MCP task: a workflow, a form, an app that asks. The harness calls as a 2026-07-28
135
+ * client that declared the tasks extension, so a Task answers at once with its task and the
136
+ * official client (`@modelcontextprotocol/ext-tasks/client`) follows it to its result; a call
137
+ * with nothing to wait for answers at once, through the same client. Several calls in one turn
138
+ * each follow their own task, side by side.
139
+ *
140
+ * The one piece of host code the library asks for is `rawDispatch`: it sends one JSON-RPC request
141
+ * on the 2026 framing and hands back the response. The host owns request ids, so it adds them.
142
+ */
143
+ async function callFollowingTasks(toolId: string, args: Record<string, unknown>, ctx: AppInvocationContext): Promise<CallResult> {
144
+ const { resultFromTaskOutcome } = await import("@modelcontextprotocol/ext-tasks/client");
145
+ const { client, session } = await openTaskSession(ctx);
133
146
  try {
134
- const session = createTaskSessionFromClient(client as never, {
135
- endpointId: `${base}/mcp`,
136
- rawDispatch: rawDispatch as never,
137
- v2RequestFraming: { protocolVersion: MODERN, clientInfo, clientCapabilities },
138
- } as never);
139
147
  const execution = await session.callTool(
140
148
  // THE TOOL NAME FROM THE TASK'S ONE URI (docs/mcp/MCP_TASKS.md § 2a): a task URI or its short
141
149
  // form `<org>/<name>` is called as `<org>.<name>`; anything else (a bare app id) as given.
@@ -257,6 +265,9 @@ export async function invokeTaskNative(
257
265
  return {
258
266
  status: "completed",
259
267
  output: { result: result.structuredContent.result },
268
+ // THE TASK'S OWN ID (owner 2026-10-24: "save the task, show it in the timeline, click it
269
+ // to see it"): the run that did this, so the caller can record it where the work belongs.
270
+ ...(result.structuredContent?.executionId ? { run: result.structuredContent.executionId } : {}),
260
271
  conversationId: result.structuredContent?.conversationId,
261
272
  message:
262
273
  "The Agent completed. `output.result` is what it found, already shown to the person on screen. " +
@@ -303,3 +314,36 @@ export async function invokeTaskNative(
303
314
  }
304
315
 
305
316
  // Memory ingest — every agent family saves completed turns the same way.
317
+
318
+ /**
319
+ * A TASK THAT ALREADY EXISTS, by its id (docs/mcp/MCP_TASKS.md § 5c): the official client's
320
+ * `session.task(id)`. Read gives where it is and, once it has finished, what it produced; answer
321
+ * replies to what a waiting task asks (`tasks/update`). Nothing else: MCP offers nothing else.
322
+ */
323
+ export async function readTask(taskId: string, ctx: Pick<AppInvocationContext, "baseUrl" | "accessToken">): Promise<{ taskId: string; status: string; statusMessage?: string; asks?: unknown; result?: string }> {
324
+ const { client, session } = await openTaskSession(ctx);
325
+ try {
326
+ const task = (session as any).task(taskId);
327
+ const view = await task.snapshot();
328
+ const raw = (view.raw ?? {}) as Record<string, any>;
329
+ const done = (raw.result?.content ?? []).find((c: any) => c?.type === "text")?.text;
330
+ return {
331
+ taskId: view.taskId,
332
+ status: view.status,
333
+ ...(view.statusMessage ? { statusMessage: view.statusMessage } : {}),
334
+ ...(raw.inputRequests && Object.keys(raw.inputRequests).length ? { asks: raw.inputRequests } : {}),
335
+ ...(typeof done === "string" && done ? { result: done } : {}),
336
+ };
337
+ } finally {
338
+ await client.close().catch(() => {});
339
+ }
340
+ }
341
+
342
+ export async function answerTask(taskId: string, inputResponses: Record<string, unknown>, ctx: Pick<AppInvocationContext, "baseUrl" | "accessToken">): Promise<void> {
343
+ const { client, session } = await openTaskSession(ctx);
344
+ try {
345
+ await (session as any).task(taskId).updateJson(inputResponses);
346
+ } finally {
347
+ await client.close().catch(() => {});
348
+ }
349
+ }
@@ -0,0 +1,124 @@
1
+ /**
2
+ * PROGRESSIVE TOOL DISCOVERY (MCP 2026-07-28, Client Best Practices; docs/mcp/MCP_2026-07-28.md
3
+ * § 4, 3e; packages/docs/nodes/mcp-services.md, "Many tools").
4
+ *
5
+ * A host with many tools does not hand the model every definition. It keeps them, gives the
6
+ * model a `search_tools` meta-tool, and loads a definition only when the job needs it. Our
7
+ * harness is the host for every Agent, so this is decided here, once, for every model.
8
+ *
9
+ * THE TOOL LIST NEVER CHANGES DURING A RUN. A found tool is not added to the list: it is called
10
+ * through `call_tool`, MCP's "single stable call_tool({name, args}) meta-tool so the array never
11
+ * changes". Adding it would break the model's prompt cache at that turn.
12
+ *
13
+ * Pure: the tools come in, the offer and the answers go out. The bridge does the calling.
14
+ */
15
+
16
+ /** A tool as the bridge holds it: what the model would be offered, plus the server it is from. */
17
+ export interface HeldTool {
18
+ name: string;
19
+ description: string;
20
+ parameters: unknown;
21
+ server?: string;
22
+ }
23
+
24
+ /**
25
+ * THE THRESHOLD, in estimated tokens of definitions. MCP: switch "once the tool definitions take
26
+ * up a significant part of the available context window", for example 1% to 5%. 2,000 is 1% of a
27
+ * 200k window and under 2% of a 128k one. Below it, every tool is offered as before.
28
+ */
29
+ export const DEFER_ABOVE_TOKENS = 2000;
30
+
31
+ /** How many tools one search hands back: a few that fit, never the catalogue. */
32
+ const MATCHES = 5;
33
+
34
+ export const SEARCH_TOOLS = "search_tools";
35
+ export const CALL_TOOL = "call_tool";
36
+
37
+ /** About four characters a token: the usual estimate, and close enough to pick a side of 2,000. */
38
+ export function estimateTokens(tools: HeldTool[]): number {
39
+ return Math.ceil(JSON.stringify(tools.map(({ name, description, parameters }) => ({ name, description, parameters }))).length / 4);
40
+ }
41
+
42
+ /** Should these tools be held back behind search? */
43
+ export function shouldDefer(tools: HeldTool[]): boolean {
44
+ return estimateTokens(tools) > DEFER_ABOVE_TOKENS;
45
+ }
46
+
47
+ /** A tool in one line: its name and the first sentence of what it does, never its schema. */
48
+ function oneLine(t: HeldTool): string {
49
+ const first = String(t.description ?? "").split(/(?<=[.!?:])\s/)[0].trim();
50
+ return `- ${t.name}: ${first.length > 140 ? `${first.slice(0, 137)}...` : first}`;
51
+ }
52
+
53
+ /**
54
+ * THE CATALOGUE: every held tool in one line, grouped by server (MCP: "names and one-line
55
+ * descriptions", "group tools by server"). The model sees what it has and loads a definition only
56
+ * when it needs it; without the lines an Agent never knew a tool existed, so it never searched and
57
+ * named tools it did not have (Bob, 2026-10-11). Stable for the run, so the prompt cache holds.
58
+ */
59
+ export function catalogue(held: HeldTool[]): string {
60
+ const groups = new Map<string, HeldTool[]>();
61
+ for (const t of held) {
62
+ const key = t.server ?? "";
63
+ groups.set(key, [...(groups.get(key) ?? []), t]);
64
+ }
65
+ return [...groups.entries()].map(([server, tools]) => [server ? `${server}:` : "", ...tools.map(oneLine)].filter(Boolean).join("\n")).join("\n");
66
+ }
67
+
68
+ /** The two meta-tools, offered in place of the held ones. The search carries the catalogue. */
69
+ export function metaTools(held: HeldTool[]): HeldTool[] {
70
+ return [
71
+ {
72
+ name: SEARCH_TOOLS,
73
+ description:
74
+ `Load the full definition (input schema) of the tools you need before you call them. Describe the job in plain words, or give a tool's name; the answer is the tools that fit, each with its server and full input schema. Call it before ${CALL_TOOL} for any tool whose schema you do not yet hold. The tools you have:\n${catalogue(held)}`,
75
+ parameters: {
76
+ type: "object",
77
+ properties: { query: { type: "string", description: "What you need to do, e.g. 'save a person's email address'." } },
78
+ required: ["query"],
79
+ },
80
+ },
81
+ {
82
+ name: CALL_TOOL,
83
+ description: `Call one tool that ${SEARCH_TOOLS} found, by its exact name, with arguments matching its input schema.`,
84
+ parameters: {
85
+ type: "object",
86
+ properties: {
87
+ name: { type: "string", description: `The tool's exact name, as ${SEARCH_TOOLS} gave it.` },
88
+ arguments: { type: "object", description: "The tool's arguments, matching its input schema." },
89
+ },
90
+ required: ["name"],
91
+ },
92
+ },
93
+ ];
94
+ }
95
+
96
+ const words = (s: string): string[] => s.toLowerCase().split(/[^a-z0-9]+/).filter((w) => w.length > 1);
97
+
98
+ /**
99
+ * KEYWORD SEARCH, the first of MCP's strategies ("simple and effective, particularly for descriptive
100
+ * tool names and descriptions"). A word of the query in the tool's name counts most, then in its
101
+ * server, then in its description. A name's own parts count: `send_message` is "send" and "message".
102
+ */
103
+ export function searchTools(held: HeldTool[], query: string): Array<{ name: string; server?: string; description: string; inputSchema: unknown }> {
104
+ const asked = [...new Set(words(query))];
105
+ const scored = held
106
+ .map((t) => {
107
+ const name = new Set(words(t.name.replace(/([a-z0-9])([A-Z])/g, "$1 $2")));
108
+ const server = new Set(words((t.server ?? "").replace(/([a-z0-9])([A-Z])/g, "$1 $2")));
109
+ const text = new Set(words(t.description));
110
+ const score = asked.reduce((s, w) => s + (name.has(w) ? 3 : 0) + (server.has(w) ? 2 : 0) + (text.has(w) ? 1 : 0), 0);
111
+ return { t, score };
112
+ })
113
+ .filter((x) => x.score > 0)
114
+ .sort((a, b) => b.score - a.score);
115
+ return scored.slice(0, MATCHES).map(({ t }) => ({ name: t.name, ...(t.server ? { server: t.server } : {}), description: t.description, inputSchema: t.parameters }));
116
+ }
117
+
118
+ /** The answer to a search the model reads: the matches, or what to do when there are none. */
119
+ export function searchAnswer(held: HeldTool[], query: string): string {
120
+ const found = searchTools(held, String(query ?? ""));
121
+ if (found.length) return JSON.stringify({ tools: found });
122
+ const servers = [...new Set(held.map((t) => t.server).filter(Boolean))];
123
+ return JSON.stringify({ tools: [], note: `No tool matched "${query}". Describe the job in other words${servers.length ? `, or name a server: ${servers.join(", ")}` : ""}.` });
124
+ }
@@ -192,7 +192,7 @@ export function componentAppInvoker(
192
192
  * `handoffTools` and nothing else, so `bridge.call` found no entry in `appInvokers` and fell
193
193
  * through to `api.callService`, which addresses SERVICE methods: the platform answered
194
194
  * "SpatialSearch has no service method \"sab-voice\"" and the call failed in 10ms while the
195
- * run still reported success. Observed live on SAB (2026-09-07): the app was found, minted
195
+ * run still reported success. Observed live on a bank (2026-09-07): the app was found, minted
196
196
  * and CALLED, and the person watched a blank panel. Every workflow-bound app shared this —
197
197
  * the path-B sibling three lines above had an invoker from the day it was written.
198
198
  */
@@ -112,7 +112,7 @@ export function inheritProps(
112
112
  // could send, which is exactly the venue-renaming this flag exists to prevent.
113
113
  // `input` TRAVELS TOO (2026-09-15). An atom that marks a field `input: false` (the button's
114
114
  // `icon` and `link`) says nobody outside fills it; without the word here the host inherited
115
- // the field as fillable and the tool asked the model for it (SAB transfer: four fields it
115
+ // the field as fillable and the tool asked the model for it (a bank's transfer form: four fields it
116
116
  // could not honestly write). Same failure as `override` above, one word later.
117
117
  const CONTRACT = ["type", "description", "maxLength", "minItems", "maxItems", "items", "hydrate", "optional", "override", "preview", "input"] as const;
118
118
  // AN ATOM INSIDE AN `Each` DESCRIBES THE ITEM, not the page. The scope is the chain of