@unoverse-platform/studio 0.1.97 → 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 +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/definitions/load/compile/component.ts +59 -4
  33. package/vendor/base/src/definitions/load/compile/sections.ts +7 -1
  34. package/vendor/base/src/definitions/load/compile/template.ts +19 -15
  35. package/vendor/base/src/definitions/load/expand/inherit.ts +5 -1
  36. package/vendor/base/src/definitions/load/expand/referenced.ts +21 -2
  37. package/vendor/base/src/definitions/load/expand/refs.ts +14 -0
  38. package/vendor/base/src/definitions/load/expand/walk.ts +88 -38
  39. package/vendor/base/src/definitions/load/load.ts +37 -28
  40. package/vendor/base/src/definitions/model.ts +8 -12
  41. package/vendor/base/src/definitions/registry/agents.ts +111 -0
  42. package/vendor/base/src/definitions/registry/catalog.ts +28 -12
  43. package/vendor/base/src/definitions/registry/datasetSql.ts +149 -0
  44. package/vendor/base/src/definitions/registry/homes.ts +69 -5
  45. package/vendor/base/src/definitions/registry/objects.ts +413 -0
  46. package/vendor/base/src/definitions/registry/orgDocuments.ts +69 -29
  47. package/vendor/base/src/definitions/registry/orgPack.ts +11 -11
  48. package/vendor/base/src/definitions/registry/pipelines.ts +197 -0
  49. package/vendor/base/src/definitions/theme.ts +1 -2
  50. package/vendor/base/src/items/collect.ts +29 -25
  51. package/vendor/base/src/items/publish.ts +30 -6
  52. package/vendor/base/src/lint/context.mjs +17 -0
  53. package/vendor/base/src/lint/design/file.mjs +199 -2
  54. package/vendor/base/src/lint/design/index.mjs +66 -73
  55. package/vendor/base/src/lint/design/vocabulary.mjs +5 -2
  56. package/vendor/base/src/lint/design/walk.mjs +14 -4
  57. package/vendor/base/src/lint/index.mjs +15 -9
  58. package/vendor/base/src/lint/nodes/blocks.mjs +6 -2
  59. package/vendor/base/src/lint/nodes/index.mjs +5 -0
  60. package/vendor/base/src/lint/nodes/node.mjs +157 -7
  61. package/vendor/base/src/lint/objects/index.d.mts +32 -0
  62. package/vendor/base/src/lint/objects/index.mjs +324 -0
  63. package/vendor/base/src/lint/objects/rules.d.mts +44 -0
  64. package/vendor/base/src/lint/objects/rules.mjs +446 -0
  65. package/vendor/base/src/lint/prompts/index.d.mts +34 -0
  66. package/vendor/base/src/lint/prompts/index.mjs +198 -0
  67. package/vendor/base/src/manifests/compose.ts +80 -9
  68. package/vendor/base/src/manifests/executor/callback.ts +15 -4
  69. package/vendor/base/src/manifests/executor/context.ts +9 -1
  70. package/vendor/base/src/manifests/executor/memory.ts +83 -3
  71. package/vendor/base/src/manifests/executor/session.ts +50 -0
  72. package/vendor/base/src/manifests/executor/toolBridge.ts +114 -49
  73. package/vendor/base/src/manifests/runtime/auth/aws.ts +14 -1
  74. package/vendor/base/src/manifests/runtime/channels/final.ts +46 -34
  75. package/vendor/base/src/manifests/runtime/channels/service.ts +30 -32
  76. package/vendor/base/src/manifests/runtime/channels/workflow.ts +6 -11
  77. package/vendor/base/src/manifests/runtime/context.ts +18 -6
  78. package/vendor/base/src/manifests/runtime/duplex/audioLane.ts +159 -3
  79. package/vendor/base/src/manifests/runtime/duplex/session.ts +304 -9
  80. package/vendor/base/src/manifests/runtime/events.ts +39 -3
  81. package/vendor/base/src/manifests/runtime/http/concurrency.ts +44 -0
  82. package/vendor/base/src/manifests/runtime/http/request.ts +36 -7
  83. package/vendor/base/src/manifests/runtime/index.ts +3 -2
  84. package/vendor/base/src/manifests/runtime/loops/repeat.ts +86 -0
  85. package/vendor/base/src/manifests/runtime/tools/toolloop.ts +30 -3
  86. package/vendor/base/src/manifests/runtime/usage.ts +10 -0
  87. package/vendor/base/src/paths.ts +16 -8
  88. package/vendor/base/src/platform/audioManager.ts +32 -0
  89. package/vendor/base/src/platform/executionContext.ts +14 -0
  90. package/vendor/base/src/platform/liveInput.ts +52 -0
  91. package/vendor/base/src/plugins/discovery.ts +1 -1
  92. package/vendor/base/src/template/SafeExpression.ts +6 -0
  93. package/vendor/base/src/workflow/GetMemoryService.ts +12 -11
  94. package/vendor/base/src/workflow/PlatformMemoryCore.ts +23 -10
  95. package/vendor/sdk/appEngine.tsx +30 -11
  96. package/vendor/sdk/lib/UnoverseComponent.tsx +8 -4
  97. package/vendor/sdk/lib/app.tsx +70 -95
  98. package/vendor/sdk/lib/connection.tsx +47 -57
  99. package/vendor/sdk/lib/core/actions.ts +6 -0
  100. package/vendor/sdk/lib/core/apps.ts +66 -81
  101. package/vendor/sdk/lib/core/client.ts +5 -14
  102. package/vendor/sdk/lib/core/connection.ts +30 -0
  103. package/vendor/sdk/lib/core/index.ts +2 -2
  104. package/vendor/sdk/lib/core/store.ts +90 -15
  105. package/vendor/sdk/lib/core/types.ts +9 -8
  106. package/vendor/sdk/lib/primitives.tsx +113 -4
  107. package/vendor/sdk/lib/realtime/useAudioPlayback.ts +8 -1
  108. package/vendor/sdk/lib/render.tsx +3 -3
  109. package/vendor/sdk/lib/voice.tsx +27 -13
  110. package/local/sample-identity/brand/brand.yaml +0 -88
  111. package/local/sample-identity/brand/manifest.yaml +0 -13
  112. package/local/sample-identity/organisation/manifest.yaml +0 -15
  113. package/local/sample-identity/organisation/organisation.yaml +0 -53
  114. package/local/sample-identity/purpose/manifest.yaml +0 -12
  115. package/local/sample-identity/purpose/purpose.yaml +0 -46
  116. package/local/sample-identity/story/manifest.yaml +0 -12
  117. package/local/sample-identity/story/story.yaml +0 -63
  118. package/vendor/base/src/definitions/registry/identitySeed.ts +0 -17
