@unoverse-platform/studio 0.1.97 → 0.1.99

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 (120) hide show
  1. package/bin/studio.mjs +2 -2
  2. package/local/catalog.ts +5 -1
  3. package/local/nodes.ts +28 -41
  4. package/local/plugin.ts +71 -13
  5. package/local/sample-identity/brand.yaml +24 -0
  6. package/local/sample-identity/organisation.yaml +14 -0
  7. package/local/sample-identity/purpose.yaml +14 -0
  8. package/local/sample-identity/story.yaml +18 -0
  9. package/local/scaffold.mjs +43 -23
  10. package/local/source.ts +3 -8
  11. package/package.json +1 -1
  12. package/screens/AgentsView.tsx +173 -0
  13. package/screens/ComponentsView.tsx +2 -2
  14. package/screens/EmptyLibrary.tsx +1 -1
  15. package/screens/IdentityView.tsx +3 -3
  16. package/screens/PromptBlocksView.tsx +1 -1
  17. package/screens/TemplatesLibraryView.tsx +91 -6
  18. package/screens/TemplatesView.tsx +24 -9
  19. package/screens/index.ts +2 -0
  20. package/src/App.tsx +8 -3
  21. package/src/host.ts +1 -14
  22. package/vendor/base/src/agent-mcp/discovery.ts +76 -18
  23. package/vendor/base/src/agent-mcp/handoff.ts +11 -31
  24. package/vendor/base/src/agent-mcp/index.ts +5 -5
  25. package/vendor/base/src/agent-mcp/invoke.ts +37 -19
  26. package/vendor/base/src/agent-mcp/lean.ts +19 -0
  27. package/vendor/base/src/agent-mcp/memoryContext.ts +15 -9
  28. package/vendor/base/src/agent-mcp/memoryIngest.ts +8 -3
  29. package/vendor/base/src/agent-mcp/rowComponents.ts +184 -72
  30. package/vendor/base/src/agent-mcp/tools.ts +136 -7
  31. package/vendor/base/src/agent-mcp/types.ts +47 -10
  32. package/vendor/base/src/auth/auth.ts +35 -5
  33. package/vendor/base/src/definitions/isolated.ts +37 -0
  34. package/vendor/base/src/definitions/load/compile/component.ts +59 -4
  35. package/vendor/base/src/definitions/load/compile/sections.ts +7 -1
  36. package/vendor/base/src/definitions/load/compile/template.ts +19 -15
  37. package/vendor/base/src/definitions/load/expand/inherit.ts +5 -1
  38. package/vendor/base/src/definitions/load/expand/referenced.ts +21 -2
  39. package/vendor/base/src/definitions/load/expand/refs.ts +14 -0
  40. package/vendor/base/src/definitions/load/expand/walk.ts +88 -38
  41. package/vendor/base/src/definitions/load/load.ts +37 -28
  42. package/vendor/base/src/definitions/model.ts +8 -12
  43. package/vendor/base/src/definitions/registry/agents.ts +117 -0
  44. package/vendor/base/src/definitions/registry/catalog.ts +40 -24
  45. package/vendor/base/src/definitions/registry/datasetSql.ts +149 -0
  46. package/vendor/base/src/definitions/registry/homes.ts +69 -5
  47. package/vendor/base/src/definitions/registry/objects.ts +413 -0
  48. package/vendor/base/src/definitions/registry/orgDocuments.ts +69 -29
  49. package/vendor/base/src/definitions/registry/orgPack.ts +11 -11
  50. package/vendor/base/src/definitions/registry/pipelines.ts +197 -0
  51. package/vendor/base/src/definitions/theme.ts +1 -2
  52. package/vendor/base/src/items/collect.ts +29 -25
  53. package/vendor/base/src/items/publish.ts +30 -6
  54. package/vendor/base/src/lint/context.mjs +17 -0
  55. package/vendor/base/src/lint/design/file.mjs +199 -2
  56. package/vendor/base/src/lint/design/index.mjs +66 -73
  57. package/vendor/base/src/lint/design/vocabulary.mjs +5 -2
  58. package/vendor/base/src/lint/design/walk.mjs +14 -4
  59. package/vendor/base/src/lint/index.mjs +15 -9
  60. package/vendor/base/src/lint/nodes/blocks.mjs +6 -2
  61. package/vendor/base/src/lint/nodes/index.mjs +5 -0
  62. package/vendor/base/src/lint/nodes/node.mjs +157 -7
  63. package/vendor/base/src/lint/objects/index.d.mts +32 -0
  64. package/vendor/base/src/lint/objects/index.mjs +324 -0
  65. package/vendor/base/src/lint/objects/rules.d.mts +44 -0
  66. package/vendor/base/src/lint/objects/rules.mjs +446 -0
  67. package/vendor/base/src/lint/prompts/index.d.mts +34 -0
  68. package/vendor/base/src/lint/prompts/index.mjs +198 -0
  69. package/vendor/base/src/manifests/compose.ts +80 -9
  70. package/vendor/base/src/manifests/executor/callback.ts +15 -4
  71. package/vendor/base/src/manifests/executor/context.ts +9 -1
  72. package/vendor/base/src/manifests/executor/memory.ts +83 -3
  73. package/vendor/base/src/manifests/executor/session.ts +50 -0
  74. package/vendor/base/src/manifests/executor/toolBridge.ts +114 -49
  75. package/vendor/base/src/manifests/runtime/auth/aws.ts +14 -1
  76. package/vendor/base/src/manifests/runtime/channels/final.ts +46 -34
  77. package/vendor/base/src/manifests/runtime/channels/service.ts +30 -32
  78. package/vendor/base/src/manifests/runtime/channels/workflow.ts +6 -11
  79. package/vendor/base/src/manifests/runtime/context.ts +18 -6
  80. package/vendor/base/src/manifests/runtime/duplex/audioLane.ts +159 -3
  81. package/vendor/base/src/manifests/runtime/duplex/session.ts +304 -9
  82. package/vendor/base/src/manifests/runtime/events.ts +39 -3
  83. package/vendor/base/src/manifests/runtime/http/concurrency.ts +44 -0
  84. package/vendor/base/src/manifests/runtime/http/request.ts +36 -7
  85. package/vendor/base/src/manifests/runtime/index.ts +3 -2
  86. package/vendor/base/src/manifests/runtime/loops/repeat.ts +86 -0
  87. package/vendor/base/src/manifests/runtime/tools/toolloop.ts +30 -3
  88. package/vendor/base/src/manifests/runtime/usage.ts +10 -0
  89. package/vendor/base/src/paths.ts +16 -8
  90. package/vendor/base/src/platform/audioManager.ts +32 -0
  91. package/vendor/base/src/platform/executionContext.ts +14 -0
  92. package/vendor/base/src/platform/liveInput.ts +52 -0
  93. package/vendor/base/src/plugins/discovery.ts +1 -1
  94. package/vendor/base/src/template/SafeExpression.ts +6 -0
  95. package/vendor/base/src/workflow/GetMemoryService.ts +12 -11
  96. package/vendor/base/src/workflow/PlatformMemoryCore.ts +23 -10
  97. package/vendor/sdk/appEngine.tsx +30 -11
  98. package/vendor/sdk/lib/UnoverseComponent.tsx +8 -4
  99. package/vendor/sdk/lib/app.tsx +70 -95
  100. package/vendor/sdk/lib/connection.tsx +47 -57
  101. package/vendor/sdk/lib/core/actions.ts +6 -0
  102. package/vendor/sdk/lib/core/apps.ts +66 -81
  103. package/vendor/sdk/lib/core/client.ts +5 -14
  104. package/vendor/sdk/lib/core/connection.ts +30 -0
  105. package/vendor/sdk/lib/core/index.ts +2 -2
  106. package/vendor/sdk/lib/core/store.ts +90 -15
  107. package/vendor/sdk/lib/core/types.ts +9 -8
  108. package/vendor/sdk/lib/primitives.tsx +113 -4
  109. package/vendor/sdk/lib/realtime/useAudioPlayback.ts +8 -1
  110. package/vendor/sdk/lib/render.tsx +3 -3
  111. package/vendor/sdk/lib/voice.tsx +27 -13
  112. package/local/sample-identity/brand/brand.yaml +0 -88
  113. package/local/sample-identity/brand/manifest.yaml +0 -13
  114. package/local/sample-identity/organisation/manifest.yaml +0 -15
  115. package/local/sample-identity/organisation/organisation.yaml +0 -53
  116. package/local/sample-identity/purpose/manifest.yaml +0 -12
  117. package/local/sample-identity/purpose/purpose.yaml +0 -46
  118. package/local/sample-identity/story/manifest.yaml +0 -12
  119. package/local/sample-identity/story/story.yaml +0 -63
  120. package/vendor/base/src/definitions/registry/identitySeed.ts +0 -17
