@juspay/neurolink 12.49.20 → 12.51.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.
Files changed (65) hide show
  1. package/CHANGELOG.md +3 -3
  2. package/dist/browser/neurolink.min.js +671 -671
  3. package/dist/cli/commands/setup.d.ts +7 -5
  4. package/dist/cli/commands/setup.js +10 -8
  5. package/dist/constants/enums.d.ts +7 -15
  6. package/dist/constants/enums.js +8 -16
  7. package/dist/core/conversationMemoryManager.js +10 -4
  8. package/dist/core/redisConversationMemoryManager.js +6 -5
  9. package/dist/factories/providerDescriptors.d.ts +17 -3
  10. package/dist/factories/providerDescriptors.js +81 -56
  11. package/dist/factories/providerRegistry.d.ts +9 -1
  12. package/dist/factories/providerRegistry.js +19 -9
  13. package/dist/models/manifestRegistry.js +40 -3
  14. package/dist/neurolink.d.ts +52 -1
  15. package/dist/neurolink.js +195 -0
  16. package/dist/providers/catalog/index.generated.d.ts +3 -1
  17. package/dist/providers/catalog/index.generated.js +11 -0
  18. package/dist/providers/catalog/laya.json +107 -0
  19. package/dist/providers/catalog/loader.d.ts +7 -4
  20. package/dist/providers/catalog/loader.js +10 -3
  21. package/dist/providers/catalog/provider-catalog.schema.json +218 -11
  22. package/dist/providers/catalog/schema.d.ts +11 -9
  23. package/dist/providers/catalog/schema.js +333 -59
  24. package/dist/providers/configuredDecision.d.ts +23 -0
  25. package/dist/providers/configuredDecision.js +111 -0
  26. package/dist/providers/decide/baseUrl.d.ts +2 -0
  27. package/dist/providers/decide/baseUrl.js +14 -0
  28. package/dist/providers/decide/errors.d.ts +8 -0
  29. package/dist/providers/decide/errors.js +57 -0
  30. package/dist/providers/decide/formatters.d.ts +2 -0
  31. package/dist/providers/decide/formatters.js +4 -0
  32. package/dist/providers/decide/paths.d.ts +1 -0
  33. package/dist/providers/decide/paths.js +21 -0
  34. package/dist/providers/decide/template.d.ts +3 -0
  35. package/dist/providers/decide/template.js +13 -0
  36. package/dist/types/conversation.d.ts +109 -0
  37. package/dist/types/livekit.d.ts +246 -3
  38. package/dist/types/providerCatalog.d.ts +74 -1
  39. package/dist/types/providerCatalog.generated.d.ts +2 -2
  40. package/dist/types/providers.d.ts +12 -9
  41. package/dist/types/sdk.d.ts +1 -1
  42. package/dist/types/stream.d.ts +8 -1
  43. package/dist/utils/conversationTurnIdentity.d.ts +24 -0
  44. package/dist/utils/conversationTurnIdentity.js +81 -0
  45. package/dist/utils/modelChoices.js +11 -25
  46. package/dist/utils/providerConfig.d.ts +0 -5
  47. package/dist/utils/providerConfig.js +0 -17
  48. package/dist/voice/livekit/brain.js +10 -7
  49. package/dist/voice/livekit/index.d.ts +2 -0
  50. package/dist/voice/livekit/index.js +2 -0
  51. package/dist/voice/livekit/memoryBinding.d.ts +47 -0
  52. package/dist/voice/livekit/memoryBinding.js +152 -0
  53. package/dist/voice/livekit/realtimeMcpTools.js +20 -1
  54. package/dist/voice/livekit/realtimeMemory.d.ts +22 -0
  55. package/dist/voice/livekit/realtimeMemory.js +111 -0
  56. package/dist/voice/livekit/realtimeVoiceAgent.js +40 -1
  57. package/dist/voice/livekit/turnRecorder.d.ts +38 -0
  58. package/dist/voice/livekit/turnRecorder.js +362 -0
  59. package/dist/voice/livekit/voiceAgent.js +43 -5
  60. package/docs-site/static/search-index.json +8 -7
  61. package/package.json +7 -2
  62. package/dist/models/manifests/laya.d.ts +0 -12
  63. package/dist/models/manifests/laya.js +0 -54
  64. package/dist/providers/laya.d.ts +0 -40
  65. package/dist/providers/laya.js +0 -152