@@ -30,6 +30,51 @@ export interface AudioLane {
30
30
  setAudioDataHandler?(fn: (sessionId: string, data: ArrayBuffer) => void | Promise<void>): void;
31
31
  setControlMessageHandler?(fn: (sessionId: string, message: any) => void | Promise<void>): void;
32
32
  startAudioSession?(sessionId: string, conversationId: string): void;
33
+
34
+ /**
35
+ * WHAT THIS LANE SPEAKS, when it is not the browser.
36
+ *
37
+ * `rate` is the lane's sample rate and `codec` how its samples are packed. Absent means the
38
+ * browser lane's own shape (16 kHz PCM16), which is what every lane was until there was a
39
+ * second one.
40
+ *
41
+ * It is on the LANE and not in a node's config for a reason worth keeping: this is a fact of
42
+ * the far end, not a preference. A phone is 8 kHz mu-law because the phone network is, and a
43
+ * wrong value is an error nowhere — the vendor transcribes at the wrong speed and it reads as
44
+ * poor accuracy. Nobody choosing it can choose better than the lane already knows.
45
+ */
46
+ rate?: number;
47
+ codec?: "pcm16" | "mulaw";
48
+
49
+ /**
50
+ * IS ANYONE THERE YET? Resolves true once the far end is listening, false if it never will be.
51
+ *
52
+ * ABSENT MEANS ALREADY CONNECTED, which is the browser: the page dialled us, so its socket
53
+ * exists before the node runs and there is nothing to wait for. A lane that has to REACH
54
+ * someone is the other case, and the difference matters more than it sounds. A telephone rings
55
+ * for twenty or thirty seconds, and without this the vendor session opens immediately, the
56
+ * model speaks its opening line to a ringing tone, and by the time a person says hello the
57
+ * greeting has already happened and been thrown away (observed live 2026-09-20).
58
+ *
59
+ * `false` is a call that was never answered: busy, declined, or rung out. The session must not
60
+ * open at all for one of those, or a realtime model sits billing by the minute against a
61
+ * telephone nobody picked up.
62
+ */
63
+ whenConnected?(conversationId: string): Promise<boolean>;
64
+
65
+ /**
66
+ * END THE CALL AT THE FAR END. Not closing our socket to the vendor: that stops the MODEL,
67
+ * and the two are different things on every lane that reaches a person.
68
+ *
69
+ * ABSENT MEANS THERE IS NOTHING TO HANG UP, which is the browser: the page holds the session
70
+ * and closing it is the page's own business. A lane that REACHED someone is the other case,
71
+ * and until this existed it could not let them go. Measured live 2026-09-22: the model said
72
+ * goodbye, the vendor session closed on time, and the telephone stayed open with the person
73
+ * listening to silence until THEY hung up, because no code anywhere ran in this direction.
74
+ *
75
+ * Called once, from the session's `finish`, so every way a call can end ends the call.
76
+ */
77
+ hangUp?(conversationId: string): void;
33
78
  }
34
79
 
35
80
 
