@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
@@ -188,22 +188,58 @@ export function makeEmitter(node: ComposedNode, onEmit: (e: Emission) => void, b
188
188
  // on, and resetting first would clear the accumulator the row is about to read.
189
189
  resetTurn(match);
190
190
  },
191
+ /**
192
+ * Add to what EVERY row sees from now on, whatever it fires from.
193
+ *
194
+ * For state the caller accumulates over a run that no single event carries: a conversation
195
+ * held on a socket keeps what has been said so far, and a row that fires on one event (a
196
+ * hand-off) or at the end (the transcript) needs all of it.
197
+ */
198
+ extend(scope: Record<string, unknown>) {
199
+ Object.assign(base, scope);
200
+ },
191
201
  narrator: (line: string) => fire("narrator", { narrator: { line } }),
192
202
  tool: (call: { name: string; args: unknown; output: unknown }) => fire("tool", { call }),
193
203
 
194
204
  /**
195
- * Send anything a throttle held back, then run the `complete` rows over the whole run.
196
- * Flushing first matters: `complete` is usually defined over what was emitted.
205
+ * SEND WHAT THE THROTTLE IS HOLDING, without touching the accumulator.
206
+ *
207
+ * A throttled row keeps the newest value as `pending` and waits for a LATER DELTA to
208
+ * carry it out — there is no tick, by design (a timer here would fire against a run
209
+ * that may already be over). That is correct while tokens keep arriving and wrong the
210
+ * moment they stop: an answer that finishes inside one throttle window leaves every
211
+ * word after the first sitting in `pending`, and the next thing to move it is whatever
212
+ * arrives next, however much later.
213
+ *
214
+ * Seen live (SAB Account Agent, 2026-09-08): the model said "I'm checking your account
215
+ * activity for food spending last month" in a few milliseconds, the reader saw "I", and
216
+ * the rest appeared eleven seconds afterwards — when a delta from the turn AFTER the
217
+ * tool call finally pushed it out. It read as the stream stopping for the tool. It was
218
+ * this, and the tool's duration was a coincidence.
219
+ *
220
+ * The CALLER decides when a turn ends, because only the loop knows: the manifest
221
+ * describes the wire, the loop counts the turns.
197
222
  */
