@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
@@ -10,6 +10,29 @@
10
10
  * Returns undefined when nothing granted tools, and the node then runs as a single request.
11
11
  */
12
12
  import type { ToolBridge } from "../runtime/index.js";
13
+ import { engineUrl } from "./context.js";
14
+
15
+ /** The screen's verbs on an app with places (docs/unoverse/design/screen/UNOVERSE_SCREEN.md §3.3). */
16
+ const SCREEN_VERBS = {
17
+ show: {
18
+ description:
19
+ "Set what the user sees. Give the ids of everything that should be on screen now: the cards your answer is about, as the search result lists them, and anything already on screen that should stay (the ON SCREEN line gives their ids). Whatever is on screen and not in your list closes, and an empty list clears the screen. If you do not call it, the screen stays as it is. Call it straight after the search, before reading details, so the user sees the cards at once. Do not list cards you will hand to a task, such as a comparison: a task you call opens by itself.",
20
+ parameters: {
21
+ type: "object",
22
+ properties: { ids: { type: "array", items: { type: "string" } } },
23
+ required: ["ids"],
24
+ additionalProperties: false,
25
+ },
26
+ },
27
+ read: {
28
+ description: "What the user is looking at: every place on screen and each interface in it, with its id, type, state and props.",
29
+ parameters: { type: "object", properties: {}, additionalProperties: false },
30
+ },
31
+ close: {
32
+ description: "Take one interface off the screen, by its id.",
33
+ parameters: { type: "object", properties: { id: { type: "string" } }, required: ["id"], additionalProperties: false },
34
+ },
35
+ } as const;
13
36
 
14
37
  export async function toolBridgeFor(executionContext: any): Promise<ToolBridge | undefined> {
15
38
  const api = executionContext?.api;
@@ -25,8 +48,6 @@ export async function toolBridgeFor(executionContext: any): Promise<ToolBridge |
25
48
  }
26
49
  if (!schema?.methods) return undefined;
27
50
 
28
- const core = new Set<string>(Object.keys(schema.methods));
29
-
30
51
  // Identity for a HELD app call: it renders on the caller's live session and elicits
31
52
  // from that human, so it needs the conversation, the turn, and the caller's token.
32
53
  // Using auth HERE is correct and different from exposing it to a manifest: this is the
@@ -62,6 +83,18 @@ export async function toolBridgeFor(executionContext: any): Promise<ToolBridge |
62
83
  */
63
84
  const discoveredApps = new Set<string>();
64
85
 
86
+ /**
87
+ * TOOLS MINTED FROM A TASK ROW, by minted name. A call to one of these is a task step on
88
+ * the execution timeline, not a bare tool call; `kindOf` answers from this set.
89
+ */
90
+ const taskTools = new Set<string>();
91
+ /**
92
+ * TOOLS WHOSE CALL HANDS THE TURN OFF: a workflow-bound app fired through the service
93
+ * lane (`workflow@trigger`, fire-and-forget). Only these can end the loop; a component
94
+ * app, a workflow task (held, its result comes back) or a platform tool never does.
95
+ */
96
+ const handoffTools = new Set<string>();
97
+
65
98
  /**
66
99
  * THE GUEST'S OWN WORDS, from the search that is about to unlock a page.
67
100
  *
@@ -79,19 +112,36 @@ export async function toolBridgeFor(executionContext: any): Promise<ToolBridge |
79
112
  const stringify = (r: unknown): string => (typeof r === "string" ? r : JSON.stringify(r));
80
113
 
81
114
  /**
82
- * PAGES ALREADY OPEN IN THIS CONVERSATION (the latch's other half, 2026-09-02).
115
+ * PAGES ALREADY OPEN IN THIS CONVERSATION (2026-09-02).
83
116
  *
84
- * A follow-up send addressed at an open page starts a FRESH agent, and the page's tool
117
+ * A follow-up send while a page is open starts a FRESH agent, and the page's tool
85
118
  * was minted in an earlier run and died with it — so the agent could only chat at the
86
- * page (observed live: three consecutive turns of clarifying questions at a latched
119
+ * page (observed live: three consecutive turns of clarifying questions at an open
87
120
  * page it had no tool to edit). At bridge build the open pages are read from the
88
121
  * server's open-apps resource and their tools minted exactly like an unlock mint,
89
- * minus the shell-open — the page is already on screen. The same resource read RE-ARMS
90
- * read-first server-side, so this new agent's first authored call is answered with the
91
- * current page rather than rendered blind. Fail-soft: any error means nothing
92
- * pre-minted, which is exactly the old behaviour.
122
+ * minus the shell-open — the page is already on screen. Fail-soft: any error means
123
+ * nothing pre-minted, which is exactly the old behaviour.
93
124
  */
