@juspay/neurolink 12.49.19 → 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 +408 -408
- package/dist/core/conversationMemoryManager.js +10 -4
- package/dist/core/nativeGenerateLoop.d.ts +5 -0
- package/dist/core/nativeGenerateLoop.js +74 -3
- package/dist/core/redisConversationMemoryManager.js +6 -5
- package/dist/neurolink.d.ts +52 -1
- package/dist/neurolink.js +195 -0
- package/dist/providers/anthropic/client.js +1 -0
- package/dist/providers/openaiChatCompletionsBase.js +1 -0
- package/dist/types/conversation.d.ts +109 -0
- package/dist/types/generate.d.ts +18 -1
- 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 +3 -1
|
@@ -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
|
-
|
|
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;
|