@@ -37,9 +82,67 @@ export interface AudioLane {
37
82
  const BYTES_PER_SAMPLE = 2;
38
83
  const DEFAULT_COALESCE_BYTES = 32768;
39
84
  const DEFAULT_COALESCE_MS = 50;
40
- /** What the browser captures at. Not configurable, because the capture side is ours. */
85
+ /** What the browser captures at. The default when a lane does not declare its own rate. */
41
86
  export const CLIENT_RATE = 16000;
42
87
 
88
+ /**
89
+ * G.711 mu-law, the codec of the telephone network, and the only non-PCM lane here.
90
+ *
91
+ * ONE BYTE PER SAMPLE instead of two, logarithmically companded: it spends its resolution
92
+ * where a human ear is sensitive and throws it away where it is not. That is why a phone call
93
+ * at 8 kHz and 64 kbit/s is intelligible at all, and also why converting is a TABLE rather than
94
+ * a scale — the relationship between a byte and a sample is not linear.
95
+ *
96
+ * Both directions are written out rather than pulled from a package, because the encoding is
97
+ * twelve lines, fixed since 1972, and a dependency that has to be audited is worse than a
98
+ * function that can be read. The tests are the reference: a round trip is lossy by design (16
99
+ * bits into 8 and back), so they assert closeness, never equality.
100
+ */
101
+ const MULAW_BIAS = 0x84;
102
+ const MULAW_CLIP = 32635;
103
+
104
+ /** PCM16 sample -> one mu-law byte. */
105
+ function encodeMulawSample(sample: number): number {
106
+ let sign = (sample >> 8) & 0x80;
107
+ if (sign !== 0) sample = -sample;
108
+ if (sample > MULAW_CLIP) sample = MULAW_CLIP;
109
+ sample += MULAW_BIAS;
110
+ // The exponent is the position of the highest set bit: mu-law is a floating point format
111
+ // with a 3-bit exponent and a 4-bit mantissa, which is exactly what the companding IS.
112
+ let exponent = 7;
113
+ for (let mask = 0x4000; (sample & mask) === 0 && exponent > 0; exponent--, mask >>= 1);
114
+ const mantissa = (sample >> (exponent + 3)) & 0x0f;
115
+ // Inverted on the wire, which is the part everyone forgets: a decoder fed un-inverted bytes
116
+ // produces sound, just the wrong sound, so nothing errors and the call is simply noise.
117
+ return ~(sign | (exponent << 4) | mantissa) & 0xff;
118
+ }
119
+
120
+ /** One mu-law byte -> PCM16 sample. */
121
+ function decodeMulawByte(byte: number): number {
122
+ byte = ~byte & 0xff;
123
+ const sign = byte & 0x80;
124
+ const exponent = (byte >> 4) & 0x07;
125
+ const mantissa = byte & 0x0f;
126
+ let sample = ((mantissa << 3) + MULAW_BIAS) << exponent;
127
+ sample -= MULAW_BIAS;
128
+ return sign !== 0 ? -sample : sample;
129
+ }
130
+
131
+ /** A buffer of mu-law bytes -> PCM16 little-endian. One byte in, two bytes out. */
132
+ export function mulawToPcm16(input: Buffer): Buffer {
133
+ const out = Buffer.alloc(input.length * BYTES_PER_SAMPLE);
134
+ for (let i = 0; i < input.length; i++) out.writeInt16LE(decodeMulawByte(input[i]), i * BYTES_PER_SAMPLE);
135
+ return out;
136
+ }
137
+
138
+ /** PCM16 little-endian -> mu-law bytes. Two bytes in, one byte out. */
139
+ export function pcm16ToMulaw(input: Buffer): Buffer {
140
+ const samples = Math.floor(input.length / BYTES_PER_SAMPLE);
141
+ const out = Buffer.alloc(samples);
142
+ for (let i = 0; i < samples; i++) out[i] = encodeMulawSample(input.readInt16LE(i * BYTES_PER_SAMPLE));
143
+ return out;
144
+ }
145
+
43
146
  /**
44
147
  * Resample PCM16 by linear interpolation.
45
148
  *
@@ -76,11 +179,50 @@ export function resamplePcm16(input: Buffer, fromRate: number, toRate: number):
76
179
  * audio must be thrown away, because the user is already talking over the sentence it belongs
77
180
  * to. Transcribed from the retired WebSocketAudioPublisher.
78
181
  */
182
+ /**
183
+ * PCM16 at the vendor's rate -> whatever this lane speaks.
184
+ *
185
+ * IDENTITY FOR THE BROWSER LANE, and that is load-bearing rather than an optimisation. The
186
+ * browser lane declares no rate and no codec, so this returns the very same Buffer object it
187
+ * was given — byte for byte what shipped before there was a second lane, and what the long
188
+ * comment in `flush` below is about. A lane that says nothing gets nothing done to it.
189
+ */
190
+ function forLane(lane: AudioLane, pcm: Buffer, vendorRate: number, passthrough = false): Buffer {
191
+ /**
192
+ * NOTHING TO DO, because the vendor is already speaking the lane's own codec.
193
+ *
194
+ * A telephone is 8 kHz mu-law and several vendors accept 8 kHz mu-law, so when a node says it
195
+ * asked for that (`in.laneCodecs` in api/audio.yaml) these bytes ARE the bytes to put on the
196
+ * wire. Converting them would decode to PCM, resample 8 kHz to 24 kHz and back, and re-encode,
197
+ * to arrive at audio no better than it started: a phone call cannot be made higher fidelity by
198
+ * being carried at a higher rate, only rounder at the edges.
199
+ *
200
+ * OPT-IN, and that is the point of the flag. A vendor that was never told to speak mu-law is
201
+ * still sending PCM, and passing that through would be noise at the wrong speed. Absent the
202
+ * declaration this is exactly the code that shipped.
203
+ */
204
+ if (passthrough) return pcm;
205
+ if (lane.rate === undefined && lane.codec === undefined) return pcm;
206
+ const resampled = resamplePcm16(pcm, vendorRate, lane.rate ?? CLIENT_RATE);
207
+ return lane.codec === "mulaw" ? pcm16ToMulaw(resampled) : resampled;
208
+ }
209
+
79
210
  export function makeAudioBuffer(
80
211
  lane: AudioLane,
81
212
  conversationId: string,
82
213
  targetBytes = DEFAULT_COALESCE_BYTES,
83
214
  maxDelayMs = DEFAULT_COALESCE_MS,
215
+ /**
216
+ * What the VENDOR is sending, so `forLane` knows what it is converting FROM. Defaults to the
217
+ * browser's rate, which is also the no-conversion case, so an existing caller is unchanged.
218
+ */
219
+ vendorRate = CLIENT_RATE,
220
+ /**
221
+ * The vendor is sending this lane's OWN codec, so the frames need no conversion at all. Set
222
+ * by the session when a node's `in.laneCodecs` names what the lane speaks. Default false: an
223
+ * existing caller converts exactly as it always did.
224
+ */
225
+ passthrough = false,
84
226
  ) {
85
227
  let chunks: Buffer[] = [];
86
228
  let total = 0;
@@ -108,6 +250,20 @@ export function makeAudioBuffer(
108
250
  if (!chunks.length) return;
109
251
  const combined = Buffer.concat(chunks);
110
252
 
253
+ /**
254
+ * ONE BYTE PER SAMPLE, so there is no odd byte to hold back.
255
+ *
256
+ * Everything below is PCM16 arithmetic: two bytes per sample, an odd length is half a sample,
257
+ * and the client's `new Int16Array(buffer)` throws on one. Mu-law is one byte per sample, so
258
+ * every length is already whole and aligning would hold back a complete sample for no reason.
259
+ */
260
+ if (passthrough) {
261
+ chunks = [];
262
+ total = 0;
263
+ lane.sendAudio(conversationId, forLane(lane, combined, vendorRate, true));
264
+ return;
265
+ }
266
+
111
267
  /**
112
268
  * SEND A WHOLE BUFFER, NEVER A VIEW. This is what the retired node did and the drift from it
113
269
  * was audible: nothing but a light crackle.
@@ -124,7 +280,7 @@ export function makeAudioBuffer(
124
280
  if (combined.length % 2 === 0) {
125
281
  chunks = [];
126
282
  total = 0;
127
- lane.sendAudio(conversationId, combined);
283
+ lane.sendAudio(conversationId, forLane(lane, combined, vendorRate, passthrough));
128
284
  return;
129
285
  }
130
286
 
@@ -143,7 +299,7 @@ export function makeAudioBuffer(
143
299
  }
144
300
  chunks = [Buffer.from(combined.subarray(aligned))];
145
301
  total = 1;
146
- lane.sendAudio(conversationId, Buffer.from(combined.subarray(0, aligned)));
302
+ lane.sendAudio(conversationId, forLane(lane, Buffer.from(combined.subarray(0, aligned)), vendorRate, passthrough));
147
303
  };
148
304
 
149
305
  return {
@@ -40,7 +40,7 @@ import { assertAllowedHost } from "../http/allowedHosts.js";
40
40
  import type { Emitter } from "../events.js";
41
41
  // The audio lane lives next door: this file owns the VENDOR socket, that one owns the bytes and
42
42
  // the browser. See the header of audioLane.ts for why they are separate files.
43
- import { makeAudioBuffer, resamplePcm16, CLIENT_RATE, type AudioLane } from "./audioLane.js";
43
+ import { makeAudioBuffer, resamplePcm16, mulawToPcm16, CLIENT_RATE, type AudioLane } from "./audioLane.js";
44
44
 
45
45
  /**
46
46
  * PLATFORM CEILINGS, independent of anything a manifest says — the same arrangement `poll` has,
@@ -55,11 +55,15 @@ import { makeAudioBuffer, resamplePcm16, CLIENT_RATE, type AudioLane } from "./a
55
55
  * MAX a session that is active but has run absurdly long, e.g. an open mic in an empty room
56
56
  * keeping VAD busy indefinitely.
57
57
  */
58
+ /** How long the line stays open after the last audio frame, once the model has asked to hang up. */
59
+ const HANG_UP_DRAIN_MS = 1500;
60
+
58
61
  const IDLE_TIMEOUT_MS = 30_000;
59
62
  const MAX_SESSION_MS = 30 * 60_000;
60
63
 
61
64
  import { offerDiscovered, parseMaybeJson, recordToolTrace, type ToolBridge } from "../tools/toolloop.js";
62
65
  import { applyInForce, renderSkillsText, type InForceSkill } from "../tools/regionSkills.js";
66
+ import { registerLiveInput, liveInputKey } from "../../../platform/liveInput.js";
63
67
 
64
68
 
65
69
  /** Resolve one manifest message against the run scope, with `extra` layered on top. */
@@ -69,7 +73,7 @@ async function message(spec: unknown, ctx: RunContext, extra: Record<string, unk
69
73
 
70
74
  export interface DuplexResult {
71
75
  /** Why the session ended, for the caller to record. */
72
- reason: "closed" | "ended" | "error" | "idle" | "maxDuration";
76
+ reason: "closed" | "ended" | "error" | "idle" | "maxDuration" | "notConnected";
73
77
  error?: string;
74
78
  }
75
79
 
@@ -110,11 +114,98 @@ export async function runDuplexSession(opts: {
110
114
  // A session always authenticates, hence `true` rather than reading the auth block.
111
115
  assertAllowedHost(url, node.allowedHosts, node.type, true);
112
116
 
117
+ /**
118
+ * WHAT THE CONVERSATION HAS ACCUMULATED, which no single event carries.
119
+ *
120
+ * seen the latest event of each type. A reply names the request it answers (a vendor's
121
+ * delegation id), and that request arrived on an earlier event than the reply.
122
+ * said the text the vendor has streamed so far, in order, one entry per run of the same
123
+ * event type. A hand-off carries no text of its own, so a row describing it reads
124
+ * what was said before it; the transcript at the end is the whole list.
125
+ *
126
+ * Given to every events row and every `send` rule. Audio deltas never reach either: they
127
+ * leave on the audio lane before this is written.
128
+ *
129
+ * SET UP BEFORE THE CONNECT GATE BELOW, and that ordering is load-bearing. A call that rings
130
+ * out returns early, and the events table still runs its `complete` rows on the way out — so
131
+ * a row reading `said` for the transcript threw "unknown identifier 'said'" and the node
132
+ * FAILED instead of finishing (observed live 2026-09-20). A call nobody answered is an
133
+ * ordinary outcome, not a broken node: the scope exists for the whole run, and the transcript
134
+ * of a call that never happened is simply empty.
135
+ */
136
+ const seen: Record<string, any> = {};
137
+ const said: { type: string; text: string }[] = [];
138
+ emitter.extend({ seen, said });
139
+
140
+ /**
141
+ * THE RINGING AND THE GETTING READY HAPPEN AT THE SAME TIME.
142
+ *
143
+ * A lane that has to REACH its far end says so with `whenConnected` (audioLane.ts). We start
144
+ * it here, which for a telephone is the moment it begins to ring, AND WE DO NOT WAIT: the
145
+ * vendor socket is opened, the handshake exchanged and the opening line composed against the
146
+ * ringing tone, so that all of it is finished before anybody picks up.
147
+ *
148
+ * IT USED TO WAIT, and the reason was that a realtime session is billed from the moment it
149
+ * opens, so a call nobody answers would be paid for in full. That was the wrong thing to
150
+ * optimise. Waiting moved the entire setup — socket, handshake, and the model composing its
151
+ * first sentence — to AFTER the person said hello, and they heard every second of it as
152
+ * silence (measured live 2026-09-20: six seconds between answer and first word). A few cents
153
+ * on a call that rings out is cheaper than a call that sounds broken on every answer.
154
+ *
155
+ * The greeting cannot be played to a ringing tone, and it is not: the lane holds what is
156
+ * generated early and flushes it the instant the call connects (twilioAudio.ts). So the only
157
+ * thing that still waits for the answer is the sound itself.
158
+ *
159
+ * The browser lane declares no `whenConnected` at all, because the page dialled US and its
160
+ * socket is already there. Nothing below changes for it.
161
+ */
162
+ let answered: Promise<boolean> | null = null;
163
+ if (lane?.whenConnected) {
164
+ lane.startAudioSession?.(conversationId, conversationId);
165
+ answered = lane.whenConnected(conversationId);
166
+ }
167
+
113
168
  const ws = new WebSocket(url, { headers });
114
- const buffer = lane && outSpec ? makeAudioBuffer(lane, conversationId, outSpec.coalesceBytes, outSpec.coalesceMs) : null;
169
+
170
+
171
+ /**
172
+ * WHAT THE VENDOR SENDS, which the lane converts FROM. `in.resampleTo` is the rate this
173
+ * session's manifest asks the vendor to LISTEN at, and every vendor here is symmetric — the
174
+ * handshake in api/run.yaml sets one format for input and output together — so it is also the
175
+ * rate the vendor SPEAKS at. Stated here rather than inferred at the far end, because a lane
176
+ * that guessed wrong would sound fine and simply be the wrong speed.
177
+ */
178
+ const vendorRate = inSpec?.resampleTo ?? CLIENT_RATE;
179
+
180
+ /**
181
+ * IS THE VENDOR ALREADY SPEAKING THIS LANE'S LANGUAGE?
182
+ *
183
+ * A node may declare in `api/audio.yaml` which lane codecs its vendor can take raw
184
+ * (`in.laneCodecs: [mulaw]`), and ask for that codec in its own handshake. When the lane
185
+ * speaks one of them, the bytes need no conversion in either direction: a telephone's 8 kHz
186
+ * mu-law goes to the vendor untouched and comes back untouched.
187
+ *
188
+ * The conversion it skips is not merely wasted, it is LOSSY IN BOTH DIRECTIONS: decode to
189
+ * PCM, interpolate 8 kHz up to 24 kHz, and at the far end interpolate back down and
190
+ * re-compand. A phone call is 8 kHz whatever is done to it, so all that work can only round
191
+ * the edges off it.
192
+ *
193
+ * ABSENT MEANS TODAY'S BEHAVIOUR, exactly. A node that declares nothing (Realtime, Grok, and
194
+ * every browser lane) converts as it always has, because a vendor that was not asked for
195
+ * mu-law is not sending it, and handing its PCM straight to a telephone would be noise.
196
+ */
197
+ const passthrough = !!lane?.codec && (inSpec?.laneCodecs ?? []).includes(lane.codec);
198
+
199
+ const buffer =
200
+ lane && outSpec
201
+ ? makeAudioBuffer(lane, conversationId, outSpec.coalesceBytes, outSpec.coalesceMs, vendorRate, passthrough)
202
+ : null;
115
203
 
116
204
  let settled = false;
117
205
  let closing = false;
206
+ /** Set once `control.endWhen` has fired; cleared only by the hang-up it schedules. */
207
+ let ending = false;
208
+ let drainTimer: ReturnType<typeof setTimeout> | undefined;
118
209
  /**
119
210
  * Is the assistant mid-utterance? The vendor has no "about to speak" event, so the FIRST audio
120
211
  * delta after silence is the start, and the client gates playback on being told. Transcribed from
@@ -136,6 +227,10 @@ export async function runDuplexSession(opts: {
136
227
  */
137
228
  const skills = new Map<string, InForceSkill>();
138
229
 
230
+
231
+ /** Set while the session is open and takes `CONTINUE` inputs; cleared when it ends. */
232
+ let unregisterLive: (() => void) | undefined;
233
+
139
234
  const send = (payload: unknown) => {
140
235
  if (ws.readyState !== WebSocket.OPEN) return;
141
236
  ws.send(JSON.stringify(payload));
@@ -183,14 +278,68 @@ export async function runDuplexSession(opts: {
183
278
  }, IDLE_TIMEOUT_MS);
184
279
  };
185
280
 
281
+ /**
282
+ * THE ONE PLACE A SESSION ENDS, and now the one place a CALL ends with it.
283
+ *
284
+ * Closing the vendor socket stops the model. It does not reach the person: on a telephone
285
+ * the carrier holds the line and will hold it until somebody hangs up. Those are two far
286
+ * ends and both have to be let go, so the lane is told here rather than at any one of the
287
+ * ways a session can finish — every one of them comes through this function, and a hang-up
288
+ * written beside only the deliberate one is a hang-up that never happens on a timeout, an
289
+ * error, or a vendor that dropped.
290
+ */
186
291
  const finish = (result: DuplexResult) => {
187
292
  if (settled) return;
188
293
  settled = true;
294
+ unregisterLive?.();
189
295
  if (idleTimer) clearTimeout(idleTimer);
296
+ if (drainTimer) clearTimeout(drainTimer);
190
297
  clearTimeout(maxTimer);
298
+ try {
299
+ lane?.hangUp?.(conversationId);
300
+ } catch (err: any) {
301
+ // A far end we could not let go of is worth saying out loud and is not worth failing
302
+ // the run over: the work the call did is already done and already emitted.
303
+ onError?.(`hang up failed: ${err?.message ?? err}`);
304
+ }
191
305
  resolve(result);
192
306
  };
193
307
 
308
+ /**
309
+ * HANGING UP AFTER THE LAST WORD, not during it.
310
+ *
311
+ * `control.endWhen` is true the moment the vendor reports the tool call, and the goodbye it
312
+ * was called alongside is still going down the wire. Shutting down there cuts the line
313
+ * mid-sentence, which is heard as the call dropping rather than ending (2026-09-22).
314
+ *
315
+ * So the decision and the hang-up are two things. The decision stands; the hang-up waits
316
+ * for the audio to stop arriving. Every frame that follows pushes this back, so a long
317
+ * closing sentence is never truncated, and a model that says nothing after calling the tool
318
+ * still hangs up within the settle.
319
+ */
320
+ const hangUpOnceQuiet = () => {
321
+ if (drainTimer) clearTimeout(drainTimer);
322
+ drainTimer = setTimeout(() => {
323
+ buffer?.flush();
324
+ void shutdown("ended").then((reason) => finish({ reason: reason ?? "ended" }));
325
+ }, HANG_UP_DRAIN_MS);
326
+ };
327
+
328
+ /**
329
+ * NOBODY EVER PICKED UP. Not an error — a call that rang out is a thing that happens, and the
330
+ * workflow should read it as such rather than as a broken node. The session we opened in front
331
+ * of it is closed here, which is the price of having been ready.
332
+ */
333
+ void answered?.then((ok) => {
334
+ if (ok) return;
335
+ try {
336
+ ws.close();
337
+ } catch {
338
+ /* never opened */
339
+ }
340
+ finish({ reason: "notConnected" });
341
+ });
342
+
194
343
  ws.on("open", async () => {
195
344
  touch();
196
345
  try {
@@ -236,15 +385,76 @@ export async function runDuplexSession(opts: {
236
385
  for (const m of ([] as any[]).concat(built ?? [])) if (m) send(m);
237
386
  }
238
387
 
388
+ /**
389
+ * TAKE `CONTINUE` INPUTS WHILE THE CALL RUNS (calls-that-loop.md, `transport: ws`).
390
+ *
391
+ * Another node's output, wired into this node's CONTINUE input, arrives here rather than
392
+ * starting the node again, which would open a second conversation. The `send` rules with
393
+ * `on: input` say what the vendor is told; the value is in scope as `input.value`.
394
+ *
395
+ * Registered AFTER the handshake, so nothing can be sent to a session the vendor has not
396
+ * been told about yet.
397
+ */
398
+ const key = liveInputKey(ctx.scope?.executionId, ctx.scope?.nodeId);
399
+ if (key) {
400
+ unregisterLive = registerLiveInput(key, async (handle, value) => {
401
+ if (ws.readyState !== WebSocket.OPEN) return false;
402
+ touch();
403
+ const scope = { input: { handle, value }, seen, said };
404
+ let sent = 0;
405
+ for (const rule of call.send ?? []) {
406
+ if (rule.on !== "input") continue;
407
+ if (rule.when && !(await evaluate(rule.when, { ...ctx, ...scope } as any))) continue;
408
+ for (const m of ([] as any[]).concat((await message(rule.message, ctx, scope)) ?? [])) {
409
+ if (m) {
410
+ send(m);
411
+ sent++;
412
+ }
413
+ }
414
+ }
415
+ // Named, not silent: an input wired to a node that says nothing about it is a
416
+ // canvas edge that looks connected and does nothing.
417
+ console.log(
418
+ sent
419
+ ? `[manifests] ${node.type}: input "${handle}" taken mid-call, ${sent} message(s) sent`
420
+ : `[manifests] ${node.type}: input "${handle}" arrived mid-call but no send rule has on: input`,
421
+ );
422
+ return true;
423
+ });
424
+ }
425
+
239
426
  if (lane && inSpec) {
240
- lane.startAudioSession?.(conversationId, conversationId);
427
+ // Already started above for a lane that had to reach its far end, and starting a phone
428
+ // lane twice would place a second call.
429
+ if (!lane.whenConnected) lane.startAudioSession?.(conversationId, conversationId);
241
430
 
242
431
  lane.setAudioDataHandler?.(async (sessionId, data) => {
243
432
  if (sessionId !== conversationId || ws.readyState !== WebSocket.OPEN) return;
244
433
  touch();
245
434
  const raw = Buffer.from(data);
246
435
  const rate = inSpec.resampleTo ?? CLIENT_RATE;
247
- const pcm = resamplePcm16(raw, CLIENT_RATE, rate);
436
+
437
+ // NOTHING TO DO: the vendor asked for this lane's own codec (see `passthrough`
438
+ // above), so these are the bytes to send. The rate travels as the LANE's, because
439
+ // that is what the samples actually are.
440
+ if (passthrough) {
441
+ send(
442
+ await message(inSpec.message, ctx, {
443
+ audio: { base64: raw.toString("base64"), bytes: raw.length, rate: lane.rate ?? CLIENT_RATE },
444
+ }),
445
+ );
446
+ return;
447
+ }
448
+ /**
449
+ * DECODE BEFORE RESAMPLING. A mu-law byte is one sample, not half of one, so
450
+ * interpolating the raw bytes would treat pairs of samples as single ones and
451
+ * produce half-speed noise. And mu-law is logarithmic, so even the arithmetic
452
+ * would be wrong: the midpoint of two bytes is not the midpoint of two sounds.
453
+ *
454
+ * The browser lane declares neither, so both lines are identity for it.
455
+ */
456
+ const decoded = lane.codec === "mulaw" ? mulawToPcm16(raw) : raw;
457
+ const pcm = resamplePcm16(decoded, lane.rate ?? CLIENT_RATE, rate);
248
458
  send(
249
459
  await message(inSpec.message, ctx, {
250
460
  audio: { base64: pcm.toString("base64"), bytes: pcm.length, rate },
@@ -275,17 +485,57 @@ export async function runDuplexSession(opts: {
275
485
  }
276
486
  const type = String(event?.type ?? "");
277
487
 
488
+ // A REJECTED SESSION MUST SAY WHY. Every vendor this runtime speaks names its complaint in
489
+ // an `error` event and then closes; without this line a refused handshake looks exactly like
490
+ // a quiet call, with nothing in any log to say which field was refused.
491
+ if (type === "error") {
492
+ console.log(`[manifests] ${node.type}: vendor error ${JSON.stringify(event.error ?? event)}`);
493
+ }
494
+
278
495
  // Billed per RESPONSE, so a ten-turn call carries ten usage blocks and the collector sums
279
496
  // them. Above the dispatch, because the frame carrying usage must not depend on which
280
497
  // branch claims it. `sniffUsage` already reads `payload.response.usage` — no manifest change.
281
498
  usage?.see(event);
282
499
 
500
+ /**
501
+ * THE MODEL HANGING UP, which nothing else can express.
502
+ *
503
+ * `control.endOn` is the LANE's list: a carrier's `stop` frame, a browser tab closing.
504
+ * Both mean the PERSON left. Nothing there can say the model is finished, and GPT-Live
505
+ * sends no end-of-call event of its own, so a call whose business was done simply stayed
506
+ * open: the assistant said goodbye, stopped talking, and the line was held until the idle
507
+ * timer fired minutes later (observed live 2026-09-22, and heard as silence).
508
+ *
509
+ * `control.endWhen` is that missing half: an expression over each vendor event, and when
510
+ * it is true the session shuts down exactly as a hang-up does. A manifest decides what
511
+ * counts — for a vendor with function calling it is a tool named `end_call`.
512
+ *
513
+ * ABSENT MEANS TODAY'S BEHAVIOUR. A node that declares nothing runs the same code it
514
+ * always did, which is why Realtime and Grok are untouched.
515
+ */
516
+ if (audio.control?.endWhen && !closing) {
517
+ let done = false;
518
+ try {
519
+ done = !!(await evaluate(audio.control.endWhen, { ...ctx, response: event } as any));
520
+ } catch {
521
+ // A malformed test must not kill a live call: the only safe reading is "not finished".
522
+ }
523
+ if (done && !ending) {
524
+ // DECIDED, NOT DONE. `hangUpOnceQuiet` waits for the goodbye to finish going out;
525
+ // see its comment for why this is not a shutdown on the spot.
526
+ ending = true;
527
+ hangUpOnceQuiet();
528
+ }
529
+ }
530
+
283
531
  try {
284
532
  // AUDIO, the one thing that bypasses an output connector, and only because MCP cannot
285
533
  // carry it.
286
534
  if (outSpec && type === outSpec.match) {
287
535
  // SPEECH_STARTED, once per utterance. The client sets up playback on it, so audio sent
288
536
  // before it is audio nobody hears.
537
+ // Still talking, so the hang-up waits: every frame pushes the settle back.
538
+ if (ending) hangUpOnceQuiet();
289
539
  if (!speaking) {
290
540
  speaking = true;
291
541
  lane?.sendControl(conversationId, {
@@ -347,6 +597,48 @@ export async function runDuplexSession(opts: {
347
597
  if (audio.control?.userStopped && type === audio.control.userStopped) {
348
598
  lane?.sendControl(conversationId, { type: "AUDIO_STATE", state: "USER_SPEECH_ENDED" });
349
599
  }
600
+ // Work done off the call (a vendor's hand-off to a backend), for the call state's "thinking".
601
+ if (audio.control?.toolUse && type === audio.control.toolUse) {
602
+ lane?.sendControl(conversationId, { type: "AUDIO_STATE", state: "TOOL_USE" });
603
+ }
604
+ if (audio.control?.toolUseCompleted && type === audio.control.toolUseCompleted) {
605
+ lane?.sendControl(conversationId, { type: "AUDIO_STATE", state: "TOOL_USE_COMPLETED" });
606
+ }
607
+
608
+ /**
609
+ * WHAT THE VENDOR ACTUALLY SENDS, on demand. Three fixes in a row were built on a guess
610
+ * about which event carries a tool call's id, and each one was wrong in a new way,
611
+ * because nothing here ever printed the truth. DUPLEX_TRACE=1 prints every inbound event
612
+ * type once, and prints in full any event carrying a call id or a delegation — the two
613
+ * things a reply has to be addressed to.
614
+ */
615
+ // Remembered BEFORE the rows run, so a row firing on this event can read it too.
616
+ seen[type] = event;
617
+
618
+ /**
619
+ * AND UNDER WHATEVER NAME THE MANIFEST ASKED FOR. `seen[type]` keeps the latest event of
620
+ * each type, which loses one thing to another whenever a vendor gives two different
621
+ * things the same type: OpenAI ends a function call and the model's own message both as
622
+ * `response.output_item.done`, so the message overwrites the call and its `call_id`
623
+ * goes with it. A reply with no id to address is a model left waiting, heard on the call
624
+ * as silence (measured 2026-09-22).
625
+ *
626
+ * The manifest says which events matter to it, so the runtime keeps no vendor knowledge.
627
+ * A test that throws keeps the slot as it was: remembering nothing is recoverable,
628
+ * killing the session over a bad expression is not.
629
+ */
630
+ for (const [name, test] of Object.entries(call.keep ?? {})) {
631
+ try {
632
+ if (await evaluate(test as string, { ...ctx, response: event } as any)) seen[name] = event;
633
+ } catch {
634
+ /* a malformed test must not take the call down */
635
+ }
636
+ }
637
+ if (typeof event?.delta === "string" && event.delta) {
638
+ const last = said[said.length - 1];
639
+ if (last && last.type === type) last.text += event.delta;
640
+ else said.push({ type, text: event.delta });
641
+ }
350
642
 
351
643
  // EVERYTHING ELSE, to the events table, exactly as an sse reply. This is the path that
352
644
  // reaches the client over MCP streaming.
@@ -463,7 +755,7 @@ export async function runDuplexSession(opts: {
463
755
  }
464
756
  // The same timeline row an HTTP agent's tool call draws. `runToolLoop` has recorded
465
757
  // these since the migration; the socket never called it, so voice tools were invisible.
466
- recordToolTrace(node, ctx, name, parseMaybeJson(rawArgs), output, toolStart, toolOk);
758
+ recordToolTrace(node, ctx, name, parseMaybeJson(rawArgs), output, toolStart, toolOk, tools.kindOf?.(name) ?? "tool");
467
759
  // After the catch, so a tool that THREW still stops the dots. Spinning forever is
468
760
  // worse than no indicator: it promises something still coming.
469
761
  lane?.sendControl(conversationId, { type: "AUDIO_STATE", state: "TOOL_USE_COMPLETED" });
@@ -486,9 +778,9 @@ export async function runDuplexSession(opts: {
486
778
  // AWAITED, for the same reason the audio path is: `evaluate` is async, so `!promise` is
487
779
  // always false and an un-awaited `when` never blocks anything. Every reactive send would
488
780
  // fire regardless of its condition — silently, since firing too often looks like working.
489
- if (rule.when && !(await evaluate(rule.when, { ...ctx, response: event } as any))) continue;
781
+ if (rule.when && !(await evaluate(rule.when, { ...ctx, response: event, seen, said } as any))) continue;
490
782
  // A list, because one rule may need several messages (add the item, then ask for a reply).
491
- for (const m of ([] as any[]).concat((await message(rule.message, ctx, { response: event })) ?? [])) {
783
+ for (const m of ([] as any[]).concat((await message(rule.message, ctx, { response: event, seen, said })) ?? [])) {
492
784
  if (m) send(m);
493
785
  }
494
786
  }
@@ -503,7 +795,10 @@ export async function runDuplexSession(opts: {
503
795
  finish({ reason: "error", error: err?.message ?? String(err) });
504
796
  });
505
797
 
506
- ws.on("close", () => {
798
+ ws.on("close", (code, reason) => {
799
+ // The vendor hanging up on its own is the other half of a refusal: its close code and
800
+ // reason are often the only explanation it gives.
801
+ if (!closing) console.log(`[manifests] ${node.type}: vendor closed the session (${code}) ${String(reason || "")}`);
507
802
  buffer?.flush();
508
803
  finish({ reason: closing ? "ended" : "closed" });
509
804
  });