94
125
  const preMinted: any[] = [];
126
+ /**
127
+ * THE SCREEN VERBS (docs/unoverse/design/screen/UNOVERSE_SCREEN.md §3.3): every agent gets them.
128
+ * A search returns data and draws nothing; the verbs are the door itself: show by id, read
129
+ * every place, close by id. Plain platform tools on /mcp, called with the session identity.
130
+ */
131
+ for (const verb of ["show", "read", "close"] as const) {
132
+ // Underscore, not dot: model providers restrict tool names to [a-zA-Z0-9_-].
133
+ const name = `screen_${verb}`;
134
+ appInvokers.set(name, async (input) => {
135
+ const { client, close } = await harness.connectPlatformMcp({ accessToken: identity.accessToken });
136
+ try {
137
+ const r = (await client.callTool({ name, arguments: { ...((input ?? {}) as Record<string, unknown>), userId: identity.userId, conversationId: identity.conversationId, chatId: identity.chatId } })) as { content?: Array<{ type: string; text?: string }> };
138
+ return r.content?.find((c) => c.type === "text")?.text ?? "";
139
+ } finally {
140
+ await close();
141
+ }
142
+ });
143
+ preMinted.push({ name, ...SCREEN_VERBS[verb] });
144
+ }
95
145
  if (identity.conversationId) {
96
146
  try {
97
147
  const { client, close } = await harness.connectPlatformMcp({ accessToken: identity.accessToken });
@@ -118,9 +168,10 @@ export async function toolBridgeFor(executionContext: any): Promise<ToolBridge |
118
168
  discoveredApps.add(mcp.component);
119
169
  preMinted.push({
120
170
  ...def,
121
- description:
122
- `${def.description}\n\nTHIS PAGE IS ALREADY OPEN in this conversation — the user is looking at it now. ` +
123
- `To act on their message, call this tool with the parts to change; your first call is answered with the current page so you read before editing.`,
171
+ // WHAT IS ON SCREEN, said where the decision is made: a message about the page itself
172
+ // is answered by updating it. "About it" is narrow on purpose: read loosely, the model
173
+ // rebuilt a comparison for "what is the best card for travel" (observed live).
174
+ description: `${def.description}\n\nON SCREEN NOW: this page is open and the user is looking at it. A message about the page itself updates it through this tool.`,
124
175
  });
125
176
  console.log(`[manifests] open page re-minted for this turn: ${def.name}`);
126
177
  }
@@ -190,45 +241,27 @@ export async function toolBridgeFor(executionContext: any): Promise<ToolBridge |
190
241
  const def = await harness.resolveToolDefNative(mcp, { accessToken: identity.accessToken });
191
242
  const invoke = harness.componentAppInvoker(mcp, identity);
192
243
  appInvokers.set(def.name, invoke);
244
+ if (mcp.task) taskTools.add(def.name);
193
245
 
194
246
  /**
195
- * THE PAGE FILLS BEFORE THE WRITER GETS THE PEN (owner ruling 2026-09-01 night).
196
- *
197
- * The shell-open renders the page skeleton and runs the page's OWN fill — its
198
- * searches, the director's picks, the tiles (§9e). It used to be fire-and-forget
199
- * while the tool was minted immediately, so the model composed captions for a page
200
- * it had never seen; a read-first bounce tried to repair that and depended on the
201
- * model re-sending, which it sometimes simply did not (observed 15:45: bounce, no
202
- * resend, ghost headings shipped). So the ORDER is now physical: the shell call is
203
- * AWAITED, and only then is the tool minted — with the page's chosen places in the
204
- * tool's own description. The writer cannot write blind, and nothing needs bouncing.
205
- *
206
- * The guest loses nothing: the skeleton and the tiles render during this await; only
207
- * the model's captions start those few seconds later. A failed shell mints the tool
208
- * with its plain description — a page that arrives the old way, never a dead turn.
209
- *
210
- * `__draftRefs` carries the UNLOCKING SEARCH'S ranked row ids (the server drafts
211
- * ref-only parts from them); the execution ids trace the page's searches into the
212
- * run's own timeline.
247
+ * A TASK OPENS WHEN THE LLM CALLS IT (UNOVERSE_RENDER_PIPELINE §4, locked
248
+ * 2026-09-06). This used to open the shell at MINT — every app a search unlocked
249
+ * was rendered before the model had read the results (observed 2026-09-07: two
250
+ * surfaces opened at once, neither asked for). Tasks are asked: the unlock mints the
251
+ * tool and nothing more. What the shell-open used to carry — the unlocking search's
252
+ * ranked rows for drafting, the guest's ask for the page's own searches, the run's
253
+ * ids for tracing — rides the model's CALL instead, beneath its own arguments.
213
254
  */
214
255
  const draftRefs = harness.rankedRowIds(resultContent);
215
- let chosenNote = "";
216
- try {
217
- const opened = await invoke({
218
- __draftRefs: draftRefs,
219
- ...(lastAsk ? { __ask: lastAsk } : {}),
220
- ...(executionContext?.workflowId ? { __workflowId: executionContext.workflowId } : {}),
221
- ...(executionContext?.executionId ? { __executionId: executionContext.executionId } : {}),
222
- ...(executionContext?.nodeId ? { __nodeId: executionContext.nodeId } : {}),
223
- });
224
- const msg = typeof (opened as { message?: unknown })?.message === "string" ? (opened as { message: string }).message : "";
225
- const chosen = msg.match(/THE PAGE'S CONTENT IS ALREADY CHOSEN — [^\n]*?never a different plan\./)?.[0];
226
- if (chosen) chosenNote = `\n\n${chosen}`;
227
- console.log(`[manifests] shell opened: ${def.name}${draftRefs.length ? ` (${draftRefs.length} draft refs)` : ""}${lastAsk ? ` ask="${lastAsk}"` : ""}${chosen ? " — chosen places ride the tool description" : ""}`);
228
- } catch (e: any) {
229
- console.log(`[manifests] shell open failed for ${def.name} — ${e?.message ?? e}`);
230
- }
231
- minted.push({ name: def.name, description: `${def.description}${chosenNote}`, parameters: def.parameters });
256
+ const ride = {
257
+ __draftRefs: draftRefs,
258
+ ...(lastAsk ? { __ask: lastAsk } : {}),
259
+ ...(executionContext?.workflowId ? { __workflowId: executionContext.workflowId } : {}),
260
+ ...(executionContext?.executionId ? { __executionId: executionContext.executionId } : {}),
261
+ ...(executionContext?.nodeId ? { __nodeId: executionContext.nodeId } : {}),
262
+ };
263
+ appInvokers.set(def.name, (input: unknown) => invoke({ ...ride, ...((input ?? {}) as Record<string, unknown>) }));
264
+ minted.push({ name: def.name, description: def.description, parameters: def.parameters, ...(def.outputSchema ? { outputSchema: def.outputSchema } : {}) });
232
265
  },
233
266
  );
