@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.
- package/CHANGELOG.md +3 -3
- package/dist/browser/neurolink.min.js +671 -671
- package/dist/cli/commands/setup.d.ts +7 -5
- package/dist/cli/commands/setup.js +10 -8
- package/dist/constants/enums.d.ts +7 -15
- package/dist/constants/enums.js +8 -16
- package/dist/core/conversationMemoryManager.js +10 -4
- package/dist/core/redisConversationMemoryManager.js +6 -5
- package/dist/factories/providerDescriptors.d.ts +17 -3
- package/dist/factories/providerDescriptors.js +81 -56
- package/dist/factories/providerRegistry.d.ts +9 -1
- package/dist/factories/providerRegistry.js +19 -9
- package/dist/models/manifestRegistry.js +40 -3
- package/dist/neurolink.d.ts +52 -1
- package/dist/neurolink.js +195 -0
- package/dist/providers/catalog/index.generated.d.ts +3 -1
- package/dist/providers/catalog/index.generated.js +11 -0
- package/dist/providers/catalog/laya.json +107 -0
- package/dist/providers/catalog/loader.d.ts +7 -4
- package/dist/providers/catalog/loader.js +10 -3
- package/dist/providers/catalog/provider-catalog.schema.json +218 -11
- package/dist/providers/catalog/schema.d.ts +11 -9
- package/dist/providers/catalog/schema.js +333 -59
- package/dist/providers/configuredDecision.d.ts +23 -0
- package/dist/providers/configuredDecision.js +111 -0
- package/dist/providers/decide/baseUrl.d.ts +2 -0
- package/dist/providers/decide/baseUrl.js +14 -0
- package/dist/providers/decide/errors.d.ts +8 -0
- package/dist/providers/decide/errors.js +57 -0
- package/dist/providers/decide/formatters.d.ts +2 -0
- package/dist/providers/decide/formatters.js +4 -0
- package/dist/providers/decide/paths.d.ts +1 -0
- package/dist/providers/decide/paths.js +21 -0
- package/dist/providers/decide/template.d.ts +3 -0
- package/dist/providers/decide/template.js +13 -0
- package/dist/types/conversation.d.ts +109 -0
- package/dist/types/livekit.d.ts +246 -3
- package/dist/types/providerCatalog.d.ts +74 -1
- package/dist/types/providerCatalog.generated.d.ts +2 -2
- package/dist/types/providers.d.ts +12 -9
- 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/utils/modelChoices.js +11 -25
- package/dist/utils/providerConfig.d.ts +0 -5
- package/dist/utils/providerConfig.js +0 -17
- 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 +8 -7
- package/package.json +7 -2
- package/dist/models/manifests/laya.d.ts +0 -12
- package/dist/models/manifests/laya.js +0 -54
- package/dist/providers/laya.d.ts +0 -40
- 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
|
-
|
|
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,
|
|
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,
|