@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.
- package/CHANGELOG.md +3 -3
- package/dist/browser/neurolink.min.js +371 -371
- package/dist/core/conversationMemoryManager.js +10 -4
- package/dist/core/redisConversationMemoryManager.js +6 -5
- package/dist/neurolink.d.ts +52 -1
- package/dist/neurolink.js +195 -0
- package/dist/types/conversation.d.ts +109 -0
- package/dist/types/livekit.d.ts +246 -3
- package/dist/types/sdk.d.ts +1 -1
- package/dist/types/stream.d.ts +8 -1
- package/dist/utils/conversationTurnIdentity.d.ts +24 -0
- package/dist/utils/conversationTurnIdentity.js +81 -0
- package/dist/voice/livekit/brain.js +10 -7
- package/dist/voice/livekit/index.d.ts +2 -0
- package/dist/voice/livekit/index.js +2 -0
- package/dist/voice/livekit/memoryBinding.d.ts +47 -0
- package/dist/voice/livekit/memoryBinding.js +152 -0
- package/dist/voice/livekit/realtimeMcpTools.js +20 -1
- package/dist/voice/livekit/realtimeMemory.d.ts +22 -0
- package/dist/voice/livekit/realtimeMemory.js +111 -0
- package/dist/voice/livekit/realtimeVoiceAgent.js +40 -1
- package/dist/voice/livekit/turnRecorder.d.ts +38 -0
- package/dist/voice/livekit/turnRecorder.js +362 -0
- package/dist/voice/livekit/voiceAgent.js +43 -5
- package/docs-site/static/search-index.json +1 -0
- package/package.json +2 -1
|
@@ -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,
|
|
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
|
|
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,
|
|
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,
|
|
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,
|
|
@@ -3668,6 +3668,7 @@
|
|
|
3668
3668
|
{"objectID":"e702f5ae42413e90dc937d8b3216485da56c3baaef4130e033bd9bdc199e74a2","title":"Language & multilingual speech","url":"/docs/features/livekit-voice-agent#language-multilingual-speech","content":"The language field on stt is a soft hint: it biases recognition toward a\nlanguage without locking to it, so a user can switch languages mid-call and still\nbe transcribed correctly.\nOmit language for full auto-detection.\nThe hint only biases the first guess; it never forces the hinted language. (A\n strict lock causes the realtime stream to stall on other-language audio, so the\n integration intentionally keeps the hint soft.)","hierarchy":{"lvl0":"Features","lvl1":"LiveKit Voice Agent — Real-Time Voice over WebRTC","lvl2":"Language & multilingual speech","lvl3":""}},
|
|
3669
3669
|
{"objectID":"804a64dd17c69cb8d4af469682d01d12f00bec2d6869a5faef30865da99f176d","title":"Speech provider selection","url":"/docs/features/livekit-voice-agent#speech-provider-selection","content":"STT and TTS plugins are chosen per agent and configured by environment credentials.\nSTT: soniox, deepgram. TTS: cartesia, elevenlabs.\nOnly set voice / model if your account supports them; otherwise omit those\n fields to use the plugin's own defaults.","hierarchy":{"lvl0":"Features","lvl1":"LiveKit Voice Agent — Real-Time Voice over WebRTC","lvl2":"Speech provider selection","lvl3":""}},
|
|
3670
3670
|
{"objectID":"5589375c3b5b8ee71271f32386ef6b0f878bc88f7097a13ee7d8ef03b694655e","title":"Conversation Memory","url":"/docs/features/livekit-voice-agent#conversation-memory","content":"The agent remembers earlier turns automatically when the NeuroLink instance you\nbuild inside createNeuroLink has conversation memory enabled. History is the\nagent's source of truth — LiveKit's own transcript context is not used for\ngeneration.\n\nHow it behaves:\nKeyed per call. Each room/call is an isolated conversation; the id is\n derived from the room name. Override the prefix with conversationIdPrefix\n (default \"voice\").\nIn-memory by default; Redis for persistence. Set REDIS_URL to use a\n shared store that survives worker restarts and is shared across worker\n replicas — important because each call runs in its own job process.\nWorks across turns within the session. The user can say \"my name is Alex\"\n and later ask \"what's my name?\" and the agent recalls it.\n\nMemory persists only when the instance is configured with\nconversationMemory.enabled. Without it, each turn is independent.","hierarchy":{"lvl0":"Features","lvl1":"LiveKit Voice Agent — Real-Time Voice over WebRTC","lvl2":"Conversation Memory","lvl3":""}},
|
|
3671
|
+
{"objectID":"e6ef692117fe2ad968f2bb5bd1692e50783a069a189d3dbfd6ac49f71ef84c73","title":"Remembering a realtime call in the user's own conversation","url":"/docs/features/livekit-voice-agent#remembering-a-realtime-call-in-the-users-own-conversation","content":"A speech-to-speech call (defineRealtimeVoiceAgent) never goes through\nstream() or generate(), so by default nothing it says is stored. Opt in with\nmemory and the call's turns are written, through\nNeuroLink.recordConversationTurn, to the same conversation memory typed chat\nuses, appended to the conversation the person already has open. The model is\nalso seeded with that conversation's recent history when the call starts, so it\nknows what was said before.\n\nWhose conversation is never taken from the client. The host that creates\nthe room signs a binding and puts it in the room metadata next to authToken\nand mcpContext. The worker stores nothing unless it verifies.\nThe signature is HMAC-SHA256 over [\"neurolink.voice.memory.v1\", room,\nuserId, sessionId, exp] (a JSON array, so no choice of values can make two\n bindings collide), base64url-encoded. It covers the room name, so a binding\n copied into another room does not verify. exp is Unix seconds; the default\n lifetime is two hours, the cap 24 hours.\nThe shared secret is at least 16 characters, read from\n NEUROLINK_VOICE_MEMORY_SECRET unless memory.secret is set on the worker.\nsessionId matches ^[A-Za-z0-9_-]{1,128}$ (no :, which is part of the\n stored key). userId is 1 to 256 characters with no whitespace or control\n characters; : is allowed because chat user ids contain it.\nFail closed. No secret, no binding, a malformed or expired binding, or a\n bad signature: the call runs normally, nothing is stored, and a warning\n names the reason (never the ids).\nA client may name the conversation to append to. The host accepts the name\n only when neurolink.conversationExists({ sessionId, userId }) is true for\n the verified user. Otherwise it mints a new voice-<uuid>.\n\nWhat is stored. Each finished exchange becomes a user message and an\nassistant message with the ids ${turnId}:user and ${turnId}:assistant, where\nturnId is the realtime session's assistant message id, and\nmetadata.modality: \"voice\".","hierarchy":{"lvl0":"Features","lvl1":"LiveKit Voice Agent — Real-Time Voice over WebRTC","lvl2":"Remembering a realtime call in the user's own conversation","lvl3":""}},
|
|
3671
3672
|
{"objectID":"7ffe7e32a7c2baea6c491d743617448eb70eeb4afe439960050efdc3ead18c48","title":"Implementation Plan","url":"/docs/features/livekit-voice-agent#implementation-plan","content":"The integration is built and validated in phases. Each phase is independently testable.","hierarchy":{"lvl0":"Features","lvl1":"LiveKit Voice Agent — Real-Time Voice over WebRTC","lvl2":"Implementation Plan","lvl3":""}},
|
|
3672
3673
|
{"objectID":"d4c9885eea3272e250fa1a0dbc1af9503b1a24f0217a82dd0c30d93bf6c5df31","title":"Phase 0 — Console-mode spike (no infrastructure)","url":"/docs/features/livekit-voice-agent#phase-0-console-mode-spike-no-infrastructure","content":"Build a minimal agent (Silero VAD + Deepgram STT + ElevenLabs TTS + llmNode → neurolink.stream()) and run it in console mode using the host machine's mic/speakers. Validates the NeuroLink brain loop, conversationId history, and a tool call — with no LiveKit server and no browser. Requires only STT/TTS and LLM credentials.","hierarchy":{"lvl0":"Features","lvl1":"LiveKit Voice Agent — Real-Time Voice over WebRTC","lvl2":"Phase 0 — Console-mode spike (no infrastructure)","lvl3":""}},
|
|
3673
3674
|
{"objectID":"9dddf98f952af643b5357bdefbcb5554a3ff2d9c33a16b0c432243fd68db5569","title":"Phase 1 — NeuroLink LiveKit module","url":"/docs/features/livekit-voice-agent#phase-1-neurolink-livekit-module","content":"Implement brain.ts, voiceAgent.ts, voiceAgentWorker.ts, tokens.ts, config.ts; add the @juspay/neurolink/livekit export and optional/peer dependencies. The worker factory accepts an external NeuroLink instance so a host application's registered tools are available. Wire abort-on-interrupt. Verify build, type-check, and lint.","hierarchy":{"lvl0":"Features","lvl1":"LiveKit Voice Agent — Real-Time Voice over WebRTC","lvl2":"Phase 1 — NeuroLink LiveKit module","lvl3":""}},
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@juspay/neurolink",
|
|
3
|
-
"version": "12.
|
|
3
|
+
"version": "12.50.0",
|
|
4
4
|
"packageManager": "pnpm@10.15.1",
|
|
5
5
|
"description": "The pipe layer of an AI nervous system: one interface connecting provider neurons to your application, across three inference types — generate, stream and decide. `decide` returns typed, calibrated judgements from a non-generative model (~400ms, ~$0.00002/call) for routing, tool selection and context budgeting. MCP-native (4 transports), voice TTS/STT/realtime, RAG, agents, memory, compaction, 9 observability exporters. OpenAI · Anthropic · Gemini · Bedrock · Azure · Ollama · TypeSafe Jev and more.",
|
|
6
6
|
"author": {
|
|
@@ -113,6 +113,7 @@
|
|
|
113
113
|
"test:adjust-body-after-400": "pnpm exec tsx test/continuous-test-suite-adjust-body-after-400.ts",
|
|
114
114
|
"test:zod3-schema-native-path": "pnpm exec tsx test/continuous-test-suite-zod3-schema-native-path.ts",
|
|
115
115
|
"test:forced-tool-declared": "pnpm exec tsx test/continuous-test-suite-forced-tool-declared.ts",
|
|
116
|
+
"test:voice-turn-memory": "pnpm exec tsx test/continuous-test-suite-voice-turn-memory.ts",
|
|
116
117
|
"test:error-classification-e2e": "pnpm exec tsx test/continuous-test-suite-error-classification-e2e.ts",
|
|
117
118
|
"test:error-classifier-contract": "pnpm exec tsx test/continuous-test-suite-error-classifier-contract.ts",
|
|
118
119
|
"test:bedrock-inference-profile": "pnpm exec tsx test/continuous-test-suite-bedrock-inference-profile.ts",
|