@juspay/neurolink 12.49.20 → 12.51.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/CHANGELOG.md +3 -3
  2. package/dist/browser/neurolink.min.js +671 -671
  3. package/dist/cli/commands/setup.d.ts +7 -5
  4. package/dist/cli/commands/setup.js +10 -8
  5. package/dist/constants/enums.d.ts +7 -15
  6. package/dist/constants/enums.js +8 -16
  7. package/dist/core/conversationMemoryManager.js +10 -4
  8. package/dist/core/redisConversationMemoryManager.js +6 -5
  9. package/dist/factories/providerDescriptors.d.ts +17 -3
  10. package/dist/factories/providerDescriptors.js +81 -56
  11. package/dist/factories/providerRegistry.d.ts +9 -1
  12. package/dist/factories/providerRegistry.js +19 -9
  13. package/dist/models/manifestRegistry.js +40 -3
  14. package/dist/neurolink.d.ts +52 -1
  15. package/dist/neurolink.js +195 -0
  16. package/dist/providers/catalog/index.generated.d.ts +3 -1
  17. package/dist/providers/catalog/index.generated.js +11 -0
  18. package/dist/providers/catalog/laya.json +107 -0
  19. package/dist/providers/catalog/loader.d.ts +7 -4
  20. package/dist/providers/catalog/loader.js +10 -3
  21. package/dist/providers/catalog/provider-catalog.schema.json +218 -11
  22. package/dist/providers/catalog/schema.d.ts +11 -9
  23. package/dist/providers/catalog/schema.js +333 -59
  24. package/dist/providers/configuredDecision.d.ts +23 -0
  25. package/dist/providers/configuredDecision.js +111 -0
  26. package/dist/providers/decide/baseUrl.d.ts +2 -0
  27. package/dist/providers/decide/baseUrl.js +14 -0
  28. package/dist/providers/decide/errors.d.ts +8 -0
  29. package/dist/providers/decide/errors.js +57 -0
  30. package/dist/providers/decide/formatters.d.ts +2 -0
  31. package/dist/providers/decide/formatters.js +4 -0
  32. package/dist/providers/decide/paths.d.ts +1 -0
  33. package/dist/providers/decide/paths.js +21 -0
  34. package/dist/providers/decide/template.d.ts +3 -0
  35. package/dist/providers/decide/template.js +13 -0
  36. package/dist/types/conversation.d.ts +109 -0
  37. package/dist/types/livekit.d.ts +246 -3
  38. package/dist/types/providerCatalog.d.ts +74 -1
  39. package/dist/types/providerCatalog.generated.d.ts +2 -2
  40. package/dist/types/providers.d.ts +12 -9
  41. package/dist/types/sdk.d.ts +1 -1
  42. package/dist/types/stream.d.ts +8 -1
  43. package/dist/utils/conversationTurnIdentity.d.ts +24 -0
  44. package/dist/utils/conversationTurnIdentity.js +81 -0
  45. package/dist/utils/modelChoices.js +11 -25
  46. package/dist/utils/providerConfig.d.ts +0 -5
  47. package/dist/utils/providerConfig.js +0 -17
  48. package/dist/voice/livekit/brain.js +10 -7
  49. package/dist/voice/livekit/index.d.ts +2 -0
  50. package/dist/voice/livekit/index.js +2 -0
  51. package/dist/voice/livekit/memoryBinding.d.ts +47 -0
  52. package/dist/voice/livekit/memoryBinding.js +152 -0
  53. package/dist/voice/livekit/realtimeMcpTools.js +20 -1
  54. package/dist/voice/livekit/realtimeMemory.d.ts +22 -0
  55. package/dist/voice/livekit/realtimeMemory.js +111 -0
  56. package/dist/voice/livekit/realtimeVoiceAgent.js +40 -1
  57. package/dist/voice/livekit/turnRecorder.d.ts +38 -0
  58. package/dist/voice/livekit/turnRecorder.js +362 -0
  59. package/dist/voice/livekit/voiceAgent.js +43 -5
  60. package/docs-site/static/search-index.json +8 -7
  61. package/package.json +7 -2
  62. package/dist/models/manifests/laya.d.ts +0 -12
  63. package/dist/models/manifests/laya.js +0 -54
  64. package/dist/providers/laya.d.ts +0 -40
  65. package/dist/providers/laya.js +0 -152
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Message identity and metadata for a stored conversation turn.
3
+ *
4
+ * Both memory managers store a turn as a user message followed by an assistant
5
+ * message. When the caller supplies a `turnId` the two messages get stable ids
6
+ * (`${turnId}:user`, `${turnId}:assistant`), which lets a repeated store of the
7
+ * same turn be recognised and skipped. When the caller sets neither a
8
+ * `turnId` nor a `modality`, nothing here changes what is stored: ids stay
9
+ * random and no metadata is added, so typed chat is byte-for-byte as before.
10
+ */
11
+ import type { ChatMessage, ChatMessageMetadata, StoreConversationTurnOptions, TurnMessageIdentity } from "../types/index.js";
12
+ export declare function userTurnMessageId(turnId: string): string;
13
+ export declare function assistantTurnMessageId(turnId: string): string;
14
+ /** Identity and metadata for the user message of a turn. */
15
+ export declare function userTurnIdentity(options: Pick<StoreConversationTurnOptions, "turnId" | "modality">): TurnMessageIdentity;
16
+ /** Identity and metadata for the assistant message of a turn. */
17
+ export declare function assistantTurnIdentity(options: Pick<StoreConversationTurnOptions, "turnId" | "modality" | "interrupted" | "callEnded">, thoughtSignature?: string): TurnMessageIdentity;
18
+ /** `id` and optional `metadata` of a stored message, ready to spread into it. */
19
+ export declare function turnMessageFields(identity: TurnMessageIdentity): {
20
+ id: string;
21
+ metadata?: ChatMessageMetadata;
22
+ };
23
+ /** True when a turn with this id is already among the session's recent messages. */
24
+ export declare function isTurnAlreadyStored(messages: ReadonlyArray<Pick<ChatMessage, "id">>, turnId: string | undefined): boolean;
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Message identity and metadata for a stored conversation turn.
3
+ *
4
+ * Both memory managers store a turn as a user message followed by an assistant
5
+ * message. When the caller supplies a `turnId` the two messages get stable ids
6
+ * (`${turnId}:user`, `${turnId}:assistant`), which lets a repeated store of the
7
+ * same turn be recognised and skipped. When the caller sets neither a
8
+ * `turnId` nor a `modality`, nothing here changes what is stored: ids stay
9
+ * random and no metadata is added, so typed chat is byte-for-byte as before.
10
+ */
11
+ import { randomUUID } from "node:crypto";
12
+ import { logger } from "./logger.js";
13
+ /** How far back a repeated `turnId` is looked for. A turn is always the newest thing in a session. */
14
+ const DUPLICATE_LOOKBACK_MESSAGES = 200;
15
+ export function userTurnMessageId(turnId) {
16
+ return `${turnId}:user`;
17
+ }
18
+ export function assistantTurnMessageId(turnId) {
19
+ return `${turnId}:assistant`;
20
+ }
21
+ /** Identity and metadata for the user message of a turn. */
22
+ export function userTurnIdentity(options) {
23
+ const metadata = {};
24
+ if (options.modality) {
25
+ metadata.modality = options.modality;
26
+ }
27
+ if (options.turnId) {
28
+ metadata.turnId = options.turnId;
29
+ }
30
+ return {
31
+ id: options.turnId ? userTurnMessageId(options.turnId) : undefined,
32
+ metadata: Object.keys(metadata).length > 0 ? metadata : undefined,
33
+ };
34
+ }
35
+ /** Identity and metadata for the assistant message of a turn. */
36
+ export function assistantTurnIdentity(options, thoughtSignature) {
37
+ const metadata = {};
38
+ if (thoughtSignature) {
39
+ metadata.thoughtSignature = thoughtSignature;
40
+ }
41
+ if (options.modality) {
42
+ metadata.modality = options.modality;
43
+ }
44
+ if (options.turnId) {
45
+ metadata.turnId = options.turnId;
46
+ }
47
+ if (options.interrupted) {
48
+ metadata.interrupted = true;
49
+ }
50
+ if (options.callEnded) {
51
+ metadata.callEnded = true;
52
+ }
53
+ return {
54
+ id: options.turnId ? assistantTurnMessageId(options.turnId) : undefined,
55
+ metadata: Object.keys(metadata).length > 0 ? metadata : undefined,
56
+ };
57
+ }
58
+ /** `id` and optional `metadata` of a stored message, ready to spread into it. */
59
+ export function turnMessageFields(identity) {
60
+ return {
61
+ id: identity.id ?? randomUUID(),
62
+ ...(identity.metadata ? { metadata: identity.metadata } : {}),
63
+ };
64
+ }
65
+ /** True when a turn with this id is already among the session's recent messages. */
66
+ export function isTurnAlreadyStored(messages, turnId) {
67
+ if (!turnId) {
68
+ return false;
69
+ }
70
+ const assistantId = assistantTurnMessageId(turnId);
71
+ const start = Math.max(0, messages.length - DUPLICATE_LOOKBACK_MESSAGES);
72
+ for (let index = messages.length - 1; index >= start; index -= 1) {
73
+ if (messages[index].id === assistantId) {
74
+ logger.debug("[conversationTurnIdentity] Turn already stored, skipping", {
75
+ turnId,
76
+ });
77
+ return true;
78
+ }
79
+ }
80
+ return false;
81
+ }
@@ -2,16 +2,19 @@
2
2
  * Centralized model choices for CLI commands