@@ -19,6 +19,7 @@ import { ensureVertexAdc, clearGeminiApiKeyEnv } from "./vertexAuth.js";
19
19
  import { readCallContextFromRoom } from "./roomContext.js";
20
20
  import { attachRealtimeEventBridge } from "./realtimeEventBridge.js";
21
21
  import { buildRealtimeMcpTools } from "./realtimeMcpTools.js";
22
+ import { setUpRealtimeCallMemory } from "./realtimeMemory.js";
22
23
  const realtimeLogEventSchema = z.object({
23
24
  level: z.enum(["debug", "info", "warn", "error"]),
24
25
  message: z.string(),
@@ -144,6 +145,7 @@ export function defineRealtimeVoiceAgent(config = {}) {
144
145
  const eventsTopic = config.eventsTopic;
145
146
  const controlTopic = config.controlTopic;
146
147
  const onLog = config.onLog;
148
+ const memoryConfig = config.memory;
147
149
  async function entry(ctx) {
148
150
  // The Gemini Live WS authenticates to Vertex via ADC; materialise it and
149
151
  // force ADC (not an API key) auth before any realtime connection.
@@ -192,6 +194,9 @@ export function defineRealtimeVoiceAgent(config = {}) {
192
194
  location: cfg.location,
193
195
  ms: sinceConnect(),
194
196
  });
197
+ // Decide whether this call may be remembered BEFORE the lifecycle installs
198
+ // its process reaper, so the shutdown flush is registered first.
199
+ const memory = await setUpRealtimeCallMemory(ctx, memoryConfig);
195
200
  await installRealtimeJobLifecycle(ctx, cfg);
196
201
  const { voice } = await import("@livekit/agents");
197
202
  const google = await import("@livekit/agents-plugin-google");
@@ -205,6 +210,7 @@ export function defineRealtimeVoiceAgent(config = {}) {
205
210
  ctx.addShutdownCallback(async () => {
206
211
  bridge.dispose();
207
212
  });
213
+ memory?.setPublisher(bridge.publishEvent);
208
214
  let agentTools;
209
215
  if (cfg.toolsEnabled) {
210
216
  const { authToken, xContext } = readCallContextFromRoom(ctx.room.metadata);
@@ -222,6 +228,12 @@ export function defineRealtimeVoiceAgent(config = {}) {
222
228
  xContext,
223
229
  publishEvent: bridge.publishEvent,
224
230
  requestConfirmation: bridge.requestConfirmation,
231
+ ...(memory
232
+ ? {
233
+ onToolStart: (call) => memory.recorder.toolStart(call),
234
+ onToolEnd: (call) => memory.recorder.toolEnd(call),
235
+ }
236
+ : {}),
225
237
  });
226
238
  agentTools = toolset.tools;
227
239
  logger.info("realtime.mcp.enabled", {
@@ -265,9 +277,23 @@ export function defineRealtimeVoiceAgent(config = {}) {
265
277
  const session = new voice.AgentSession({
266
278
  llm: new google.realtime.RealtimeModel(modelOptions),
267
279
  });
280
+ // The conversation so far, so a call that appends to an open conversation
281
+ // starts knowing it. Empty (and omitted) when there is nothing to seed.
282
+ let seededChatCtx;
283
+ if (memory && memory.seed.length > 0) {
284
+ const { llm } = await import("@livekit/agents");
285
+ seededChatCtx = llm.ChatContext.empty();
286
+ for (const message of memory.seed) {
287
+ seededChatCtx.addMessage({
288
+ role: message.role,
289
+ content: message.content,
290
+ });
291
+ }
292
+ }
268
293
  const agent = new voice.Agent({
269
294
  instructions: cfg.systemPrompt,
270
295
  ...(agentTools ? { tools: agentTools } : {}),
296
+ ...(seededChatCtx ? { chatCtx: seededChatCtx } : {}),
271
297
  });
272
298
  agent.transcriptionNode = async (textStream, _modelSettings) => {
273
299
  const [forUi, forDownstream] = textStream.tee();
@@ -290,6 +316,7 @@ export function defineRealtimeVoiceAgent(config = {}) {
290
316
  });
291
317
  }
292
318
  bridge.publishEvent("text", { delta: chunk });
319
+ memory?.recorder.assistantDelta(chunk);
293
320
  }
294
321
  }
295
322
  }
@@ -328,6 +355,10 @@ export function defineRealtimeVoiceAgent(config = {}) {
328
355
  text: ev.transcript,
329
356
  final: ev.isFinal,
330
357
  });
358
+ memory?.recorder.userTranscript({
359
+ text: ev.transcript,
360
+ final: ev.isFinal,
361
+ });
331
362
  });
