@voicethere/agent 0.6.0 → 0.7.0

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.
@@ -10891,6 +10891,18 @@ function isRecordingControlAckMessage(value) {
10891
10891
  const msg = value;
10892
10892
  return msg.type === "recording_control_ack" && typeof msg.requestId === "string";
10893
10893
  }
10894
+ function isMixControlAckMessage(value) {
10895
+ if (!value || typeof value !== "object")
10896
+ return false;
10897
+ const msg = value;
10898
+ return msg.type === "mix_control_ack" && typeof msg.requestId === "string";
10899
+ }
10900
+ function isSttControlAckMessage(value) {
10901
+ if (!value || typeof value !== "object")
10902
+ return false;
10903
+ const msg = value;
10904
+ return msg.type === "stt_control_ack" && typeof msg.requestId === "string";
10905
+ }
10894
10906
  function isWebhookMessage(value) {
10895
10907
  if (!value || typeof value !== "object")
10896
10908
  return false;
@@ -10933,7 +10945,7 @@ function isParentMessage(value) {
10933
10945
  if (!value || typeof value !== "object")
10934
10946
  return false;
10935
10947
  const msg = value;
10936
- return msg.type === "session_start" || msg.type === "speech_event" || msg.type === "session_end" || msg.type === "data_channel_message" || msg.type === "data_channel_binary" || msg.type === "idle_timeout" || msg.type === "recording_control_ack" || msg.type === "webhook";
10948
+ return msg.type === "session_start" || msg.type === "speech_event" || msg.type === "session_end" || msg.type === "data_channel_message" || msg.type === "data_channel_binary" || msg.type === "idle_timeout" || msg.type === "recording_control_ack" || msg.type === "mix_control_ack" || msg.type === "stt_control_ack" || msg.type === "webhook";
10937
10949
  }
10938
10950
  function isSessionScopedParentMessage(value) {
10939
10951
  return isParentMessage(value) && !isWebhookMessage(value);
@@ -10947,8 +10959,12 @@ function parseDataChannelPayload(raw) {
10947
10959
  }
10948
10960
  var peerEnvBySessionId = /* @__PURE__ */ new Map();
10949
10961
  var recordingAvailableBySessionId = /* @__PURE__ */ new Map();
10962
+ var mixAvailableBySessionId = /* @__PURE__ */ new Map();
10963
+ var ttsPoseAvailableBySessionId = /* @__PURE__ */ new Map();
10950
10964
  var endedSessionIds = /* @__PURE__ */ new Set();
10951
10965
  var pendingRecordingAcks = /* @__PURE__ */ new Map();
10966
+ var pendingMixAcks = /* @__PURE__ */ new Map();
10967
+ var pendingSttAcks = /* @__PURE__ */ new Map();
10952
10968
  function handleRecordingControlAck(message) {
10953
10969
  const pending = pendingRecordingAcks.get(message.requestId);
10954
10970
  if (!pending)
@@ -10961,6 +10977,30 @@ function handleRecordingControlAck(message) {
10961
10977
  requestId: message.requestId
10962
10978
  });
10963
10979
  }
10980
+ function handleMixControlAck(message) {
10981
+ const pending = pendingMixAcks.get(message.requestId);
10982
+ if (!pending)
10983
+ return;
10984
+ clearTimeout(pending.timer);
10985
+ pendingMixAcks.delete(message.requestId);
10986
+ pending.resolve({
10987
+ ok: message.ok,
10988
+ reason: message.reason,
10989
+ requestId: message.requestId
10990
+ });
10991
+ }
10992
+ function handleSttControlAck(message) {
10993
+ const pending = pendingSttAcks.get(message.requestId);
10994
+ if (!pending)
10995
+ return;
10996
+ clearTimeout(pending.timer);
10997
+ pendingSttAcks.delete(message.requestId);
10998
+ pending.resolve({
10999
+ ok: message.ok,
11000
+ reason: message.reason,
11001
+ requestId: message.requestId
11002
+ });
11003
+ }
10964
11004
  function clearPendingRecordingAcksForSession(sessionId, reason) {
10965
11005
  for (const [requestId, pending] of pendingRecordingAcks) {
10966
11006
  if (pending.sessionId !== sessionId)
@@ -11016,6 +11056,11 @@ function allowOutboundForSession(sessionId) {
11016
11056
  return queue.isLive(sessionId);
11017
11057
  }
11018
11058
  function sendParentMessage(message) {
11059
+ const msgType = message && typeof message === "object" && "type" in message ? message.type : void 0;
11060
+ if (msgType === "mix_control" || msgType === "stt_control") {
11061
+ process.send?.(message);
11062
+ return;
11063
+ }
11019
11064
  const sessionId = message && typeof message === "object" && "sessionId" in message && typeof message.sessionId === "string" ? message.sessionId : void 0;
11020
11065
  if (!allowOutboundForSession(sessionId))
11021
11066
  return;
@@ -11097,6 +11142,8 @@ async function handleParentMessage(message, handlers) {
11097
11142
  endedSessionIds.delete(message.sessionId);
11098
11143
  peerEnvBySessionId.set(message.sessionId, message.env);
11099
11144
  recordingAvailableBySessionId.set(message.sessionId, message.recordingAvailable ?? false);
11145
+ mixAvailableBySessionId.set(message.sessionId, message.mixAvailable ?? false);
11146
+ ttsPoseAvailableBySessionId.set(message.sessionId, message.ttsPoseAvailable ?? false);
11100
11147
  const sessionStartInitDelayMs = resolveSessionStartInitDelayMs();
11101
11148
  if (sessionStartInitDelayMs > 0) {
11102
11149
  await new Promise((resolve) => setTimeout(resolve, sessionStartInitDelayMs));
@@ -11104,7 +11151,9 @@ async function handleParentMessage(message, handlers) {
11104
11151
  await (handlers.onClientJoin ?? handlers.onSessionStart)?.({
11105
11152
  sessionId: message.sessionId,
11106
11153
  env: message.env,
11107
- recordingAvailable: message.recordingAvailable ?? false
11154
+ recordingAvailable: message.recordingAvailable ?? false,
11155
+ mixAvailable: message.mixAvailable ?? false,
11156
+ ttsPoseAvailable: message.ttsPoseAvailable ?? false
11108
11157
  });
11109
11158
  sendParentMessage({
11110
11159
  type: "session_start_ack",
@@ -11142,6 +11191,8 @@ async function handleParentMessage(message, handlers) {
11142
11191
  clearPendingRecordingAcksForSession(message.sessionId, "session_ended");
11143
11192
  peerEnvBySessionId.delete(message.sessionId);
11144
11193
  recordingAvailableBySessionId.delete(message.sessionId);
11194
+ mixAvailableBySessionId.delete(message.sessionId);
11195
+ ttsPoseAvailableBySessionId.delete(message.sessionId);
11145
11196
  await (handlers.onClientLeave ?? handlers.onSessionEnd)?.({
11146
11197
  sessionId: message.sessionId
11147
11198
  });
@@ -11161,6 +11212,14 @@ function defineAgent(handlers) {
11161
11212
  handleRecordingControlAck(message);
11162
11213
  return;
11163
11214
  }
11215
+ if (isMixControlAckMessage(message)) {
11216
+ handleMixControlAck(message);
11217
+ return;
11218
+ }
11219
+ if (isSttControlAckMessage(message)) {
11220
+ handleSttControlAck(message);
11221
+ return;
11222
+ }
11164
11223
  if (isWebhookMessage(message)) {
11165
11224
  void agentStartReady.then(() => handleWebhookMessage(message, handlers));
11166
11225
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@voicethere/agent",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "VoiceThere customer agent SDK — IPC types and runtime helpers for sandboxed child bundles",
5
5
  "type": "module",
6
6
  "exports": {
@@ -95,9 +95,9 @@
95
95
  "@node-webrtc-rust/sdk": ">=0.6.5"
96
96
  },
97
97
  "devDependencies": {
98
- "@node-webrtc-rust/helpers": "0.7.4",
99
- "@node-webrtc-rust/sdk": "0.7.4",
100
- "@node-webrtc-rust/signaling": "0.7.4",
98
+ "@node-webrtc-rust/helpers": "0.8.0",
99
+ "@node-webrtc-rust/sdk": "0.8.0",
100
+ "@node-webrtc-rust/signaling": "0.8.0",
101
101
  "@types/node": "22.20.1",
102
102
  "http-server": "14.1.1",
103
103
  "ioredis": "5.11.1",
@@ -15,10 +15,10 @@ import {
15
15
 
16
16
  ## Product vs e2e
17
17
 
18
- | Kind | Dashboard create | Prebuilt seed bundle | Typical consumer |
19
- | ----------- | -------------------------------------------------------------------------------------------------------------------------- | ------------------------------------ | ----------------------- |
20
- | **product** | Yes (`echo`, `echo-dc`, `voice-starter`, `game-sync`, `voice-showcase`, `recording-consent`, `webhooks`, `webhooks-redis`) | Yes — `dist/templates/<id>/agent.js` | Platform project create |
21
- | **e2e** | No | No — build from sources at test time | `voicethere/e2e` smokes |
18
+ | Kind | Dashboard create | Prebuilt seed bundle | Typical consumer |
19
+ | ----------- | -------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------ | ----------------------- |
20
+ | **product** | Yes (`echo`, `echo-dc`, `voice-starter`, `game-sync`, `voice-showcase`, `recording-consent`, `positional-tts`, `webhooks`, `webhooks-redis`) | Yes — `dist/templates/<id>/agent.js` | Platform project create |
21
+ | **e2e** | No | No — build from sources at test time | `voicethere/e2e` smokes |
22
22
 
23
23
  Product templates always set `seedOnCreate: true`. CI fails if a product template is missing its prebuilt bundle after `npm run build`.
24
24
 
@@ -73,6 +73,12 @@ Demonstrates conversation recording consent on connect: asks whether recording i
73
73
 
74
74
  Sources: `recording-consent/agent.ts` (defineAgent wiring), `conversation.ts` (pure state machine).
75
75
 
76
+ ### `positional-tts/` (`positional-tts`)
77
+
78
+ Voice+Data demo — enables positional mixing and orbits each listener’s TTS speaker with `setTtsPose` on a ~50 ms timer. Speaks a short greeting on connect and echoes voice finals / chat so you hear panning while TTS plays. Requires Voice+Data runner mode (`isMixAvailable`).
79
+
80
+ Sources: `positional-tts/agent.ts` (defineAgent wiring), `positional-tts/orbit.ts` (pure circle helper for tests).
81
+
76
82
  ### `webhooks.ts` (`webhooks`)
77
83
 
78
84
  Inbound HTTP webhook sample — verifies `x-agent-webhook-signature` HMAC on the **raw body** with `AGENT_WEBHOOK_SIGNING_SECRET`, then `JSON.parse` and fans out to connected sessions via DataChannel + `speak`.
@@ -0,0 +1,224 @@
1
+ /**
2
+ * E2E agent for voice-data-mix-smoke — positional mix via data-channel commands.
3
+ *
4
+ * Protocol (JSON on control DC):
5
+ * - `{ type: "mix", action: "whoami" }`
6
+ * - `{ type: "mix", action: "create_group", groupId, clientIds }`
7
+ * - `{ type: "mix", action: "set_pose", clientId, pose }`
8
+ * - `{ type: "mix", action: "set_positional", enabled }`
9
+ * - `{ type: "mix", action: "list_clients" }` (optional)
10
+ *
11
+ * Acks: `{ type: "mix_ack", action, ok: true, ... }` or `{ ok: false, error }`.
12
+ * Ignores `ping` / chat strings (voice-control readiness). No TTS during smoke.
13
+ */
14
+ import {
15
+ MIX_REQUIRES_VOICE_PLUS_DATA,
16
+ createMixGroup,
17
+ defineAgent,
18
+ sendToClient,
19
+ setClientPose,
20
+ setPositionalMixing,
21
+ } from "@voicethere/agent";
22
+
23
+ /** E2E fixture marker — rewritten before each fixture upload when needed. */
24
+ export const FIXTURE_MARKER = "mix-smoke-fixture-a";
25
+
26
+ export type MixPose = {
27
+ position: { x: number; y: number; z: number };
28
+ orientation: { x: number; y: number; z: number; w: number };
29
+ };
30
+
31
+ export type MixCommand =
32
+ | { type: "mix"; action: "whoami" }
33
+ | {
34
+ type: "mix";
35
+ action: "create_group";
36
+ groupId: string;
37
+ clientIds: string[];
38
+ }
39
+ | { type: "mix"; action: "set_pose"; clientId: string; pose: MixPose }
40
+ | { type: "mix"; action: "set_positional"; enabled: boolean }
41
+ | { type: "mix"; action: "list_clients" };
42
+
43
+ export type MixAck =
44
+ | {
45
+ type: "mix_ack";
46
+ action: string;
47
+ ok: true;
48
+ sessionId?: string;
49
+ clientIds?: string[];
50
+ }
51
+ | { type: "mix_ack"; action: string; ok: false; error: string };
52
+
53
+ const connectedSessions = new Set<string>();
54
+ let mixAvailableForChild = false;
55
+
56
+ export function isMixCommand(message: unknown): message is MixCommand {
57
+ if (!message || typeof message !== "object") return false;
58
+ const record = message as { type?: unknown; action?: unknown };
59
+ if (record.type !== "mix" || typeof record.action !== "string") {
60
+ return false;
61
+ }
62
+ switch (record.action) {
63
+ case "whoami":
64
+ case "list_clients":
65
+ return true;
66
+ case "create_group": {
67
+ const group = message as {
68
+ groupId?: unknown;
69
+ clientIds?: unknown;
70
+ };
71
+ return (
72
+ typeof group.groupId === "string" &&
73
+ group.groupId.trim().length > 0 &&
74
+ Array.isArray(group.clientIds) &&
75
+ group.clientIds.every((id) => typeof id === "string")
76
+ );
77
+ }
78
+ case "set_pose": {
79
+ const poseMsg = message as { clientId?: unknown; pose?: unknown };
80
+ return (
81
+ typeof poseMsg.clientId === "string" &&
82
+ poseMsg.clientId.trim().length > 0 &&
83
+ isMixPose(poseMsg.pose)
84
+ );
85
+ }
86
+ case "set_positional": {
87
+ const positional = message as { enabled?: unknown };
88
+ return typeof positional.enabled === "boolean";
89
+ }
90
+ default:
91
+ return false;
92
+ }
93
+ }
94
+
95
+ export function isMixPose(value: unknown): value is MixPose {
96
+ if (!value || typeof value !== "object") return false;
97
+ const pose = value as MixPose;
98
+ return isVec3(pose.position) && isQuat(pose.orientation);
99
+ }
100
+
101
+ function isVec3(value: unknown): value is { x: number; y: number; z: number } {
102
+ if (!value || typeof value !== "object") return false;
103
+ const v = value as { x?: unknown; y?: unknown; z?: unknown };
104
+ return (
105
+ typeof v.x === "number" &&
106
+ typeof v.y === "number" &&
107
+ typeof v.z === "number"
108
+ );
109
+ }
110
+
111
+ function isQuat(
112
+ value: unknown,
113
+ ): value is { x: number; y: number; z: number; w: number } {
114
+ if (!value || typeof value !== "object") return false;
115
+ const q = value as { x?: unknown; y?: unknown; z?: unknown; w?: unknown };
116
+ return (
117
+ typeof q.x === "number" &&
118
+ typeof q.y === "number" &&
119
+ typeof q.z === "number" &&
120
+ typeof q.w === "number"
121
+ );
122
+ }
123
+
124
+ function ackOk(
125
+ sessionId: string,
126
+ action: string,
127
+ extra?: { sessionId?: string; clientIds?: string[] },
128
+ ): void {
129
+ sendToClient(sessionId, {
130
+ type: "mix_ack",
131
+ action,
132
+ ok: true,
133
+ ...extra,
134
+ } satisfies MixAck);
135
+ }
136
+
137
+ function ackError(sessionId: string, action: string, error: string): void {
138
+ sendToClient(sessionId, {
139
+ type: "mix_ack",
140
+ action,
141
+ ok: false,
142
+ error,
143
+ } satisfies MixAck);
144
+ }
145
+
146
+ function requireMix(sessionId: string, action: string): boolean {
147
+ if (mixAvailableForChild) {
148
+ return true;
149
+ }
150
+ ackError(sessionId, action, MIX_REQUIRES_VOICE_PLUS_DATA);
151
+ return false;
152
+ }
153
+
154
+ async function handleMixCommand(
155
+ sessionId: string,
156
+ command: MixCommand,
157
+ ): Promise<void> {
158
+ const { action } = command;
159
+
160
+ switch (action) {
161
+ case "whoami":
162
+ ackOk(sessionId, action, { sessionId });
163
+ return;
164
+ case "list_clients":
165
+ ackOk(sessionId, action, {
166
+ clientIds: [...connectedSessions],
167
+ });
168
+ return;
169
+ case "create_group": {
170
+ if (!requireMix(sessionId, action)) return;
171
+ const result = await createMixGroup({
172
+ id: command.groupId,
173
+ clientIds: command.clientIds,
174
+ });
175
+ if (!result.ok) {
176
+ ackError(sessionId, action, result.reason ?? "create_group failed");
177
+ return;
178
+ }
179
+ ackOk(sessionId, action);
180
+ return;
181
+ }
182
+ case "set_pose": {
183
+ if (!requireMix(sessionId, action)) return;
184
+ const result = await setClientPose(command.clientId, command.pose);
185
+ if (!result.ok) {
186
+ ackError(sessionId, action, result.reason ?? "set_pose failed");
187
+ return;
188
+ }
189
+ ackOk(sessionId, action);
190
+ return;
191
+ }
192
+ case "set_positional": {
193
+ if (!requireMix(sessionId, action)) return;
194
+ const result = await setPositionalMixing(command.enabled);
195
+ if (!result.ok) {
196
+ ackError(sessionId, action, result.reason ?? "set_positional failed");
197
+ return;
198
+ }
199
+ ackOk(sessionId, action);
200
+ return;
201
+ }
202
+ default:
203
+ ackError(sessionId, "unknown", "unsupported mix action");
204
+ }
205
+ }
206
+
207
+ defineAgent({
208
+ onClientJoin(ctx) {
209
+ mixAvailableForChild = ctx.mixAvailable;
210
+ connectedSessions.add(ctx.sessionId);
211
+ },
212
+
213
+ onClientLeave({ sessionId }) {
214
+ connectedSessions.delete(sessionId);
215
+ },
216
+
217
+ onDataChannelMessage(ctx) {
218
+ if (isMixCommand(ctx.message)) {
219
+ void handleMixCommand(ctx.sessionId, ctx.message);
220
+ return;
221
+ }
222
+ // Ignore ping / chat — voice-control readiness only.
223
+ },
224
+ });
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Positional TTS template — each listener hears TTS orbiting their own pose.
3
+ *
4
+ * Requires voice or Voice+Data runner mode ({@link isTtsPoseAvailable}). Mix group
5
+ * APIs still require Voice+Data ({@link isMixAvailable}).
6
+ *
7
+ * Build:
8
+ * npx @voicethere/agent build --entry templates/positional-tts/agent.ts
9
+ */
10
+ import {
11
+ agentLog,
12
+ clearTtsPose,
13
+ defineAgent,
14
+ isTtsPoseAvailable,
15
+ parseChatText,
16
+ setPositionalMixing,
17
+ setTtsPose,
18
+ speak,
19
+ } from "@voicethere/agent";
20
+
21
+ import { orbitTtsPose } from "./orbit.js";
22
+
23
+ const ORBIT_INTERVAL_MS = 50;
24
+
25
+ const orbitTimers = new Map<string, ReturnType<typeof setInterval>>();
26
+ const sessionStartTimes = new Map<string, number>();
27
+
28
+ function clearOrbit(sessionId: string): void {
29
+ const timer = orbitTimers.get(sessionId);
30
+ if (timer) {
31
+ clearInterval(timer);
32
+ orbitTimers.delete(sessionId);
33
+ }
34
+ sessionStartTimes.delete(sessionId);
35
+ }
36
+
37
+ async function safeMixCall(
38
+ sessionId: string,
39
+ label: string,
40
+ fn: () => Promise<{ ok: boolean; reason?: string }>,
41
+ ): Promise<void> {
42
+ try {
43
+ const result = await fn();
44
+ if (!result.ok) {
45
+ agentLog(
46
+ "warn",
47
+ `positional-tts ${label} failed: ${result.reason}`,
48
+ sessionId,
49
+ );
50
+ }
51
+ } catch (err) {
52
+ agentLog(
53
+ "warn",
54
+ `positional-tts ${label} error: ${String(err)}`,
55
+ sessionId,
56
+ );
57
+ }
58
+ }
59
+
60
+ defineAgent({
61
+ onAgentStart() {
62
+ orbitTimers.clear();
63
+ sessionStartTimes.clear();
64
+ },
65
+
66
+ onSessionStart(ctx) {
67
+ const { sessionId } = ctx;
68
+
69
+ if (!isTtsPoseAvailable(ctx)) {
70
+ agentLog(
71
+ "warn",
72
+ "positional-tts requires voice or Voice+Data — TTS pose APIs unavailable",
73
+ sessionId,
74
+ );
75
+ return;
76
+ }
77
+
78
+ void (async () => {
79
+ await safeMixCall(sessionId, "setPositionalMixing", () =>
80
+ setPositionalMixing(true),
81
+ );
82
+
83
+ const startMs = Date.now();
84
+ sessionStartTimes.set(sessionId, startMs);
85
+
86
+ const timer = setInterval(() => {
87
+ const started = sessionStartTimes.get(sessionId);
88
+ if (!started) return;
89
+ const elapsedSec = (Date.now() - started) / 1000;
90
+ void safeMixCall(sessionId, "setTtsPose", () =>
91
+ setTtsPose(sessionId, orbitTtsPose(elapsedSec)),
92
+ );
93
+ }, ORBIT_INTERVAL_MS);
94
+
95
+ orbitTimers.set(sessionId, timer);
96
+ speak(sessionId, "I'll circle around you.");
97
+ })();
98
+ },
99
+
100
+ onUserSpeechFinal({ sessionId, text }) {
101
+ speak(sessionId, `You said: ${text}`);
102
+ },
103
+
104
+ onDataChannelMessage(ctx) {
105
+ const text = parseChatText(ctx.message);
106
+ if (!text) return;
107
+ speak(ctx.sessionId, `You said: ${text}`);
108
+ },
109
+
110
+ onSessionEnd({ sessionId }) {
111
+ clearOrbit(sessionId);
112
+ void safeMixCall(sessionId, "clearTtsPose", () => clearTtsPose(sessionId));
113
+ agentLog("info", `positional-tts session_end ${sessionId}`, sessionId);
114
+ },
115
+ });
@@ -0,0 +1,11 @@
1
+ import type { MixPose } from "@voicethere/agent";
2
+
3
+ /** Circle in the XZ plane around the listener origin (Y-up, look −Z). */
4
+ export function orbitTtsPose(elapsedSec: number, radius = 2): MixPose {
5
+ const x = Math.cos(elapsedSec) * radius;
6
+ const z = Math.sin(elapsedSec) * radius;
7
+ return {
8
+ position: { x, y: 0, z },
9
+ orientation: { x: 0, y: 0, z: 0, w: 1 },
10
+ };
11
+ }