3
3
  * Derives choices from model enums to ensure consistency
4
4
  */
5
- import { AIProviderName, OpenAIModels, AnthropicModels, GoogleAIModels, BedrockModels, VertexModels, OllamaModels, AzureOpenAIModels, LiteLLMModels, SageMakerModels, OpenRouterModels, NvidiaNimModels, CohereModels, VoyageModels, JinaModels, StabilityModels, IdeogramModels, RecraftModels, TypeSafeModels, LayaModels, XorModels, PerplexityDeciderModels, CloudflareClefModels, ReplicateModels, } from "../constants/enums.js";
6
- import { getCatalogJsonEntries } from "../providers/catalog/loader.js";
5
+ import { AIProviderName, OpenAIModels, AnthropicModels, GoogleAIModels, BedrockModels, VertexModels, OllamaModels, AzureOpenAIModels, LiteLLMModels, SageMakerModels, OpenRouterModels, NvidiaNimModels, CohereModels, VoyageModels, JinaModels, StabilityModels, IdeogramModels, RecraftModels, TypeSafeModels, XorModels, PerplexityDeciderModels, CloudflareClefModels, ReplicateModels, } from "../constants/enums.js";
6
+ import { getCatalogJsonEntries, getDecideCatalogEntries, } from "../providers/catalog/loader.js";
7
7
  /**
8
8
  * Looks up the JSON catalog entry for a provider, if it is one of the 11
9
9
  * JSON-catalog providers. Used to short-circuit the hand tables below so a
10
10
  * catalog provider's models are always sourced from its catalog JSON, never
11
- * from stale hand-written duplicates.
11
+ * from stale hand-written duplicates. A decide provider's entry is searched
12
+ * after the chat catalog (the two never share an id), so its models need no
13
+ * hand-written rows either.
12
14
  */
