@juspay/neurolink 12.49.20 → 12.50.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.
@@ -0,0 +1,152 @@
1
+ /**
2
+ * Signed memory binding for a voice call.
3
+ *
4
+ * A voice worker persists a call's turns to a conversation, so it has to know
5
+ * WHOSE conversation and WHICH one — and it must not take either from anything
6
+ * a participant can influence. The manager that creates the room (for example
7
+ * a `/start` endpoint) already holds the verified session, so it signs a small
8
+ * binding and writes it into the room metadata next to `authToken` and
9
+ * `mcpContext`:
10
+ *
11
+ * base64(JSON({ authToken, mcpContext, memory: { userId, sessionId, exp, sig } }))
12
+ *
13
+ * The worker verifies the signature with the same shared secret. The signature
14
+ * covers the room name, so a binding copied into another room does not verify.
15
+ * Room metadata is readable by participants, so this does not hide the ids; it
16
+ * stops anyone without the secret from choosing them.
17
+ *
18
+ * Everything here fails closed. A missing secret, a missing or malformed
19
+ * binding, a bad signature or an expired binding all yield `{ ok: false }`, and
20
+ * the caller carries on with the call, storing nothing.
21
+ *
22
+ * See docs/features/livekit-voice-agent.md.
23
+ */
24
+ import { createHmac, timingSafeEqual } from "node:crypto";
25
+ import { Buffer } from "node:buffer";
26
+ import { z } from "zod";
27
+ /** Domain-separation label that starts every signed message. */
28
+ export const VOICE_MEMORY_BINDING_VERSION = "neurolink.voice.memory.v1";
29
+ /** Environment variable the worker reads the shared secret from when none is passed in. */
30
+ export const VOICE_MEMORY_SECRET_ENV = "NEUROLINK_VOICE_MEMORY_SECRET";
31
+ /** Longest expiry, counted from now, that a worker accepts (24 hours). */
32
+ export const VOICE_MEMORY_MAX_TTL_SECONDS = 24 * 60 * 60;
33
+ /** A secret shorter than this is treated as absent. */
34
+ const MIN_SECRET_LENGTH = 16;
35
+ const SESSION_ID_PATTERN = /^[A-Za-z0-9_-]{1,128}$/;
36
+ // Whitespace and control characters are refused; ":" is allowed because the
37
+ // chat user id is itself composed with it (customer:merchant:shop).
38
+ // eslint-disable-next-line no-control-regex
39
+ const USER_ID_PATTERN = /^[^\s\u0000-\u001f\u007f]{1,256}$/u;
40
+ /** A conversation id a voice call may append to: letters, digits, "_" and "-", up to 128, no ":". */
41
+ export function isValidVoiceSessionId(value) {
42
+ return typeof value === "string" && SESSION_ID_PATTERN.test(value);
43
+ }
44
+ /** A user id a voice call may be bound to: 1 to 256 characters, no whitespace or control characters. */
45
+ export function isValidVoiceUserId(value) {
46
+ return typeof value === "string" && USER_ID_PATTERN.test(value);
47
+ }
48
+ function usableSecret(secret) {
49
+ return typeof secret === "string" && secret.length >= MIN_SECRET_LENGTH;
50
+ }
51
+ /**
52
+ * What is signed. A JSON array, not a delimiter-joined string, so no choice of
53
+ * field values can make two different bindings produce the same message.
54
+ */
55
+ function signedMessage(room, userId, sessionId, exp) {
56
+ return JSON.stringify([
57
+ VOICE_MEMORY_BINDING_VERSION,
58
+ room,
59
+ userId,
60
+ sessionId,
61
+ exp,
62
+ ]);
63
+ }
64
+ function signature(secret, message) {
65
+ return createHmac("sha256", secret).update(message).digest();
66
+ }
67
+ /**
68
+ * Sign a binding for a room. Throws on an unusable secret or invalid ids: the
69
+ * signer is the trusted side, and a binding it cannot sign correctly is a bug
70
+ * to surface there, not something to paper over.
71
+ */
72
+ export function signVoiceMemoryBinding(input) {
73
+ const { room, userId, sessionId, secret } = input;
74
+ if (!room) {
75
+ throw new Error("A voice memory binding needs the room name");
76
+ }
77
+ if (!usableSecret(secret)) {
78
+ throw new Error(`The voice memory secret must be at least ${MIN_SECRET_LENGTH} characters`);
79
+ }
80
+ if (!isValidVoiceUserId(userId)) {
81
+ throw new Error("Invalid voice memory userId");
82
+ }
83
+ if (!isValidVoiceSessionId(sessionId)) {
84
+ throw new Error("Invalid voice memory sessionId");
85
+ }
86
+ const now = input.now ?? Math.floor(Date.now() / 1000);
87
+ const ttl = input.ttlSeconds ?? 2 * 60 * 60;
88
+ if (!Number.isInteger(ttl) ||
89
+ ttl <= 0 ||
90
+ ttl > VOICE_MEMORY_MAX_TTL_SECONDS) {
91
+ throw new Error(`ttlSeconds must be a whole number between 1 and ${VOICE_MEMORY_MAX_TTL_SECONDS}`);
92
+ }
93
+ const exp = now + ttl;
94
+ const sig = signature(secret, signedMessage(room, userId, sessionId, exp)).toString("base64url");
95
+ return { userId, sessionId, exp, sig };
96
+ }
97
+ const bindingSchema = z.object({
98
+ userId: z.string(),
99
+ sessionId: z.string(),
100
+ exp: z.number().int(),
101
+ sig: z.string(),
102
+ });
103
+ const roomMetadataSchema = z.object({ memory: z.unknown().optional() });
104
+ function decodeMetadata(encoded) {
105
+ try {
106
+ return JSON.parse(Buffer.from(encoded, "base64").toString("utf-8"));
107
+ }
108
+ catch {
109
+ return undefined;
110
+ }
111
+ }
112
+ /**
113
+ * Read and verify the memory binding in a room's metadata.
114
+ *
115
+ * Never throws and never logs ids. `reason` says why a binding was rejected so
116
+ * the caller can log it.
117
+ */
118
+ export function verifyVoiceMemoryBinding(input) {
119
+ const { roomMetadata, roomName, secret } = input;
120
+ if (!usableSecret(secret)) {
121
+ return { ok: false, reason: "no-secret" };
122
+ }
123
+ if (!roomName || !roomMetadata) {
124
+ return { ok: false, reason: "no-binding" };
125
+ }
126
+ const metadata = roomMetadataSchema.safeParse(decodeMetadata(roomMetadata));
127
+ if (!metadata.success || metadata.data.memory === undefined) {
128
+ return { ok: false, reason: "no-binding" };
129
+ }
130
+ const parsed = bindingSchema.safeParse(metadata.data.memory);
131
+ if (!parsed.success) {
132
+ return { ok: false, reason: "malformed" };
133
+ }
134
+ const { userId, sessionId, exp, sig } = parsed.data;
135
+ if (!isValidVoiceUserId(userId) || !isValidVoiceSessionId(sessionId)) {
136
+ return { ok: false, reason: "malformed" };
137
+ }
138
+ const expected = signature(secret, signedMessage(roomName, userId, sessionId, exp));
139
+ const presented = Buffer.from(sig, "base64url");
140
+ if (presented.length !== expected.length ||
141
+ !timingSafeEqual(presented, expected)) {
142
+ return { ok: false, reason: "bad-signature" };
143
+ }
144
+ const now = input.now ?? Math.floor(Date.now() / 1000);
145
+ if (exp <= now) {
146
+ return { ok: false, reason: "expired" };
147
+ }
148
+ if (exp - now > VOICE_MEMORY_MAX_TTL_SECONDS) {
149
+ return { ok: false, reason: "expiry-too-far" };
150
+ }
151
+ return { ok: true, userId, sessionId, exp };
152
+ }
@@ -94,7 +94,18 @@ function registerToolAliases(toolContext, mcpToolName, handler) {
94
94
  * filtering is needed.
95
95
  */
96
96
  export async function buildRealtimeMcpTools(params) {
97
- const { mcpUrl, authToken, xContext, publishEvent, requestConfirmation, toolTimeoutMs = DEFAULT_TOOL_TIMEOUT_MS, } = params;
97
+ const { mcpUrl, authToken, xContext, publishEvent, requestConfirmation, toolTimeoutMs = DEFAULT_TOOL_TIMEOUT_MS, onToolStart, onToolEnd, } = params;
98
+ // Observers feed the turn recorder; a fault in one must never reach the tool.
99
+ const observe = (notify) => {
100
+ try {
101
+ notify();
102
+ }
103
+ catch (error) {
104
+ logger.warn("realtime.tool.observerFailed", {
105
+ error: error instanceof Error ? error.message : String(error),
106
+ });
107
+ }
108
+ };
98
109
  const { llm } = await import("@livekit/agents");
99
110
  const { Client: McpClient } = await import("@modelcontextprotocol/sdk/client/index.js");
100
111
  const { StreamableHTTPClientTransport } = await import("@modelcontextprotocol/sdk/client/streamableHttp.js");
@@ -147,6 +158,7 @@ export async function buildRealtimeMcpTools(params) {
147
158
  args: args ?? {},
148
159
  });
149
160
  publishEvent("tool-start", { name: mcpTool.name });
161
+ observe(() => onToolStart?.({ name: mcpTool.name, args: args ?? {} }));
150
162
  const startedAt = Date.now();
151
163
  try {
152
164
  // Third argument is RequestOptions; the SDK aborts the in-flight
@@ -167,6 +179,12 @@ export async function buildRealtimeMcpTools(params) {
167
179
  // Forward the RAW result so the client can extract a
168
180
  // chart/UIComponent and clear the in-progress indicator.
169
181
  publishEvent("tool-result", { name: mcpTool.name, result });
182
+ observe(() => onToolEnd?.({
183
+ name: mcpTool.name,
184
+ ...(result.isError === true
185
+ ? { error: text }
186
+ : { result: text }),
187
+ }));
170
188
  return text;
171
189
  }
172
190
  catch (error) {
@@ -176,6 +194,7 @@ export async function buildRealtimeMcpTools(params) {
176
194
  error: error instanceof Error ? error.message : String(error),
177
195
  });
178
196
  publishEvent("tool-result", { name: mcpTool.name, result: null });
197
+ observe(() => onToolEnd?.({ name: mcpTool.name, error: String(error) }));
179
198
  return `Tool ${mcpTool.name} failed: ${String(error)}`;
180
199
  }
181
200
  },
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Per-call memory for the realtime (speech-to-speech) voice agent.
3
+ *
4
+ * Decides, once per call and before anything else is wired, whether this call
5
+ * may be remembered: the room metadata must carry a memory binding that
6
+ * verifies against the shared secret and the actual room name. If it does, a
7
+ * NeuroLink instance is built, the conversation's recent history is read for
8
+ * seeding, and a turn recorder is created that writes each finished exchange
9
+ * through `recordConversationTurn`. If it does not, the call proceeds exactly
10
+ * as it would without memory, and a warning says why (never the ids).
11
+ *
12
+ * The shutdown flush is registered here, before the job lifecycle installs its
13
+ * process reaper. LiveKit starts every shutdown callback in registration order
14
+ * and the reaper exits the process about a second later, so the flush gets a
15
+ * budget below that second and starts first.
16
+ *
17
+ * See docs/features/livekit-voice-agent.md.
18
+ */
19
+ import type { JobContext } from "@livekit/agents";
20
+ import type { RealtimeCallMemory, RealtimeVoiceMemoryConfig } from "../../types/index.js";
21
+ /** Verify the call's binding and prepare its recorder; `undefined` means "no memory for this call". */
22
+ export declare function setUpRealtimeCallMemory(ctx: Pick<JobContext, "room" | "addShutdownCallback">, config: RealtimeVoiceMemoryConfig | undefined): Promise<RealtimeCallMemory | undefined>;
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Per-call memory for the realtime (speech-to-speech) voice agent.
3
+ *
4
+ * Decides, once per call and before anything else is wired, whether this call
5
+ * may be remembered: the room metadata must carry a memory binding that
6
+ * verifies against the shared secret and the actual room name. If it does, a
7
+ * NeuroLink instance is built, the conversation's recent history is read for
8
+ * seeding, and a turn recorder is created that writes each finished exchange
9
+ * through `recordConversationTurn`. If it does not, the call proceeds exactly
10
+ * as it would without memory, and a warning says why (never the ids).
11
+ *
12
+ * The shutdown flush is registered here, before the job lifecycle installs its
13
+ * process reaper. LiveKit starts every shutdown callback in registration order
14
+ * and the reaper exits the process about a second later, so the flush gets a
15
+ * budget below that second and starts first.
16
+ *
17
+ * See docs/features/livekit-voice-agent.md.
18
+ */
19
+ import { logger } from "../../utils/logger.js";
20
+ import { VOICE_MEMORY_SECRET_ENV, verifyVoiceMemoryBinding, } from "./memoryBinding.js";
21
+ import { createVoiceTurnRecorder } from "./turnRecorder.js";
22
+ const DEFAULT_FLUSH_BUDGET_MS = 800;
23
+ /** Verify the call's binding and prepare its recorder; `undefined` means "no memory for this call". */
24
+ export async function setUpRealtimeCallMemory(ctx, config) {
25
+ if (!config) {
26
+ return undefined;
27
+ }
28
+ const verdict = verifyVoiceMemoryBinding({
29
+ roomMetadata: ctx.room.metadata,
30
+ roomName: ctx.room.name,
31
+ secret: config.secret ?? process.env[VOICE_MEMORY_SECRET_ENV],
32
+ });
33
+ if (!verdict.ok) {
34
+ logger.warn("realtime.memory.bindingRejected", {
35
+ reason: verdict.reason,
36
+ action: "continuing-without-memory",
37
+ });
38
+ return undefined;
39
+ }
40
+ const { userId, sessionId } = verdict;
41
+ let neurolink;
42
+ try {
43
+ neurolink = await config.createNeuroLink();
44
+ }
45
+ catch (error) {
46
+ logger.error("realtime.memory.setupFailed", {
47
+ error: error instanceof Error ? error.message : String(error),
48
+ action: "continuing-without-memory",
49
+ });
50
+ return undefined;
51
+ }
52
+ let publish;
53
+ const recorder = createVoiceTurnRecorder({
54
+ ...(config.settleMs !== undefined ? { settleMs: config.settleMs } : {}),
55
+ write: async (turn) => {
56
+ const result = await neurolink.recordConversationTurn({
57
+ sessionId,
58
+ userId,
59
+ userMessage: turn.userMessage,
60
+ aiResponse: turn.aiResponse,
61
+ modality: "voice",
62
+ turnId: turn.turnId,
63
+ interrupted: turn.interrupted,
64
+ callEnded: turn.callEnded,
65
+ startTime: turn.startTime,
66
+ ...(turn.events ? { events: turn.events } : {}),
67
+ });
68
+ if (!result.recorded) {
69
+ logger.debug("realtime.memory.turnSkipped", {
70
+ reason: result.reason,
71
+ turnId: turn.turnId,
72
+ });
73
+ }
74
+ },
75
+ onPermanentFailure: (turn) => {
76
+ publish?.("memory", { state: "not-saved", turnId: turn.turnId });
77
+ },
78
+ });
79
+ ctx.addShutdownCallback(async () => {
80
+ const stats = await recorder.flush(config.flushBudgetMs ?? DEFAULT_FLUSH_BUDGET_MS);
81
+ logger.info("realtime.memory.flushed", { ...stats });
82
+ });
83
+ let seed = [];
84
+ if (config.seed !== false && neurolink.getConversationSeed) {
85
+ try {
86
+ seed = await neurolink.getConversationSeed({
87
+ sessionId,
88
+ userId,
89
+ ...(config.seed?.maxTurns !== undefined
90
+ ? { maxTurns: config.seed.maxTurns }
91
+ : {}),
92
+ ...(config.seed?.maxChars !== undefined
93
+ ? { maxChars: config.seed.maxChars }
94
+ : {}),
95
+ });
96
+ }
97
+ catch (error) {
98
+ logger.warn("realtime.memory.seedFailed", {
99
+ error: error instanceof Error ? error.message : String(error),
100
+ });
101
+ }
102
+ }
103
+ logger.info("realtime.memory.enabled", { seeded: seed.length });
104
+ return {
105
+ recorder,
106
+ seed,
107
+ setPublisher: (publisher) => {
108
+ publish = publisher;
109
+ },
110
+ };
111
+ }
@@ -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;