@unoverse-platform/studio 0.1.96 → 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.
Files changed (118) hide show
  1. package/bin/studio.mjs +3 -3
  2. package/local/authoring.ts +1 -1
  3. package/local/catalog.ts +22 -2
  4. package/local/keys.ts +1 -1
  5. package/local/nodes.ts +29 -42
  6. package/local/plugin.ts +75 -13
  7. package/local/sample-identity/brand.yaml +24 -0
  8. package/local/sample-identity/organisation.yaml +14 -0
  9. package/local/sample-identity/purpose.yaml +14 -0
  10. package/local/sample-identity/story.yaml +18 -0
  11. package/local/scaffold.mjs +51 -22
  12. package/local/source.ts +4 -9
  13. package/package.json +1 -1
  14. package/screens/AgentsView.tsx +173 -0
  15. package/screens/ComponentsView.tsx +2 -2
  16. package/screens/EmptyLibrary.tsx +1 -1
  17. package/screens/IdentityHeader.tsx +1 -1
  18. package/screens/IdentityView.tsx +206 -0
  19. package/screens/PromptBlocksView.tsx +1 -1
  20. package/screens/SourceView.tsx +1 -1
  21. package/screens/TemplatesLibraryView.tsx +91 -6
  22. package/screens/TemplatesView.tsx +24 -9
  23. package/screens/index.ts +5 -2
  24. package/src/App.tsx +14 -4
  25. package/src/NodeKeys.tsx +1 -1
  26. package/src/host.ts +3 -16
  27. package/vendor/base/src/agent-mcp/discovery.ts +76 -18
  28. package/vendor/base/src/agent-mcp/handoff.ts +11 -31
  29. package/vendor/base/src/agent-mcp/index.ts +5 -5
  30. package/vendor/base/src/agent-mcp/invoke.ts +37 -19
  31. package/vendor/base/src/agent-mcp/lean.ts +19 -0
  32. package/vendor/base/src/agent-mcp/memoryContext.ts +15 -9
  33. package/vendor/base/src/agent-mcp/memoryIngest.ts +8 -3
  34. package/vendor/base/src/agent-mcp/rowComponents.ts +184 -72
  35. package/vendor/base/src/agent-mcp/tools.ts +136 -7
  36. package/vendor/base/src/agent-mcp/types.ts +47 -10
  37. package/vendor/base/src/definitions/load/compile/component.ts +59 -4
  38. package/vendor/base/src/definitions/load/compile/sections.ts +7 -1
  39. package/vendor/base/src/definitions/load/compile/template.ts +19 -15
  40. package/vendor/base/src/definitions/load/expand/inherit.ts +5 -1
  41. package/vendor/base/src/definitions/load/expand/referenced.ts +21 -2
  42. package/vendor/base/src/definitions/load/expand/refs.ts +14 -0
  43. package/vendor/base/src/definitions/load/expand/walk.ts +88 -38
  44. package/vendor/base/src/definitions/load/load.ts +37 -28
  45. package/vendor/base/src/definitions/model.ts +8 -12
  46. package/vendor/base/src/definitions/registry/agents.ts +111 -0
  47. package/vendor/base/src/definitions/registry/catalog.ts +28 -12
  48. package/vendor/base/src/definitions/registry/datasetSql.ts +149 -0
  49. package/vendor/base/src/definitions/registry/homes.ts +69 -5
  50. package/vendor/base/src/definitions/registry/objects.ts +413 -0
  51. package/vendor/base/src/definitions/registry/orgDocuments.ts +107 -0
  52. package/vendor/base/src/definitions/registry/orgPack.ts +50 -0
  53. package/vendor/base/src/definitions/registry/pipelines.ts +197 -0
  54. package/vendor/base/src/definitions/theme.ts +1 -2
  55. package/vendor/base/src/items/collect.ts +33 -25
  56. package/vendor/base/src/items/publish.ts +31 -7
  57. package/vendor/base/src/lint/context.mjs +17 -0
  58. package/vendor/base/src/lint/design/file.mjs +199 -2
  59. package/vendor/base/src/lint/design/index.mjs +66 -73
  60. package/vendor/base/src/lint/design/vocabulary.mjs +6 -3
  61. package/vendor/base/src/lint/design/walk.mjs +14 -4
  62. package/vendor/base/src/lint/index.mjs +15 -9
  63. package/vendor/base/src/lint/nodes/blocks.mjs +6 -2
  64. package/vendor/base/src/lint/nodes/index.mjs +6 -1
  65. package/vendor/base/src/lint/nodes/node.mjs +157 -7
  66. package/vendor/base/src/lint/objects/index.d.mts +32 -0
  67. package/vendor/base/src/lint/objects/index.mjs +324 -0
  68. package/vendor/base/src/lint/objects/rules.d.mts +44 -0
  69. package/vendor/base/src/lint/objects/rules.mjs +446 -0
  70. package/vendor/base/src/lint/prompts/index.d.mts +34 -0
  71. package/vendor/base/src/lint/prompts/index.mjs +198 -0
  72. package/vendor/base/src/manifests/compose.ts +81 -10
  73. package/vendor/base/src/manifests/executor/callback.ts +15 -4
  74. package/vendor/base/src/manifests/executor/context.ts +12 -1
  75. package/vendor/base/src/manifests/executor/memory.ts +83 -3
  76. package/vendor/base/src/manifests/executor/session.ts +50 -0
  77. package/vendor/base/src/manifests/executor/toolBridge.ts +114 -49
  78. package/vendor/base/src/manifests/index.ts +1 -1
  79. package/vendor/base/src/manifests/runtime/auth/aws.ts +14 -1
  80. package/vendor/base/src/manifests/runtime/channels/final.ts +46 -34
  81. package/vendor/base/src/manifests/runtime/channels/service.ts +30 -32
  82. package/vendor/base/src/manifests/runtime/channels/workflow.ts +6 -11
  83. package/vendor/base/src/manifests/runtime/context.ts +18 -6
  84. package/vendor/base/src/manifests/runtime/duplex/audioLane.ts +159 -3
  85. package/vendor/base/src/manifests/runtime/duplex/session.ts +304 -9
  86. package/vendor/base/src/manifests/runtime/events.ts +39 -3
  87. package/vendor/base/src/manifests/runtime/http/concurrency.ts +44 -0
  88. package/vendor/base/src/manifests/runtime/http/request.ts +36 -7
  89. package/vendor/base/src/manifests/runtime/index.ts +3 -2
  90. package/vendor/base/src/manifests/runtime/loops/repeat.ts +86 -0
  91. package/vendor/base/src/manifests/runtime/tools/toolloop.ts +30 -3
  92. package/vendor/base/src/manifests/runtime/usage.ts +10 -0
  93. package/vendor/base/src/manifests/source.ts +1 -1
  94. package/vendor/base/src/paths.ts +16 -8
  95. package/vendor/base/src/platform/audioManager.ts +32 -0
  96. package/vendor/base/src/platform/executionContext.ts +14 -0
  97. package/vendor/base/src/platform/liveInput.ts +52 -0
  98. package/vendor/base/src/plugins/README.md +1 -1
  99. package/vendor/base/src/plugins/discovery.ts +1 -1
  100. package/vendor/base/src/template/SafeExpression.ts +6 -0
  101. package/vendor/base/src/workflow/GetMemoryService.ts +12 -11
  102. package/vendor/base/src/workflow/PlatformMemoryCore.ts +23 -10
  103. package/vendor/sdk/appEngine.tsx +31 -12
  104. package/vendor/sdk/lib/UnoverseComponent.tsx +8 -4
  105. package/vendor/sdk/lib/app.tsx +70 -95
  106. package/vendor/sdk/lib/connection.tsx +47 -57
  107. package/vendor/sdk/lib/core/actions.ts +6 -0
  108. package/vendor/sdk/lib/core/apps.ts +66 -81
  109. package/vendor/sdk/lib/core/client.ts +5 -14
  110. package/vendor/sdk/lib/core/connection.ts +30 -0
  111. package/vendor/sdk/lib/core/index.ts +2 -2
  112. package/vendor/sdk/lib/core/store.ts +90 -15
  113. package/vendor/sdk/lib/core/types.ts +9 -8
  114. package/vendor/sdk/lib/primitives.tsx +113 -4
  115. package/vendor/sdk/lib/realtime/useAudioPlayback.ts +8 -1
  116. package/vendor/sdk/lib/render.tsx +3 -3
  117. package/vendor/sdk/lib/voice.tsx +27 -13
  118. package/vite.config.ts +1 -1