234
267
 
@@ -246,9 +279,37 @@ export async function toolBridgeFor(executionContext: any): Promise<ToolBridge |
246
279
  for (const mcp of harness.parseDiscoveredMCPs([{ name: toolName, resultContent }]))
247
280
  if (!harness.isBindinglessComponentApp(mcp)) {
248
281
  const def = harness.toolDefFromDiscoveredMCP(mcp);
249
- minted.push({ name: def.name, description: def.description, parameters: def.parameters });
282
+ // A WORKFLOW TASK (2026-09-06) is called NATIVELY: the held call runs the Agent
283
+ // and returns its result. Registered by the minted name, like a component app.
284
+ if (harness.isWorkflowTask(mcp)) appInvokers.set(def.name, harness.workflowTaskInvoker(mcp, identity));
285
+ else if (mcp.workflowId) {
286
+ // A WORKFLOW-BOUND APP (path A) OPENS, then ends the turn. It used to only end
287
+ // the turn: nothing registered an invoker, so the model's call fell through to
288
+ // `api.callService` and died as an unknown SERVICE method while the run reported
289
+ // success and the screen stayed blank (SAB, 2026-09-07). Both, in this order —
290
+ // the app tool renders the surface and fires no workflow itself (server
291
+ // mcp/server/tools.ts), so the handoff that follows is not a second run.
292
+ appInvokers.set(def.name, harness.workflowAppInvoker(mcp, identity));
293
+ handoffTools.add(def.name);
294
+ }
295
+ if (mcp.task) taskTools.add(def.name);
296
+ minted.push({ name: def.name, description: def.description, parameters: def.parameters, ...(def.outputSchema ? { outputSchema: def.outputSchema } : {}) });
250
297
  }