13
15
  function catalogEntryFor(provider) {
14
- return getCatalogJsonEntries().find((entry) => entry.id === provider);
16
+ return (getCatalogJsonEntries().find((entry) => entry.id === provider) ??
17
+ getDecideCatalogEntries().find((entry) => entry.id === provider));
15
18
  }
16
19
  /**
17
20
  * Derives CLI-prompt entries for a catalog provider. Uses the curated
@@ -374,24 +377,6 @@ const TOP_MODELS_CONFIG = {
374
377
  description: "Preview build of Jev",
375
378
  },
376
379
  ],
377
- [AIProviderName.LAYA]: [
378
- {
379
- model: LayaModels.TYPED_DECISIONS,
380
- description: "Recommended - Laya fine-tuned for typed decisions",
381
- },
382
- {
383
- model: LayaModels.MULTILINGUAL,
384
- description: "Laya, 100+ languages",
385
- },
386
- {
387
- model: LayaModels.ENGLISH,
388
- description: "Laya, English base checkpoint (512-token window)",
389
- },
390
- {
391
- model: LayaModels.AUTO,
392
- description: "Let Laya pick a checkpoint by language",
393
- },
394
- ],
395
380
  [AIProviderName.XOR]: [
396
381
  {
397
382
  model: XorModels.XOR_1_1,
@@ -460,7 +445,6 @@ export const DEFAULT_MODELS = {
460
445
  [AIProviderName.IDEOGRAM]: IdeogramModels.IDEOGRAM_V3,
461
446
  [AIProviderName.RECRAFT]: RecraftModels.RECRAFT_V3,
462
447
  [AIProviderName.TYPESAFE]: TypeSafeModels.JEV_LATEST,
463
- [AIProviderName.LAYA]: LayaModels.TYPED_DECISIONS,
464
448
  [AIProviderName.XOR]: XorModels.XOR_1_1,
465
449
  [AIProviderName.PERPLEXITY_DECIDER]: PerplexityDeciderModels.PPLX_DECIDER_V1_27B,
466
450
  [AIProviderName.CLOUDFLARE_CLEF]: CloudflareClefModels.CLEF,
@@ -496,7 +480,6 @@ const MODEL_ENUMS = {
496
480
  [AIProviderName.IDEOGRAM]: IdeogramModels,
497
481
  [AIProviderName.RECRAFT]: RecraftModels,
498
482
  [AIProviderName.TYPESAFE]: TypeSafeModels,
499
- [AIProviderName.LAYA]: LayaModels,
500
483
  [AIProviderName.XOR]: XorModels,
501
484
  [AIProviderName.PERPLEXITY_DECIDER]: PerplexityDeciderModels,
502
485
  [AIProviderName.CLOUDFLARE_CLEF]: CloudflareClefModels,
@@ -695,7 +678,10 @@ export function getPopularModelsAcrossProviders() {
695
678
  }
696
679
  // JSON-catalog providers are excluded from TOP_MODELS_CONFIG above; merge
697
680
  // their models in here, derived the same way getTopModelChoices() does.
698
- for (const entry of getCatalogJsonEntries()) {
681
+ for (const entry of [
682
+ ...getCatalogJsonEntries(),
683
+ ...getDecideCatalogEntries(),
684
+ ]) {
699
685
  pushTop2(entry.id, catalogTopModels(entry));
700
686
  }
701
687
  return popularModels;
@@ -400,11 +400,6 @@ export declare function resolveOpenAICompatConfig(entry: OpenAICompatConfigInput
400
400
  * TypeSafe (Jev) — the `decide` inference type. Not a text provider.
401
401
  */