198
- async finish() {
223
+ flush() {
199
224
  for (let i = 0; i < rows.length; i++) {
200
225
  const s = state.get(i);
201
226
  if (s?.pending === undefined) continue;
202
227
  const emission = { ...destination(rows[i], s.to), value: s.pending };
203
228
  s.pending = undefined;
229
+ // The clock too, or the first delta of the next turn is held by a window that
230
+ // started before the flush and the same stall repeats one turn later.
231
+ s.lastAt = 0;
204
232
  emissions.push(emission);
205
233
  onEmit(emission);
206
234
  }
235
+ },
236
+
237
+ /**
238
+ * Send anything a throttle held back, then run the `complete` rows over the whole run.
239
+ * Flushing first matters: `complete` is usually defined over what was emitted.
240
+ */
241
+ async finish() {
242
+ this.flush();
207
243
  await fire("complete", { events: emissions.slice() });
208
244
  return emissions;
209
245
  },
@@ -0,0 +1,44 @@
1
+ /**
2
+ * `concurrency` ON A CALL: at most this many of the call in flight at once, in this process,
3
+ * per credential (DECLARATIVE_NODES.md 8a). Earned against Hyperbrowser on 2026-09-09: the
4
+ * plan runs one browser session at a time, three product reads started their fetches in the
5
+ * same second, and two came back 429. The retry list already had 429, and it did not help:
6
+ * an exponential backoff of a second or so is nothing beside a twenty-second scrape.
7
+ *
8
+ * A vendor's limit is the node's fact, declared once on the call, so every caller gets it: a
9
+ * canvas workflow fanning out over ten URLs as much as an ingestion read. The caller says
10
+ * nothing. Absent means unbounded, which is what every call was before this existed.
11
+ *
12
+ * Process-wide by design: the manifest runtime is the one place every request goes through,
13
+ * and a per-process gate is exactly the shape of the limit (sessions the process holds open).
14
+ * Two engines on two hosts are two limits, and that is the vendor's problem to reject, which
15
+ * the retry then absorbs.
16
+ */
17
+ interface Gate {
18
+ active: number;
19
+ waiting: Array<() => void>;
20
+ }
21
+
22
+ const gates = new Map<string, Gate>();
23
+
24
+ /** Run `fn` when fewer than `limit` runs share `key`; otherwise wait in arrival order. */
25
+ export async function withConcurrency<T>(key: string, limit: number, fn: () => Promise<T>): Promise<T> {
26
+ if (!(limit >= 1)) return fn();
27
+ let gate = gates.get(key);
28
+ if (!gate) { gate = { active: 0, waiting: [] }; gates.set(key, gate); }
29
+ if (gate.active >= limit) await new Promise<void>((resolve) => gate!.waiting.push(resolve));
30
+ gate.active += 1;
31
+ try {
32
+ return await fn();
33
+ } finally {
34
+ gate.active -= 1;
35
+ const next = gate.waiting.shift();
36
+ if (next) next();
37
+ else if (gate.active === 0) gates.delete(key);
38
+ }
39
+ }
40
+
41
+ /** How many runs share `key` right now, for a test or a status line. */
42
+ export function inFlight(key: string): number {
43
+ return gates.get(key)?.active ?? 0;
44
+ }
@@ -14,9 +14,11 @@ import { mintClientCredentials, 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";
17
+ import { runRepeat } from "../loops/repeat.js";
17
18
  import { performLoop } from "../loops/loop.js";
18
19
  import { performDoc } from "../docstore/index.js";
19
20
  import { sendChunked } from "../loops/chunk.js";
21
+ import { withConcurrency } from "./concurrency.js";
20
22
 
21
23
  /**
22
24
  * Build the HTTP request from a call. Auth schemes are executor capabilities.
@@ -235,7 +237,7 @@ async function resolveValue(value: any, ctx: RunContext): Promise<unknown> {
235
237
  * A timeout is an ERROR, not an empty result: it fails with a message naming the limit and the call,
236
238
  * so the manifest's own number appears in the log that reports it.
237
239
  */
238
- async function fetchWithTimeout(url: string, init: any, timeoutMs: unknown, label: string): Promise<Response> {
240
+ export async function fetchWithTimeout(url: string, init: any, timeoutMs: unknown, label: string): Promise<Response> {
239
241
  const ms = typeof timeoutMs === "number" && timeoutMs > 0 ? timeoutMs : 0;
240
242
  if (!ms) return fetch(url, init);
241
243
 
@@ -592,7 +594,7 @@ export async function runCalls(
592
594
 
593
595
  // A CHUNKED call is many requests over one collection: { sent, batches, results, errors }.
594
596
  if (call.chunk) {
595
- const written = await sendChunked(node, call, scoped, `${label}.${call.name}`);
597
+ const written = await gated(node, call, scoped, () => sendChunked(node, call, scoped, `${label}.${call.name}`));
596
598
  results[call.name] = written;
597
599
  last = written;
598
600
  continue;
@@ -600,7 +602,7 @@ export async function runCalls(
600
602
 
601
603
  // A PAGINATED call is many requests and one reply: { items, pages, truncated }.
602
604
  if (call.paginate) {
603
- const walked = await fetchPaginated(node, call, scoped, `${label}.${call.name}`);
605
+ const walked = await gated(node, call, scoped, () => fetchPaginated(node, call, scoped, `${label}.${call.name}`));
604
606
  results[call.name] = walked;
605
607
  last = walked;
606
608
  continue;
@@ -609,12 +611,21 @@ export async function runCalls(
609
611
  // A POLLED call is a JOB: start it, then ask until it is done. The reply is the final
610
612
  // status payload, never the start receipt.
611
613
  if (call.poll) {
612
- const finished = await fetchPolled(node, call, scoped, `${label}.${call.name}`);
614
+ const finished = await gated(node, call, scoped, () => fetchPolled(node, call, scoped, `${label}.${call.name}`));
613
615
  results[call.name] = finished;
614
616
  last = finished;
615
617
  continue;
616
618
  }
617
619
 
620
+ // A REPEATED call is turns of ordinary calls, each built from the one before, until a reply
621
+ // says done: { last, turns, stopped } (loops/repeat.ts). Each inner call is gated on its own.
622
+ if (call.repeat) {
623
+ const walked = await runRepeat(node, call, scoped, `${label}.${call.name}`, runCalls, store);
624
+ results[call.name] = walked;
625
+ last = walked;
626
+ continue;
627
+ }
628
+
618
629
  // SETTLING transports. `xml` settles exactly like json — one body, parsed once — so it
619
630
  // belongs here; only a STREAM is barred from a non-final position.
620
631
  if (call.transport !== "json" && call.transport !== "text" && call.transport !== "xml" && call.transport !== "headers" && call.transport !== "binary") {
@@ -625,9 +636,12 @@ export async function runCalls(
625
636
  );
626
637
  }
627
638
 
628
- const res = await sendRequest(node, call, scoped, `${label}.${call.name}`);
629
- const payload = await readSettled(res, call.transport, call.encoding);
630
- await assertOk(node, call, payload, `${label}.${call.name}`);
639
+ const payload = await gated(node, call, scoped, async () => {
640
+ const res = await sendRequest(node, call, scoped, `${label}.${call.name}`);
641
+ const settled = await readSettled(res, call.transport, call.encoding);
642
+ await assertOk(node, call, settled, `${label}.${call.name}`);
643
+ return settled;
644
+ });
631
645
 
632
646
  results[call.name] = payload;
633
647
  last = payload;
@@ -635,3 +649,18 @@ export async function runCalls(
635
649
 
636
650
  return { results, last };
637
651
  }
652
+
653
+ /**
654
+ * A call that declares `concurrency` runs through one gate per node, call and credential
655
+ * (concurrency.ts): the whole call, its polling included, because the vendor's session is
656
+ * held until the job ends. The credential is part of the key so two orgs' keys are two
657
+ * limits, as the vendor counts them. Used here for the lead calls and in channels/final.ts
658
+ * for the last one: a one-call node like HyperbrowserScrape only ever runs through final,
659
+ * which is where the first live run slipped past the gate (2026-09-09, two fetches at once).
660
+ */
661
+ export async function gated<T>(node: ComposedNode, call: any, scoped: RunContext, fn: () => Promise<T>): Promise<T> {
662
+ const limit = Number(call.concurrency);
663
+ if (!(limit >= 1)) return fn();
664
+ const credential = call.credential?.value ? String(render(call.credential.value, scoped)) : "";
665
+ return withConcurrency(`${node.type}:${call.name}:${credential}`, limit, fn);
666
+ }
@@ -25,7 +25,7 @@
25
25
  * service.ts an AGENT calls it: one method, one value handed straight back
26
26
  * http/ building and sending. One fetch, one allowedHosts check
27
27
  * auth/ the schemes, per scheme
28
- * loops/ many requests, or many passes, for one node: paginate, poll, chunk, loop
28
+ * loops/ many requests, or many passes, for one node: paginate, poll, chunk, repeat, loop
29
29
  * duplex/ a socket that stays open. Two of them: the vendor's and the audio lane
30
30
  * tools/ the HTTP tool exchange, by turns
31
31
  */
@@ -39,6 +39,7 @@ export { readSse, readSettled, assertOk } from "./http/response.js";
39
39
  export { makeStateStore, performState, type StateStore } from "./state.js";
40
40
  export { fetchPaginated } from "./loops/paginate.js";
41
41
  export { fetchPolled } from "./loops/poll.js";
42
+ export { runRepeat, HARD_TURN_CAP } from "./loops/repeat.js";
42
43
  export { sendChunked } from "./loops/chunk.js";
43
44
  // Iteration bookkeeping for the LoopStart/LoopEnd pair. ONE capability rather than the seven raw
44
45
  // Redis commands the retired nodes reached for — see loops/loop.ts for why that distinction is
@@ -51,7 +52,7 @@ export { performDoc, MUTATING_DOC_OPS, docKeyFor } from "./docstore/index.js";
51
52
  export { runDuplexSession } from "./duplex/session.js";
52
53
  // The audio lane is its own module: session.ts owns the VENDOR socket, audioLane.ts owns the bytes
53
54
  // and the browser. Two sockets, two files.
54
- export { resamplePcm16, makeAudioBuffer, CLIENT_RATE, type AudioLane } from "./duplex/audioLane.js";
55
+ export { resamplePcm16, mulawToPcm16, pcm16ToMulaw, makeAudioBuffer, CLIENT_RATE, type AudioLane } from "./duplex/audioLane.js";
55
56
  export { performApi, type RunResult } from "./channels/workflow.js";
56
57
  export { runFinal } from "./channels/final.js";
57
58
  export { performService } from "./channels/service.js";
@@ -0,0 +1,86 @@
1
+ /**
2
+ * GO ROUND IN TURNS — the fourth loop (packages/docs/nodes/calls-that-loop.md `repeat`;
3
+ * DECLARATIVE_NODES.md, the capability table).
4
+ *
5
+ * `paginate` walks one API's pages, `chunk` sends one collection in slices, `poll` asks one
6
+ * question until the answer changes. None can say "ask different questions, each chosen by the
7
+ * last reply, until a reply says done", which is what walking a tree a branch at a time needs
8
+ * (OpenAISearchDetail). A manifest describes ONE turn as a short list of ordinary calls; the
9
+ * turning is computation, so it lives here.
10
+ *
11
+ * What a turn sees: every earlier call of the SAME turn as `calls.<name>`, as at the top level,
12
+ * and the turn before as `previous.<name>`. `previous` is null on the first turn, never absent:
13
+ * the expression sandbox refuses a name it does not know, so "is there a turn before?" must be
14
+ * a question it can answer (`previous ? … : …`).
15
+ *
16
+ * What stops it, borrowed from the agent node's own tool loop (toolExchange.yaml):
17
+ * `until` asked after every turn over that turn's calls; true ends the walk
18
+ * `maxTurns` the author's bound
19
+ * `stuckAfterRepeats` a turn identical to the one before, that many times running, is stuck
20
+ * HARD_TURN_CAP the platform's bound, because the author's can be wrong
21
+ *
22
+ * The reply keeps EVERY turn, not only the last: the path taken is the lineage of the answer,
23
+ * and throwing it away would leave an answer nobody can check. Every turn is also drawn on the
24
+ * run's timeline with the same helper the tool loop uses, so the walk shows as it happens.
25
+ */
26
+ import type { ComposedNode } from "../../compose.js";
27
+ import type { RunContext } from "../context.js";
28
+ import type { StateStore } from "../state.js";
29
+ import { evaluate, render } from "../templating.js";
30
+ import { recordToolTrace } from "../tools/toolloop.js";
31
+
32
+ /** The platform's ceiling on turns, whatever the manifest says. */
33
+ export const HARD_TURN_CAP = 20;
34
+
35
+ /** How a turn's calls are run: the runtime's own call list, handed in so this file owns no request code. */
36
+ export type RunList = (node: ComposedNode, calls: any[], ctx: RunContext, label: string, store?: StateStore) => Promise<{ results: Record<string, any>; last: any }>;
37
+
38
+ /** A repeated call's reply: the last turn, every turn in order, and why it ended. */
39
+ export interface Repeated {
40
+ last: Record<string, any>;
41
+ turns: Record<string, any>[];
42
+ stopped: "until" | "maxTurns" | "stuck";
43
+ }
44
+
45
+ /** A bound written as a number, a template (`{{ config.maxTurns }}`) or an expression. */
46
+ async function numberOf(v: unknown, ctx: RunContext): Promise<number> {
47
+ if (typeof v === "number") return v;
48
+ if (typeof v !== "string") return NaN;
49
+ const out = v.trim().startsWith("return") ? await evaluate(v, ctx as unknown as Record<string, unknown>) : render(v, ctx);
50
+ return Number(out);
51
+ }
52
+
53
+ export async function runRepeat(node: ComposedNode, call: any, ctx: RunContext, label: string, run: RunList, store?: StateStore): Promise<Repeated> {
54
+ const r = call.repeat;
55
+ // NEVER A REPEAT INSIDE A TURN. Each is bounded, but nested they multiply (20 turns of 20),
56
+ // and a walk that needs a walk inside it is two nodes, not one. Lint refuses it too.
57
+ if (!Array.isArray(r?.calls) || !r.calls.length) throw new Error(`${label}: repeat has no calls, so a turn would do nothing`);
58
+ if (r.calls.some((c: any) => c?.repeat)) throw new Error(`${label}: a turn cannot itself repeat; nested repeats multiply their bounds`);
59
+ const asked = await numberOf(r.maxTurns, ctx);
60
+ const bound = Math.min(HARD_TURN_CAP, asked >= 1 ? Math.floor(asked) : HARD_TURN_CAP);
61
+ const stuckAt = Number(r.stuckAfterRepeats) >= 2 ? Math.floor(Number(r.stuckAfterRepeats)) : 0;
62
+
63
+ const turns: Record<string, any>[] = [];
64
+ let previous: Record<string, any> | null = null;
65
+ let same = 0;
66
+ let lastKey = "";
67
+ for (let n = 1; n <= bound; n++) {
68
+ const started = Date.now();
69
+ const turnCtx: RunContext = { ...ctx, previous };
70
+ const { results } = await run(node, r.calls, turnCtx, `${label} (turn ${n})`, store);
71
+ turns.push(results);
72
+ recordToolTrace(node, ctx, `${call.name} · turn ${n}`, { turn: n }, results, started, true);
73
+
74
+ if (await evaluate(r.until, { ...turnCtx, calls: results } as unknown as Record<string, unknown>)) return { last: results, turns, stopped: "until" };
75
+
76
+ // STUCK: the same turn again. `maxTurns` alone would let a walk that keeps choosing the
77
+ // same branch spend every turn it has doing so.
78
+ const key = JSON.stringify(results);
79
+ same = key === lastKey ? same + 1 : 1;
80
+ lastKey = key;
81
+ if (stuckAt && same >= stuckAt) return { last: results, turns, stopped: "stuck" };
82
+
83
+ previous = results;
84
+ }
85
+ return { last: turns[turns.length - 1] ?? {}, turns, stopped: "maxTurns" };
86
+ }
@@ -47,6 +47,12 @@ export interface ToolBridge {
47
47
  ): Promise<{ content: string; minted: any[]; skills?: Array<{ name: string; title?: string; instructions: string; regionId?: string; regionName?: string | null; ms?: number }> }>;
48
48
  /** Does this exchange end the turn? Depends on which tools were WIRED. */
49
49
  endsTurn(calls: { name: string; resultContent: string }[]): boolean;
50
+ /**
51
+ * WHAT SORT OF STEP a call to this tool is on the timeline. The bridge minted the tool,
52
+ * so the bridge knows whether it came from a task row; the loop only relays the answer
53
+ * to the trace. Optional, because a core-tools-only bridge has nothing but tools.
54
+ */
55
+ kindOf?(toolName: string): "tool" | "task";
50
56
  }
51
57
 
52
58
  /**
@@ -92,7 +98,7 @@ export function recordToolTrace(
92
98
  * on the same timeline but is not a tool the model chose, so it is told apart by a stated
93
99
  * kind rather than by the viewer sniffing its name.
94
100
  */
95
- kind: "tool" | "skill" = "tool",
101
+ kind: "tool" | "skill" | "task" = "tool",
96
102
  ): void {
97
103
  const executionId = (ctx as any)?.scope?.executionId;
98
104
  const parentNodeId = (ctx as any)?.scope?.nodeId;
@@ -108,12 +114,20 @@ export function recordToolTrace(
108
114
  */
109
115
  const parsed = parseMaybeJson(result);
110
116
 
117
+ /**
118
+ * THE ARGUMENTS AS RUN. A tool whose own settings add to what the model sent (Spatial's
119
+ * always-on objects) says so in its result as `arguments`, and the trace shows those, so
120
+ * Arguments is what the call really ran with, never only what the model typed.
121
+ */
122
+ const ran = parsed && typeof parsed === "object" && !Array.isArray(parsed) ? (parsed as any).arguments : null;
123
+ const shown = ran && typeof ran === "object" && args && typeof args === "object" ? { ...(args as object), ...ran } : args;
124
+
111
125
  const endTime = Date.now();
112
126
  void saveMCPTraceToWorkflow({
113
127
  executionId,
114
128
  parentNodeId,
115
129
  toolName,
116
- arguments: args,
130
+ arguments: shown,
117
131
  result: parsed,
118
132
  startTime,
119
133
  endTime,
@@ -280,6 +294,19 @@ export async function runToolLoop(
280
294
  });
281
295
  if (chainPayload) chainId = String(await evaluate(te.continuity.from, { response: chainPayload }));
282
296
 
297
+ // THE TURN IS OVER, SO NOTHING MORE IS COMING TO CARRY A THROTTLE OUT.
298
+ //
299
+ // A throttled row holds its newest value and waits for a LATER DELTA to send it. Within
300
+ // a turn that is right. At the end of one it strands whatever arrived in the last
301
+ // window — and if the model answered inside a single window, that is the whole answer
302
+ // but its first token. It then surfaces on the next turn's first delta, which is after
303
+ // any tool this turn called: the reader saw "I", then the rest eleven seconds later,
304
+ // and it read as the stream stopping for the tool (SAB, 2026-09-08).
305
+ //
306
+ // Here rather than in the manifest, and by the LOOP rather than a timer: only the loop
307
+ // knows where a turn ends, which is the same reason it owns counting them.
308
+ emitter.flush();
309
+
283
310
  const calls = await readCalls(te, raw);
284
311
 
285
312
  // Answered rather than called. Done.
@@ -334,7 +361,7 @@ export async function runToolLoop(
334
361
  ok = false;
335
362
  content = JSON.stringify({ error: err?.message ?? String(err) });
336
363
  }
337
- recordToolTrace(node, ctx, call.name, args, content, startedAt, ok);
364
+ recordToolTrace(node, ctx, call.name, args, content, startedAt, ok, bridge.kindOf?.(call.name) ?? "tool");
338
365
  exchanges.push({ name: call.name, resultContent: content });
339
366
  // The RESULT, and only now that it exists. It is never in the HTTP stream: our loop
340
367
  // produced it, which is why it cannot be a `from: response` row. PARSED for the
@@ -32,6 +32,12 @@ export function sniffUsage(payload: any): Record<string, unknown> | undefined {
32
32
  export interface UsageCollector {
33
33
  /** Look at one payload (streamed event or settled body); remember any usage block. */
34
34
  see(payload: any): void;
35
+ /**
36
+ * Record a figure the PLATFORM measured, replacing whatever the vendor reported under the same
37
+ * name. A voice call's `seconds` is this: the runtime opened and closed the call, so its clock is
38
+ * the length. A vendor that reports running totals (GPT-Live) would otherwise be summed.
39
+ */
40
+ set(field: string, value: number): void;
35
41
  /** Post the summed usage to the engine. No usage seen, or no execution scope → no-op. */
36
42
  save(): void;
37
43
  }
@@ -68,6 +74,10 @@ export function makeUsageCollector(node: ComposedNode, ctx: RunContext): UsageCo
68
74
  addInto(totals, u);
69
75
  model ??= payload?.response?.model ?? payload?.model ?? undefined;
70
76
  },
77
+ set(field: string, value: number) {
78
+ seen = true;
79
+ totals[field] = value;
80
+ },
71
81
  save() {
72
82
  if (!seen) return;
73
83
  const { workflowId, executionId, nodeId } = ctx.scope ?? {};
@@ -9,7 +9,7 @@
9
9
  * Everything past this file works on the composed definition and never learns where
10
10
  * it came from.
11
11
  *
12
- * See docs/architecture/DECLARATIVE_NODES.md.
12
+ * See docs/architecture/authoring/DECLARATIVE_NODES.md.
13
13
  */
14
14
  import * as fs from "fs";
15
15
  import * as path from "path";
@@ -27,10 +27,11 @@ function findHome(): string {
27
27
 
28
28
  let dir = path.dirname(fileURLToPath(import.meta.url));
29
29
  for (;;) {
30
- // The in-repo home. Checked for `nodes` as well as the folder itself, so a stray
31
- // directory of the same name elsewhere cannot claim it.
30
+ // The in-repo home. Checked for `design` as well as the folder itself, so a stray
31
+ // directory of the same name elsewhere cannot claim it. (`nodes` was the marker until
32
+ // 2026-09-08, when nodes moved under design/marketplace with every other kind.)
32
33
  const candidate = path.join(dir, "apps/unoverse");
33
- if (existsSync(path.join(candidate, "nodes"))) return candidate;
34
+ if (existsSync(path.join(candidate, "design"))) return candidate;
34
35
  const parent = path.dirname(dir);
35
36
  if (parent === dir) break;
36
37
  dir = parent;
@@ -41,7 +42,7 @@ function findHome(): string {
41
42
  // a path under cwd, and anything reading them will fail somewhere obvious.
42
43
  console.warn(
43
44
  "[unoverse:base] UNOVERSE_HOME is not set and no apps/unoverse was found above this package. " +
44
- "Falling back to the working directory; set UNOVERSE_HOME to the folder holding nodes/, design/ and prompts/.",
45
+ "Falling back to the working directory; set UNOVERSE_HOME to the folder holding design/.",
45
46
  );
46
47
  return process.cwd();
47
48
  }
@@ -50,7 +51,7 @@ let HOME = findHome();
50
51
 
51
52
  /** Everything the runtime reads off disk, in one place. */
52
53
  export interface Paths {
53
- /** Node manifests and node packages authored for this deployment. */
54
+ /** Node packages: the marketplace tier's nodes, under design/marketplace/nodes. */
54
55
  nodes: string;
55
56
  /** Design data: components, templates, styles. */
56
57
  design: string;
@@ -96,10 +97,17 @@ export function designDir(root: string): string {
96
97
 
97
98
  function derive(home: string): Paths {
98
99
  return {
99
- nodes: path.join(home, "nodes"),
100
100
  design: designDir(home),
101
- skills: path.join(home, "prompts/skills"),
102
- promptBlocks: path.join(home, "prompts/blocks"),
101
+ // THE MARKETPLACE TIER, and it lives under design/ with every other kind.
102
+ //
103
+ // Skills and blocks were authored in a `prompts/` folder beside design/, left from
104
+ // before components, templates, atoms and styles took their marketplace home under
105
+ // `design/marketplace/`. Two roots for one tier is what let a surface read half of
106
+ // it and look right. There is one authoring root now: `design/`. Nodes were the
107
+ // last kind outside it, at `nodes/` beside design/, until 2026-09-08.
108
+ nodes: path.join(designDir(home), "marketplace/nodes"),
109
+ skills: path.join(designDir(home), "marketplace/skills"),
110
+ promptBlocks: path.join(designDir(home), "marketplace/blocks"),
103
111
  plugins: path.join(home, "plugins"),
104
112
  // DELIBERATELY under the home, not at the repo root.
105
113
  //
@@ -89,6 +89,38 @@ class DistributedAudioManager implements AudioWebSocketManager {
89
89
  }
90
90
  }
91
91
 
92
+ /**
93
+ * NAMED AUDIO LANES, beside the browser one.
94
+ *
95
+ * The browser manager above is fetched with no arguments, because for a long time it was the
96
+ * only lane. A phone lane has to PLACE A CALL, so it is built per run from that run's settings
97
+ * and credentials, which is why this is a factory rather than an instance.
98
+ *
99
+ * IT LIVES HERE, in base, for the reason the comment on `getAudioWebSocketManager` in
100
+ * executionContext.ts already gives: a node reaches the platform through two different context
101
+ * builders — the engine's plugin API in-process, and the remote one used by
102
+ * `POST /execute-stream` — and BOTH must arrive at the same lane. Registering it on only one of
103
+ * them is a voice node that finds its phone line locally and finds nothing remotely, which is
104
+ * exactly what happened (2026-09-20): no error, no audio, and a perfectly healthy conversation
105
+ * nobody could hear.
106
+ */
107
+ let audioLaneFactory: ((spec: any) => any) | null = null;
108
+
109
+ export function setAudioLaneFactory(factory: (spec: any) => any): void {
110
+ audioLaneFactory = factory;
111
+ }
112
+
113
+ /** A lane by name, built for this run. `null` when the platform has no such lane. */
114
+ export function getAudioLane(spec: any): any {
115
+ if (!audioLaneFactory) return null;
116
+ try {
117
+ return audioLaneFactory(spec);
118
+ } catch (error: any) {
119
+ console.error(`[audio] lane "${spec?.lane}" could not be built: ${error?.message ?? error}`);
120
+ return null;
121
+ }
122
+ }
123
+
92
124
  // Singleton instance
93
125
  let distributedAudioManager: DistributedAudioManager | null = null;
94
126
 
@@ -11,6 +11,7 @@ import {
11
11
  deliverToNodeInWorkflow,
12
12
  } from "./serviceCalls.js";
13
13
  import { getRedisClient } from "./redis.js";
14
+ import { getAudioLane } from "./audioManager.js";
14
15
  import { getDistributedAudioManager } from "./audioManager.js";
15
16
 
16
17
  /**
@@ -49,6 +50,12 @@ export function buildExecutionContext(nodeType: string, inputs: any, config: any
49
50
  abortSignal: context.abortSignal,
50
51
  logger: console,
51
52
  auth: context.auth,
53
+ /**
54
+ * A CANVAS TEST RUN. `debugMode` is the word that crosses the remote boundary
55
+ * (CallbackNodeActor's context whitelist); everything that withholds an effect with no
56
+ * inverse reads it. Dropping it here made a phone lane think every run was real.
57
+ */
58
+ debugMode: context.debugMode === true,
52
59
  };
53
60
 
54
61
  // Build API with callService that routes to workflow-service
@@ -97,6 +104,13 @@ export function buildExecutionContext(nodeType: string, inputs: any, config: any
97
104
  * Same accessor `pluginAPI.ts` uses, so both paths reach one manager rather than two.
98
105
  */
99
106
  getAudioWebSocketManager: () => getDistributedAudioManager(),
107
+ /**
108
+ * A lane BY NAME (DECLARATIVE_NODES.md §14, VOICE_STREAMING_GUIDE.md). Same registry the
109
+ * plugin API reads, for the same reason as the line above: a voice node runs through
110
+ * whichever of these two context builders its host chose, and a lane registered on only one
111
+ * of them is a phone that works in one place and silently does not in the other.
112
+ */
113
+ getAudioLane: (spec: any) => getAudioLane(spec),
100
114
  callService: async (method: string, params: any) => {
101
115
  return callServiceViaWorkflow(method, params, executionContext);
102
116
  },
@@ -0,0 +1,52 @@
1
+ /**
2
+ * LIVE INPUT: a value arriving on a node's `CONTINUE` input while that node is still running.
3
+ *
4
+ * A node that holds a conversation open (a duplex voice session) cannot be started again for each
5
+ * input the way a settle-once node can, because starting it again would open a second
6
+ * conversation. So the running node registers here, and the engine hands a changed `CONTINUE`
7
+ * input to it instead of dropping it.
8
+ *
9
+ * Keyed by `executionId:nodeId`, the two ids the engine and the running node both hold for the
10
+ * same run.
11
+ *
12
+ * ON globalThis, not a module-level Map. The engine and the node runtime share one process but
13
+ * may load this module under two specifiers (the package name, a relative path), and two copies
14
+ * of a Map would each be empty to the other: the engine would find no running node and drop the
15
+ * input exactly as before, with nothing to say why.
16
+ */
17
+
18
+ /** Hand one input to the running node. Resolves false when the node could not take it. */
19
+ export type LiveInputHandler = (handle: string, value: unknown) => Promise<boolean> | boolean;
20
+
21
+ const REGISTRY = Symbol.for("unoverse.liveInput");
22
+
23
+ function registry(): Map<string, LiveInputHandler> {
24
+ const g = globalThis as any;
25
+ return (g[REGISTRY] ??= new Map<string, LiveInputHandler>());
26
+ }
27
+
28
+ /** The key for one node in one run. Undefined when either id is missing: nothing to address. */
29
+ export function liveInputKey(executionId?: string, nodeId?: string): string | undefined {
30
+ return executionId && nodeId ? `${executionId}:${nodeId}` : undefined;
31
+ }
32
+
33
+ /**
34
+ * Register the running node. Returns the unregister function.
35
+ *
36
+ * Unregistering removes only THIS handler, so a node that ended after a newer one registered
37
+ * under the same key cannot take the newer one with it.
38
+ */
39
+ export function registerLiveInput(key: string, handler: LiveInputHandler): () => void {
40
+ const map = registry();
41
+ map.set(key, handler);
42
+ return () => {
43
+ if (map.get(key) === handler) map.delete(key);
44
+ };
45
+ }
46
+
47
+ /** Deliver to the running node. False when nothing is registered under the key. */
48
+ export async function deliverLiveInput(key: string, handle: string, value: unknown): Promise<boolean> {
49
+ const handler = registry().get(key);
50
+ if (!handler) return false;
51
+ return (await handler(handle, value)) !== false;
52
+ }
@@ -54,4 +54,4 @@ The two Vite warnings a developer sees when starting Studio come from `discovery
54
54
  `loader.ts` (dynamic `import()` of a package by name). They are correct, they are
55
55
  unfixable while this folder exists, and they leave when it does.
56
56
 
57
- See `docs/architecture/DECLARATIVE_NODES.md` §7a and §10.
57
+ See `docs/architecture/authoring/DECLARATIVE_NODES.md` §7a and §10.
@@ -39,7 +39,7 @@ function scanPluginDir(dir: string, scopes: string[]): string[] {
39
39
  return plugins;
40
40
  }
41
41
 
42
- /** Nodes that have been moved INTO Unoverse (apps/unoverse/nodes). */
42
+ /** The marketplace tier's node packages (apps/unoverse/design/marketplace/nodes). */
43
43
  export function discoverNodesHome(): string[] {
44
44
  return scanPluginDir(NODES_HOME, ["@unoverse-platform/"]);
45
45
  }
@@ -521,6 +521,12 @@ const SUPPORTED = new Set([
521
521
  "BlockStatement", "CallExpression", "ConditionalExpression", "EmptyStatement",
522
522
  "ExpressionStatement", "Identifier", "IfStatement", "Literal", "LogicalExpression",
523
523
  "MemberExpression", "ObjectExpression", "Program", "Property", "ReturnStatement",
524
+ // `...x` in an object or an array. evalNode implements BOTH (ObjectExpression assigns the
525
+ // spread in, ArrayExpression pushes it), so leaving it out of this list made the static
526
+ // check refuse a form the interpreter runs — the exact inversion of what this list is for.
527
+ // It is only reached at run time by makeHelpers, so the three manifests that spread were
528
+ // never actually throwing; they were failing the guard that is supposed to mirror evalNode.
529
+ "SpreadElement",
524
530
  "TemplateLiteral", "TemplateElement", "UnaryExpression", "VariableDeclaration",
525
531
  "VariableDeclarator",
526
532
  ]);