251
298
 
299
+ /**
300
+ * THE FOUND ROWS' OWN FUNCTIONS (docs/unoverse/data/spatial/objects-in-spatial.md, "Found
301
+ * objects' functions as tools", 2026-09-16). The engine names, once per name, every public
302
+ * function the result's objects declare; each becomes a tool here, called on a row by its
303
+ * id through the engine's one function door. Spatial's node keeps only its two searches:
304
+ * a document's getDetail, a fund's documents, a skill's readSkill all arrive this way, the
305
+ * moment a row of theirs is found. Deduped downstream by tool name, like the apps above.
306
+ */
307
+ for (const fn of harness.parseOfferedFunctions(toolName, resultContent)) {
308
+ const def = harness.objectFunctionToolDef(fn);
309
+ appInvokers.set(def.name, harness.objectFunctionInvoker(fn, engineUrl()));
310
+ minted.push({ name: def.name, description: def.description, parameters: def.parameters });
311
+ }
312
+
252
313
  /**
253
314
  * SKILLS IN FORCE, lifted before the lean drops them. Handed back beside `minted`
254
315
  * because they are the same kind of fact: something the reply UNLOCKED that the loop
@@ -260,7 +321,11 @@ export async function toolBridgeFor(executionContext: any): Promise<ToolBridge |
260
321
  },
261
322
 
262
323
  endsTurn(calls) {
263
- return harness.isTurnEndingHandoff(calls, core);
324
+ return harness.isTurnEndingHandoff(calls, handoffTools);
325
+ },
326
+
327
+ kindOf(toolName) {
328
+ return taskTools.has(toolName) ? "task" : "tool";
264
329
  },
265
330
  };
266
331
  }
@@ -123,6 +123,19 @@ export async function signAwsRequest(
123
123
  return signed.headers as Record<string, string>;
124
124
  }
125
125
 
126
+ /**
127
+ * ONE LINK PER HOUR, NOT PER CALL (2026-09-12: a document's picture reopened in a panel was
128
+ * downloaded again on every open, because every open minted a new link and a browser caches
129
+ * by URL). A link of two hours or more is signed as of the start of the current hour, so every
130
+ * ask inside that hour gets the same URL and the browser keeps what it already loaded. Such a
131
+ * link lives `expiresIn` from that hour's start: up to an hour less than asked, never more. A
132
+ * shorter link is signed at its own second, so it is never cut by a large share of its life.
133
+ */
134
+ const HOUR_MS = 3_600_000;
135
+ export function signingDateOf(expiresIn: number, now = Date.now()): Date {
136
+ return expiresIn * 1000 >= 2 * HOUR_MS ? new Date(Math.floor(now / HOUR_MS) * HOUR_MS) : new Date(now);
137
+ }
138
+
126
139
  /**
127
140
  * A PRESIGNED URL: authority to fetch one object, handed to someone who has no credentials.
128
141
  *
@@ -174,7 +187,7 @@ export async function presignAwsUrl(
174
187
  // as a dependency for one header.
175
188
  headers: { host: parsed.host, "x-amz-content-sha256": "UNSIGNED-PAYLOAD" },
176
189
  }),
177
- { expiresIn },
190
+ { expiresIn, signingDate: signingDateOf(expiresIn) },
178
191
  );
179
192
 
180
193
  const out = new URL(`${signed.protocol}//${signed.hostname}${signed.path}`);
@@ -21,13 +21,14 @@
21
21
  import type { ComposedNode } from "../../compose.js";
22
22
  import type { RunContext } from "../context.js";
23
23
  import { evaluate, render } from "../templating.js";
24
- import { sendRequest, buildRequest, performPresign } from "../http/request.js";
24
+ import { sendRequest, buildRequest, performPresign, gated, runCalls } from "../http/request.js";
25
25
  import { readSse, readSettled, assertOk } from "../http/response.js";
26
26
  import { runDuplexSession } from "../duplex/session.js";
27
27
  import type { AudioLane } from "../duplex/audioLane.js";
28
28
  import { performState, type StateStore } from "../state.js";
29
29
  import { fetchPaginated } from "../loops/paginate.js";
30
30
  import { fetchPolled } from "../loops/poll.js";
31
+ import { runRepeat } from "../loops/repeat.js";
31
32
  import { sendChunked } from "../loops/chunk.js";
32
33
  import { performLoop } from "../loops/loop.js";
33
34
  import { performDoc } from "../docstore/index.js";
@@ -82,13 +83,17 @@ export async function runFinal(
82
83
  if (call.presign) return settle(await performPresign(node, call, ctx));
83
84
 
84
85
  // Chunked: many requests over one collection, one summary reply.
85
- if (call.chunk) return settle(await sendChunked(node, call, ctx, node.type));
86
+ if (call.chunk) return settle(await gated(node, call, ctx, () => sendChunked(node, call, ctx, node.type)));
86
87
 
87
88
  // Paginated: many requests, one accumulated reply, so the events table sees the whole set.
88
- if (call.paginate) return settle(await fetchPaginated(node, call, ctx, node.type));
89
+ if (call.paginate) return settle(await gated(node, call, ctx, () => fetchPaginated(node, call, ctx, node.type)));
89
90
 
90
91
  // Polled: a job, started and waited on, settling as the final status payload.
91
- if (call.poll) return settle(await fetchPolled(node, call, ctx, node.type));
92
+ if (call.poll) return settle(await gated(node, call, ctx, () => fetchPolled(node, call, ctx, node.type)));
93
+
94
+ // Repeated: turns of ordinary calls, each built from the one before, until a reply says done.
95
+ // Settles on { last, turns, stopped }, so the events table sees the answer and its path.
96
+ if (call.repeat) return settle(await runRepeat(node, call, ctx, node.type, runCalls, store));
92
97
 
93
98
  // DUPLEX, and it must come BEFORE sendRequest, because a socket handshake is not an HTTP
94
99
  // request and issuing one would open a second connection to the vendor and pay for it.
@@ -112,6 +117,9 @@ export async function runFinal(
112
117
  // no tokens at all. `usage.save()` sits BEFORE the throw: a call that ended badly still
113
118
  // burned what it spoke.
114
119
  const usage = makeUsageCollector(node, ctx);
120
+ // THE CALL'S LENGTH, timed here because this is what opens and closes it. Per-minute vendors
121
+ // (GPT-Live, Grok) bill on it; per-token ones get it shown beside their tokens.
122
+ const opened = Date.now();
115
123
  const result = await runDuplexSession({
116
124
  node,
117
125
  call,
@@ -129,43 +137,47 @@ export async function runFinal(
129
137
  void emitter.narrator(message);
130
138
  },
131
139
  });
140
+ usage.set("seconds", Math.round((Date.now() - opened) / 100) / 10);
132
141
  usage.save();
133
142
  if (result.reason === "error") throw new Error(`${node.type}: duplex session failed: ${result.error}`);
134
143
  return 200;
135
144
  }
136
145
 
137
- const res = await sendRequest(node, call, ctx, node.type);
138
- const transport = call.transport;
139
-
140
- if (transport === "json" || transport === "text") {
141
- const payload = await readSettled(res, transport, call.encoding);
142
- await assertOk(node, call, payload, node.type);
143
- // Token usage: a settled vendor body carries it inline (Chat Completions puts it
144
- // top-level). One payload, so see + save in one breath.
145
- const usage = makeUsageCollector(node, ctx);
146
- usage.see(payload);
147
- usage.save();
148
- // Named BEFORE emitting, so a row reading calls.<name> sees it.
149
- //
150
- // Settling only. A streaming call has no single reply to name: it is a sequence, and
151
- // each event arrives as `response`. Inventing a value for `calls.<name>` there would be
152
- // a guess about which event counted.
153
- (base.calls as Record<string, unknown>)[call.name] = payload;
154
- // No `match`: a settled body is one event, so every `from: response` row fires over it.
155
- await emitter.response(payload);
156
- return res.status;
157
- }
146
+ // The settling or streaming request, under the call's concurrency gate when it declares one.
147
+ return gated(node, call, ctx, async () => {
148
+ const res = await sendRequest(node, call, ctx, node.type);
149
+ const transport = call.transport;
158
150
 
159
- if (transport === "sse") {
160
- const usage = makeUsageCollector(node, ctx);
161
- await readSse(res, call.terminator, async (payload) => {
151
+ if (transport === "json" || transport === "text") {
152
+ const payload = await readSettled(res, transport, call.encoding);
162
153
  await assertOk(node, call, payload, node.type);
154
+ // Token usage: a settled vendor body carries it inline (Chat Completions puts it
155
+ // top-level). One payload, so see + save in one breath.
156
+ const usage = makeUsageCollector(node, ctx);
163
157
  usage.see(payload);
164
- await emitter.response(payload, payload.type);
165
- });
166
- usage.save();
167
- return res.status;
168
- }
158
+ usage.save();
159
+ // Named BEFORE emitting, so a row reading calls.<name> sees it.
160
+ //
161
+ // Settling only. A streaming call has no single reply to name: it is a sequence, and
162
+ // each event arrives as `response`. Inventing a value for `calls.<name>` there would be
163
+ // a guess about which event counted.
164
+ (base.calls as Record<string, unknown>)[call.name] = payload;
165
+ // No `match`: a settled body is one event, so every `from: response` row fires over it.
166
+ await emitter.response(payload);
167
+ return res.status;
168
+ }
169
+
170
+ if (transport === "sse") {
171
+ const usage = makeUsageCollector(node, ctx);
172
+ await readSse(res, call.terminator, async (payload) => {
173
+ await assertOk(node, call, payload, node.type);
174
+ usage.see(payload);
175
+ await emitter.response(payload, payload.type);
176
+ });
177
+ usage.save();
178
+ return res.status;
179
+ }
169
180
 
170
- throw new Error(`transport "${transport}" is declared in the schema but not implemented in the executor`);
181
+ throw new Error(`transport "${transport}" is declared in the schema but not implemented in the executor`);
182
+ });
171
183
  }
@@ -9,9 +9,9 @@
9
9
  import type { ComposedNode } from "../../compose.js";
10
10
  import type { RunContext } from "../context.js";
11
11
  import { evaluate } from "../templating.js";
12
- import { runCalls } from "../http/request.js";
12
+ import { runCalls, resolveBody } from "../http/request.js";
13
13
  import type { StateStore } from "../state.js";
14
- import { rowComponentsFromResults, renderRowComponents, screenLine } from "../../../agent-mcp/rowComponents.js";
14
+ import { rowComponentsFromResults, placesOn, placesLine, rememberRows } from "../../../agent-mcp/rowComponents.js";
15
15
  import { withHelpers } from "../helpers.js";
16
16
 
17
17
  /**
@@ -47,6 +47,16 @@ export async function performService(
47
47
  return withHelpers(node, () => serviceMethod(node, method, params, ctx, store, session));
48
48
  }
49
49
 
50
+ /**
51
+ * A METHOD'S REPLY: one `return` expression, or an object whose values may be expressions, the
52
+ * way a call's `body` already is (docs/unoverse/data/spatial/objects-in-spatial.md). What forced
53
+ * it: `getSchema` returns a large fixed schema by `$ref`, and the Spatial node adds one value to
54
+ * it computed per run (the objects the agent may turn on). A literal value is returned as written.
55
+ */
56
+ function reply(returns: unknown, scope: Record<string, unknown>): Promise<unknown> {
57
+ return returns && typeof returns === "object" ? resolveBody(returns, scope as unknown as RunContext) : evaluate(returns, scope);
58
+ }
59
+
50
60
  async function serviceMethod(
51
61
  node: ComposedNode,
52
62
  method: string,
@@ -88,7 +98,7 @@ async function serviceMethod(
88
98
  * manifest declaring this stays pure everywhere it is not wanted, with no flag to juggle.
89
99
  */
90
100
  if (spec.renderComponents) {
91
- const rows = await evaluate(spec.renderComponents, { response: last, calls: results, params, config: ctx.config });
101
+ const rows = await evaluate(spec.renderComponents, { response: last, calls: results, params });
92
102
  const found = rowComponentsFromResults(rows);
93
103
  // SAY WHAT HAPPENED, always: this lane no-ops on no component rows or no live session,
94
104
  // and silence must not read as success. `renderRowComponents` logs its own settles.
@@ -99,40 +109,28 @@ async function serviceMethod(
99
109
  accessToken: session?.accessToken,
100
110
  };
101
111
  const log = (m: string) => console.log(`[manifests] ${node.type}: ${m}`);
102
- if (found.length) {
103
- if (!session?.userId) {
104
- console.log(`[manifests] ${node.type}: ${found.length} row component(s) not rendered — no live session on this run`);
105
- }
106
- const outcomes = renderRowComponents(found, sessionCtx, log);
107
- /**
108
- * THE SEARCH MIRRORS TOO (MCP_APP_PROTOCOL §0.1, ruled 2026-09-05). When the delivery
109
- * holds a component that takes input (a shell), the model must answer the SCREEN,
110
- * not the rows: it wait for the delivery's verdict — instant here, the ladder at the
111
- * door leaves one candidate and the rules brain answers at once — and the result
112
- * carries a `screen` line saying what is on screen and what was not shown.
113
- * Card-only deliveries stay fire-and-forget, as fast as ever.
114
- */
115
- if (found.some((i) => i.shell)) {
116
- const screen = screenLine(await outcomes);
117
- const result = await evaluate(spec.returns, { response: last, calls: results, params, config: ctx.config, user: ctx.user });
118
- return screen && result && typeof result === "object" && !Array.isArray(result) ? { ...(result as Record<string, unknown>), screen } : result;
119
- }
120
- } else {
121
- const n = Array.isArray(rows) ? rows.length : 0;
122
- console.log(`[manifests] ${node.type}: no row components in ${n} row(s)`);
123
- // The empty delivery is announced (§12e mechanical spent rule): this search
124
- // ran and surfaced nothing, and the standing set's fate hangs on that fact.
125
- void renderRowComponents(found, sessionCtx, log);
126
- }
112
+ /**
113
+ * AN APP WITH PLACES (docs/unoverse/design/screen/UNOVERSE_SCREEN.md §3.1): a search returns
114
+ * data and draws nothing. Its cards are remembered for the turn and listed for the model by
115
+ * id; the model shows the ones its answer is about with screen_show.
116
+ */
117
+ if (session?.conversationId && session?.userId) rememberRows(session.conversationId, session.chatId ?? session.conversationId, found);
118
+ else if (found.length) console.log(`[manifests] ${node.type}: ${found.length} row component(s) not remembered — no live session on this run`);
119
+ const result = await reply(spec.returns, { response: last, calls: results, params, user: ctx.user });
120
+ const task = result && typeof result === "object" && (result as any).task && typeof (result as any).task === "object" ? (result as any).task : null;
121
+ const screen = placesLine(session?.conversationId, placesOn(session?.conversationId), found, task);
122
+ return result && typeof result === "object" && !Array.isArray(result) ? { ...(result as Record<string, unknown>), screen } : result;
127
123
  }
128
124
 
129
- // `params`, `config` and `user` are in scope so a method can shape its result from what
130
- // it was asked for and who asked, not only from what came back.
131
- return evaluate(spec.returns, {
125
+ // `params` and `user` are in scope so a method can shape its result from what it was
126
+ // asked for and who asked, not only from what came back. `config` is NOT (owner ruling
127
+ // 2026-09-20, docs/unoverse/mail/README.md "who sees what"): a method's reply goes to a
128
+ // MODEL, and a box's settings are the author's, never the model's. The calls still read
129
+ // config to build their requests; that happens here, server-side, and never leaves.
130
+ return reply(spec.returns, {
132
131
  response: last,
133
132
  calls: results,
134
133
  params,
135
- config: ctx.config,
136
134
  user: ctx.user,
137
135
  });
138
136
  }
@@ -23,7 +23,7 @@ import { runToolLoop, type ToolBridge } from "../tools/toolloop.js";
23
23
  import { runFinal } from "./final.js";
24
24
  import { withHelpers } from "../helpers.js";
25
25
  import { clientTransport } from "../../../platform/clientTransport.js";
26
- import { rowComponentsFromResults, renderRowComponents } from "../../../agent-mcp/rowComponents.js";
26
+ import { rowComponentsFromResults, rememberRows } from "../../../agent-mcp/rowComponents.js";
27
27
  import type { CallerSession } from "./service.js";
28
28
 
29
29
  export interface RunResult {
@@ -272,16 +272,11 @@ async function renderRunComponents(
272
272
  accessToken: session?.accessToken,
273
273
  };
274
274
  const found = rowComponentsFromResults(rows);
275
- if (found.length) {
276
- if (!session?.userId) log(`${found.length} row component(s) not rendered — no live session on this run`);
277
- renderRowComponents(found, sessionCtx, log);
278
- } else {
279
- const n = Array.isArray(rows) ? rows.length : 0;
280
- log(`no row components in ${n} row(s)`);
281
- // The empty delivery is announced (§12e mechanical spent rule): this search
282
- // ran and surfaced nothing, and the standing set's fate hangs on that fact.
283
- renderRowComponents(found, sessionCtx, log);
284
- }
275
+ // A SEARCH RETURNS DATA AND DRAWS NOTHING (docs/unoverse/design/screen/UNOVERSE_SCREEN.md §3.1):
276
+ // its cards are remembered for the turn; the model shows the ones its answer is about with
277
+ // screen_show.
278
+ if (session?.conversationId && session?.userId) rememberRows(session.conversationId, session.chatId ?? session.conversationId, found);
279
+ log(found.length ? `${found.length} row component(s) remembered for screen_show` : `no row components in ${Array.isArray(rows) ? rows.length : 0} row(s)`);
285
280
  }
286
281
 
287
282
  /**
@@ -29,6 +29,12 @@ export interface RunContext {
29
29
  * phrase for two different things.
30
30
  */
31
31
  calls: Record<string, any>;
32
+ /**
33
+ * THE TURN BEFORE THIS ONE, inside a `repeat` (loops/repeat.ts): that turn's calls by name.
34
+ * Null on a repeat's first turn, so an expression can ask whether there is one; absent
35
+ * everywhere outside a repeat.
36
+ */
37
+ previous?: Record<string, any> | null;
32
38
  /**
33
39
  * The CALLER'S DISCONNECT, when the node runs over a stream. Set from the request's
34
40
  * own signal; a turn loop checks it between turns and stops. Absent for a node that
@@ -85,7 +91,7 @@ export interface RunContext {
85
91
  * its own address. Loopback, because :4106 is the ungated internal listener and is published to
86
92
  * 127.0.0.1 only.
87
93
  */
88
- scope: { userId?: string; workflowId?: string; org?: string; conversationId?: string; chatId?: string; executionId?: string; nodeId?: string; platformUrl?: string; engineUrl?: string };
94
+ scope: { userId?: string; workflowId?: string; org?: string; conversationId?: string; chatId?: string; debug?: boolean; executionId?: string; nodeId?: string; platformUrl?: string; engineUrl?: string };
89
95
  /**
90
96
  * THE CONVERSATION SO FAR, for a tool loop against a vendor with no chain id.
91
97
  *
@@ -132,13 +138,19 @@ export const RESOLVERS: Record<string, (value: any, node: ComposedNode) => any>
132
138
  modelTier(value: any, node: ComposedNode) {
133
139
  const field = node.definition.configSchema?.properties?.model;
134
140
  const options: string[] = field?.enum ?? [];
135
- if (!value || options.includes(value)) return value || options[0];
141
+ // THE LARGEST MODEL IS NEVER TAKEN BY ACCIDENT (owner, 2026-09-12: "we should never have
142
+ // been using Sol in the first place"; a pipeline that named no model ran every call on the
143
+ // frontier tier, first in the list, for days). A node that names no model runs on its own
144
+ // declared default; the large tier only ever runs because someone chose it.
145
+ const fallback: string | undefined = typeof field?.default === "string" && options.includes(field.default) ? field.default : options[1] ?? options[0];
146
+ if (!value) return fallback;
147
+ if (options.includes(value)) return value;
136
148
  const m = String(value).toLowerCase();
137
- // Tier words cover both families the packages ship: GPT (luna/terra) and Claude
138
- // (haiku/sonnet). Anything unrecognised lands on large, the safe over-provision.
139
- const tier = /luna|mini|small|nano|haiku/.test(m) ? 0 : /terra|balanced|medium|sonnet/.test(m) ? 1 : 2;
149
+ // Tier words cover both families the packages ship: GPT (luna/terra/sol) and Claude
150
+ // (haiku/sonnet/opus). A retired id keeps its tier; one naming no tier takes the default.
151
+ const tier = /luna|mini|small|nano|haiku/.test(m) ? 0 : /terra|balanced|medium|sonnet/.test(m) ? 1 : /sol|large|frontier|opus/.test(m) ? 2 : -1;
140
152
  // Options are listed large first, so index from the end for small.
141
- const picked = [options[options.length - 1], options[1], options[0]][tier] ?? options[0];
153
+ const picked = tier < 0 ? fallback : ([options[options.length - 1], options[1], options[0]][tier] ?? fallback);
142
154
  console.warn(`[manifests] model "${value}" is no longer available — using "${picked}". Update this node.`);
143
155
  return picked;
144
156
  },