402
402
  export declare function createTypeSafeConfig(): ProviderConfigOptions;
403
- /**
404
- * Laya — the `decide` inference type. Not a text provider, and it has no
405
- * built-in endpoint: LAYA_BASE_URL is required alongside the key.
406
- */
407
- export declare function createLayaConfig(): ProviderConfigOptions;
408
403
  /**
409
404
  * XOR — the `decide` inference type. Not a text provider, and it has no
410
405
  * built-in endpoint: XOR_BASE_URL is required alongside the key.
@@ -1324,23 +1324,6 @@ export function createTypeSafeConfig() {
1324
1324
  ],
1325
1325
  };
1326
1326
  }
1327
- /**
1328
- * Laya — the `decide` inference type. Not a text provider, and it has no
1329
- * built-in endpoint: LAYA_BASE_URL is required alongside the key.
1330
- */
1331
- export function createLayaConfig() {
1332
- return {
1333
- providerName: "Laya",
1334
- envVarName: "LAYA_API_KEY",
1335
- setupUrl: "https://github.com/NandhaKishorM/laya",
1336
- description: "API key for your Laya endpoint",
1337
- instructions: [
1338
- "1. Run a Laya server, or use a LiteLLM proxy with a pass-through route to one",
1339
- "2. Set LAYA_BASE_URL to its base URL (NeuroLink calls <LAYA_BASE_URL>/predict)",
1340
- "3. Set LAYA_API_KEY to the key it accepts (on LiteLLM, a virtual key with that route in its Allowed Routes)",
1341
- ],
1342
- };
1343
- }
1344
1327
  /**
1345
1328
  * XOR — the `decide` inference type. Not a text provider, and it has no
1346
1329
  * built-in endpoint: XOR_BASE_URL is required alongside the key.
@@ -37,16 +37,17 @@ function extractTextDelta(chunk) {
37
37
  export function createVoiceBrain(config) {
38
38
  const { neurolink, provider, model, systemPrompt, temperature, maxTokens, userId, } = config;
39
39
  async function* streamReply(turn) {
40
- const { transcript, conversationId, signal } = turn;
40
+ const { transcript, conversationId, signal, ephemeral } = turn;
41
41
  if (signal?.aborted) {
42
42
  return;
43
43
  }
44
- const context = {
45
- sessionId: conversationId,
46
- conversationId,
47
- };
48
- if (userId !== undefined) {
49
- context.userId = userId;
44
+ // No sessionId means no history read and no turn stored for this call.
45
+ const context = ephemeral
46
+ ? {}
47
+ : { sessionId: conversationId, conversationId };
48
+ const turnUserId = turn.userId ?? userId;
49
+ if (!ephemeral && turnUserId !== undefined) {
50
+ context.userId = turnUserId;
50
51
  }
51
52
  const result = await neurolink.stream({
52
53
  input: { text: transcript },
@@ -56,6 +57,8 @@ export function createVoiceBrain(config) {
56
57
  temperature,
57
58
  maxTokens,
58
59
  context,
60
+ // Every turn through this brain is spoken; stored messages are marked so.
61
+ modality: "voice",
59
62
  abortSignal: signal,
60
63
  disableTools: false,
61
64
  });
@@ -18,5 +18,7 @@ export { defineRealtimeVoiceAgent } from "./realtimeVoiceAgent.js";
18
18
  export { startVoiceAgentWorker, startRealtimeVoiceAgentWorker, installVoiceWorkerProcessGuards, } from "./voiceAgentWorker.js";
19
19
  export { ensureVertexAdc, clearGeminiApiKeyEnv } from "./vertexAuth.js";
20
20
  export { readCallContextFromRoom } from "./roomContext.js";
21
+ export { signVoiceMemoryBinding, verifyVoiceMemoryBinding, isValidVoiceSessionId, isValidVoiceUserId, VOICE_MEMORY_SECRET_ENV, VOICE_MEMORY_BINDING_VERSION, VOICE_MEMORY_MAX_TTL_SECONDS, } from "./memoryBinding.js";
22
+ export { createVoiceTurnRecorder } from "./turnRecorder.js";
21
23
  export { sanitizeSchema, sanitizeToolParameters, findSchemaIssue, } from "./schemaSanitizer.js";
22
24
  export { buildRealtimeMcpTools, mcpResultToText } from "./realtimeMcpTools.js";
@@ -18,5 +18,7 @@ export { defineRealtimeVoiceAgent } from "./realtimeVoiceAgent.js";
18
18
  export { startVoiceAgentWorker, startRealtimeVoiceAgentWorker, installVoiceWorkerProcessGuards, } from "./voiceAgentWorker.js";
19
19
  export { ensureVertexAdc, clearGeminiApiKeyEnv } from "./vertexAuth.js";
20
20
  export { readCallContextFromRoom } from "./roomContext.js";
21
+ export { signVoiceMemoryBinding, verifyVoiceMemoryBinding, isValidVoiceSessionId, isValidVoiceUserId, VOICE_MEMORY_SECRET_ENV, VOICE_MEMORY_BINDING_VERSION, VOICE_MEMORY_MAX_TTL_SECONDS, } from "./memoryBinding.js";
22
+ export { createVoiceTurnRecorder } from "./turnRecorder.js";
21
23
  export { sanitizeSchema, sanitizeToolParameters, findSchemaIssue, } from "./schemaSanitizer.js";
22
24
  export { buildRealtimeMcpTools, mcpResultToText } from "./realtimeMcpTools.js";
@@ -0,0 +1,47 @@
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 type { VoiceMemoryBinding, VoiceMemoryBindingInput, VoiceMemoryBindingVerdict, VoiceMemoryBindingVerifyInput } from "../../types/index.js";
25
+ /** Domain-separation label that starts every signed message. */
26
+ export declare const VOICE_MEMORY_BINDING_VERSION = "neurolink.voice.memory.v1";
27
+ /** Environment variable the worker reads the shared secret from when none is passed in. */
28
+ export declare const VOICE_MEMORY_SECRET_ENV = "NEUROLINK_VOICE_MEMORY_SECRET";
29
+ /** Longest expiry, counted from now, that a worker accepts (24 hours). */
30
+ export declare const VOICE_MEMORY_MAX_TTL_SECONDS: number;
31
+ /** A conversation id a voice call may append to: letters, digits, "_" and "-", up to 128, no ":". */
32
+ export declare function isValidVoiceSessionId(value: unknown): value is string;
33
+ /** A user id a voice call may be bound to: 1 to 256 characters, no whitespace or control characters. */
34
+ export declare function isValidVoiceUserId(value: unknown): value is string;
35
+ /**
36
+ * Sign a binding for a room. Throws on an unusable secret or invalid ids: the
37
+ * signer is the trusted side, and a binding it cannot sign correctly is a bug
38
+ * to surface there, not something to paper over.
39
+ */
40
+ export declare function signVoiceMemoryBinding(input: VoiceMemoryBindingInput): VoiceMemoryBinding;
41
+ /**
42
+ * Read and verify the memory binding in a room's metadata.
43
+ *
44
+ * Never throws and never logs ids. `reason` says why a binding was rejected so
45
+ * the caller can log it.
46
+ */
47
+ export declare function verifyVoiceMemoryBinding(input: VoiceMemoryBindingVerifyInput): VoiceMemoryBindingVerdict;
@@ -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
+ }