@@ -6,7 +6,7 @@
6
6
  * memory block (identity + current state, non-intrusion framing included)
7
7
  * from the memory server's GET /context at LOOP START and inlines it into the
8
8
  * agent's system context — memory arrives with the agent, never as a
9
- * model-visible tool call (MEMORY_SPEC.md v3 § Serving: zero-call standing lane).
9
+ * model-visible tool call (docs/architecture/signal/memory/MEMORY.md § Serving: the zero-call injected lane).
10
10
  *
11
11
  * Every agent family calls this once when building its instructions. Soft-fail
12
12
  * by design: memory server down / cold / no identity → returns "" and the agent
@@ -14,7 +14,11 @@
14
14
  */
15
15
 
16
16
  const MEMORY_SERVICE_URL = process.env.MEMORY_SERVICE_URL || "http://localhost:4114";
17
- const CONTEXT_FETCH_TIMEOUT_MS = 3_000;
17
+ // SIX SECONDS, NOT THREE (2026-09-18). The memory service now keeps the block ready, so a
18
+ // read is milliseconds; this only matters for the one cold build after a day's silence, and
19
+ // three seconds lost that race against a database in another region. A call opened without
20
+ // the block told a known caller "I don't have your name yet".
21
+ const CONTEXT_FETCH_TIMEOUT_MS = 6_000;
18
22
 