332
363
  session.on(voice.AgentSessionEventTypes.AgentStateChanged, (ev) => {
333
364
  logger.debug("realtime.session.stateChanged", {
@@ -342,7 +373,14 @@ export function defineRealtimeVoiceAgent(config = {}) {
342
373
  ev.item.role === "assistant" &&
343
374
  ev.item.textContent) {
344
375
  logger.debug("realtime.session.turnDone", { ms: sinceConnect() });
345
- bridge.publishEvent("done", {});
376
+ // The id the stored messages carry, so a client can merge its live
377
+ // copy of the turn with history instead of showing it twice.
378
+ bridge.publishEvent("done", memory ? { turnId: ev.item.id } : {});
379
+ memory?.recorder.assistantMessage({
380
+ id: ev.item.id,
381
+ text: ev.item.textContent,
382
+ interrupted: ev.item.interrupted,
383
+ });
346
384
  }
347
385
  });
348
386
  session.on(voice.AgentSessionEventTypes.Error, (ev) => {
@@ -357,6 +395,7 @@ export function defineRealtimeVoiceAgent(config = {}) {
357
395
  bridge.publishEvent("status", { state: "listening" });
358
396
  if (cfg.greeting.trim().length > 0) {
359
397
  logger.info("realtime.session.greeting", { ms: sinceConnect() });
398
+ memory?.recorder.expectGreeting();
360
399
  session.generateReply({ instructions: cfg.greeting });
361
400
  }
362
401
  }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Turns a live voice call's event stream into stored conversation turns.
3
+ *
4
+ * A realtime voice model reports a call as separate, loosely ordered events:
5
+ * final user transcripts, assistant text deltas, tool calls, and the added
6
+ * assistant message that closes a reply. This recorder groups them into
7
+ * exchanges and hands each finished exchange to a `write` function (normally
8
+ * `NeuroLink.recordConversationTurn`).
9
+ *
10
+ * It knows nothing about LiveKit, so it can be driven by a synthetic event
11
+ * stream in tests. The rules it applies:
12
+ *
13
+ * - An exchange is the user's final transcript(s) plus the assistant message
14
+ * that answers them. The assistant message's id is the exchange's `turnId`,
15
+ * which makes a retried write idempotent.
16
+ * - Settle rule. A user's final transcript can arrive after the assistant has
17
+ * started, or even finished, replying. A closed reply with no user text is
18
+ * held for `settleMs`; a transcript arriving in that window completes it.
19
+ * A reply still without user text after the window (the opening greeting,
20
+ * typically) is dropped, because a stored turn needs both sides.
21
+ * - A new user transcript arriving while the previous reply is still being
22
+ * spoken, and after that exchange already had user text, closes the
23
+ * previous exchange as interrupted (barge-in) and starts a new one.
24
+ * - `flush` is for shutdown. It closes the open exchange with what was said
25
+ * so far, marks it interrupted and call-ended, and appends a short note so
26
+ * a later reader knows the reply is incomplete. A user turn with no reply
27
+ * gets the note alone. The flush waits for pending writes only up to a
28
+ * budget, because the worker process is reaped about a second after the
29
+ * shutdown callbacks start.
30
+ * - Writes run strictly one at a time, in order. A failed write is retried;
31
+ * after the last retry it is reported through `onPermanentFailure` and the
32
+ * recorder carries on — a storage problem never stops a call.
33
+ *
34
+ * See docs/features/livekit-voice-agent.md.
35
+ */
36
+ import type { VoiceTurnRecorder, VoiceTurnRecorderOptions } from "../../types/index.js";
37
+ /** Create a recorder for one call. */
38
+ export declare function createVoiceTurnRecorder(options: VoiceTurnRecorderOptions): VoiceTurnRecorder;
@@ -0,0 +1,362 @@
1
+ /**
2
+ * Turns a live voice call's event stream into stored conversation turns.
3
+ *
4
+ * A realtime voice model reports a call as separate, loosely ordered events:
5
+ * final user transcripts, assistant text deltas, tool calls, and the added
6
+ * assistant message that closes a reply. This recorder groups them into
7
+ * exchanges and hands each finished exchange to a `write` function (normally
8
+ * `NeuroLink.recordConversationTurn`).
9
+ *
10
+ * It knows nothing about LiveKit, so it can be driven by a synthetic event
11
+ * stream in tests. The rules it applies:
12
+ *
13
+ * - An exchange is the user's final transcript(s) plus the assistant message
14
+ * that answers them. The assistant message's id is the exchange's `turnId`,
15
+ * which makes a retried write idempotent.
16
+ * - Settle rule. A user's final transcript can arrive after the assistant has
17
+ * started, or even finished, replying. A closed reply with no user text is
18
+ * held for `settleMs`; a transcript arriving in that window completes it.
19
+ * A reply still without user text after the window (the opening greeting,
20
+ * typically) is dropped, because a stored turn needs both sides.
21
+ * - A new user transcript arriving while the previous reply is still being
22
+ * spoken, and after that exchange already had user text, closes the
23
+ * previous exchange as interrupted (barge-in) and starts a new one.
24
+ * - `flush` is for shutdown. It closes the open exchange with what was said
25
+ * so far, marks it interrupted and call-ended, and appends a short note so
26
+ * a later reader knows the reply is incomplete. A user turn with no reply
27
+ * gets the note alone. The flush waits for pending writes only up to a
28
+ * budget, because the worker process is reaped about a second after the
29
+ * shutdown callbacks start.
30
+ * - Writes run strictly one at a time, in order. A failed write is retried;
31
+ * after the last retry it is reported through `onPermanentFailure` and the
32
+ * recorder carries on — a storage problem never stops a call.
33
+ *
34
+ * See docs/features/livekit-voice-agent.md.
35
+ */
36
+ import { randomUUID } from "node:crypto";
37
+ import { logger } from "../../utils/logger.js";
38
+ const DEFAULT_SETTLE_MS = 1500;
39
+ const DEFAULT_RETRIES = 2;
40
+ const DEFAULT_RETRY_DELAY_MS = 250;
41
+ const DEFAULT_CALL_ENDED_NOTE = "[The call ended before this reply finished.]";
42
+ const DEFAULT_NO_REPLY_NOTE = "[The call ended before a reply.]";
43
+ /** Tool arguments and results are stored with the turn; keep one entry's text bounded. */
44
+ const MAX_TOOL_TEXT_CHARS = 4000;
45
+ function boundedText(value) {
46
+ return value.length > MAX_TOOL_TEXT_CHARS
47
+ ? `${value.slice(0, MAX_TOOL_TEXT_CHARS)}…[truncated]`
48
+ : value;
49
+ }
50
+ function boundedPayload(value) {
51
+ if (typeof value === "string") {
52
+ return boundedText(value);
53
+ }
54
+ try {
55
+ const text = JSON.stringify(value);
56
+ return text !== undefined && text.length > MAX_TOOL_TEXT_CHARS
57
+ ? boundedText(text)
58
+ : value;
59
+ }
60
+ catch {
61
+ return "[unserializable]";
62
+ }
63
+ }
64
+ function wait(ms) {
65
+ return new Promise((resolve) => {
66
+ setTimeout(resolve, ms);
67
+ });
68
+ }
69
+ /**
70
+ * Store one exchange, retrying with a growing delay. After the last attempt (or
71
+ * once shutdown has run out of time) the failure is counted, logged and handed
72
+ * to `onPermanentFailure`; nothing here throws, so a storage problem never
73
+ * stops a call.
74
+ */
75
+ async function writeWithRetry(record, context) {
76
+ const { options, stats, retries, retryDelayMs, isOutOfTime } = context;
77
+ let lastError;
78
+ for (let attempt = 0; attempt <= retries; attempt += 1) {
79
+ try {
80
+ await options.write(record);
81
+ stats.written += 1;
82
+ options.onRecorded?.(record);
83
+ return;
84
+ }
85
+ catch (error) {
86
+ lastError = error;
87
+ if (attempt === retries || isOutOfTime()) {
88
+ break;
89
+ }
90
+ await wait(retryDelayMs * (attempt + 1));
91
+ }
92
+ }
93
+ stats.failed += 1;
94
+ logger.error("voice.turnRecorder.writeFailed", {
95
+ turnId: record.turnId,
96
+ error: lastError instanceof Error ? lastError.message : String(lastError),
97
+ });
98
+ try {
99
+ options.onPermanentFailure?.(record, lastError);
100
+ }
101
+ catch {
102
+ /* a failure sink must never break the call */
103
+ }
104
+ }
105
+ /** Create a recorder for one call. */
106
+ export function createVoiceTurnRecorder(options) {
107
+ const settleMs = options.settleMs ?? DEFAULT_SETTLE_MS;
108
+ const retries = options.retries ?? DEFAULT_RETRIES;
109
+ const retryDelayMs = options.retryDelayMs ?? DEFAULT_RETRY_DELAY_MS;
110
+ const callEndedNote = options.callEndedNote ?? DEFAULT_CALL_ENDED_NOTE;
111
+ const noReplyNote = options.noReplyNote ?? DEFAULT_NO_REPLY_NOTE;
112
+ const now = options.now ?? Date.now;
113
+ const callId = options.callId ?? randomUUID();
114
+ const stats = {
115
+ written: 0,
116
+ failed: 0,
117
+ dropped: 0,
118
+ pending: 0,
119
+ };
120
+ let open = newExchange();
121
+ // Every reply id that has been written or queued, so a reply delivered a
122
+ // second time can never be paired with the next question.
123
+ const seenReplyIds = new Set();
124
+ let greetingPending = false;
125
+ const awaitingUser = [];
126
+ let queue = Promise.resolve();
127
+ let eventSeq = 0;
128
+ let syntheticTurns = 0;
129
+ let closing = false;
130
+ let deadline;
131
+ function newExchange() {
132
+ return {
133
+ userSegments: [],
134
+ assistantPartial: "",
135
+ assistantStarted: false,
136
+ events: [],
137
+ startedAt: now(),
138
+ };
139
+ }
140
+ function nextEvent(type, fields) {
141
+ const event = {
142
+ type,
143
+ seq: eventSeq,
144
+ timestamp: now(),
145
+ ...fields,
146
+ };
147
+ eventSeq += 1;
148
+ return event;
149
+ }
150
+ function enqueue(record) {
151
+ stats.pending += 1;
152
+ queue = queue.then(async () => {
153
+ try {
154
+ await writeWithRetry(record, {
155
+ options,
156
+ stats,
157
+ retries,
158
+ retryDelayMs,
159
+ isOutOfTime: () => closing && deadline !== undefined && now() >= deadline,
160
+ });
161
+ }
162
+ finally {
163
+ stats.pending -= 1;
164
+ }
165
+ });
166
+ }
167
+ function finishExchange(exchange) {
168
+ enqueue({
169
+ turnId: exchange.turnId,
170
+ userMessage: exchange.user,
171
+ aiResponse: exchange.assistant,
172
+ interrupted: exchange.interrupted,
173
+ callEnded: exchange.callEnded,
174
+ startTime: new Date(exchange.startedAt),
175
+ ...(exchange.events.length > 0 ? { events: exchange.events } : {}),
176
+ });
177
+ }
178
+ function syntheticTurnId() {
179
+ syntheticTurns += 1;
180
+ return `call-${callId}-${syntheticTurns}`;
181
+ }
182
+ function dropClosed(exchange) {
183
+ clearTimeout(exchange.timer);
184
+ stats.dropped += 1;
185
+ logger.debug("voice.turnRecorder.assistantOnlyDropped", {
186
+ turnId: exchange.turnId,
187
+ });
188
+ }
189
+ return {
190
+ userTranscript(segment) {
191
+ const text = segment.text.trim();
192
+ if (closing || !segment.final || !text) {
193
+ return;
194
+ }
195
+ const waiting = awaitingUser.shift();
196
+ if (waiting) {
197
+ clearTimeout(waiting.timer);
198
+ finishExchange({
199
+ turnId: waiting.turnId,
200
+ user: text,
201
+ assistant: waiting.assistantText,
202
+ interrupted: waiting.interrupted,
203
+ callEnded: false,
204
+ events: waiting.events,
205
+ startedAt: waiting.startedAt,
206
+ });
207
+ return;
208
+ }
209
+ if (open.assistantStarted && open.userSegments.length > 0) {
210
+ // The user spoke again while the previous reply was still going: close
211
+ // that exchange as cut off, and start a new one with this transcript.
212
+ finishExchange({
213
+ turnId: syntheticTurnId(),
214
+ user: open.userSegments.join(" "),
215
+ assistant: open.assistantPartial.trim() || noReplyNote,
216
+ interrupted: true,
217
+ callEnded: false,
218
+ events: open.events,
219
+ startedAt: open.startedAt,
220
+ });
221
+ open = newExchange();
222
+ }
223
+ open.userSegments.push(text);
224
+ },
225
+ assistantDelta(delta) {
226
+ if (closing || !delta) {
227
+ return;
228
+ }
229
+ open.assistantStarted = true;
230
+ open.assistantPartial += delta;
231
+ },
232
+ assistantMessage(message) {
233
+ if (closing) {
234
+ return;
235
+ }
236
+ const text = message.text.trim() || open.assistantPartial.trim();
237
+ if (!text) {
238
+ // An item with no spoken text (a tool-only message) closes nothing.
239
+ return;
240
+ }
241
+ if (seenReplyIds.has(message.id)) {
242
+ // The same reply delivered again, whether it is still waiting for its
243
+ // user text or was already written.
244
+ return;
245
+ }
246
+ seenReplyIds.add(message.id);
247
+ if (greetingPending) {
248
+ // The agent's opening line is an instruction, not an answer: it is not
249
+ // a turn, and it must never be paired with the user's first question
250
+ // (they may speak over it).
251
+ greetingPending = false;
252
+ // Whatever the user has already said stays: it belongs to the reply
253
+ // that follows, not to the greeting.
254
+ open.assistantPartial = "";
255
+ open.assistantStarted = false;
256
+ return;
257
+ }
258
+ const exchange = open;
259
+ open = newExchange();
260
+ const interrupted = message.interrupted === true;
261
+ if (exchange.userSegments.length > 0) {
262
+ finishExchange({
263
+ turnId: message.id,
264
+ user: exchange.userSegments.join(" "),
265
+ assistant: text,
266
+ interrupted,
267
+ callEnded: false,
268
+ events: exchange.events,
269
+ startedAt: exchange.startedAt,
270
+ });
271
+ return;
272
+ }
273
+ const closed = {
274
+ turnId: message.id,
275
+ assistantText: text,
276
+ interrupted,
277
+ events: exchange.events,
278
+ startedAt: exchange.startedAt,
279
+ timer: setTimeout(() => {
280
+ const index = awaitingUser.indexOf(closed);
281
+ if (index >= 0) {
282
+ awaitingUser.splice(index, 1);
283
+ dropClosed(closed);
284
+ }
285
+ }, settleMs),
286
+ };
287
+ closed.timer.unref?.();
288
+ awaitingUser.push(closed);
289
+ },
290
+ expectGreeting() {
291
+ greetingPending = true;
292
+ },
293
+ toolStart(call) {
294
+ if (closing) {
295
+ return;
296
+ }
297
+ open.events.push(nextEvent("tool:start", {
298
+ tool: call.name,
299
+ toolName: call.name,
300
+ ...(call.args !== undefined
301
+ ? { args: boundedPayload(call.args) }
302
+ : {}),
303
+ }));
304
+ },
305
+ toolEnd(call) {
306
+ if (closing) {
307
+ return;
308
+ }
309
+ open.events.push(nextEvent("tool:end", {
310
+ tool: call.name,
311
+ toolName: call.name,
312
+ success: call.error === undefined,
313
+ ...(call.error !== undefined
314
+ ? { error: boundedText(call.error) }
315
+ : { result: boundedPayload(call.result) }),
316
+ }));
317
+ },
318
+ async flush(budgetMs) {
319
+ if (!closing) {
320
+ closing = true;
321
+ deadline = now() + budgetMs;
322
+ for (const waiting of awaitingUser.splice(0)) {
323
+ dropClosed(waiting);
324
+ }
325
+ const user = open.userSegments.join(" ");
326
+ if (user) {
327
+ const partial = open.assistantPartial.trim();
328
+ finishExchange({
329
+ turnId: syntheticTurnId(),
330
+ user,
331
+ assistant: partial ? `${partial} ${callEndedNote}` : noReplyNote,
332
+ interrupted: partial.length > 0,
333
+ callEnded: true,
334
+ events: open.events,
335
+ startedAt: open.startedAt,
336
+ });
337
+ }
338
+ else if (open.assistantPartial.trim()) {
339
+ stats.dropped += 1;
340
+ }
341
+ open = newExchange();
342
+ }
343
+ let timedOut = false;
344
+ await Promise.race([
345
+ queue,
346
+ wait(Math.max(0, (deadline ?? now()) - now())).then(() => {
347
+ timedOut = true;
348
+ }),
349
+ ]);
350
+ if (timedOut && stats.pending > 0) {
351
+ logger.warn("voice.turnRecorder.flushBudgetExceeded", {
352
+ pending: stats.pending,
353
+ budgetMs,
354
+ });
355
+ }
356
+ return { ...stats };
357
+ },
358
+ stats() {
359
+ return { ...stats };
360
+ },
361
+ };
362
+ }
@@ -19,6 +19,7 @@ import { logger } from "../../utils/logger.js";
19
19
  import { createVoiceBrain } from "./brain.js";
20
20
  import { resolveBrainDefaults, resolveEouTurnDetection } from "./config.js";
21
21
  import { attachEventBridge } from "./eventBridge.js";
22
+ import { VOICE_MEMORY_SECRET_ENV, verifyVoiceMemoryBinding, } from "./memoryBinding.js";
22
23
  const DEFAULT_CONVERSATION_PREFIX = "voice";
23
24
  // Turn-end timing defaults (approach A: silence tuning). Longer silence +
24
25
  // endpointing floor so natural mid-sentence pauses ("...and so, [pause] um")
@@ -40,15 +41,47 @@ function latestUserText(chatCtx) {
40
41
  }
41
42
  return undefined;
42
43
  }
44
+ /**
45
+ * Who a call belongs to and which conversation it appends to.
46
+ *
47
+ * A verified memory binding names both. Without one (no `memory` option, or a
48
+ * binding that does not verify) the call keeps the static identity and the
49
+ * room-derived conversation id, exactly as it always did.
50
+ */
51
+ function resolveCallIdentity(ctx, config, conversationPrefix) {
52
+ const fallbackId = `${conversationPrefix}-${ctx.room.name ?? ctx.job.id}`;
53
+ if (!config.memory) {
54
+ return { conversationId: fallbackId, bound: false };
55
+ }
56
+ const verdict = verifyVoiceMemoryBinding({
57
+ roomMetadata: ctx.room.metadata,
58
+ roomName: ctx.room.name,
59
+ secret: config.memory.secret ?? process.env[VOICE_MEMORY_SECRET_ENV],
60
+ });
61
+ if (!verdict.ok) {
62
+ logger.warn("voice.agent.memoryBindingRejected", {
63
+ reason: verdict.reason,
64
+ action: "continuing-with-static-identity",
65
+ });
66
+ return { conversationId: fallbackId, bound: false };
67
+ }
68
+ return {
69
+ conversationId: verdict.sessionId,
70
+ userId: verdict.userId,
71
+ bound: true,
72
+ };
73
+ }
43
74
  /**
44
75
  * Build a text stream for a single turn from the brain, abortable on cancel.
45
76
  * When the framework cancels the stream (barge-in), the brain's turn is aborted.
46
77
  */
47
- function brainTurnStream(brain, transcript, conversationId, onAbortedBeforeOutput) {
78
+ function brainTurnStream(brain, transcript, call, onAbortedBeforeOutput) {
48
79
  const controller = new AbortController();
49
80
  const generator = brain.streamReply({
50
81
  transcript,
51
- conversationId,
82
+ conversationId: call.conversationId,
83
+ ...(call.userId !== undefined ? { userId: call.userId } : {}),
84
+ ...(call.ephemeral ? { ephemeral: true } : {}),
52
85
  signal: controller.signal,
53
86
  });
54
87
  const iterator = generator[Symbol.asyncIterator]();
@@ -217,7 +250,7 @@ export function defineVoiceAgent(config) {
217
250
  maxTokens: config.maxTokens,
218
251
  userId: config.userId,
219
252
  });
220
- const conversationId = `${conversationPrefix}-${ctx.room.name ?? ctx.job.id}`;
253
+ const callIdentity = resolveCallIdentity(ctx, config, conversationPrefix);
221
254
  const { voice, llm } = await import("@livekit/agents");
222
255
  const [vad, stt, tts, eouTurnDetector] = await Promise.all([
223
256
  loadVad(config.vad),
@@ -284,7 +317,7 @@ export function defineVoiceAgent(config) {
284
317
  ms: Date.now() - userStoppedSpeakingAt,
285
318
  });
286
319
  }
287
- return brainTurnStream(brain, promptText, conversationId, () => {
320
+ return brainTurnStream(brain, promptText, callIdentity, () => {
288
321
  // Interrupted before producing any reply → carry this turn's text
289
322
  // forward; the next turn merges it (prompt + UI).
290
323
  pendingPrefix = promptText;
@@ -402,7 +435,12 @@ export function defineVoiceAgent(config) {
402
435
  });
403
436
  await session.start({ agent, room: ctx.room });
404
437
  if (config.greeting !== undefined && config.greeting.trim().length > 0) {
405
- const greetingStream = brainTurnStream(brain, config.greeting, conversationId);
438
+ const greetingStream = brainTurnStream(brain, config.greeting, {
439
+ ...callIdentity,
440
+ // The greeting is an instruction to the agent; on a bound call it must
441
+ // not be filed in the person's conversation as something they said.
442
+ ephemeral: callIdentity.bound,
443
+ });
406
444
  session.say(greetingStream, {
407
445
  addToChatCtx: true,
408
446
  allowInterruptions: true,