@@ -7,7 +7,7 @@
7
7
  * outlived the accuracy (renamed 2026-08-09).
8
8
  */
9
9
  import type { RowComponent, AppInvocationContext } from "./types.js";
10
- import { invokeComponentAppNative } from "./invoke.js";
10
+ import { invokeTaskNative } from "./invoke.js";
11
11
  // Pure section helpers, shared with every renderer so the rules live in one place.
12
12
  import { toDocument } from "../markdown/document.js";
13
13
 
@@ -32,27 +32,10 @@ export function rowComponentsFromResults(results: unknown): RowComponent[] {
32
32
  const seen = new Set<string>();
33
33
  if (!Array.isArray(results)) return out;
34
34
  for (const item of results as any[]) {
35
- if (item?.object_type === "mcp") {
36
- /**
37
- * A REGISTRY ROW THAT IS A COMPONENT ARRIVES AS ITS SHELL (TEMPLATE_MODEL §12e "One
38
- * ladder, one delivery", ruled 2026-09-04). A bindingless component (a manifest, no
39
- * workflow — the card finder) used to be only UNLOCKED here as a tool and rendered a
40
- * turn later when the model called it; meanwhile the display cards from the same
41
- * search had already landed, so a `page` card held the screen for the second before
42
- * the `focus` component existed. Now it lands in the SAME delivery as the cards, in
43
- * its declared state, and the ladder decides among them at once. Delivered, never
44
- * invoked: no row props, no outputs collected — the model's own call (still minted
45
- * by discovery) merges its arguments into this slice and collects the answers.
46
- * Workflow-bound apps (path A) and native MCP tools stay tools.
47
- */
48
- const uri = item?.metadata?.app;
49
- if (typeof uri !== "string" || !uri.startsWith("unoverse://components/") || item.metadata?.workflow || item.metadata?.schema?.methods) continue;
50
- const component = componentTypeFromAppUri(uri);
51
- if (!component || seen.has(component)) continue;
52
- seen.add(component);
53
- out.push({ id: component, component, props: {}, shell: true });
54
- continue;
55
- }
35
+ // TASKS ARE ASKED, CONTENT IS RENDERED (RENDER_PIPELINE §4, locked 2026-09-06). A
36
+ // registry row (a component, a task, an app) is a door the LLM calls; it never reaches
37
+ // the screen through this lane. Only rows with a component attached — content — do.
38
+ if (item?.object_type === "mcp" || item?.object_type === "task") continue;
56
39
  const app = item?.metadata?.app;
57
40
  if (typeof app !== "string" || !app.startsWith("unoverse://components/") || item.metadata.workflow) continue;
58
41
  const component = componentTypeFromAppUri(app);
@@ -62,12 +45,16 @@ export function rowComponentsFromResults(results: unknown): RowComponent[] {
62
45
  out.push({
63
46
  id,
64
47
  component,
48
+ rank: rankOf(item),
65
49
  // The row's content IS the component's state. Row-level facts ride along:
66
50
  // `link` is the row's page as a LABELLED markdown link — tagline (fallback
67
51
  // title) linked to source_url, ready for a Markdown bind.
68
52
  props: {
69
53
  title: item.title,
70
54
  description: item.description,
55
+ // THE SOURCE (owner, 2026-09-11): the row's source_id rides its card, so the screen
56
+ // can name it and the model can scope a search to what is open.
57
+ ...(item.source_id ? { source_id: item.source_id } : {}),
71
58
  // EVERY row carries `sections`, so a renderer has ONE thing to read. A row promoted
72
59
  // before the structuring pass existed has only `bodyCopy`, which is a valid document
73
60
  // of one prose section — synthesised here rather than in each renderer, and never
@@ -86,6 +73,25 @@ export function rowComponentsFromResults(results: unknown): RowComponent[] {
86
73
  return out;
87
74
  }
88
75
 
76
+ /**
77
+ * THE ROW'S RELEVANCE, read the same way the search's own merge reads it
78
+ * (routes/dictionary/search/ranking.ts `rank`): the score where there is one, else the
79
+ * negated distance, so higher is always better. Rows in one delivery come from one search
80
+ * call, so their numbers compare even though intent scores cosine and discovery scores
81
+ * proximity.
82
+ */
83
+ function rankOf(item: any): number | undefined {
84
+ const r =
85
+ typeof item?.similarity_score === "number"
86
+ ? item.similarity_score
87
+ : typeof item?.similarity === "number"
88
+ ? item.similarity
89
+ : typeof item?.distance === "number"
90
+ ? -item.distance
91
+ : undefined;
92
+ return typeof r === "number" && Number.isFinite(r) ? r : undefined;
93
+ }
94
+
89
95
  /**
90
96
  * Render the components discovered rows carry, on the caller's live session — the ONE
91
97
  * place this lane executes (every agent family calls this; adapters never re-implement
@@ -93,40 +99,149 @@ export function rowComponentsFromResults(results: unknown): RowComponent[] {
93
99
  * held call resolves immediately server-side) and a failed render never breaks the
94
100
  * conversation.
95
101
  */
102
+ /** ONE INSTANCE ON SCREEN, as the screen document says it (docs/unoverse/design/screen/UNOVERSE_SCREEN.md §2.5). */
103
+ export interface ScreenItem {
104
+ /** The instance id; for a row card, the row's own id. */
105
+ id: string;
106
+ type: string;
107
+ /** True when the LLM called it (a task). */
108
+ asked?: boolean;
109
+ /** The state the instance is in now, the guest's own move included. */
110
+ state?: string;
111
+ /** The state it arrived in. Differs from `state` when the guest moved it: they opened it. */
112
+ arrived?: string;
113
+ /** What it shows: its props, as the delivery carried them. */
114
+ props?: Record<string, unknown>;
115
+ /** The place it is shown in, on an app that declares places. */
116
+ place?: string;
117
+ }
118
+
119
+ /** WHAT IS ON SCREEN, asked of the server's screen document, which registers this at boot; the
120
+ * search result reads it so the model is told what the person is looking at. */
121
+ let screenReader: ((conversationId: string) => ScreenItem[]) | null = null;
122
+ export function onScreenRead(fn: (conversationId: string) => ScreenItem[]): void {
123
+ screenReader = fn;
124
+ }
125
+ /** THE PLACES a conversation's app declares (docs/unoverse/design/screen/UNOVERSE_SCREEN.md), asked of
126
+ * the server, which registers this at boot. The conversation is always among them; nothing is
127
+ * shown unless something shows it. */
128
+ let placesReader: ((conversationId: string) => string[] | null) | null = null;
129
+ export function onScreenPlaces(fn: (conversationId: string) => string[] | null): void {
130
+ placesReader = fn;
131
+ }
132
+ export function placesOn(conversationId: string | undefined): string[] {
133
+ return (conversationId && placesReader ? placesReader(conversationId) : null) ?? ["chat"];
134
+ }
135
+ export function screenOf(conversationId: string | undefined): ScreenItem[] {
136
+ return conversationId && screenReader ? screenReader(conversationId) : [];
137
+ }
138
+ /** The open tasks standing, by component id: what the LLM called and holds the screen. */
139
+ export function openTasksOn(conversationId: string | undefined): string[] {
140
+ return screenOf(conversationId).filter((i) => i.asked).map((i) => i.type).filter(Boolean);
141
+ }
142
+ /** The instances the GUEST opened: moved out of the state they arrived in (a card tapped
143
+ * into its page). Derived from the move, never from a state's name: no word is special. */
144
+ export function openedOn(conversationId: string | undefined): ScreenItem[] {
145
+ return screenOf(conversationId).filter((i) => !i.asked && i.state && i.arrived && i.state !== i.arrived);
146
+ }
147
+
148
+ /** WHEN AN OPEN PAGE IS THE ANSWER (RENDER_PIPELINE §3.3, ruled 2026-09-07). "About it" is
149
+ * narrow: a message about the page itself updates it; a NEW QUESTION is answered and shown,
150
+ * and the page closes. Read loosely ("if the message is about it") the model rebuilt a
151
+ * comparison for "what is the best card for travel", a question that never asked to compare
152
+ * (Gavin: "but I did not ask to compare"). ONE sentence, said in the search mirror and in the
153
+ * re-minted page tool, so the two can never drift. */
154
+ export const ABOUT_THE_PAGE =
155
+ "Call that tool ONLY when the user's message is about the page itself: change it, add to it, swap one of its items, explain one of its rows. A new question is NOT about the page, even if the page could hold the answer: answer it and call screen_show, which closes the page.";
156
+
157
+ /** WHAT THE GUEST OPENED, said in the mirror (ruled 2026-09-10): the item and its id, so
158
+ * the model's next search and its getDetail carry the id of what is open. ONE sentence,
159
+ * the same in the mirror and in screen_read. */
160
+ export const ABOUT_THE_OPENED =
161
+ "The user is looking at it: a question is about it unless they say otherwise. Its full record is getDetail(<id>); what it is linked to, one step at a time, is walk(<id>); pass its source_id as sourceId to a search when the question is inside it.";
162
+
163
+ /** The mirror's first line: any open page (a task the LLM called, with its tool), then
164
+ * anything the guest opened (a card moved into its page, with its id), then the rules. */
165
+ export function onScreenLine(open: string[], toolNameOf: (id: string) => string, opened: ScreenItem[] = []): string {
166
+ const page = open.length ? `ON SCREEN NOW: ${open.map((id) => `${id} (open page; its tool is ${toolNameOf(id)})`).join(", ")}. ${ABOUT_THE_PAGE} ` : "";
167
+ const moved = opened.length
168
+ ? `THE USER OPENED: ${opened.map((i) => `${i.type} ${i.id} (now in ${i.state}${titleOf(i) ? `, "${titleOf(i)}"` : ""}${sourceOf(i) ? `, source_id ${sourceOf(i)}` : ""})`).join(", ")}. ${ABOUT_THE_OPENED} `
169
+ : "";
170
+ return `${page}${moved}`;
171
+ }
172
+ const titleOf = (i: ScreenItem): string => (typeof i.props?.title === "string" ? (i.props.title as string).slice(0, 80) : "");
173
+ const sourceOf = (i: ScreenItem): string => (typeof i.props?.source_id === "string" ? (i.props.source_id as string) : "");
174
+
175
+ /** WHAT IS ON SCREEN, said in a search result on an app with places
176
+ * (docs/unoverse/design/screen/UNOVERSE_SCREEN.md §3.1): each place and what it holds, then what this
177
+ * search showed. The model reads it right before it answers. */
96
178
  /**
97
- * THE EMPTY DELIVERY IS SAID, NOT INFERRED (UNOVERSE_TEMPLATE_MODEL §12e, the
98
- * mechanical spent rule): a component-capable search that ran and surfaced
99
- * nothing has REPLACED the previous answer with nothing, and the receiver needs
100
- * to know. One listener (the server's director bookkeeping) registers here at
101
- * boot; the lane announces, the receiver decides. In-process only by design —
102
- * if the lane ever runs out of process the note goes missing and the rule fails
103
- * toward KEEP, the safe side.
179
+ * WHAT THIS TURN'S SEARCHES FOUND (docs/unoverse/design/screen/UNOVERSE_SCREEN.md §3.1). A search
180
+ * draws nothing: the rows that carry a card wait here, per conversation, until the model shows
181
+ * the ones its answer is about with `screen_show`. Searches in one turn add up (a run the turn
182
+ * started extends its chat id); the first search of a new turn replaces them.
104
183
  */
105
- let emptyDeliveryListener: ((chatId: string) => void) | null = null;
106
- export function onEmptyRowDelivery(fn: (chatId: string) => void): void {
107
- emptyDeliveryListener = fn;
184
+ const foundRows = new Map<string, { turn: string; rows: RowComponent[] }>();
185
+ const MAX_FOUND = 10000;
186
+
187
+ export function rememberRows(conversationId: string, chatId: string, rows: RowComponent[]): void {
188
+ const held = foundRows.get(conversationId);
189
+ const sameTurn = !!held && (chatId === held.turn || chatId.startsWith(`${held.turn}:`));
190
+ const kept = sameTurn ? held!.rows.filter((r) => !rows.some((x) => x.id === r.id)) : [];
191
+ foundRows.delete(conversationId);
192
+ foundRows.set(conversationId, { turn: sameTurn ? held!.turn : chatId, rows: [...kept, ...rows] });
193
+ if (foundRows.size > MAX_FOUND) foundRows.delete(foundRows.keys().next().value!);
194
+ }
195
+
196
+ /** The id the model is given for a card: the row's universal id, as the search result shows it. */
197
+ export const cardIdOf = (row: RowComponent): string => String(row.props?.universal_id ?? row.id);
198
+
199
+ /** This turn's cards with these ids, in the order the model gave them. */
200
+ export function rowsById(conversationId: string, ids: string[]): RowComponent[] {
201
+ const rows = foundRows.get(conversationId)?.rows ?? [];
202
+ const out: RowComponent[] = [];
203
+ for (const id of ids) {
204
+ const row = rows.find((r) => r.id === id || cardIdOf(r) === id);
205
+ if (row && !out.includes(row)) out.push(row);
206
+ }
207
+ return out;
208
+ }
209
+
210
+ /** The task the search put first (objects-in-spatial.md, "A task first"). */
211
+ export interface TaskFirstLine {
212
+ universal_id: string;
213
+ title: string;
214
+ }
215
+
216
+ export function placesLine(conversationId: string | undefined, places: string[], cards: RowComponent[], task?: TaskFirstLine | null): string {
217
+ const items = screenOf(conversationId);
218
+ const named = places.filter((p) => p !== "chat");
219
+ const now = named
220
+ .map((p) => {
221
+ const here = items.filter((i) => i.place === p);
222
+ return here.length ? `${p}: ${here.map((i) => `${i.type} ${i.id}${titleOf(i) ? ` "${titleOf(i)}"` : ""}`).join(", ")}` : `${p}: empty`;
223
+ })
224
+ .join("; ");
225
+ const list = cards
226
+ .slice(0, 12)
227
+ .map((c) => `${cardIdOf(c)}${typeof c.props?.title === "string" ? ` "${c.props.title}"` : ""}`)
228
+ .join(", ");
229
+ const found = cards.length
230
+ ? ` This search found ${cards.length} card(s): ${list}. None is shown yet. If your answer is these cards themselves, call screen_show with their ids now, before reading their details: the user sees them at once, and you read while they look. If you will hand them to a task instead, such as a comparison, do not show them: the task is what the user sees.`
231
+ : "";
232
+ // A TASK FIRST (objects-in-spatial.md, 2026-09-18): the search ranked a task first, so
233
+ // the fact is said, and the order is: call it now, read after.
234
+ const opened = task
235
+ ? ` A task answers this request: ${task.title} (${task.universal_id}). Call it now, before reading any detail, and give it the cards it needs from this search; it is what the user sees, so do not show the cards.`
236
+ : "";
237
+ return `ON SCREEN: ${now}.${opened}${found}`;
108
238
  }
109
239
 
110
- /** What the delivery did with one row: rendered, held back by the director or the ladder,
111
- * delivered as a shell, or failed. The search's MIRROR is written from these. */
240
+ /** What the delivery did with one row: rendered, or failed. */
112
241
  export interface RowOutcome {
113
242
  component: string;
114
243
  id: string;
115
- action: "rendered" | "held-back" | "shell" | "failed";
116
- }
117
-
118
- /**
119
- * THE SEARCH'S MIRROR (MCP_APP_PROTOCOL §0.1, ruled 2026-09-05): one line saying what the
120
- * delivery put on screen and what it did not show, so the model answers the screen and
121
- * not the rows. Empty when nothing is on screen (card-only deliveries do not mirror).
122
- */
123
- export function screenLine(outcomes: RowOutcome[]): string {
124
- const shown = outcomes.filter((o) => o.action === "shell" || o.action === "rendered");
125
- const hidden = outcomes.filter((o) => o.action === "held-back");
126
- if (!shown.some((o) => o.action === "shell")) return "";
127
- const on = shown.map((o) => (o.action === "shell" ? `${o.component} (on screen, in its opening state)` : o.component)).join(", ");
128
- const off = hidden.length ? ` Not shown: ${hidden.length} result card(s), ranked below it on the app's ladder — do not describe them as visible.` : "";
129
- return `On screen: ${on}. Answer by pointing the guest to what is on screen.${off}`;
244
+ action: "rendered" | "failed";
130
245
  }
131
246
 
132
247
  export function renderRowComponents(
@@ -136,15 +251,8 @@ export function renderRowComponents(
136
251
  ): Promise<RowOutcome[]> {
137
252
  // No live session (builder/test/headless callers) → nothing to render; the
138
253
  // search stays pure automatically — no flag juggling needed by the caller.
139
- if (!ctx.userId) return Promise.resolve([]);
140
- if (!items?.length) {
141
- // The search ran on a live session and surfaced nothing — say so (§12e).
142
- if (ctx.chatId) emptyDeliveryListener?.(ctx.chatId);
143
- return Promise.resolve([]);
144
- }
145
- // ONE DELIVERY, announced as a batch (UNOVERSE_TEMPLATE_MODEL §12e): the server's
146
- // director waits for all siblings, judges once, and only the held set renders —
147
- // in verdict order, before anything reaches the screen.
254
+ if (!ctx.userId || !items?.length) return Promise.resolve([]);
255
+ // ONE DELIVERY, carried as a batch: the screen keeps the cards of one search in its order.
148
256
  const batchId = `${ctx.chatId ?? ctx.conversationId ?? "b"}:${Math.random().toString(36).slice(2, 8)}`;
149
257
  let index = 0;
150
258
  const outcomes: Promise<RowOutcome>[] = [];
@@ -160,24 +268,19 @@ export function renderRowComponents(
160
268
  // Without it the brief referee judges the row — a component that renders the Document
161
269
  // inherits its prose/keyFacts briefs and gets rejected for parts nobody composes.
162
270
  // Fire-and-forget, log on settle: a slow render must not hold up the answer.
163
- // A shell keys the component's own slice (no per-row instance), so the model's later
164
- // call for the same component merges into it rather than drawing a second one.
165
271
  outcomes.push(
166
- invokeComponentAppNative(item.component, {
272
+ invokeTaskNative(item.component, {
167
273
  ...ctx,
168
274
  message: "",
169
275
  props: item.props,
170
- ...(item.shell ? { shell: true } : { instanceId: item.id }),
276
+ instanceId: item.id,
277
+ ...(typeof item.rank === "number" ? { rank: item.rank } : {}),
171
278
  fromRow: true,
172
279
  batch: { id: batchId, size: items.length, index: index++ },
173
280
  })
174
- // "RENDERED" must mean rendered: a director-dropped card resolves too
175
- // (action: "held-back") and saying RENDERED for it is the exact
176
- // proof-of-nothing this log line was once rewritten to stop telling.
177
- .then((r): RowOutcome => {
178
- const action: RowOutcome["action"] = r.action === "held-back" ? "held-back" : r.action === "shell" ? "shell" : "rendered";
179
- log?.(action === "held-back" ? `🧩 HELD BACK ${item.component} (${item.id})` : `🧩 RENDERED ${item.component} (${item.id})`);
180
- return { component: item.component, id: item.id, action };
281
+ .then((): RowOutcome => {
282
+ log?.(`🧩 RENDERED ${item.component} (${item.id})`);
283
+ return { component: item.component, id: item.id, action: "rendered" };
181
284
  })
182
285
  .catch((e: any): RowOutcome => {
183
286
  log?.(`🧩 FAILED ${item.component} (${item.id}) — ${e?.message ?? e}`);
@@ -189,7 +292,7 @@ export function renderRowComponents(
189
292
  }
190
293
 
191
294
  /** `unoverse://components/journey-finder` → `journey-finder`;
192
- * `unoverse://components/bpp/journey-finder` → `bpp/journey-finder` (the render ref);
295
+ * `unoverse://components/<org>/finder` → `<org>/finder` (the render ref);
193
296
  * `unoverse://templates/<org>/<page>` → `<org>/<page>`.
194
297
  * The org survives: names are unique only within a tier, so stripping to the last
195
298
  * segment would collapse two orgs' same-named components into one ambiguous type.
@@ -201,9 +304,18 @@ export function renderRowComponents(
201
304
  * `unoverse:..templates.<org>.<page>`. Observed live 2026-08-31: an agent
202
305
  * composed an entire page, every part filled, and the call bounced with -32602 at the
203
306
  * door. Silent but for the agent's own trace, since nothing downstream logs a name it
204
- * never resolved. */
307
+ * never resolved.
308
+ *
309
+ * THE HASH IS NOT PART OF THE NAME (docs/unoverse/data/objects/README.md, the object's `app`):
310
+ * `unoverse://components/<org>/<name>#<state>` names the interface, and the hash the state
311
+ * the canvas opens ONE row in. A delivery of rows names the component alone: where an
312
+ * arrival lands is the receiving template's (UNOVERSE_TEMPLATE_MODEL §5d, the grid place
313
+ * takes arrivals). Observed live 2026-09-11: the deal object named `#page`, the tool asked
314
+ * for was `better-alpha.deal#page`, -32602 at the door, and the voice agent described a
315
+ * deal nobody could see. */
205
316
  export function componentTypeFromAppUri(app: string): string {
206
317
  return String(app)
318
+ .replace(/#.*$/, "")
207
319
  .replace(/^unoverse:\/\/(components|templates)\//, "")
208
320
  .replace(/^\/+|\/+$/g, "");
209
321
  }
@@ -215,7 +327,7 @@ export function componentTypeFromAppUri(app: string): string {
215
327
  * Every row, not only the ones carrying a component: a draft fills parts that need no
216
328
  * authoring (an image ref, a gallery of pure refs), and those reference CONTENT, which has
217
329
  * nothing to do with whether a row has a card attached. Restricting this to card-carrying
218
- * rows is what left a page drafting from three hotels while the attractions sat unusable.
330
+ * rows is what left a page drafting from rows of one kind while rows of another sat unusable.
219
331
  */
220
332
  export function rankedRowIds(resultContent: string, limit = 12): string[] {
221
333
  try {
@@ -4,8 +4,8 @@
4
4
  * One discovered row becomes one tool definition. A path-B component app has no schema on
5
5
  * the row at all, so its input schema is read LIVE from the app's own resource at mint time.
6
6
  */
7
- import type { AgentToolDef, DiscoveredMCP, AppInvocationContext } from "./types.js";
8
- import { connectPlatformMcp, invokeComponentAppNative } from "./invoke.js";
7
+ import type { AgentToolDef, DiscoveredMCP, AppInvocationContext, OfferedFunction } from "./types.js";
8
+ import { connectPlatformMcp, invokeTaskNative } from "./invoke.js";
9
9
  import { APP_ETIQUETTE } from "./discovery.js";
10
10
 
11
11
  /**
@@ -72,8 +72,12 @@ export async function resolveToolDefNative(
72
72
  const contract = JSON.parse(res.contents?.[0]?.text ?? "{}") as {
73
73
  description?: string;
74
74
  inputSchema?: Record<string, unknown> | null;
75
+ outputSchema?: Record<string, unknown> | null;
75
76
  };
76
- if (!contract.inputSchema) return toolDefFromDiscoveredMCP(mcp); // not briefed — generic contract
77
+ // WHAT COMES BACK rides the tool (MCP `outputSchema`), briefed or not: a finder with no
78
+ // fillable field still tells the model, before the call, what it hands back.
79
+ const returns = contract.outputSchema ? { outputSchema: contract.outputSchema } : {};
80
+ if (!contract.inputSchema) return { ...toolDefFromDiscoveredMCP(mcp), ...returns }; // not briefed — generic contract
77
81
  // A BRIEFED schema carries the composer directive in its top-level description.
78
82
  // Models read TOOL descriptions far more reliably than schema descriptions —
79
83
  // surface it there too, so the briefed tool self-instructs at both levels.
@@ -82,23 +86,60 @@ export async function resolveToolDefNative(
82
86
  name: modelToolName(mcp.methodName),
83
87
  description: `${contract.description || mcp.title || mcp.methodName}${directive} ${APP_ETIQUETTE}`,
84
88
  parameters: contract.inputSchema,
89
+ ...returns,
85
90
  };
86
91
  } finally {
87
92
  await close();
88
93
  }
89
- } catch {
94
+ } catch (err) {
95
+ // SAID, NOT SWALLOWED (2026-09-16): a contract read that fails hands the model a tool with
96
+ // no schema either way, and until this line nothing reported it. The card finder reached
97
+ // the model with `output_schema: null` while the server's contract carried one, and the
98
+ // only path that produces that shape is this fallback.
99
+ console.warn(`[agent-mcp] contract read failed for ${mcp.appUri ?? mcp.methodName}; minted without its schema: ${err instanceof Error ? err.message : String(err)}`);
90
100
  return toolDefFromDiscoveredMCP(mcp);
91
101
  }
92
102
  }
93
103
 
94
104
  /**
95
105
  * A bindingless component app (path B): a `component` app-tool id and NO workflow. The
96
- * adapter routes it to `invokeComponentAppNative` (held `/mcp` call) instead of firing.
106
+ * adapter routes it to `invokeTaskNative` (held `/mcp` call) instead of firing.
97
107
  */
98
108
  export function isBindinglessComponentApp(mcp: DiscoveredMCP): boolean {
99
109
  return !!mcp.component && !mcp.workflowId;
100
110
  }
101
111
 
112
+ /** A workflow task (2026-09-06): a task-augmented native tool, held until the Agent's run completes. */
113
+ export function isWorkflowTask(mcp: DiscoveredMCP): boolean {
114
+ return !!mcp.taskTool;
115
+ }
116
+
117
+ /**
118
+ * THE ONE workflow-task adapter every agent family wires for a discovered workflow task.
119
+ * The model's arguments go to the tool as they are (the task's own input schema shaped
120
+ * them); the identity rides beside them so the server pushes the face into THIS
121
+ * conversation and runs the Agent as THIS caller. The held call resolves with the run's
122
+ * result in `output.result`.
123
+ */
124
+ export function workflowTaskInvoker(
125
+ mcp: DiscoveredMCP,
126
+ identity: Pick<AppInvocationContext, "conversationId" | "userId" | "chatId" | "accessToken" | "baseUrl">,
127
+ ): (input: unknown) => ReturnType<typeof invokeTaskNative> {
128
+ const toolId = mcp.taskTool as string;
129
+ return (input: unknown) => {
130
+ const { message, ...rest } = (input ?? {}) as Record<string, unknown>;
131
+ return invokeTaskNative(toolId, {
132
+ conversationId: identity.conversationId,
133
+ userId: identity.userId,
134
+ accessToken: identity.accessToken,
135
+ chatId: identity.chatId,
136
+ ...(identity.baseUrl ? { baseUrl: identity.baseUrl } : {}),
137
+ message: typeof message === "string" ? message : "",
138
+ ...(Object.keys(rest).length ? { args: rest } : {}),
139
+ });
140
+ };
141
+ }
142
+
102
143
  /**
103
144
  * The ONE component-app adapter every agent family wires for a discovered bindingless
104
145
  * component app (path B). MCP-NATIVE by design: the tool's schema is the instruction
@@ -116,15 +157,50 @@ export function isBindinglessComponentApp(mcp: DiscoveredMCP): boolean {
116
157
  export function componentAppInvoker(
117
158
  mcp: DiscoveredMCP,
118
159
  identity: Pick<AppInvocationContext, "conversationId" | "userId" | "chatId" | "accessToken">,
119
- ): (input: unknown) => ReturnType<typeof invokeComponentAppNative> {
160
+ ): (input: unknown) => ReturnType<typeof invokeTaskNative> {
120
161
  const toolId = mcp.component as string;
121
162
  return (input: unknown) => {
122
163
  const { message, ...hydrated } = (input ?? {}) as Record<string, unknown>;
123
- return invokeComponentAppNative(toolId, {
164
+ return invokeTaskNative(toolId, {
165
+ conversationId: identity.conversationId,
166
+ userId: identity.userId,
167
+ accessToken: identity.accessToken,
168
+ chatId: identity.chatId,
169
+ message: typeof message === "string" ? message : "",
170
+ ...(Object.keys(hydrated).length ? { props: hydrated } : {}),
171
+ });
172
+ };
173
+ }
174
+
175
+ /**
176
+ * The ONE workflow-bound app adapter (path A), and the reason a called app now opens.
177
+ *
178
+ * An app is ALREADY a native MCP tool on the platform's own server — `registerTools`
179
+ * (server mcp/server/tools.ts) registers every app under its bare id plus an `<org>.<name>`
180
+ * alias — so opening one is the same held `/mcp` call a component app makes. Only the id
181
+ * differs: a path-A row carries no `component`, and its identity is the app's own name.
182
+ *
183
+ * WITHOUT THIS, a workflow-bound app minted a tool that nothing could invoke. The mint set
184
+ * `handoffTools` and nothing else, so `bridge.call` found no entry in `appInvokers` and fell
185
+ * through to `api.callService`, which addresses SERVICE methods: the platform answered
186
+ * "SpatialSearch has no service method \"sab-voice\"" and the call failed in 10ms while the
187
+ * run still reported success. Observed live on SAB (2026-09-07): the app was found, minted
188
+ * and CALLED, and the person watched a blank panel. Every workflow-bound app shared this —
189
+ * the path-B sibling three lines above had an invoker from the day it was written.
190
+ */
191
+ export function workflowAppInvoker(
192
+ mcp: DiscoveredMCP,
193
+ identity: Pick<AppInvocationContext, "conversationId" | "userId" | "chatId" | "accessToken" | "baseUrl">,
194
+ ): (input: unknown) => ReturnType<typeof invokeTaskNative> {
195
+ const toolId = String(mcp.methodName);
196
+ return (input: unknown) => {
197
+ const { message, ...hydrated } = (input ?? {}) as Record<string, unknown>;
198
+ return invokeTaskNative(toolId, {
124
199
  conversationId: identity.conversationId,
125
200
  userId: identity.userId,
126
201
  accessToken: identity.accessToken,
127
202
  chatId: identity.chatId,
203
+ ...(identity.baseUrl ? { baseUrl: identity.baseUrl } : {}),
128
204
  message: typeof message === "string" ? message : "",
129
205
  ...(Object.keys(hydrated).length ? { props: hydrated } : {}),
130
206
  });
@@ -132,3 +208,56 @@ export function componentAppInvoker(
132
208
  }
133
209
 
134
210
  /** Context a held app call needs to reach the human on the live session. */
211
+
212
+ /**
213
+ * A FOUND OBJECT'S FUNCTION AS A TOOL (docs/unoverse/data/spatial/objects-in-spatial.md, "Found
214
+ * objects' functions as tools"). Named by the function, never per object: the model sees one
215
+ * `getDetail`, told which objects answer it, and passes the row's universal_id beside the
216
+ * function's own arguments. `input: false` never reaches a tool; the function's `input` is what
217
+ * its author declared for callers, so it is handed over as written, with the id required.
218
+ */
219
+ export function objectFunctionToolDef(fn: OfferedFunction): AgentToolDef {
220
+ const input = (fn.input ?? { type: "object", properties: {} }) as Record<string, any>;
221
+ const properties = { ...(input.properties && typeof input.properties === "object" ? input.properties : {}) };
222
+ const required = Array.isArray(input.required) ? input.required.filter((r: unknown) => typeof r === "string") : [];
223
+ const who = fn.objects.length ? ` Offered by: ${fn.objects.join(", ")}.` : "";
224
+ return {
225
+ name: modelToolName(fn.name),
226
+ description: `${fn.description || fn.name}${who} Pass the universal_id of a result you already hold, exactly as it appeared; never invented.`,
227
+ parameters: {
228
+ ...input,
229
+ type: "object",
230
+ properties: {
231
+ universal_id: { type: "string", description: "The result's universal_id, exactly as it appeared in the search result." },
232
+ ...properties,
233
+ },
234
+ required: ["universal_id", ...required.filter((r: string) => r !== "universal_id")],
235
+ },
236
+ };
237
+ }
238
+
239
+ /**
240
+ * The call: the engine's one function door, `POST /dictionary/function/<name>/<universal_id>`,
241
+ * with the model's other arguments as the function's params. The engine runs the function of
242
+ * THAT row's object (a fund's `documents`, a document's `getDetail`), or answers 404 when the
243
+ * row's object offers no such function, which comes back to the model as the error it is.
244
+ */
245
+ export function objectFunctionInvoker(fn: OfferedFunction, engineUrl: string): (input: unknown) => Promise<unknown> {
246
+ return async (input: unknown) => {
247
+ const { universal_id, ...params } = (input ?? {}) as Record<string, unknown>;
248
+ const id = typeof universal_id === "string" ? universal_id.trim() : "";
249
+ if (!id) return { error: `${fn.name} needs the universal_id of a result you already hold.` };
250
+ const res = await fetch(`${engineUrl}/dictionary/function/${encodeURIComponent(fn.name)}/${encodeURIComponent(id)}`, {
251
+ method: "POST",
252
+ headers: { "content-type": "application/json" },
253
+ body: JSON.stringify(params),
254
+ });
255
+ const text = await res.text();
256
+ if (!res.ok) return { error: text || `${fn.name} answered ${res.status}` };
257
+ try {
258
+ return JSON.parse(text);
259
+ } catch {
260
+ return text;
261
+ }
262
+ };
263
+ }
@@ -25,11 +25,12 @@
25
25
  *
26
26
  * THE ONE-WIRE-CALL RULE — where our code ends and native MCP begins. Everything here
27
27
  * is pure interpretation/routing and never touches the MCP wire, EXCEPT
28
- * `invokeComponentAppNative` — the single function that opens an MCP client and makes a
28
+ * `invokeTaskNative` — the single function that opens an MCP client and makes a
29
29
  * real `tools/call`. From that call onward it is all native MCP (the server renders +
30
- * elicits; the answers resolve the call). Node-MCP rows and path-A workflow apps route
31
- * in-process via the caller's `api.callService` because they have a node/workflow to run;
32
- * only path-B component apps (nothing local to call) ride the wire. Full map:
30
+ * elicits, or runs an Agent and holds until it completes; the answers resolve the call).
31
+ * Node-MCP rows route in-process via the caller's `api.callService` because they have a node
32
+ * to run; component apps and WORKFLOW TASKS (every task is a native MCP tool, 2026-09-06)
33
+ * ride the wire. Full map:
33
34
  * docs/MCP_COMPLETE_GUIDE.md → "where YOUR code ends and native MCP begins".
34
35
  */
35
36
 
@@ -54,7 +55,7 @@ export interface DiscoveredMCP {
54
55
  /**
55
56
  * PATH B (UNOVERSE_MCP_APP_PROTOCOL §0.1 / §4b): a self-contained component app —
56
57
  * an `app` URI but NO workflow. Holds the app-tool id the adapter hands to
57
- * `invokeComponentAppNative` (the held native `/mcp` call) instead of firing
58
+ * `invokeTaskNative` (the held native `/mcp` call) instead of firing
58
59
  * `workflow@trigger`; `workflowId`/`nodeId` are empty. Undefined for node-MCP + path-A apps.
59
60
  */
60
61
  component?: string;
@@ -62,6 +63,33 @@ export interface DiscoveredMCP {
62
63
  * schema handle. The tool's input schema is read LIVE from `<appUri>/schema` at mint
63
64
  * time (`resolveToolDefNative`); spatial rows carry no schema copy. */
64
65
  appUri?: string;
66
+ /**
67
+ * A WORKFLOW TASK (2026-09-06): a task-augmented native tool on the platform's /mcp. The
68
+ * value is the tool's identity (`<org>/<name>`), spelled dotted on the wire. Calling it
69
+ * holds until the Agent's run completes and returns the terminal node's output.
70
+ */
71
+ taskTool?: string;
72
+ /**
73
+ * MINTED FROM A TASK ROW (object_type `task`): an outcome with a door, whichever door it
74
+ * has. The bridge reports a call to it as a `task` step, so the execution timeline draws
75
+ * it as a task and not as one more tool.
76
+ */
77
+ task?: true;
78
+ }
79
+
80
+ /**
81
+ * A FUNCTION THE ROWS OF A SEARCH RESULT OFFER (docs/unoverse/data/spatial/objects-in-spatial.md,
82
+ * "Found objects' functions as tools"): the engine names, once per NAME across the result, every
83
+ * public function the found rows' objects declare in api/service.yaml. The harness mints one tool
84
+ * per name, its input the function's own plus the row's universal_id, and a call runs that row's
85
+ * own object's function through the engine's one function door.
86
+ */
87
+ export interface OfferedFunction {
88
+ name: string;
89
+ description: string;
90
+ input?: Record<string, unknown>;
91
+ /** The objects in the result that offer it, by title. */
92
+ objects: string[];
65
93
  }
66
94
 
67
95
  /** A neutral function-tool definition; each family maps this to its wire format. */
@@ -69,6 +97,8 @@ export interface AgentToolDef {
69
97
  name: string;
70
98
  description: string;
71
99
  parameters: Record<string, unknown>;
100
+ /** What the tool returns, from a component's `outputs` block (MCP `outputSchema`). */
101
+ outputSchema?: Record<string, unknown>;
72
102
  }
73
103
 
74
104
  /** Mint the model-facing tool definition for a discovered MCP. */
@@ -91,10 +121,17 @@ export interface AppInvocationContext {
91
121
  /** Deliver the component's SHELL only: render it in its declared state and return
92
122
  * at once — never collect its outputs (§12e "Discovery delivers; it never invokes"). */
93
123
  shell?: boolean;
124
+ /** The row's relevance as the search scored it — the director's ordering. */
125
+ rank?: number;
94
126
  /** The delivery batch this render belongs to (UNOVERSE_TEMPLATE_MODEL §12e): the
95
127
  * server's director judges the WHOLE batch before anything renders. `size` is how
96
128
  * many siblings to wait for; `index` is this render's arrival position. */
97
129
  batch?: { id: string; size: number; index: number };
130
+ /**
131
+ * A WORKFLOW TASK's own arguments (2026-09-06), as the model wrote them against the task's
132
+ * input schema. Spread into the call beside the identity; the server shapes the run from them.
133
+ */
134
+ args?: Record<string, unknown>;
98
135
  /** Forwarded as a Bearer token so the internal `/mcp` call rides the gated lane too. */
99
136
  accessToken?: string;
100
137
  /** Internal MCP base; defaults to UNOVERSE_SERVICE_URL or http://localhost:4106. */
@@ -130,9 +167,9 @@ export interface RowComponent {
130
167
  component: string;
131
168
  /** The row's content AS-IS — the component's state (binds resolve by name). */
132
169
  props: Record<string, unknown>;
133
- /** A SHELL delivery (TEMPLATE_MODEL §12e "One ladder, one delivery"): the row is a
134
- * component that takes input (a manifest, no workflow). It arrives in the same
135
- * delivery as the cards, in its declared state, with its own defaults and NO row
136
- * props; it is never invoked here — its form opens only on the model's call. */
137
- shell?: boolean;
170
+ /** THE ROW'S RELEVANCE, as the search scored it (`similarity_score`, else `similarity`,
171
+ * else `-distance`, read exactly as `routes/dictionary/search/ranking.ts` ranks). Carried
172
+ * as a NUMBER because arrival position is not rank: renders dispatch in parallel. Rows
173
+ * within one delivery come from one search call, so their numbers compare. */
174
+ rank?: number;
138
175
  }