@unoverse-platform/studio 0.1.89 → 0.1.91
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/plugin.ts +20 -12
- package/package.json +13 -3
- package/screens/TemplatesLibraryView.tsx +31 -6
- package/screens/TemplatesView.tsx +1 -1
- package/vendor/base/src/agent-mcp/discovery.ts +203 -0
- package/vendor/base/src/agent-mcp/handoff.ts +56 -0
- package/vendor/base/src/agent-mcp/index.ts +44 -0
- package/vendor/base/src/agent-mcp/invoke.ts +140 -0
- package/vendor/base/src/agent-mcp/lean.ts +111 -0
- package/vendor/base/src/agent-mcp/memoryContext.ts +104 -0
- package/vendor/base/src/agent-mcp/memoryIngest.ts +88 -0
- package/vendor/base/src/agent-mcp/rowComponents.ts +172 -0
- package/vendor/base/src/agent-mcp/tools.ts +134 -0
- package/vendor/base/src/agent-mcp/types.ts +130 -0
- package/vendor/base/src/auth/auth.ts +278 -0
- package/vendor/base/src/auth/authConfig.ts +73 -0
- package/vendor/base/src/boot.ts +245 -0
- package/vendor/base/src/definitions/definitions.ts +18 -0
- package/vendor/base/src/definitions/dsPackage.ts +45 -0
- package/vendor/base/src/definitions/fsCache.ts +128 -0
- package/vendor/base/src/definitions/index.ts +19 -0
- package/vendor/base/src/definitions/inputs.ts +30 -0
- package/vendor/base/src/definitions/load/compile/component.ts +80 -0
- package/vendor/base/src/definitions/load/compile/index.ts +12 -0
- package/vendor/base/src/definitions/load/compile/sections.ts +132 -0
- package/vendor/base/src/definitions/load/compile/template.ts +153 -0
- package/vendor/base/src/definitions/load/expand/includes.ts +27 -0
- package/vendor/base/src/definitions/load/expand/index.ts +17 -0
- package/vendor/base/src/definitions/load/expand/inherit.ts +169 -0
- package/vendor/base/src/definitions/load/expand/referenced.ts +164 -0
- package/vendor/base/src/definitions/load/expand/refs.ts +106 -0
- package/vendor/base/src/definitions/load/expand/walk.ts +91 -0
- package/vendor/base/src/definitions/load/load.ts +487 -0
- package/vendor/base/src/definitions/model.ts +238 -0
- package/vendor/base/src/definitions/registry/catalog.ts +643 -0
- package/vendor/base/src/definitions/registry/homes.ts +252 -0
- package/vendor/base/src/definitions/theme.ts +383 -0
- package/vendor/base/src/executor.ts +284 -0
- package/vendor/base/src/http/uwsFetchAdapter.ts +260 -0
- package/vendor/base/src/items/baseVersion.ts +96 -0
- package/vendor/base/src/items/collect.ts +199 -0
- package/vendor/base/src/items/fingerprint.ts +34 -0
- package/vendor/base/src/items/publish.ts +198 -0
- package/vendor/base/src/lint/context.mjs +56 -0
- package/vendor/base/src/lint/design/defs.mjs +46 -0
- package/vendor/base/src/lint/design/file.mjs +737 -0
- package/vendor/base/src/lint/design/index.d.mts +30 -0
- package/vendor/base/src/lint/design/index.mjs +996 -0
- package/vendor/base/src/lint/design/tokens.mjs +128 -0
- package/vendor/base/src/lint/design/vocabulary.mjs +181 -0
- package/vendor/base/src/lint/design/walk.mjs +444 -0
- package/vendor/base/src/lint/index.mjs +28 -0
- package/vendor/base/src/lint/nodes/blocks.mjs +63 -0
- package/vendor/base/src/lint/nodes/index.mjs +64 -0
- package/vendor/base/src/lint/nodes/load.mjs +126 -0
- package/vendor/base/src/lint/nodes/node.mjs +764 -0
- package/vendor/base/src/lint/nodes/package.mjs +112 -0
- package/vendor/base/src/lint/nodes/schema.mjs +75 -0
- package/vendor/base/src/logger.ts +33 -0
- package/vendor/base/src/manifests/compose.ts +539 -0
- package/vendor/base/src/manifests/executor/callback.ts +107 -0
- package/vendor/base/src/manifests/executor/context.ts +107 -0
- package/vendor/base/src/manifests/executor/index.ts +47 -0
- package/vendor/base/src/manifests/executor/lookup.ts +21 -0
- package/vendor/base/src/manifests/executor/memory.ts +62 -0
- package/vendor/base/src/manifests/executor/promise.ts +71 -0
- package/vendor/base/src/manifests/executor/refire.ts +34 -0
- package/vendor/base/src/manifests/executor/send.ts +65 -0
- package/vendor/base/src/manifests/executor/session.ts +46 -0
- package/vendor/base/src/manifests/executor/toolBridge.ts +266 -0
- package/vendor/base/src/manifests/index.ts +280 -0
- package/vendor/base/src/manifests/integrity.ts +93 -0
- package/vendor/base/src/manifests/rows.ts +33 -0
- package/vendor/base/src/manifests/runtime/auth/aws.ts +240 -0
- package/vendor/base/src/manifests/runtime/auth/oauth.ts +76 -0
- package/vendor/base/src/manifests/runtime/authorize.ts +159 -0
- package/vendor/base/src/manifests/runtime/channels/final.ts +171 -0
- package/vendor/base/src/manifests/runtime/channels/service.ts +125 -0
- package/vendor/base/src/manifests/runtime/channels/workflow.ts +310 -0
- package/vendor/base/src/manifests/runtime/context.ts +150 -0
- package/vendor/base/src/manifests/runtime/docstore/handlers.ts +547 -0
- package/vendor/base/src/manifests/runtime/docstore/index.ts +135 -0
- package/vendor/base/src/manifests/runtime/docstore/sectionizer.ts +184 -0
- package/vendor/base/src/manifests/runtime/docstore/store.ts +172 -0
- package/vendor/base/src/manifests/runtime/docstore/types.ts +60 -0
- package/vendor/base/src/manifests/runtime/duplex/audioLane.ts +165 -0
- package/vendor/base/src/manifests/runtime/duplex/session.ts +511 -0
- package/vendor/base/src/manifests/runtime/events.ts +223 -0
- package/vendor/base/src/manifests/runtime/helpers.ts +53 -0
- package/vendor/base/src/manifests/runtime/http/allowedHosts.ts +118 -0
- package/vendor/base/src/manifests/runtime/http/request.ts +637 -0
- package/vendor/base/src/manifests/runtime/http/response.ts +162 -0
- package/vendor/base/src/manifests/runtime/index.ts +57 -0
- package/vendor/base/src/manifests/runtime/loops/chunk.ts +98 -0
- package/vendor/base/src/manifests/runtime/loops/loop.ts +191 -0
- package/vendor/base/src/manifests/runtime/loops/paginate.ts +143 -0
- package/vendor/base/src/manifests/runtime/loops/poll.ts +169 -0
- package/vendor/base/src/manifests/runtime/narrate.ts +53 -0
- package/vendor/base/src/manifests/runtime/state.ts +171 -0
- package/vendor/base/src/manifests/runtime/templating.ts +110 -0
- package/vendor/base/src/manifests/runtime/tools/regionSkills.ts +125 -0
- package/vendor/base/src/manifests/runtime/tools/toolloop.ts +542 -0
- package/vendor/base/src/manifests/runtime/usage.ts +87 -0
- package/vendor/base/src/manifests/source.ts +187 -0
- package/vendor/base/src/manifests/watch.ts +67 -0
- package/vendor/base/src/markdown/document.ts +141 -0
- package/vendor/base/src/paths.ts +180 -0
- package/vendor/base/src/platform/audioManager.ts +100 -0
- package/vendor/base/src/platform/clientTransport.ts +65 -0
- package/vendor/base/src/platform/executionContext.ts +154 -0
- package/vendor/base/src/platform/index.ts +10 -0
- package/vendor/base/src/platform/pluginAPI.ts +146 -0
- package/vendor/base/src/platform/redis.ts +54 -0
- package/vendor/base/src/platform/serviceCalls.ts +222 -0
- package/vendor/base/src/pluginBase.ts +248 -0
- package/vendor/base/src/plugins/README.md +57 -0
- package/vendor/base/src/plugins/discovery.ts +87 -0
- package/vendor/base/src/plugins/index.ts +16 -0
- package/vendor/base/src/plugins/loader.ts +179 -0
- package/vendor/base/src/plugins/redis.ts +36 -0
- package/vendor/base/src/plugins/state.ts +53 -0
- package/vendor/base/src/registry.ts +95 -0
- package/vendor/base/src/security/permissions.ts +93 -0
- package/vendor/base/src/security/rules.ts +73 -0
- package/vendor/base/src/template/SafeExpression.ts +556 -0
- package/vendor/base/src/template/StringTemplateResolver.ts +75 -0
- package/vendor/base/src/template/index.ts +22 -0
- package/vendor/base/src/types.ts +50 -0
- package/vendor/base/src/workflow/GetMemoryService.ts +158 -0
- package/vendor/base/src/workflow/PlatformMemoryCore.ts +438 -0
- package/vendor/base/src/workflow/routing.ts +122 -0
- package/vendor/sdk/lib/core/store.ts +26 -6
|
@@ -0,0 +1,511 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `transport: ws` — a DUPLEX SESSION, and the audio lane beside it.
|
|
3
|
+
*
|
|
4
|
+
* Every other transport here is one request and a reply the executor reads. A voice model is
|
|
5
|
+
* a conversation both sides write to, held open for the length of a call, and that is the only
|
|
6
|
+
* reason this file exists as something other than another branch in `response.ts`.
|
|
7
|
+
*
|
|
8
|
+
* TWO SOCKETS, and confusing them is the mistake this file is arranged to prevent:
|
|
9
|
+
*
|
|
10
|
+
* the VENDOR socket this file opens it, to OpenAI or xAI. `api/run.yaml` describes it.
|
|
11
|
+
* the AUDIO LANE the platform already owns it, to the browser. `api/audio.yaml` binds
|
|
12
|
+
* to it, and it exists for ONE reason: MCP cannot carry binary audio.
|
|
13
|
+
*
|
|
14
|
+
* So everything inbound that is NOT audio goes to `emitter.response(payload, payload.type)`,
|
|
15
|
+
* exactly as an `sse` reply does, and reaches the client over MCP streaming by landing on an
|
|
16
|
+
* output connector. Only audio bypasses that, because only audio has to.
|
|
17
|
+
*
|
|
18
|
+
* WHAT IS HERE RATHER THAN IN YAML. Four things, and each is computation over bytes or time
|
|
19
|
+
* rather than a description of the vendor's API, which is the test in DECLARATIVE_NODES.md §2:
|
|
20
|
+
*
|
|
21
|
+
* resampling linear interpolation across samples. The client captures 16 kHz; OpenAI and
|
|
22
|
+
* xAI want 24 kHz. Getting it wrong is not an error anywhere — the vendor
|
|
23
|
+
* transcribes at the wrong speed and it reads as poor accuracy.
|
|
24
|
+
* coalescing a byte budget and a timer. One frame per vendor delta floods the client.
|
|
25
|
+
* barge-in DISCARDING a buffer rather than flushing it. Flushing plays a fragment of a
|
|
26
|
+
* sentence the user already talked over, which is the most obvious way a
|
|
27
|
+
* voice call feels broken.
|
|
28
|
+
* ordering the flush goes out BEFORE the end signal, because SPEECH_ENDED marks the END of
|
|
29
|
+
* the utterance and the client finishes playback once its queue drains. A frame
|
|
30
|
+
* sent after it lands on a player that has already stopped.
|
|
31
|
+
*
|
|
32
|
+
* All four are identical for every vendor, which is precisely why they are not in the manifest.
|
|
33
|
+
*/
|
|
34
|
+
import WebSocket from "ws";
|
|
35
|
+
import type { ComposedNode } from "../../compose.js";
|
|
36
|
+
import type { RunContext } from "../context.js";
|
|
37
|
+
import { resolveBody } from "../http/request.js";
|
|
38
|
+
import { evaluate } from "../templating.js";
|
|
39
|
+
import { assertAllowedHost } from "../http/allowedHosts.js";
|
|
40
|
+
import type { Emitter } from "../events.js";
|
|
41
|
+
// The audio lane lives next door: this file owns the VENDOR socket, that one owns the bytes and
|
|
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";
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* PLATFORM CEILINGS, independent of anything a manifest says — the same arrangement `poll` has,
|
|
47
|
+
* and for the same reason: the author's bound can be wrong.
|
|
48
|
+
*
|
|
49
|
+
* A duplex session ends when the vendor closes it or the person hangs up. If neither happens the
|
|
50
|
+
* socket stays open forever, and a voice session is billed by the minute, so "forever" is a real
|
|
51
|
+
* cost rather than a leaked handle. Two bounds, because they catch different failures:
|
|
52
|
+
*
|
|
53
|
+
* IDLE nothing inbound at all. A vendor that accepted the handshake and then went quiet, or a
|
|
54
|
+
* call nobody ever spoke on. This is what makes the node testable without a microphone.
|
|
55
|
+
* MAX a session that is active but has run absurdly long, e.g. an open mic in an empty room
|
|
56
|
+
* keeping VAD busy indefinitely.
|
|
57
|
+
*/
|
|
58
|
+
const IDLE_TIMEOUT_MS = 30_000;
|
|
59
|
+
const MAX_SESSION_MS = 30 * 60_000;
|
|
60
|
+
|
|
61
|
+
import { offerDiscovered, parseMaybeJson, recordToolTrace, type ToolBridge } from "../tools/toolloop.js";
|
|
62
|
+
import { applyInForce, renderSkillsText, type InForceSkill } from "../tools/regionSkills.js";
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
/** Resolve one manifest message against the run scope, with `extra` layered on top. */
|
|
66
|
+
async function message(spec: unknown, ctx: RunContext, extra: Record<string, unknown> = {}) {
|
|
67
|
+
return resolveBody(spec, { ...ctx, ...extra } as RunContext);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface DuplexResult {
|
|
71
|
+
/** Why the session ended, for the caller to record. */
|
|
72
|
+
reason: "closed" | "ended" | "error" | "idle" | "maxDuration";
|
|
73
|
+
error?: string;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Hold one duplex session open until the vendor closes it, the client hangs up, or it errors.
|
|
78
|
+
*
|
|
79
|
+
* Resolves only when the session is OVER, because a voice node's run IS the call: the workflow
|
|
80
|
+
* step is in progress for as long as the person is talking.
|
|
81
|
+
*/
|
|
82
|
+
export async function runDuplexSession(opts: {
|
|
83
|
+
node: ComposedNode;
|
|
84
|
+
call: any;
|
|
85
|
+
ctx: RunContext;
|
|
86
|
+
emitter: Emitter;
|
|
87
|
+
url: string;
|
|
88
|
+
headers: Record<string, string>;
|
|
89
|
+
lane: AudioLane | null;
|
|
90
|
+
conversationId: string;
|
|
91
|
+
/** MCP tools, when an `mcp` provider is wired to this node's consumer handle. */
|
|
92
|
+
tools?: ToolBridge;
|
|
93
|
+
/**
|
|
94
|
+
* THE TOKEN BILL, collected exactly as every HTTP transport collects it.
|
|
95
|
+
*
|
|
96
|
+
* Owned by the CALLER, like the collector on the json and sse paths, because the caller is
|
|
97
|
+
* what knows the run is over and must `save()` once. This only feeds it.
|
|
98
|
+
*/
|
|
99
|
+
usage?: { see(payload: any): void };
|
|
100
|
+
onError?: (message: string, detail?: unknown) => void;
|
|
101
|
+
}): Promise<DuplexResult> {
|
|
102
|
+
const { node, call, ctx, emitter, url, headers, lane, conversationId, tools, usage, onError } = opts;
|
|
103
|
+
const exchange = node.api?.toolExchange;
|
|
104
|
+
const audio = node.api?.audio ?? {};
|
|
105
|
+
const outSpec = audio.out;
|
|
106
|
+
const inSpec = audio.in;
|
|
107
|
+
|
|
108
|
+
// Checked here as well as for HTTP calls: a socket url carries the credential in a header on
|
|
109
|
+
// the handshake, so an undeclared host is exactly the exfiltration allowedHosts exists to stop.
|
|
110
|
+
// A session always authenticates, hence `true` rather than reading the auth block.
|
|
111
|
+
assertAllowedHost(url, node.allowedHosts, node.type, true);
|
|
112
|
+
|
|
113
|
+
const ws = new WebSocket(url, { headers });
|
|
114
|
+
const buffer = lane && outSpec ? makeAudioBuffer(lane, conversationId, outSpec.coalesceBytes, outSpec.coalesceMs) : null;
|
|
115
|
+
|
|
116
|
+
let settled = false;
|
|
117
|
+
let closing = false;
|
|
118
|
+
/**
|
|
119
|
+
* Is the assistant mid-utterance? The vendor has no "about to speak" event, so the FIRST audio
|
|
120
|
+
* delta after silence is the start, and the client gates playback on being told. Transcribed from
|
|
121
|
+
* the retired node's `audioStarted` flag, which existed for exactly this.
|
|
122
|
+
*/
|
|
123
|
+
let speaking = false;
|
|
124
|
+
|
|
125
|
+
/** The tool list, which GROWS: a search mid-call can add to it and the socket re-offers it. */
|
|
126
|
+
let live: any[] = [];
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* The skills in force, which CHANGE: a search mid-call can put one in force or clear it,
|
|
130
|
+
* and the socket re-sends its instructions.
|
|
131
|
+
*
|
|
132
|
+
* Session-scoped, not persisted. The HTTP loop keeps its held set in Redis because a
|
|
133
|
+
* request/response conversation is many separate runs and the set has to survive between
|
|
134
|
+
* them. Here the call IS the run: it lives as long as the socket, and when the socket
|
|
135
|
+
* closes the conversation is over.
|
|
136
|
+
*/
|
|
137
|
+
const skills = new Map<string, InForceSkill>();
|
|
138
|
+
|
|
139
|
+
const send = (payload: unknown) => {
|
|
140
|
+
if (ws.readyState !== WebSocket.OPEN) return;
|
|
141
|
+
ws.send(JSON.stringify(payload));
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
/** Run the `close` list, then close. Ordered, because Nova rejects a teardown out of order. */
|
|
145
|
+
const shutdown = async (reason: DuplexResult["reason"]) => {
|
|
146
|
+
if (closing) return;
|
|
147
|
+
closing = true;
|
|
148
|
+
for (const spec of call.close ?? []) {
|
|
149
|
+
try {
|
|
150
|
+
// Same array flattening as `open`, for the same reason.
|
|
151
|
+
for (const m of ([] as any[]).concat((await message(spec, ctx)) ?? [])) if (m) send(m);
|
|
152
|
+
} catch (err: any) {
|
|
153
|
+
onError?.(`close message failed: ${err?.message ?? err}`);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
buffer?.flush();
|
|
157
|
+
try {
|
|
158
|
+
ws.close();
|
|
159
|
+
} catch {
|
|
160
|
+
/* already gone */
|
|
161
|
+
}
|
|
162
|
+
return reason;
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
return new Promise<DuplexResult>((resolve) => {
|
|
166
|
+
let idleTimer: ReturnType<typeof setTimeout> | undefined;
|
|
167
|
+
const maxTimer = setTimeout(() => {
|
|
168
|
+
void shutdown("maxDuration").then(() => finish({ reason: "maxDuration" }));
|
|
169
|
+
}, MAX_SESSION_MS);
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Restarted by every inbound vendor frame AND by every outbound mic frame, so it only fires
|
|
173
|
+
* when BOTH sides have gone quiet. The vendor is legitimately silent while the person speaks
|
|
174
|
+
* (server VAD sends speech_started and then nothing until the pause), so vendor silence alone
|
|
175
|
+
* is not idleness — a call the microphone is still streaming on is a call someone is on. The
|
|
176
|
+
* timer still catches what it exists for: a vendor that accepted the handshake and died, on a
|
|
177
|
+
* session nobody is speaking into.
|
|
178
|
+
*/
|
|
179
|
+
const touch = () => {
|
|
180
|
+
if (idleTimer) clearTimeout(idleTimer);
|
|
181
|
+
idleTimer = setTimeout(() => {
|
|
182
|
+
void shutdown("idle").then(() => finish({ reason: "idle" }));
|
|
183
|
+
}, IDLE_TIMEOUT_MS);
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
const finish = (result: DuplexResult) => {
|
|
187
|
+
if (settled) return;
|
|
188
|
+
settled = true;
|
|
189
|
+
if (idleTimer) clearTimeout(idleTimer);
|
|
190
|
+
clearTimeout(maxTimer);
|
|
191
|
+
resolve(result);
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
ws.on("open", async () => {
|
|
195
|
+
touch();
|
|
196
|
+
try {
|
|
197
|
+
/**
|
|
198
|
+
* THE TOOLS THE CALL OPENS WITH, discovered before the handshake goes out because the
|
|
199
|
+
* handshake is where they are first offered. Exposed to the `open` messages as `tools`,
|
|
200
|
+
* which is why the manifest writes the vendor's envelope itself instead of the executor
|
|
201
|
+
* guessing at it.
|
|
202
|
+
*
|
|
203
|
+
* These are the SEED, not the whole set — `offer` below carries what discovery adds
|
|
204
|
+
* later.
|
|
205
|
+
*
|
|
206
|
+
* Empty when nothing is wired, and that is the normal case: the node degrades to a plain
|
|
207
|
+
* voice call rather than failing, exactly as an agent with no MCP provider does.
|
|
208
|
+
*/
|
|
209
|
+
if (tools && exchange) {
|
|
210
|
+
try {
|
|
211
|
+
live = await offerDiscovered(exchange, tools);
|
|
212
|
+
} catch (err: any) {
|
|
213
|
+
onError?.(`tool discovery failed, continuing without tools: ${err?.message ?? err}`);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
// In scope as `services.<connector>.tools`, which is the DOCUMENTED root a consumer reads
|
|
217
|
+
// (07-service-connectors.md) and the same place the HTTP loop puts them. The handshake
|
|
218
|
+
// therefore says `return services.mcpService.tools` rather than re-deriving the envelope.
|
|
219
|
+
const connector = exchange?.from ?? "mcpService";
|
|
220
|
+
const withTools = {
|
|
221
|
+
...ctx,
|
|
222
|
+
services: { ...ctx.services, [connector]: { ...(ctx.services as any)?.[connector], tools: live } },
|
|
223
|
+
} as RunContext;
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* The handshake, IN ORDER. A list because Nova's sessionStart/promptStart/contentStart
|
|
227
|
+
* sequence is only valid in that order, while OpenAI sends one session.update.
|
|
228
|
+
*
|
|
229
|
+
* An entry may resolve to an ARRAY, and each element is then sent in turn. That is what
|
|
230
|
+
* makes a variable-length handshake expressible: replaying N turns of conversation history
|
|
231
|
+
* is one entry that maps over the array, and an entry that resolves to [] sends nothing —
|
|
232
|
+
* which is how an optional opening message is written without a `when` on every item.
|
|
233
|
+
*/
|
|
234
|
+
for (const spec of call.open ?? []) {
|
|
235
|
+
const built = await message(spec, withTools);
|
|
236
|
+
for (const m of ([] as any[]).concat(built ?? [])) if (m) send(m);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
if (lane && inSpec) {
|
|
240
|
+
lane.startAudioSession?.(conversationId, conversationId);
|
|
241
|
+
|
|
242
|
+
lane.setAudioDataHandler?.(async (sessionId, data) => {
|
|
243
|
+
if (sessionId !== conversationId || ws.readyState !== WebSocket.OPEN) return;
|
|
244
|
+
touch();
|
|
245
|
+
const raw = Buffer.from(data);
|
|
246
|
+
const rate = inSpec.resampleTo ?? CLIENT_RATE;
|
|
247
|
+
const pcm = resamplePcm16(raw, CLIENT_RATE, rate);
|
|
248
|
+
send(
|
|
249
|
+
await message(inSpec.message, ctx, {
|
|
250
|
+
audio: { base64: pcm.toString("base64"), bytes: pcm.length, rate },
|
|
251
|
+
}),
|
|
252
|
+
);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
lane.setControlMessageHandler?.(async (sessionId, msg) => {
|
|
256
|
+
if (sessionId !== conversationId) return;
|
|
257
|
+
const endOn: string[] = audio.control?.endOn ?? [];
|
|
258
|
+
if (endOn.includes(String(msg?.type))) finish({ reason: await shutdown("ended") ?? "ended" });
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
} catch (err: any) {
|
|
262
|
+
onError?.(`session open failed: ${err?.message ?? err}`);
|
|
263
|
+
await shutdown("error");
|
|
264
|
+
finish({ reason: "error", error: err?.message ?? String(err) });
|
|
265
|
+
}
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
ws.on("message", async (raw) => {
|
|
269
|
+
touch();
|
|
270
|
+
let event: any;
|
|
271
|
+
try {
|
|
272
|
+
event = JSON.parse(String(raw));
|
|
273
|
+
} catch {
|
|
274
|
+
return; // a frame that is not JSON is not something a manifest can describe
|
|
275
|
+
}
|
|
276
|
+
const type = String(event?.type ?? "");
|
|
277
|
+
|
|
278
|
+
// Billed per RESPONSE, so a ten-turn call carries ten usage blocks and the collector sums
|
|
279
|
+
// them. Above the dispatch, because the frame carrying usage must not depend on which
|
|
280
|
+
// branch claims it. `sniffUsage` already reads `payload.response.usage` — no manifest change.
|
|
281
|
+
usage?.see(event);
|
|
282
|
+
|
|
283
|
+
try {
|
|
284
|
+
// AUDIO, the one thing that bypasses an output connector, and only because MCP cannot
|
|
285
|
+
// carry it.
|
|
286
|
+
if (outSpec && type === outSpec.match) {
|
|
287
|
+
// SPEECH_STARTED, once per utterance. The client sets up playback on it, so audio sent
|
|
288
|
+
// before it is audio nobody hears.
|
|
289
|
+
if (!speaking) {
|
|
290
|
+
speaking = true;
|
|
291
|
+
lane?.sendControl(conversationId, {
|
|
292
|
+
type: "AUDIO_STATE",
|
|
293
|
+
state: "SPEECH_STARTED",
|
|
294
|
+
message: "Assistant started speaking",
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
/**
|
|
298
|
+
* AWAITED. `evaluate` is async, and forgetting that here was the crackling: `String(promise)`
|
|
299
|
+
* is the literal text "[object Promise]", which base64-decodes to 11 bytes of garbage, so
|
|
300
|
+
* every audio frame carried noise instead of speech.
|
|
301
|
+
*
|
|
302
|
+
* It was invisible in the transcripts because those go through `emitter.response`, which
|
|
303
|
+
* awaits properly — the call sounded broken while every log line looked perfect.
|
|
304
|
+
*/
|
|
305
|
+
const b64 = await evaluate(outSpec.value, { ...ctx, response: event } as any);
|
|
306
|
+
if (b64) buffer?.add(Buffer.from(String(b64), "base64"));
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
if (outSpec?.done && type === outSpec.done) {
|
|
310
|
+
// Flush FIRST. SPEECH_ENDED marks the end of the utterance (`markAsLastChunk` in the
|
|
311
|
+
// SDK's playback hook), and the client finishes as soon as its queue drains — so a frame
|
|
312
|
+
// sent after it can arrive on a player that has already stopped and reset.
|
|
313
|
+
buffer?.flush();
|
|
314
|
+
speaking = false;
|
|
315
|
+
lane?.sendControl(conversationId, {
|
|
316
|
+
type: "AUDIO_STATE",
|
|
317
|
+
state: "SPEECH_ENDED",
|
|
318
|
+
message: "Assistant finished speaking",
|
|
319
|
+
});
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
if (outSpec?.interruptOn && type === outSpec.interruptOn) {
|
|
323
|
+
/**
|
|
324
|
+
* DISCARD ONLY — no control message of its own.
|
|
325
|
+
*
|
|
326
|
+
* The client cuts playback on USER_SPEECH_STARTED (`playback.stopAll()` in the SDK's
|
|
327
|
+
* voice hook), and the VAD forwarding below already sends that on this very event. A
|
|
328
|
+
* second control here would be redundant at best.
|
|
329
|
+
*
|
|
330
|
+
* It was actively wrong when it sent SPEECH_ENDED: that only clears the assistant hint and
|
|
331
|
+
* marks the end of the utterance, so the buffered audio kept playing over the person.
|
|
332
|
+
*
|
|
333
|
+
* So the server's job on barge-in is exactly one thing: throw away the audio the person
|
|
334
|
+
* talked over, so it cannot be flushed later.
|
|
335
|
+
*/
|
|
336
|
+
buffer?.discard();
|
|
337
|
+
speaking = false;
|
|
338
|
+
// NOT returned. Barge-in is also a fact the workflow may want, so it still goes to
|
|
339
|
+
// the events table below.
|
|
340
|
+
}
|
|
341
|
+
// The vendor's own voice-activity detection, forwarded as the retired node forwarded it.
|
|
342
|
+
// Separate from `interruptOn` even when both name the same event: this tells the client the
|
|
343
|
+
// person is talking, that one throws away the audio they talked over.
|
|
344
|
+
if (audio.control?.userSpeaking && type === audio.control.userSpeaking) {
|
|
345
|
+
lane?.sendControl(conversationId, { type: "AUDIO_STATE", state: "USER_SPEECH_STARTED" });
|
|
346
|
+
}
|
|
347
|
+
if (audio.control?.userStopped && type === audio.control.userStopped) {
|
|
348
|
+
lane?.sendControl(conversationId, { type: "AUDIO_STATE", state: "USER_SPEECH_ENDED" });
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// EVERYTHING ELSE, to the events table, exactly as an sse reply. This is the path that
|
|
352
|
+
// reaches the client over MCP streaming.
|
|
353
|
+
await emitter.response(event, type);
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* A TOOL CALL, mid-conversation.
|
|
357
|
+
*
|
|
358
|
+
* The socket difference from `runToolLoop`: there are no TURNS. An HTTP agent sends a
|
|
359
|
+
* request, reads the tool calls out of the reply, and sends another request — so it can
|
|
360
|
+
* count turns and stop. Here the conversation never ended, so the result simply goes back
|
|
361
|
+
* down the same socket and the model carries on talking. `maxTurns` and
|
|
362
|
+
* `stuckAfterRepeats` have nothing to count, which is why this reads the exchange's
|
|
363
|
+
* `call`/`result` shape and none of its budget keys.
|
|
364
|
+
*/
|
|
365
|
+
if (exchange && tools && type === exchange.call?.match) {
|
|
366
|
+
// The SAME contract the HTTP loop reads — match / id / name / arguments — so a vendor's
|
|
367
|
+
// tool shape is described once and both paths agree. `when` narrows it where that event
|
|
368
|
+
// type carries more than calls.
|
|
369
|
+
const scope = { ...ctx, response: event } as any;
|
|
370
|
+
const wanted = !exchange.call.when || (await evaluate(exchange.call.when, scope));
|
|
371
|
+
if (wanted) {
|
|
372
|
+
const id = String(await evaluate(exchange.call.id, scope) ?? "");
|
|
373
|
+
const name = String(await evaluate(exchange.call.name, scope) ?? "");
|
|
374
|
+
// A JSON STRING, per the schema, which is the trap: handed over unparsed the tool gets
|
|
375
|
+
// a string where it expected its parameters.
|
|
376
|
+
const rawArgs = String((await evaluate(exchange.call.arguments, scope)) ?? "{}");
|
|
377
|
+
if (name) {
|
|
378
|
+
let output: string;
|
|
379
|
+
// A lookup takes 8-9s, and silence on a call reads as a dropped line. The client,
|
|
380
|
+
// the lookup-indicator and the docs all had this wired already; nothing ever sent it.
|
|
381
|
+
lane?.sendControl(conversationId, {
|
|
382
|
+
type: "AUDIO_STATE",
|
|
383
|
+
state: "TOOL_USE",
|
|
384
|
+
metadata: { toolName: name },
|
|
385
|
+
});
|
|
386
|
+
// Started BEFORE the call, so the timeline bar measures the wait the caller
|
|
387
|
+
// actually sat through, not the bookkeeping after it.
|
|
388
|
+
const toolStart = Date.now();
|
|
389
|
+
let toolOk = true;
|
|
390
|
+
try {
|
|
391
|
+
const args = JSON.parse(rawArgs || "{}");
|
|
392
|
+
/**
|
|
393
|
+
* THE SAME ABSORBER the HTTP loop uses, which is the point of it being one
|
|
394
|
+
* function: a voice call that discovers an app must open its page exactly as a
|
|
395
|
+
* typed one does, and the result must be leaned before it goes back — on a
|
|
396
|
+
* voice call every extra token is latency the person hears.
|
|
397
|
+
*/
|
|
398
|
+
const absorbed = await tools.absorb(name, await tools.call(name, args));
|
|
399
|
+
/**
|
|
400
|
+
* THE TOOL LIST IS DISCOVERED, NOT DECLARED: a search returns rows pointing at apps,
|
|
401
|
+
* each of which becomes a tool. The handshake's tools are only the seed.
|
|
402
|
+
*
|
|
403
|
+
* These used to be dropped on the claim that a session's tool list is fixed at open.
|
|
404
|
+
* That was true of this code, never of the vendor: verified live 2026-08-12, a
|
|
405
|
+
* mid-call `session.update` is accepted, MERGES, and the tool is callable next turn.
|
|
406
|
+
* Sent immediately — `session.update` is not gated on an idle conversation, only
|
|
407
|
+
* `response.create` is.
|
|
408
|
+
*/
|
|
409
|
+
/**
|
|
410
|
+
* A SKILL COMING INTO FORCE, mid-call.
|
|
411
|
+
*
|
|
412
|
+
* The socket's difference from the HTTP loop, and the whole reason this needs
|
|
413
|
+
* saying out loud: the loop rebuilds its prompt every turn, so a skill it
|
|
414
|
+
* absorbs is simply in the next request for free. A socket sent its
|
|
415
|
+
* instructions ONCE, at the handshake, so a skill absorbed here changes
|
|
416
|
+
* nothing at all unless it is announced.
|
|
417
|
+
*
|
|
418
|
+
* `session.update` MERGES — the same fact `offer` relies on to grow the tool
|
|
419
|
+
* list mid-call, verified live 2026-08-12 — so sending instructions alone
|
|
420
|
+
* leaves voice, modalities and turn detection untouched.
|
|
421
|
+
*
|
|
422
|
+
* ONLY WHEN IT CHANGED. A re-send on every tool call would re-state the whole
|
|
423
|
+
* system prompt several times a minute for no reason, and on a voice call
|
|
424
|
+
* anything sent is latency somebody hears.
|
|
425
|
+
*/
|
|
426
|
+
const moved = applyInForce(skills, absorbed.skills as InForceSkill[] | undefined, node, ctx);
|
|
427
|
+
if (moved.changed) {
|
|
428
|
+
if (exchange.reinstruct) {
|
|
429
|
+
const again = await message(exchange.reinstruct, ctx, { skillsText: renderSkillsText(skills) });
|
|
430
|
+
for (const m of ([] as any[]).concat(again ?? [])) if (m) send(m);
|
|
431
|
+
console.log(
|
|
432
|
+
`[manifests] ${node.type}: re-sent instructions after ${moved.added.length} skill(s) in force, ${moved.dropped.length} cleared`,
|
|
433
|
+
);
|
|
434
|
+
} else {
|
|
435
|
+
// Named, not silent. A package whose skills resolve but can never reach
|
|
436
|
+
// the model is the exact failure this whole path existed to stop.
|
|
437
|
+
console.log(
|
|
438
|
+
`[manifests] ${node.type}: ${moved.added.length} skill(s) in force but this package's toolExchange declares no 'reinstruct' — the model will not be told`,
|
|
439
|
+
);
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
if (absorbed.minted.length) {
|
|
444
|
+
for (const tool of absorbed.minted) live.push(await evaluate(exchange.tool, { tool } as any));
|
|
445
|
+
if (exchange.offer) {
|
|
446
|
+
const grown = await message(exchange.offer, ctx, { tools: live });
|
|
447
|
+
for (const m of ([] as any[]).concat(grown ?? [])) if (m) send(m);
|
|
448
|
+
console.log(
|
|
449
|
+
`[manifests] ${node.type}: re-offered ${live.length} tool(s) after ${absorbed.minted.length} discovered by ${name}`,
|
|
450
|
+
);
|
|
451
|
+
} else {
|
|
452
|
+
console.log(
|
|
453
|
+
`[manifests] ${node.type}: ${absorbed.minted.length} tool(s) discovered mid-call and not registered — this package's toolExchange declares no 'offer'`,
|
|
454
|
+
);
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
output = absorbed.content;
|
|
458
|
+
} catch (err: any) {
|
|
459
|
+
// Reported TO THE MODEL rather than thrown. A failed tool on a live call should let
|
|
460
|
+
// the assistant say it could not do the thing, not drop the conversation.
|
|
461
|
+
toolOk = false;
|
|
462
|
+
output = `Tool "${name}" failed: ${err?.message ?? err}`;
|
|
463
|
+
}
|
|
464
|
+
// The same timeline row an HTTP agent's tool call draws. `runToolLoop` has recorded
|
|
465
|
+
// these since the migration; the socket never called it, so voice tools were invisible.
|
|
466
|
+
recordToolTrace(node, ctx, name, parseMaybeJson(rawArgs), output, toolStart, toolOk);
|
|
467
|
+
// After the catch, so a tool that THREW still stops the dots. Spinning forever is
|
|
468
|
+
// worse than no indicator: it promises something still coming.
|
|
469
|
+
lane?.sendControl(conversationId, { type: "AUDIO_STATE", state: "TOOL_USE_COMPLETED" });
|
|
470
|
+
// PARSED for the connector lane only (readable mcpResult, same as the HTTP
|
|
471
|
+
// loop); the model message two lines down keeps the raw `output` string —
|
|
472
|
+
// the vendor wire requires text.
|
|
473
|
+
await emitter.tool({ name, args: parseMaybeJson(rawArgs), output: parseMaybeJson(output) });
|
|
474
|
+
// A LIST, because a vendor may need more than one message: OpenAI adds the item and
|
|
475
|
+
// then asks for a response, and without the second the model never speaks the result.
|
|
476
|
+
const back = await message(exchange.result, ctx, { call: { id, name, output } });
|
|
477
|
+
for (const m of ([] as any[]).concat(back ?? [])) send(m);
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// Reactive sends: a tool result going back, a response asked for. The triggering event
|
|
483
|
+
// is in scope as `response`, as in an events row.
|
|
484
|
+
for (const rule of call.send ?? []) {
|
|
485
|
+
if (rule.on !== type) continue;
|
|
486
|
+
// AWAITED, for the same reason the audio path is: `evaluate` is async, so `!promise` is
|
|
487
|
+
// always false and an un-awaited `when` never blocks anything. Every reactive send would
|
|
488
|
+
// 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;
|
|
490
|
+
// 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 })) ?? [])) {
|
|
492
|
+
if (m) send(m);
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
} catch (err: any) {
|
|
496
|
+
onError?.(`handling ${type} failed: ${err?.message ?? err}`, err);
|
|
497
|
+
}
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
ws.on("error", (err: any) => {
|
|
501
|
+
onError?.(`socket error: ${err?.message ?? err}`);
|
|
502
|
+
buffer?.discard();
|
|
503
|
+
finish({ reason: "error", error: err?.message ?? String(err) });
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
ws.on("close", () => {
|
|
507
|
+
buffer?.flush();
|
|
508
|
+
finish({ reason: closing ? "ended" : "closed" });
|
|
509
|
+
});
|
|
510
|
+
});
|
|
511
|
+
}
|