19
23
  /**
20
24
  * Fetch the user's standing memory block, ready to append to the agent's
@@ -31,16 +35,18 @@ export async function fetchUserMemoryContext(
31
35
 
32
36
  const pubCtx = executionContext?.publishingContext;
33
37
  const userId = pubCtx?.userId;
34
- const workflowId = executionContext?.workflow?.id;
35
- if (!userId || !workflowId) {
38
+ // The org the run works as is the scope of a person's memory (MEMORY.md § 2.10); named by
39
+ // the run, else the canvas's default. Same precedence as memoryIngest.ts.
40
+ const org = pubCtx?.org ?? executionContext?.workflow?.org;
41
+ if (!userId || !org) {
36
42
  logger?.debug?.("[MemoryContext] Skipped — incomplete identity", {
37
43
  hasUserId: !!userId,
38
- hasWorkflowId: !!workflowId,
44
+ hasOrg: !!org,
39
45
  });
40
46
  return "";
41
47
  }
42
48
 
43
- return fetchUserMemoryBlock(userId, workflowId, executionContext?.auth?.accessToken, logger);
49
+ return fetchUserMemoryBlock(userId, org, executionContext?.auth?.accessToken, logger);
44
50
  }
45
51
 
46
52
  /**
@@ -48,13 +54,13 @@ export async function fetchUserMemoryContext(
48
54
  * director's verdict lane reads it this way — no node config, no execution
49
55
  * context). Same endpoint, same soft-fail: any problem returns "".
50
56
  */
51
- export async function fetchUserMemoryBlock(userId: string, workflowId: string, accessToken?: string, logger?: any): Promise<string> {
52
- if (!userId || !workflowId) return "";
57
+ export async function fetchUserMemoryBlock(userId: string, org: string, accessToken?: string, logger?: any): Promise<string> {
58
+ if (!userId || !org) return "";
53
59
  const headers: Record<string, string> = { "Content-Type": "application/json" };
54
60
  if (accessToken) headers["Authorization"] = `Bearer ${accessToken}`;
55
61
 
56
62
  try {
57
- const params = new URLSearchParams({ userId, workflowId });
63
+ const params = new URLSearchParams({ userId, org });
58
64
  const res = await fetch(`${MEMORY_SERVICE_URL}/context?${params}`, {
59
65
  headers,
60
66
  signal: AbortSignal.timeout(CONTEXT_FETCH_TIMEOUT_MS),
@@ -5,7 +5,7 @@
5
5
  * (config.enableUserMemory) is ON, each completed turn (original user input +
6
6
  * final agent answer — never intermediate iterations or tool chatter) is
7
7
  * POSTed to the memory server's /memory/ingest, which feeds the L1→L4
8
- * cascade (see docs/architecture/MEMORY_SPEC.md). Replaces the retired
8
+ * consolidation pipeline (see docs/architecture/signal/memory/MEMORY.md). Replaces the retired
9
9
  * GravityMemoryStore canvas node.
10
10
  *
11
11
  * Every agent family calls this at turn completion with its own node type;
@@ -38,12 +38,16 @@ export function ingestConversationTurn(
38
38
 
39
39
  const pubCtx = executionContext?.publishingContext;
40
40
  const userId = pubCtx?.userId;
41
+ // THE ORG IS THE SCOPE (MEMORY.md § 2.10): a person's memory belongs to the organisation the
42
+ // run works as, never to one canvas. Named by the run, else the canvas's default — the same
43
+ // precedence every manifest gets (manifests/executor/context.ts). The canvas rides as a stamp.
44
+ const org = pubCtx?.org ?? executionContext?.workflow?.org;
41
45
  const workflowId = executionContext?.workflow?.id;
42
46
  const conversationId = pubCtx?.conversationId;
43
- if (!userId || !workflowId || !conversationId) {
47
+ if (!userId || !org || !conversationId) {
44
48
  logger?.debug?.("[MemoryIngest] Skipped — incomplete identity", {
45
49
  hasUserId: !!userId,
46
- hasWorkflowId: !!workflowId,
50
+ hasOrg: !!org,
47
51
  hasConversationId: !!conversationId,
48
52
  });
49
53
  return;
@@ -51,6 +55,7 @@ export function ingestConversationTurn(
51
55
 
52
56
  const payload: Record<string, any> = {
53
57
  userId,
58
+ org,
54
59
  workflowId,
55
60
  conversationId,
56
61
  executionId: executionContext?.executionId || "",
@@ -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
+ }