@unoverse-platform/studio 0.1.116 → 0.1.117
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/local/scaffold.d.mts +1 -0
- package/local/scaffold.mjs +12 -0
- package/package.json +1 -1
- package/screens/AgentChain.tsx +41 -25
- package/screens/AgentsView.tsx +41 -1
- package/screens/TemplatesLibraryView.tsx +32 -3
- package/screens/TemplatesView.tsx +2 -2
- package/screens/index.ts +1 -1
- package/src/host.ts +5 -0
- package/src/index.css +11 -0
- package/vendor/base/src/agent-mcp/discovery.ts +1 -1
- package/vendor/base/src/agent-mcp/index.ts +3 -1
- package/vendor/base/src/agent-mcp/invoke.ts +62 -18
- package/vendor/base/src/agent-mcp/progressive.ts +124 -0
- package/vendor/base/src/agent-mcp/tools.ts +1 -1
- package/vendor/base/src/definitions/load/expand/inherit.ts +1 -1
- package/vendor/base/src/definitions/load/expand/refs.ts +1 -1
- package/vendor/base/src/definitions/load/expand/walk.ts +1 -1
- package/vendor/base/src/definitions/registry/agentFolders.ts +2 -2
- package/vendor/base/src/definitions/registry/catalog.ts +1 -1
- package/vendor/base/src/definitions/registry/objects.ts +94 -6
- package/vendor/base/src/definitions/registry/pipelines.ts +13 -2
- package/vendor/base/src/http/uwsFetchAdapter.ts +39 -3
- package/vendor/base/src/items/collect.ts +10 -0
- package/vendor/base/src/items/publish.ts +10 -0
- package/vendor/base/src/lint/design/index.mjs +4 -1
- package/vendor/base/src/lint/design/vocabulary.mjs +1 -1
- package/vendor/base/src/lint/objects/index.mjs +1 -1
- package/vendor/base/src/lint/objects/rules.d.mts +3 -0
- package/vendor/base/src/lint/objects/rules.mjs +93 -5
- package/vendor/base/src/manifests/compose.ts +3 -0
- package/vendor/base/src/manifests/executor/callback.ts +3 -2
- package/vendor/base/src/manifests/executor/memory.ts +27 -0
- package/vendor/base/src/manifests/executor/toolBridge.ts +70 -11
- package/vendor/base/src/manifests/runtime/auth/oauth.ts +43 -10
- package/vendor/base/src/manifests/runtime/events.ts +1 -1
- package/vendor/base/src/manifests/runtime/http/request.ts +43 -3
- package/vendor/base/src/manifests/runtime/loops/poll.ts +27 -5
- package/vendor/base/src/manifests/runtime/tools/toolloop.ts +1 -1
- package/vendor/base/src/platform/inbound.ts +67 -0
- package/vendor/base/src/platform/tokenCost.ts +27 -8
- package/vendor/sdk/lib/core/actions.ts +10 -0
- package/vendor/sdk/lib/core/apps.ts +2 -2
- package/vendor/sdk/lib/core/client.ts +11 -0
- package/vendor/sdk/lib/core/store.ts +1 -1
- package/vendor/sdk/lib/core/types.ts +2 -0
- package/vendor/sdk/lib/dom/app.ts +3 -2
- package/vendor/sdk/lib/dom/component.ts +38 -1
- package/vendor/sdk/lib/dom/element.ts +23 -0
- package/vendor/sdk/lib/dom/primitives.ts +97 -6
- package/vendor/sdk/lib/voiceService.ts +3 -1
- package/vendor/sdk/main.ts +1 -1
|
@@ -140,3 +140,30 @@ export function sessionTurnRecorder(
|
|
|
140
140
|
flush: save,
|
|
141
141
|
};
|
|
142
142
|
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* THE TASK THE APP'S ADDRESS NAMES, in the call before it starts (docs/mcp/MCP_TASKS.md § 5c).
|
|
146
|
+
* A task found in Spatial lands in the conversation as the search's result, so the model knows
|
|
147
|
+
* it; a task opened by an address has no search, so what it holds is replayed instead, read by
|
|
148
|
+
* its id with the official client (`tasks/get`), the same way the memory block above is. Only an
|
|
149
|
+
* id: a URI (`<org>/<name>`, which a task's own run also carries) is not an opened task.
|
|
150
|
+
*/
|
|
151
|
+
export async function hydrateOpenedTask(config: any, executionContext: any): Promise<any> {
|
|
152
|
+
const id = executionContext?.publishingContext?.task;
|
|
153
|
+
if (typeof id !== "string" || !id || id.includes("/")) return config;
|
|
154
|
+
try {
|
|
155
|
+
const harness: any = await import("../../agent-mcp/index.js");
|
|
156
|
+
const task = await harness.readTask(id, { accessToken: executionContext?.auth?.accessToken });
|
|
157
|
+
const block =
|
|
158
|
+
`This conversation was opened on a task (${task.status}).` +
|
|
159
|
+
(task.statusMessage ? ` ${task.statusMessage}` : "") +
|
|
160
|
+
(task.asks ? `\nIt asks: ${JSON.stringify(task.asks)}` : "") +
|
|
161
|
+
(task.result ? `\nWhat it holds:\n${task.result}` : "");
|
|
162
|
+
const history = Array.isArray(config?.conversationHistory) ? config.conversationHistory : [];
|
|
163
|
+
console.log(`[manifests] opened task ${id} replayed into the call (${task.status})`);
|
|
164
|
+
return { ...config, conversationHistory: [...history, { role: "user", content: block }] };
|
|
165
|
+
} catch (e: any) {
|
|
166
|
+
console.log(`[manifests] opened task ${id} could not be read (non-blocking): ${e?.message ?? e}`);
|
|
167
|
+
return config;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
import type { ToolBridge } from "../runtime/index.js";
|
|
13
13
|
import { engineUrl } from "./context.js";
|
|
14
14
|
import { saveAvailableTools } from "../../platform/serviceCalls.js";
|
|
15
|
+
import { recordCall } from "../runtime/tools/toolloop.js";
|
|
15
16
|
|
|
16
17
|
/** The screen's verbs on an app with places (docs/unoverse/design/screen/UNOVERSE_SCREEN.md §3.3). */
|
|
17
18
|
const SCREEN_VERBS = {
|
|
@@ -182,22 +183,79 @@ export async function toolBridgeFor(executionContext: any): Promise<ToolBridge |
|
|
|
182
183
|
}
|
|
183
184
|
}
|
|
184
185
|
|
|
185
|
-
|
|
186
|
+
/**
|
|
187
|
+
* THE TASK THE APP'S ADDRESS NAMES, by its id (docs/mcp/MCP_TASKS.md § 5c). An edge or Spatial
|
|
188
|
+
* hands the model a task to start; an id names one that already exists, connected through the
|
|
189
|
+
* official client's `session.task(id)`. The model can read it, and answer it while it waits:
|
|
190
|
+
* the two things MCP offers on an existing task. A URI (`<org>/<name>`, which a task's own run
|
|
191
|
+
* also carries) is not this. Fail-soft: a task that cannot be read adds nothing.
|
|
192
|
+
*/
|
|
193
|
+
const named = pub?.task;
|
|
194
|
+
if (typeof named === "string" && named && !named.includes("/")) {
|
|
195
|
+
try {
|
|
196
|
+
const now = await harness.readTask(named, identity);
|
|
197
|
+
appInvokers.set("read_task", () => harness.readTask(named, identity));
|
|
198
|
+
preMinted.push({
|
|
199
|
+
name: "read_task",
|
|
200
|
+
description: `The task this conversation was opened on (${now.status}). Read it for where it is, what it asks and what it produced.`,
|
|
201
|
+
parameters: { type: "object", properties: {} },
|
|
202
|
+
});
|
|
203
|
+
if (now.status === "input_required") {
|
|
204
|
+
appInvokers.set("answer_task", async (input: any) => {
|
|
205
|
+
await harness.answerTask(named, (input?.answers ?? {}) as Record<string, unknown>, identity);
|
|
206
|
+
return harness.readTask(named, identity);
|
|
207
|
+
});
|
|
208
|
+
preMinted.push({
|
|
209
|
+
name: "answer_task",
|
|
210
|
+
description: "Answer what the task this conversation was opened on asks, each answer under the key it asks with.",
|
|
211
|
+
parameters: { type: "object", properties: { answers: { type: "object", description: "Each answer under the key the task asks with." } }, required: ["answers"] },
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
console.log(`[manifests] task ${named} connected (${now.status})`);
|
|
215
|
+
} catch (e: any) {
|
|
216
|
+
console.log(`[manifests] task ${named} could not be read: ${e?.message ?? e}`);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** The tools held behind search_tools on this run, or null when every tool is offered. */
|
|
221
|
+
let held: Array<{ name: string; description: string; parameters: unknown; server?: string }> | null = null;
|
|
222
|
+
|
|
223
|
+
const bridge: ToolBridge = {
|
|
186
224
|
async discover() {
|
|
187
|
-
const
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
225
|
+
const wired = Object.entries<any>(schema.methods).map(([name, m]) => ({
|
|
226
|
+
name,
|
|
227
|
+
description: m.description || `Execute ${name}`,
|
|
228
|
+
parameters: m.input || { type: "object", properties: {} },
|
|
229
|
+
...(m.server ? { server: String(m.server) } : {}),
|
|
230
|
+
}));
|
|
231
|
+
// MANY TOOLS ARE HELD BEHIND SEARCH (harness.shouldDefer, MCP Client Best Practices). Spatial
|
|
232
|
+
// search stays offered: it is how an Agent finds what it does not hold, and what it finds is
|
|
233
|
+
// minted as a tool. The screen and open pages (preMinted) are never held.
|
|
234
|
+
const deferrable = wired.filter((t) => !harness.DISCOVERY_TOOL_NAMES.includes(t.name));
|
|
235
|
+
held = harness.shouldDefer(deferrable) ? deferrable : null;
|
|
236
|
+
if (held) console.log(`[manifests] ${held.length} tools held behind ${harness.SEARCH_TOOLS} (about ${harness.estimateTokens(held)} tokens of definitions)`);
|
|
237
|
+
const strip = ({ server: _server, ...t }: any) => t;
|
|
238
|
+
const tools = [...(held ? [...wired.filter((t) => !held!.includes(t)), ...harness.metaTools(held)] : wired).map(strip), ...preMinted];
|
|
195
239
|
// Everything the model starts with, the screen and open pages included.
|
|
196
240
|
if (caller.executionId && caller.nodeId) saveAvailableTools(caller.executionId, caller.nodeId, tools.map((t) => t.name));
|
|
197
241
|
return tools;
|
|
198
242
|
},
|
|
199
243
|
|
|
200
|
-
async call(name, args, hooks) {
|
|
244
|
+
async call(name, args, hooks): Promise<string> {
|
|
245
|
+
// A SEARCH IS A CALL LIKE ANY OTHER, recorded on the run with what it asked and what it found:
|
|
246
|
+
// without it nobody could see which tools an Agent looked for, or why it never used one.
|
|
247
|
+
if (held && name === harness.SEARCH_TOOLS) {
|
|
248
|
+
const startTime = Date.now();
|
|
249
|
+
const answer = harness.searchAnswer(held, args?.query);
|
|
250
|
+
recordCall({ executionId: caller.executionId, parentNodeId: caller.nodeId, toolName: harness.SEARCH_TOOLS, args, result: answer, startTime, success: true, who: "harness" });
|
|
251
|
+
return answer;
|
|
252
|
+
}
|
|
253
|
+
// A held tool, called by name. Only a tool this Agent was given: the list is its limit.
|
|
254
|
+
if (held && name === harness.CALL_TOOL) {
|
|
255
|
+
const target = String(args?.name ?? "");
|
|
256
|
+
if (!held.some((t) => t.name === target)) return JSON.stringify({ error: `No tool named "${target}". Find it with ${harness.SEARCH_TOOLS} first.` });
|
|
257
|
+
return bridge.call(target, args?.arguments ?? {}, hooks);
|
|
258
|
+
}
|
|
201
259
|
const app = appInvokers.get(name);
|
|
202
260
|
// A followed task's status, as it changes, goes to whoever asked (the tool loop narrates it).
|
|
203
261
|
if (app) return stringify(await app(args, hooks));
|
|
@@ -276,7 +334,7 @@ export async function toolBridgeFor(executionContext: any): Promise<ToolBridge |
|
|
|
276
334
|
// A WORKFLOW-BOUND APP (path A) OPENS, then ends the turn. It used to only end
|
|
277
335
|
// the turn: nothing registered an invoker, so the model's call fell through to
|
|
278
336
|
// `api.callService` and died as an unknown SERVICE method while the run reported
|
|
279
|
-
// success and the screen stayed blank (
|
|
337
|
+
// success and the screen stayed blank (a bank, 2026-09-07). Both, in this order —
|
|
280
338
|
// the app tool renders the surface and fires no workflow itself (server
|
|
281
339
|
// mcp/server/tools.ts), so the handoff that follows is not a second run.
|
|
282
340
|
appInvokers.set(def.name, harness.workflowAppInvoker(mcp, called));
|
|
@@ -315,4 +373,5 @@ export async function toolBridgeFor(executionContext: any): Promise<ToolBridge |
|
|
|
315
373
|
return harness.isTurnEndingHandoff(calls, handoffTools);
|
|
316
374
|
},
|
|
317
375
|
};
|
|
376
|
+
return bridge;
|
|
318
377
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* OAUTH2 CLIENT CREDENTIALS.
|
|
3
|
-
* every node that needs
|
|
2
|
+
* OAUTH2 CLIENT CREDENTIALS AND REFRESH TOKENS. Auth schemes, so they are written once here
|
|
3
|
+
* rather than in every node that needs them (DECLARATIVE_NODES.md §2).
|
|
4
4
|
*
|
|
5
5
|
* Unlike a bearer key, this is not a value the manifest can template: the credential is a
|
|
6
6
|
* client id and secret that must be EXCHANGED for a short-lived token, over a form-encoded
|
|
@@ -10,7 +10,15 @@
|
|
|
10
10
|
* The token response is put back into scope as `token`, because some vendors return where
|
|
11
11
|
* to talk as well as how. Salesforce returns `instance_url` and the org's API lives there,
|
|
12
12
|
* not at the login host you authenticated against.
|
|
13
|
+
*
|
|
14
|
+
* A REFRESH TOKEN is the same exchange with one more secret. Some vendors (LinkedIn) never
|
|
15
|
+
* issue a token to an app alone: a person approves the app once, and what that leaves behind
|
|
16
|
+
* is a long-lived refresh token the app trades for short-lived access tokens with nobody
|
|
17
|
+
* signed in. The person makes it once in the vendor's developer portal and it is pasted into
|
|
18
|
+
* the credential, as the client secret is.
|
|
13
19
|
*/
|
|
20
|
+
import { createHash } from "node:crypto";
|
|
21
|
+
|
|
14
22
|
|
|
15
23
|
interface Minted {
|
|
16
24
|
accessToken: string;
|
|
@@ -22,16 +30,23 @@ interface Minted {
|
|
|
22
30
|
* Cached per connected-app identity, not per node or per run.
|
|
23
31
|
*
|
|
24
32
|
* A workflow that makes six calls must not mint six tokens, and a vendor will rate-limit or
|
|
25
|
-
* refuse if it does. Keyed by client id AND token url so two orgs never share an entry
|
|
33
|
+
* refuse if it does. Keyed by client id AND token url so two orgs never share an entry, and
|
|
34
|
+
* for a refresh token by that token too: two orgs may connect through the same vendor app.
|
|
26
35
|
*/
|
|
27
36
|
const CACHE = new Map<string, Minted>();
|
|
28
37
|
|
|
29
38
|
/** Default life. Salesforce client-credentials sessions are ~2h; refresh well before. */
|
|
30
39
|
const DEFAULT_TTL_MS = 110 * 60 * 1000;
|
|
31
40
|
|
|
41
|
+
function cacheKey(tokenUrl: string, clientId: string, refreshToken?: string): string {
|
|
42
|
+
// A digest, never the refresh token itself, so the key holds no secret.
|
|
43
|
+
const who = refreshToken ? `#${createHash("sha256").update(refreshToken).digest("hex").slice(0, 16)}` : "";
|
|
44
|
+
return `${clientId}@${tokenUrl}${who}`;
|
|
45
|
+
}
|
|
46
|
+
|
|
32
47
|
/** Drop a cached token, so the next call mints a fresh one. */
|
|
33
|
-
export function invalidateToken(tokenUrl: string, clientId: string): void {
|
|
34
|
-
CACHE.delete(
|
|
48
|
+
export function invalidateToken(tokenUrl: string, clientId: string, refreshToken?: string): void {
|
|
49
|
+
CACHE.delete(cacheKey(tokenUrl, clientId, refreshToken));
|
|
35
50
|
}
|
|
36
51
|
|
|
37
52
|
export async function mintClientCredentials(
|
|
@@ -41,15 +56,33 @@ export async function mintClientCredentials(
|
|
|
41
56
|
scope: string | undefined,
|
|
42
57
|
force = false,
|
|
43
58
|
): Promise<Minted> {
|
|
44
|
-
const
|
|
59
|
+
const form = new URLSearchParams({ grant_type: "client_credentials", client_id: clientId, client_secret: clientSecret });
|
|
60
|
+
if (scope) form.set("scope", scope);
|
|
61
|
+
return mint("oauth2ClientCredentials", tokenUrl, form, cacheKey(tokenUrl, clientId), force);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function mintFromRefreshToken(
|
|
65
|
+
tokenUrl: string,
|
|
66
|
+
clientId: string,
|
|
67
|
+
clientSecret: string,
|
|
68
|
+
refreshToken: string,
|
|
69
|
+
force = false,
|
|
70
|
+
): Promise<Minted> {
|
|
71
|
+
const form = new URLSearchParams({
|
|
72
|
+
grant_type: "refresh_token",
|
|
73
|
+
refresh_token: refreshToken,
|
|
74
|
+
client_id: clientId,
|
|
75
|
+
client_secret: clientSecret,
|
|
76
|
+
});
|
|
77
|
+
return mint("oauth2RefreshToken", tokenUrl, form, cacheKey(tokenUrl, clientId, refreshToken), force);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function mint(scheme: string, tokenUrl: string, form: URLSearchParams, key: string, force: boolean): Promise<Minted> {
|
|
45
81
|
const hit = CACHE.get(key);
|
|
46
82
|
if (!force && hit && hit.expiresAt > Date.now()) return hit;
|
|
47
83
|
|
|
48
84
|
// FORM ENCODED, not JSON. The OAuth2 spec says so and vendors enforce it, which is why
|
|
49
85
|
// this cannot ride the ordinary body path: that one always sends JSON.
|
|
50
|
-
const form = new URLSearchParams({ grant_type: "client_credentials", client_id: clientId, client_secret: clientSecret });
|
|
51
|
-
if (scope) form.set("scope", scope);
|
|
52
|
-
|
|
53
86
|
const res = await fetch(tokenUrl, {
|
|
54
87
|
method: "POST",
|
|
55
88
|
headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json" },
|
|
@@ -61,7 +94,7 @@ export async function mintClientCredentials(
|
|
|
61
94
|
// The description, not just the status: "invalid_client" tells you which of the two
|
|
62
95
|
// secrets is wrong, and a bare 400 does not.
|
|
63
96
|
const detail = data?.error_description ?? data?.error ?? res.statusText;
|
|
64
|
-
throw new Error(
|
|
97
|
+
throw new Error(`${scheme}: token request to ${tokenUrl} failed (${res.status}): ${detail}`);
|
|
65
98
|
}
|
|
66
99
|
|
|
67
100
|
const minted: Minted = {
|
|
@@ -211,7 +211,7 @@ export function makeEmitter(node: ComposedNode, onEmit: (e: Emission) => void, b
|
|
|
211
211
|
* word after the first sitting in `pending`, and the next thing to move it is whatever
|
|
212
212
|
* arrives next, however much later.
|
|
213
213
|
*
|
|
214
|
-
* Seen live (
|
|
214
|
+
* Seen live (a bank's account agent, 2026-09-08): the model said "I'm checking your account
|
|
215
215
|
* activity for food spending last month" in a few milliseconds, the reader saw "I", and
|
|
216
216
|
* the rest appeared eleven seconds afterwards — when a delta from the turn AFTER the
|
|
217
217
|
* tool call finally pushed it out. It read as the stream stopping for the tool. It was
|
|
@@ -10,7 +10,7 @@ import { render, evaluate, primeTemplating } from "../templating.js";
|
|
|
10
10
|
import { assertAllowedHost } from "./allowedHosts.js";
|
|
11
11
|
import { readSettled, assertOk } from "./response.js";
|
|
12
12
|
import { performState, type StateStore } from "../state.js";
|
|
13
|
-
import { mintClientCredentials, invalidateToken } from "../auth/oauth.js";
|
|
13
|
+
import { mintClientCredentials, mintFromRefreshToken, invalidateToken } from "../auth/oauth.js";
|
|
14
14
|
import { signAwsRequest, presignAwsUrl, encodeDynamoJson, decodeDynamoJson, type AwsSigning } from "../auth/aws.js";
|
|
15
15
|
import { fetchPaginated } from "../loops/paginate.js";
|
|
16
16
|
import { fetchPolled } from "../loops/poll.js";
|
|
@@ -116,6 +116,17 @@ export async function buildRequest(
|
|
|
116
116
|
scoped = { ...ctx, token: { instanceUrl: minted.instanceUrl } } as RunContext;
|
|
117
117
|
break;
|
|
118
118
|
}
|
|
119
|
+
case "oauth2RefreshToken": {
|
|
120
|
+
const minted = await mintFromRefreshToken(
|
|
121
|
+
await required(auth.tokenUrl, "a token url"),
|
|
122
|
+
await required(auth.clientId, "a client id"),
|
|
123
|
+
await required(auth.clientSecret, "a client secret"),
|
|
124
|
+
await required(auth.refreshToken, "a refresh token"),
|
|
125
|
+
forceReauth,
|
|
126
|
+
);
|
|
127
|
+
authHeaders.Authorization = `Bearer ${minted.accessToken}`;
|
|
128
|
+
break;
|
|
129
|
+
}
|
|
119
130
|
case "awsSigV4": {
|
|
120
131
|
// The manifest names the SERVICE and the REGION, because those decide what the
|
|
121
132
|
// signature is scoped to and they are properties of the endpoint being described.
|
|
@@ -237,6 +248,15 @@ async function resolveValue(value: any, ctx: RunContext): Promise<unknown> {
|
|
|
237
248
|
* A timeout is an ERROR, not an empty result: it fails with a message naming the limit and the call,
|
|
238
249
|
* so the manifest's own number appears in the log that reports it.
|
|
239
250
|
*/
|
|
251
|
+
/** How many times a call whose connection dropped is made again: 2s, 4s, 8s, 16s, 32s, 60s, 60s, 60s, 60s, about five minutes. */
|
|
252
|
+
export const DROPPED_RETRIES = 9;
|
|
253
|
+
/** A failure with no reply at all: the connection dropped, was refused, reset, or never resolved. Never a status. */
|
|
254
|
+
export function dropped(error: any): boolean {
|
|
255
|
+
const code = String(error?.cause?.code ?? error?.code ?? "");
|
|
256
|
+
const text = String(error?.message ?? error);
|
|
257
|
+
return /^(ECONNRESET|ECONNREFUSED|EAI_AGAIN|ENOTFOUND|EPIPE|ETIMEDOUT|UND_ERR_SOCKET|UND_ERR_CONNECT_TIMEOUT)$/.test(code) || /^fetch failed$/i.test(text) || /socket hang up|other side closed/i.test(text);
|
|
258
|
+
}
|
|
259
|
+
|
|
240
260
|
export async function fetchWithTimeout(url: string, init: any, timeoutMs: unknown, label: string): Promise<Response> {
|
|
241
261
|
const ms = typeof timeoutMs === "number" && timeoutMs > 0 ? timeoutMs : 0;
|
|
242
262
|
if (!ms) return fetch(url, init);
|
|
@@ -408,6 +428,21 @@ export async function sendRequest(node: ComposedNode, requestSpec: any, ctx: Run
|
|
|
408
428
|
waiter?.unref?.();
|
|
409
429
|
try {
|
|
410
430
|
res = await fetchWithTimeout(url, init, requestSpec?.timeoutMs, label);
|
|
431
|
+
} catch (error: any) {
|
|
432
|
+
/**
|
|
433
|
+
* A DROPPED CONNECTION IS ASKED AGAIN (2026-10-22). A host that scales to zero answers its first
|
|
434
|
+
* call by booting; the gateway in front of it drops the connection while a model loads (NuExtract
|
|
435
|
+
* on Beam: four and a half minutes), and the engine saw "fetch failed" with no status at all. That
|
|
436
|
+
* is not the vendor's answer, it is no answer, so the call is made again with a growing wait, up
|
|
437
|
+
* to about five minutes in all. A reply with a status, good or bad, is never retried here.
|
|
438
|
+
*/
|
|
439
|
+
if (dropped(error) && attempt <= DROPPED_RETRIES) {
|
|
440
|
+
const wait = Math.min(60_000, 2_000 * 2 ** (attempt - 1));
|
|
441
|
+
console.warn(`[manifests] ${label}: the connection dropped (${String(error?.cause?.code ?? error?.message ?? error).slice(0, 80)}); asking again in ${Math.round(wait / 1000)}s (${attempt}/${DROPPED_RETRIES})`);
|
|
442
|
+
await new Promise((r) => setTimeout(r, wait));
|
|
443
|
+
continue;
|
|
444
|
+
}
|
|
445
|
+
throw error;
|
|
411
446
|
} finally {
|
|
412
447
|
if (waiter) clearInterval(waiter);
|
|
413
448
|
}
|
|
@@ -428,8 +463,13 @@ export async function sendRequest(node: ComposedNode, requestSpec: any, ctx: Run
|
|
|
428
463
|
// A minted token can be revoked or expire early, and the vendor says so with a 401.
|
|
429
464
|
// Refresh ONCE and retry, exactly as the retired client did. Guarded by !forceReauth so
|
|
430
465
|
// a genuinely rejected credential fails rather than looping.
|
|
431
|
-
|
|
432
|
-
|
|
466
|
+
const scheme = requestSpec?.credential?.scheme;
|
|
467
|
+
if (res.status === 401 && (scheme === "oauth2ClientCredentials" || scheme === "oauth2RefreshToken") && !forceReauth) {
|
|
468
|
+
invalidateToken(
|
|
469
|
+
String(render(requestSpec.credential.tokenUrl, ctx)),
|
|
470
|
+
String(render(requestSpec.credential.clientId, ctx)),
|
|
471
|
+
scheme === "oauth2RefreshToken" ? String(render(requestSpec.credential.refreshToken, ctx)) : undefined,
|
|
472
|
+
);
|
|
433
473
|
console.warn(`[manifests] ${label}: 401, refreshing the access token and retrying once`);
|
|
434
474
|
return sendRequest(node, requestSpec, ctx, label, true);
|
|
435
475
|
}
|
|
@@ -141,12 +141,34 @@ export async function fetchPolled(
|
|
|
141
141
|
`[manifests] ${label}: job started — polling ${url.split("?")[0]} every ${Math.round(interval / 1000)}s (up to ${attempts} attempts)`,
|
|
142
142
|
);
|
|
143
143
|
|
|
144
|
+
// THE FIRST CHECKS COME QUICKLY (2026-10-25): one second, then two, four, doubling up to the manifest's interval. A job
|
|
145
|
+
// answered from the vendor's cache is ready at once, and a fixed ten-second wait before the first check made every
|
|
146
|
+
// stored parse take ten seconds; a long job still settles into the full interval, so its patience is barely shorter.
|
|
147
|
+
let wait = Math.min(1000, interval);
|
|
144
148
|
for (let attempt = 1; attempt <= attempts; attempt++) {
|
|
145
|
-
await new Promise((r) => setTimeout(r,
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
149
|
+
await new Promise((r) => setTimeout(r, wait));
|
|
150
|
+
wait = Math.min(wait * 2, interval);
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* A MISSED POLL IS NOT A LOST JOB (2026-10-22). The job runs on the vendor's side; a status ask
|
|
154
|
+
* that cannot get through (the connection dropped, a gateway answered 502/503/504 while a
|
|
155
|
+
* container turned over) says nothing about the job. One such miss ended a parse that the
|
|
156
|
+
* vendor's own log shows finished sixteen seconds later. A miss is counted as an attempt and
|
|
157
|
+
* the wait goes on; only the attempts bound, or an answer that is an error, ends it.
|
|
158
|
+
*/
|
|
159
|
+
let payload: any;
|
|
160
|
+
try {
|
|
161
|
+
const res = await sendRequest(node, statusCall, ctx, `${label} (poll ${attempt}/${attempts})`);
|
|
162
|
+
payload = await readSettled(res, call.transport, call.encoding);
|
|
163
|
+
await assertOk(node, call, payload, `${label} (poll ${attempt}/${attempts})`);
|
|
164
|
+
} catch (error: any) {
|
|
165
|
+
const text = String(error?.message ?? error);
|
|
166
|
+
if (attempt < attempts && /\b50[234]\b|fetch failed|ECONN|EAI_AGAIN|socket|timed out|aborted/i.test(text)) {
|
|
167
|
+
console.warn(`[manifests] ${label}: poll ${attempt}/${attempts} could not reach the job (${text.slice(0, 160)}); asking again`);
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
throw error;
|
|
171
|
+
}
|
|
150
172
|
|
|
151
173
|
const status = typeof payload?.status === "string" ? payload.status : undefined;
|
|
152
174
|
if (status !== lastStatus || attempt % heartbeat === 0)
|
|
@@ -331,7 +331,7 @@ export async function runToolLoop(
|
|
|
331
331
|
// window — and if the model answered inside a single window, that is the whole answer
|
|
332
332
|
// but its first token. It then surfaces on the next turn's first delta, which is after
|
|
333
333
|
// any tool this turn called: the reader saw "I", then the rest eleven seconds later,
|
|
334
|
-
// and it read as the stream stopping for the tool (
|
|
334
|
+
// and it read as the stream stopping for the tool (a bank, 2026-09-08).
|
|
335
335
|
//
|
|
336
336
|
// Here rather than in the manifest, and by the LOOP rather than a timer: only the loop
|
|
337
337
|
// knows where a turn ends, which is the same reason it owns counting them.
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* IS THIS MESSAGE GENUINE? (docs/auth-security/INBOUND.md § 2 step 3; UNO-239)
|
|
3
|
+
*
|
|
4
|
+
* The verifiers, written here and nowhere else. A node only NAMES its scheme and the field of its
|
|
5
|
+
* credential holding the secret (`inbound:` in node.yaml); it can never describe its own check, so
|
|
6
|
+
* no node can switch checking off. Each one fails closed: no secret, no timestamp, a wrong or old
|
|
7
|
+
* signature, all refused. Compared in constant time over the raw bytes, before anything is parsed.
|
|
8
|
+
*
|
|
9
|
+
* A service's one-time address check (Slack's `url_verification`) is answered here too, but only
|
|
10
|
+
* once the request has passed the same check: an unsigned challenge is refused like anything else.
|
|
11
|
+
*/
|
|
12
|
+
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
13
|
+
|
|
14
|
+
/** What a verifier decides. `challenge`: the service is checking the address; echo this, do nothing else. */
|
|
15
|
+
export type Verdict = { ok: false; reason: string } | { ok: true; challenge?: string };
|
|
16
|
+
|
|
17
|
+
/** The schemes a node may name. */
|
|
18
|
+
export const SCHEMES = ["slack"] as const;
|
|
19
|
+
export type Scheme = (typeof SCHEMES)[number];
|
|
20
|
+
|
|
21
|
+
/** Older than this, or this far in the future, and a message is a replay (Slack's own window). */
|
|
22
|
+
export const MAX_SKEW_SECONDS = 300;
|
|
23
|
+
|
|
24
|
+
function same(a: string, b: string): boolean {
|
|
25
|
+
const x = Buffer.from(a);
|
|
26
|
+
const y = Buffer.from(b);
|
|
27
|
+
return x.length === y.length && timingSafeEqual(x, y);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* SLACK (docs.slack.dev, "Verifying requests from Slack"): `X-Slack-Signature` is `v0=` and the hex
|
|
32
|
+
* HMAC-SHA256, keyed by the app's signing secret, of `v0:<X-Slack-Request-Timestamp>:<raw body>`.
|
|
33
|
+
*/
|
|
34
|
+
function slack(headers: Headers, raw: string, secret: string, nowSeconds: number): Verdict {
|
|
35
|
+
const ts = headers.get("x-slack-request-timestamp") ?? "";
|
|
36
|
+
const signature = headers.get("x-slack-signature") ?? "";
|
|
37
|
+
if (!/^\d+$/.test(ts) || !signature) return { ok: false, reason: "unsigned" };
|
|
38
|
+
if (Math.abs(nowSeconds - Number(ts)) > MAX_SKEW_SECONDS) return { ok: false, reason: "too old" };
|
|
39
|
+
const expected = `v0=${createHmac("sha256", secret).update(`v0:${ts}:${raw}`).digest("hex")}`;
|
|
40
|
+
if (!same(signature, expected)) return { ok: false, reason: "wrong signature" };
|
|
41
|
+
try {
|
|
42
|
+
const body = JSON.parse(raw);
|
|
43
|
+
if (body?.type === "url_verification" && typeof body.challenge === "string") return { ok: true, challenge: body.challenge };
|
|
44
|
+
} catch {
|
|
45
|
+
/* a signed body that is not JSON is still genuine; what it says is the next step's to read */
|
|
46
|
+
}
|
|
47
|
+
return { ok: true };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const VERIFIERS: Record<Scheme, (headers: Headers, raw: string, secret: string, nowSeconds: number) => Verdict> = { slack };
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Check one message with the scheme its node names. `secrets` holds the current secret and, while
|
|
54
|
+
* one is being replaced, the next: either passing is genuine. No secret at all is a refusal.
|
|
55
|
+
*/
|
|
56
|
+
export function verifyInbound(scheme: string, headers: Headers, raw: string, secrets: Array<string | undefined | null>, nowSeconds = Math.floor(Date.now() / 1000)): Verdict {
|
|
57
|
+
const verify = VERIFIERS[scheme as Scheme];
|
|
58
|
+
if (!verify) return { ok: false, reason: `no verifier for "${scheme}"` };
|
|
59
|
+
const usable = secrets.filter((s): s is string => typeof s === "string" && s.length > 0);
|
|
60
|
+
if (!usable.length) return { ok: false, reason: "no secret" };
|
|
61
|
+
let last: Verdict = { ok: false, reason: "no secret" };
|
|
62
|
+
for (const secret of usable) {
|
|
63
|
+
last = verify(headers, raw, secret, nowSeconds);
|
|
64
|
+
if (last.ok) return last;
|
|
65
|
+
}
|
|
66
|
+
return last;
|
|
67
|
+
}
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
type RawUsage = any;
|
|
10
10
|
/** What a model costs, per 1M tokens (or per minute). */
|
|
11
11
|
export interface ModelPricing {
|
|
12
|
-
input?: number; cachedInput?: number; output?: number; perMinute?: number;
|
|
12
|
+
input?: number; cachedInput?: number; cacheWrite?: number; output?: number; perMinute?: number;
|
|
13
13
|
audioInput?: number; cachedAudioInput?: number; audioOutput?: number; imageInput?: number; cachedImageInput?: number;
|
|
14
14
|
}
|
|
15
15
|
|
|
@@ -69,6 +69,9 @@ const parseOpenAI = (usage: RawUsage) => {
|
|
|
69
69
|
const inDetails = usage.input_tokens_details || usage.input_token_details || {};
|
|
70
70
|
const outDetails = usage.output_tokens_details || usage.output_token_details || {};
|
|
71
71
|
const cachedTokens = inDetails.cached_tokens || 0;
|
|
72
|
+
// WRITTEN TO THE CACHE, the other part of input with its own price (GPT-5.6 bills a cache write
|
|
73
|
+
// above a fresh token). Like cached, it is counted inside `input_tokens`.
|
|
74
|
+
const cacheWriteTokens = inDetails.cache_write_tokens || 0;
|
|
72
75
|
const reasoningTokens = outDetails.reasoning_tokens || 0;
|
|
73
76
|
// AUDIO IS PRICED DIFFERENTLY from text, so voice cost cannot be read off a total alone.
|
|
74
77
|
const audioIn = inDetails.audio_tokens || 0;
|
|
@@ -101,9 +104,11 @@ const parseOpenAI = (usage: RawUsage) => {
|
|
|
101
104
|
output: usage.output_tokens || 0,
|
|
102
105
|
reasoning: reasoningTokens,
|
|
103
106
|
cached: cachedTokens,
|
|
107
|
+
cacheWrite: cacheWriteTokens,
|
|
104
108
|
breakdown: {
|
|
105
109
|
input: usage.input_tokens || 0,
|
|
106
110
|
cached: cachedTokens,
|
|
111
|
+
...(cacheWriteTokens ? { cacheWrite: cacheWriteTokens } : {}),
|
|
107
112
|
reasoning: reasoningTokens,
|
|
108
113
|
completion: (usage.output_tokens || 0) - reasoningTokens,
|
|
109
114
|
// Present only on a voice call; omitted rather than shown as 0 for text models, so a
|
|
@@ -208,13 +213,18 @@ const parseBedrockClaude = (usage: RawUsage) => {
|
|
|
208
213
|
const parseGeneric = (usage: RawUsage) => {
|
|
209
214
|
const input = usage.input_tokens || usage.inputTokens || usage.prompt_tokens || 0;
|
|
210
215
|
const output = usage.output_tokens || usage.outputTokens || usage.completion_tokens || 0;
|
|
216
|
+
// CACHED IS A SUBSET OF INPUT, as OpenAI reports it, and every OpenAI-compatible vendor (Z.AI
|
|
217
|
+
// GLM) copies that shape. Dropping it priced every cached token at the fresh rate: a GLM Agent's
|
|
218
|
+
// turn cost about three times what Z.AI bills.
|
|
219
|
+
const cached = usage.prompt_tokens_details?.cached_tokens || usage.input_tokens_details?.cached_tokens || 0;
|
|
211
220
|
|
|
212
221
|
return {
|
|
213
222
|
total: usage.total_tokens || input + output,
|
|
214
223
|
input,
|
|
215
224
|
output,
|
|
216
225
|
reasoning: 0,
|
|
217
|
-
|
|
226
|
+
cached,
|
|
227
|
+
breakdown: { input, cached, output },
|
|
218
228
|
};
|
|
219
229
|
};
|
|
220
230
|
|
|
@@ -259,7 +269,14 @@ export const calculateTokenCost = (model: string, parsedUsage: any): string => {
|
|
|
259
269
|
).toFixed(4);
|
|
260
270
|
}
|
|
261
271
|
|
|
262
|
-
|
|
272
|
+
// Cached input and cache writes are parts of input, each billed at its own rate where the vendor
|
|
273
|
+
// has one; the rest is fresh.
|
|
274
|
+
const cached = Math.min(parsedUsage.cached || 0, parsedUsage.input || 0);
|
|
275
|
+
const written = Math.min(parsedUsage.cacheWrite || 0, (parsedUsage.input || 0) - cached);
|
|
276
|
+
const inputCost =
|
|
277
|
+
per((parsedUsage.input || 0) - cached - written, pricing.input) +
|
|
278
|
+
per(cached, pricing.cachedInput ?? pricing.input) +
|
|
279
|
+
per(written, pricing.cacheWrite ?? pricing.input);
|
|
263
280
|
const outputCost = per(parsedUsage.output, pricing.output);
|
|
264
281
|
|
|
265
282
|
return (inputCost + outputCost).toFixed(4);
|
|
@@ -270,11 +287,13 @@ export const calculateTokenCost = (model: string, parsedUsage: any): string => {
|
|
|
270
287
|
*/
|
|
271
288
|
export const getModelPricing = (model: string): ModelPricing => {
|
|
272
289
|
const pricing: Record<string, ModelPricing> = {
|
|
273
|
-
// GPT-5.6 family (Sol/Terra/Luna)
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
"gpt-5.6
|
|
277
|
-
"gpt-5.6-
|
|
290
|
+
// GPT-5.6 family (Sol/Terra/Luna): standard, short context (up to 272K input), verified against
|
|
291
|
+
// developers.openai.com/api/docs/pricing 2026-10-11. Cached input and cache writes have their own
|
|
292
|
+
// rates; without them every cached token was billed fresh.
|
|
293
|
+
"gpt-5.6": { input: 4.0, cachedInput: 0.4, cacheWrite: 5.0, output: 20.0 }, // alias of gpt-5.6-sol
|
|
294
|
+
"gpt-5.6-sol": { input: 4.0, cachedInput: 0.4, cacheWrite: 5.0, output: 20.0 },
|
|
295
|
+
"gpt-5.6-terra": { input: 2.0, cachedInput: 0.2, cacheWrite: 2.5, output: 12.0 },
|
|
296
|
+
"gpt-5.6-luna": { input: 0.2, cachedInput: 0.02, cacheWrite: 0.25, output: 1.2 },
|
|
278
297
|
|
|
279
298
|
// Z.AI GLM — verified against docs.z.ai/guides/overview/pricing 2026-10-09
|
|
280
299
|
"glm-5.3": { input: 1.4, cachedInput: 0.26, output: 4.4 },
|
|
@@ -22,6 +22,8 @@ export interface ActionContext {
|
|
|
22
22
|
/** Where APP state lives, when it is not `store`: a template a website page draws keeps its
|
|
23
23
|
* own slice in its own store and shares the app's state with everything on the page. */
|
|
24
24
|
appStore?: ComponentStore;
|
|
25
|
+
/** Print what is on screen (host-provided; the `print` action). Resolves when it is done. */
|
|
26
|
+
print?: () => void | Promise<void>;
|
|
25
27
|
}
|
|
26
28
|
|
|
27
29
|
/** Resolve a value: a `"{{path}}"` template reads from `scope` (dot-path); anything
|
|
@@ -103,6 +105,14 @@ export function dispatchAction(
|
|
|
103
105
|
if (ctx.nodeId) ctx.store.apply({ type: "COMPONENT_DATA", chatId: ctx.chatId, nodeId: ctx.nodeId, data: { [field]: scope.text } });
|
|
104
106
|
else (ctx.appStore ?? ctx.store).mergeAppState({ [field]: scope.text });
|
|
105
107
|
}
|
|
108
|
+
} else if (spec.type === "print") {
|
|
109
|
+
// PRINT asks the host to print what is on screen (`ctx.print`; the web host waits for a still
|
|
110
|
+
// screen first, dom/element.ts printWhenStill). `then` runs once the print is done. A host
|
|
111
|
+
// that cannot print does nothing.
|
|
112
|
+
if (!ctx.print) return;
|
|
113
|
+
const then = spec.then;
|
|
114
|
+
void Promise.resolve(ctx.print()).then(() => { if (then) dispatchAction(then, scope, ctx); });
|
|
115
|
+
return;
|
|
106
116
|
} else {
|
|
107
117
|
// SERVER — narrow the payload by `send` if given, else forward the whole scope.
|
|
108
118
|
const payload = spec.send ? Object.fromEntries(spec.send.map((k) => [k, scope[k]])) : scope;
|
|
@@ -57,7 +57,7 @@ export const cssWidth = (w: unknown): string | undefined => (typeof w === "strin
|
|
|
57
57
|
*
|
|
58
58
|
* ONE function for every caller, because the slot frame once had its own copy without the
|
|
59
59
|
* `flex` word: beside a full-width app it rendered the task surface in a zero-width frame
|
|
60
|
-
* the moment the org's `flex` token went (
|
|
60
|
+
* the moment the org's `flex` token went (a bank's comparison, blank, 2026-09-07).
|
|
61
61
|
*/
|
|
62
62
|
export function placeWidthStyle(declared: unknown, appSize?: Record<string, string>): Record<string, unknown> | null {
|
|
63
63
|
const width = cssWidth(typeof declared === "string" ? (appSize?.[declared] ?? declared) : undefined);
|
|
@@ -377,7 +377,7 @@ export function selectPointers(store: ComponentStore, node: UnoverseNode, scope?
|
|
|
377
377
|
// THE MOST RECENT WINS (the `limit: 1` law) for EVERY capped select, not only a state
|
|
378
378
|
// select. A slot pinned to a component by `type` with `limit: 1` kept the OLDEST card:
|
|
379
379
|
// the sort lived inside the `where` branch, so a name-pinned cap sliced arrival order
|
|
380
|
-
// (
|
|
380
|
+
// (a bank's voice app, two Account Agent cards, 2026-09-10). A cap means "the newest N"; an
|
|
381
381
|
// uncapped flow keeps arrival order.
|
|
382
382
|
if (sel.where || sel.limit != null) pointers = [...pointers].sort((a, b) => store.touchOf(b) - store.touchOf(a));
|
|
383
383
|
if (sel.limit != null) pointers = pointers.slice(0, sel.limit);
|
|
@@ -129,6 +129,17 @@ export class UnoverseClient {
|
|
|
129
129
|
return this.withSession(() => this.client.callTool({ name, arguments: args }));
|
|
130
130
|
}
|
|
131
131
|
|
|
132
|
+
/**
|
|
133
|
+
* THE TASK A COMPONENT CARRIES (docs/design/lifecycle-hooks.md), run as this viewer: the server
|
|
134
|
+
* runs the hook in the component's own folder with the instance's fields as `props` and answers
|
|
135
|
+
* with the fields it filled. A component with no hook for the phase answers `{}`.
|
|
136
|
+
*/
|
|
137
|
+
async runTask(uri: string, phase: "onStart" | "onEnterView", props?: Record<string, unknown>, state?: string): Promise<Record<string, unknown>> {
|
|
138
|
+
const res = (await this.callTool("template_task", { uri, phase, ...(props ? { props } : {}), ...(state ? { state } : {}) })) as { content?: Array<{ text?: string }> };
|
|
139
|
+
const text = res?.content?.[0]?.text;
|
|
140
|
+
return text ? (JSON.parse(text) as Record<string, unknown>) : {};
|
|
141
|
+
}
|
|
142
|
+
|
|
132
143
|
|
|
133
144
|
/** resources/read a definition by URI (e.g. unoverse://components/card). Cached. A hash on
|
|
134
145
|
* the address names the state it opens in (`stateOfUri`) and is never sent to the server. */
|
|
@@ -493,7 +493,7 @@ export class ComponentStore {
|
|
|
493
493
|
// recomputed order on EVERY update, which is stable only while one thing is updating.
|
|
494
494
|
// A held Agent task has two: the task's face ticking through its steps, and the
|
|
495
495
|
// reply resuming once the result lands. They alternate, so the bottom slot alternated
|
|
496
|
-
// with them and the pair visibly swapped places on screen (
|
|
496
|
+
// with them and the pair visibly swapped places on screen (a bank's account agent,
|
|
497
497
|
// 2026-09-08). The case it was written for is served by arrival order too: text
|
|
498
498
|
// placed before a form stays above it.
|
|
499
499
|
this.merge(key, this.shieldGuestFace(key, event.data));
|
|
@@ -42,6 +42,8 @@ export interface UnoverseNode {
|
|
|
42
42
|
* content grows (new turn or streaming text), unless the user has scrolled up. Generic
|
|
43
43
|
* scroll-container behavior, opt-in via data (e.g. the conversation state's scroll box). */
|
|
44
44
|
autoScroll?: boolean;
|
|
45
|
+
/** Drag to move around this box's overflow, keeping the centre when its content changes size. */
|
|
46
|
+
pan?: boolean;
|
|
45
47
|
/** Box: a sticky bar (its own `position: sticky` + `top` in the design definition) that stays HIDDEN while
|
|
46
48
|
* the element before it (its previous sibling — e.g. a hero image) is on screen, then
|
|
47
49
|
* fades in once that sibling scrolls away. Overlays the content (no flow gap). Generic
|