@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.
@@ -11,6 +11,7 @@ import { tracers, withSpan } from "../telemetry/index.js";
11
11
  import { ConversationMemoryError } from "../types/index.js";
12
12
  import { buildContextFromPointer, getEffectiveTokenThreshold, } from "../utils/conversationMemory.js";
13
13
  import { logger } from "../utils/logger.js";
14
+ import { assistantTurnIdentity, isTurnAlreadyStored, userTurnIdentity, } from "../utils/conversationTurnIdentity.js";
14
15
  import { extractIsErrorText } from "../utils/toolResultStatus.js";
15
16
  export class ConversationMemoryManager {
16
17
  sessions = new Map();
@@ -82,11 +83,14 @@ export class ConversationMemoryManager {
82
83
  session = this.createNewSession(options.sessionId, options.userId);
83
84
  this.sessions.set(options.sessionId, session);
84
85
  }
86
+ if (isTurnAlreadyStored(session.messages, options.turnId)) {
87
+ return;
88
+ }
85
89
  const tokenThreshold = options.providerDetails
86
90
  ? getEffectiveTokenThreshold(options.providerDetails.provider, options.providerDetails.model, this.config.tokenThreshold, session.tokenThreshold)
87
91
  : this.config.tokenThreshold || 50000;
88
- const userMsg = await this.validateAndPrepareMessage(options.userMessage, "user", tokenThreshold);
89
- const assistantMsg = await this.validateAndPrepareMessage(options.aiResponse, "assistant", tokenThreshold);
92
+ const userMsg = await this.validateAndPrepareMessage(options.userMessage, "user", tokenThreshold, userTurnIdentity(options));
93
+ const assistantMsg = await this.validateAndPrepareMessage(options.aiResponse, "assistant", tokenThreshold, assistantTurnIdentity(options));
90
94
  if (options.events && options.events.length > 0) {
91
95
  assistantMsg.events = options.events;
92
96
  }
@@ -147,8 +151,8 @@ export class ConversationMemoryManager {
147
151
  * Validate and prepare a message before adding to session
148
152
  * Truncates if message exceeds token limit
149
153
  */
150
- async validateAndPrepareMessage(content, role, threshold) {
151
- const id = randomUUID();
154
+ async validateAndPrepareMessage(content, role, threshold, identity) {
155
+ const id = identity?.id ?? randomUUID();
152
156
  const tokenCount = TokenUtils.estimateTokenCount(content);
153
157
  const maxMessageSize = Math.floor(threshold * MEMORY_THRESHOLD_PERCENTAGE);
154
158
  if (tokenCount > maxMessageSize) {
@@ -166,6 +170,7 @@ export class ConversationMemoryManager {
166
170
  content: truncated,
167
171
  timestamp: new Date().toISOString(),
168
172
  metadata: {
173
+ ...identity?.metadata,
169
174
  truncated: true,
170
175
  },
171
176
  };
@@ -175,6 +180,7 @@ export class ConversationMemoryManager {
175
180
  role,
176
181
  content,
177
182
  timestamp: new Date().toISOString(),
183
+ ...(identity?.metadata ? { metadata: identity.metadata } : {}),
178
184
  };
179
185
  }
180
186
  /**
@@ -28,6 +28,11 @@ import type { NativeGenerateLoopArgs, NativeGenerateLoopResult, StepResult, Step
28
28
  * how a caller forces a tool on step 3 only. A hook that returns no
29
29
  * `toolChoice` defers to the rule. Nothing else on the hook's result is read.
30
30
  *
31
+ * A forced single-tool choice, from the hook or from the turn, that names a
32
+ * tool outside `declaredTools` is warned about and not sent. A hook's choice
33
+ * is then treated as "no override" and the rule decides; the turn's own
34
+ * choice becomes `"auto"`. Without `declaredTools` nothing is checked.
35
+ *
31
36
  * A hook that THROWS is logged and treated as "no override": the steps
32
37
  * already billed are kept and the rule decides. An abort of the turn while
33
38
  * the hook is pending is not a hook failure and is re-thrown. The two are
@@ -32,6 +32,60 @@ const isForcedToolChoice = (choice) => {
32
32
  }
33
33
  return false;
34
34
  };
35
+ /**
36
+ * The tool a choice forces by name, when it forces one. Both wire mappers
37
+ * honour exactly `{ type: "tool", toolName }` with a non-empty name, so
38
+ * that is the only shape that names a function on the wire.
39
+ */
40
+ const forcedToolName = (choice) => {
41
+ if (typeof choice !== "object" || choice === null) {
42
+ return undefined;
43
+ }
44
+ const { type, toolName } = choice;
45
+ return type === "tool" && typeof toolName === "string" && toolName !== ""
46
+ ? toolName
47
+ : undefined;
48
+ };
49
+ /**
50
+ * The name a choice forces when `declaredTools` is known and lacks it, else
51
+ * undefined. Own keys only: the live record may have a null prototype or be
52
+ * keyed by a name such as "constructor".
53
+ */
54
+ const undeclaredForcedToolName = (choice, declaredTools) => {
55
+ if (!declaredTools) {
56
+ return undefined;
57
+ }
58
+ const name = forcedToolName(choice);
59
+ return name !== undefined && !Object.hasOwn(declaredTools, name)
60
+ ? name
61
+ : undefined;
62
+ };
63
+ /**
64
+ * A forced choice for a tool the request does not declare is dropped for the
65
+ * step rather than sent, so a stale hook or a platform-gated tool degrades
66
+ * the turn to the model's own choice instead of failing it with a 400.
67
+ */
68
+ const warnUndeclaredForcedTool = (toolName, step, source) => {
69
+ logger.warn(source === "prepareStep"
70
+ ? `prepareStep forced tool "${toolName}" at step ${step}, but the request does not declare it; ignoring the override for this step`
71
+ : `toolChoice forces tool "${toolName}" at step ${step}, but the request does not declare it; using "auto" for this step`, { toolName, step, source });
72
+ };
73
+ /**
74
+ * The names this request actually carries, in the record shape
75
+ * `declaredTools` takes. The loop sends `args.tools`, built once by the
76
+ * caller; `args.toolsRecord` can hold more (discovery hydrates into it, and
77
+ * this loop never re-declares), so only `tools` answers "is it offered".
78
+ * Null prototype: a tool may be named `__proto__` or `constructor`.
79
+ */
80
+ const declaredToolRecord = (tools) => {
81
+ const declared = Object.create(null);
82
+ for (const tool of tools ?? []) {
83
+ if (typeof tool.name === "string") {
84
+ declared[tool.name] = true;
85
+ }
86
+ }
87
+ return declared;
88
+ };
35
89
  /**
36
90
  * `toolChoiceSteps` is a count of leading steps, so anything but a
37
91
  * non-negative integer is meaningless. Fall back to the default with a WARN
@@ -127,6 +181,11 @@ const abortReason = (signal) => {
127
181
  * how a caller forces a tool on step 3 only. A hook that returns no
128
182
  * `toolChoice` defers to the rule. Nothing else on the hook's result is read.
129
183
  *
184
+ * A forced single-tool choice, from the hook or from the turn, that names a
185
+ * tool outside `declaredTools` is warned about and not sent. A hook's choice
186
+ * is then treated as "no override" and the rule decides; the turn's own
187
+ * choice becomes `"auto"`. Without `declaredTools` nothing is checked.
188
+ *
130
189
  * A hook that THROWS is logged and treated as "no override": the steps
131
190
  * already billed are kept and the rule decides. An abort of the turn while
132
191
  * the hook is pending is not a hook failure and is re-thrown. The two are
@@ -156,16 +215,27 @@ export async function resolveStepToolChoice(input) {
156
215
  }
157
216
  warnIgnoredPrepareStepFields(prepared);
158
217
  if (prepared?.toolChoice !== undefined) {
159
- return prepared.toolChoice;
218
+ const undeclaredPrepared = undeclaredForcedToolName(prepared.toolChoice, input.declaredTools);
219
+ if (undeclaredPrepared === undefined) {
220
+ return prepared.toolChoice;
221
+ }
222
+ warnUndeclaredForcedTool(undeclaredPrepared, input.step, "prepareStep");
160
223
  }
161
224
  }
162
225
  if (input.base === undefined) {
163
226
  return undefined;
164
227
  }
165
- if (!isForcedToolChoice(input.base) ||
166
- input.step < normalizeToolChoiceSteps(input.toolChoiceSteps)) {
228
+ if (!isForcedToolChoice(input.base)) {
229
+ return input.base;
230
+ }
231
+ if (input.step >= normalizeToolChoiceSteps(input.toolChoiceSteps)) {
232
+ return "auto";
233
+ }
234
+ const undeclaredBase = undeclaredForcedToolName(input.base, input.declaredTools);
235
+ if (undeclaredBase === undefined) {
167
236
  return input.base;
168
237
  }
238
+ warnUndeclaredForcedTool(undeclaredBase, input.step, "base");
169
239
  return "auto";
170
240
  }
171
241
  /**
@@ -356,6 +426,7 @@ export async function runNativeGenerateLoop(args, toolExecutionSummaries) {
356
426
  steps: stepRecords,
357
427
  maxSteps: args.maxSteps,
358
428
  model: args.modelId,
429
+ declaredTools: declaredToolRecord(args.tools),
359
430
  ...(args.abortSignal ? { abortSignal: args.abortSignal } : {}),
360
431
  })
361
432
  : undefined;
@@ -15,6 +15,7 @@ import { withTimeout } from "../utils/errorHandling.js";
15
15
  import { buildContextFromPointer, getEffectiveTokenThreshold, } from "../utils/conversationMemory.js";
16
16
  import { runWithCurrentLangfuseContext } from "../services/server/ai/observability/instrumentation.js";
17
17
  import { logger } from "../utils/logger.js";
18
+ import { assistantTurnIdentity, isTurnAlreadyStored, turnMessageFields, userTurnIdentity, } from "../utils/conversationTurnIdentity.js";
18
19
  import { extractIsErrorText } from "../utils/toolResultStatus.js";
19
20
  import { deserializeConversation, encodeStoredMessages, getSessionMessagesKey, MESSAGES_KEY_SUFFIX, isSessionMessagesKey, parseStoredMessages, serializeConversationMetadata, usesSplitMessageStorage, getNormalizedConfig, getPooledRedisClient, getSessionKey, getUserSessionsKey, releasePooledRedisClient, scanKeys, serializeConversation, } from "../utils/redis.js";
20
21
  const redisTracer = tracers.redis;
@@ -393,6 +394,9 @@ export class RedisConversationMemoryManager {
393
394
  const redisKey = getSessionKey(this.redisConfig, options.sessionId, options.userId);
394
395
  const conversationData = await this.redisClient.get(redisKey);
395
396
  let conversation = await this.hydrateMessages(deserializeConversation(conversationData), redisKey);
397
+ if (isTurnAlreadyStored(conversation?.messages ?? [], options.turnId)) {
398
+ return;
399
+ }
396
400
  // Split-storage bookkeeping: whether this session already keeps its
397
401
  // messages in the companion LIST, and how many it held before this
398
402
  // turn — so only the new ones are appended rather than rewriting the
@@ -457,7 +461,7 @@ export class RedisConversationMemoryManager {
457
461
  ? getEffectiveTokenThreshold(options.providerDetails.provider, options.providerDetails.model, this.config.tokenThreshold, conversation.tokenThreshold)
458
462
  : this.config.tokenThreshold || 50000;
459
463
  const userMsg = {
460
- id: randomUUID(),
464
+ ...turnMessageFields(userTurnIdentity(options)),
461
465
  timestamp: options.startTimeStamp?.toISOString() || this.generateTimestamp(),
462
466
  role: "user",
463
467
  content: options.userMessage,
@@ -471,14 +475,11 @@ export class RedisConversationMemoryManager {
471
475
  conversation.messages.push(...options.skillMessages);
472
476
  }
473
477
  const assistantMsg = {
474
- id: randomUUID(),
478
+ ...turnMessageFields(assistantTurnIdentity(options, options.thoughtSignature)),
475
479
  timestamp: this.generateTimestamp(),
476
480
  role: "assistant",
477
481
  content: options.aiResponse,
478
482
  events: options.events || undefined,
479
- ...(options.thoughtSignature && {
480
- metadata: { thoughtSignature: options.thoughtSignature },
481
- }),
482
483
  };
483
484
  conversation.messages.push(assistantMsg);
484
485
  // Store API-reported token counts if available
@@ -6,7 +6,7 @@
6
6
  * Uses real MCP infrastructure for tool discovery and execution.
7
7
  */
8
8
  import type { DecisionCallerOptions, DecisionLimitsQuery, DecisionLimitsReading, DecisionOptions, DecisionResult, AgentDefinition, AgentNetworkConfig, AgentRunOptions, AgentRunOutcome, AgentToolRegistrationOptions, IsolatedAgentDefinition, NetworkExecutionInput, NetworkExecutionOptions, NetworkExecutionResult, NetworkStreamChunk, WorkerInstanceOptions } from "./types/index.js";
9
- import type { CompactionConfig, CompactionResult, ArtifactStore, SpanData, ObservabilityConfig, MetricsSummary, MCPToolAnnotations, TraceView, AuthenticatedContext, AuthProvider, JsonObject, NeuroLinkEvents, TypedEventEmitter, MCPEnhancementsConfig, NeuroLinkAuthConfig, NeurolinkConstructorConfig, ChatMessage, ExternalMCPOperationResult, ExternalMCPServerInstance, ExternalMCPServerHealth, ExternalMCPToolInfo, GenerateOptions, GenerateResult, ProviderStatus, TextGenerationOptions, TextGenerationResult, MCPExecutableTool, MCPServerInfo, MCPStatus, StreamOptions, StreamResult, ToolExecutionContext, ToolExecutionSummary, ToolInfo, ToolRegistrationOptions, BatchOperationResult, StreamGenerationEndContext, ToolRoutingServerDescriptor, ToolDedupConfig, ToolConfig, KnowledgeEngineStatus, ChecklistState, ArtifactPageRequest, BankArtifactOptions, BankedArtifactRef, DelegateCollectRequest, DelegateCollectResult, DelegateHandle, DelegateRegistrationOptions, DelegateSpawnOptions, BackgroundCommandHandle, BackgroundCommandOptions, BackgroundCommandOutputPage, BackgroundCommandPageRequest, BackgroundCommandPolicy, BackgroundCommandStatus, GitToolResult, GitToolsetOptions } from "./types/index.js";
9
+ import type { CompactionConfig, CompactionResult, ArtifactStore, SpanData, ObservabilityConfig, MetricsSummary, MCPToolAnnotations, TraceView, AuthenticatedContext, AuthProvider, JsonObject, NeuroLinkEvents, TypedEventEmitter, MCPEnhancementsConfig, NeuroLinkAuthConfig, NeurolinkConstructorConfig, ChatMessage, ConversationSeedMessage, ConversationSeedOptions, RecordConversationTurnOptions, RecordConversationTurnResult, ExternalMCPOperationResult, ExternalMCPServerInstance, ExternalMCPServerHealth, ExternalMCPToolInfo, GenerateOptions, GenerateResult, ProviderStatus, TextGenerationOptions, TextGenerationResult, MCPExecutableTool, MCPServerInfo, MCPStatus, StreamOptions, StreamResult, ToolExecutionContext, ToolExecutionSummary, ToolInfo, ToolRegistrationOptions, BatchOperationResult, StreamGenerationEndContext, ToolRoutingServerDescriptor, ToolDedupConfig, ToolConfig, KnowledgeEngineStatus, ChecklistState, ArtifactPageRequest, BankArtifactOptions, BankedArtifactRef, DelegateCollectRequest, DelegateCollectResult, DelegateHandle, DelegateRegistrationOptions, DelegateSpawnOptions, BackgroundCommandHandle, BackgroundCommandOptions, BackgroundCommandOutputPage, BackgroundCommandPageRequest, BackgroundCommandPolicy, BackgroundCommandStatus, GitToolResult, GitToolsetOptions } from "./types/index.js";
10
10
  import { ConversationMemoryManager } from "./core/conversationMemoryManager.js";
11
11
  import type { RedisConversationMemoryManager } from "./core/redisConversationMemoryManager.js";
12
12
  import { ExternalServerManager } from "./mcp/externalServerManager.js";
@@ -2120,6 +2120,57 @@ export declare class NeuroLink {
2120
2120
  * @returns Array of ChatMessage objects, or empty array if session doesn't exist
2121
2121
  */
2122
2122
  getSessionMessages(sessionId: string, userId?: string): Promise<ChatMessage[]>;
2123
+ /**
2124
+ * Record one finished turn from a transport that does not go through
2125
+ * generate()/stream() — a voice call — into the same conversation memory
2126
+ * typed chat uses: same key, same manager, same summarization and title
2127
+ * handling, same optional long-term memory write.
2128
+ *
2129
+ * Identity is never defaulted: `userId` is required, because typed chat falls
2130
+ * back to a shared placeholder user when it is missing and a spoken turn must
2131
+ * never be filed there. Recording the same `turnId` twice stores it once, so
2132
+ * a caller can retry after an ambiguous failure.
2133
+ *
2134
+ * A turn with a blank user message, or a blank response and no tool events,
2135
+ * is not stored (an empty assistant message makes some providers reject the
2136
+ * next request); the result says which. Storage failures throw — the caller
2137
+ * decides whether to retry or to carry on without the turn.
2138
+ *
2139
+ * @throws NeuroLinkError (INVALID_PARAMETERS) for a bad sessionId or userId
2140
+ * @throws NeuroLinkError (MISSING_CONFIGURATION) when conversation memory is not enabled
2141
+ * @throws ConversationMemoryError (STORAGE_ERROR) when the store rejects the write
2142
+ */
2143
+ recordConversationTurn(options: RecordConversationTurnOptions): Promise<RecordConversationTurnResult>;
2144
+ /**
2145
+ * The most recent user/assistant messages of a conversation, bounded, for
2146
+ * seeding a model that has no history of its own — a voice model at call
2147
+ * start. Summaries, pinned skills, tool traffic and blank messages are left
2148
+ * out; the oldest messages are dropped first when `maxChars` would be
2149
+ * exceeded. Best effort: a missing conversation, disabled memory or a read
2150
+ * failure yields an empty seed and never throws, because a call must start
2151
+ * either way.
2152
+ */
2153
+ getConversationSeed(options: ConversationSeedOptions): Promise<ConversationSeedMessage[]>;
2154
+ /**
2155
+ * Whether a conversation already holds messages under this user. A client
2156
+ * may name the conversation a call appends to; the server accepts the name
2157
+ * only when this is true for the user the session is verified as.
2158
+ */
2159
+ conversationExists(options: {
2160
+ sessionId: string;
2161
+ userId: string;
2162
+ }): Promise<boolean>;
2163
+ /**
2164
+ * Whether a stored conversation is owned by someone other than `userId`.
2165
+ *
2166
+ * The Redis manager includes the user in the key, so a different user simply
2167
+ * never finds the conversation. The in-process manager keys sessions by id
2168
+ * alone and only records the owner, so ownership is checked here: a session
2169
+ * that exists with no recorded owner, or another one, is not `userId`'s.
2170
+ */
2171
+ private isOwnedByAnotherUser;
2172
+ /** Shared identity check for the voice-memory methods. */
2173
+ private static assertConversationIdentity;
2123
2174
  /**
2124
2175
  * Replace the entire messages array for a session.
2125
2176
  * @param sessionId - The session ID to update
package/dist/neurolink.js CHANGED
@@ -9342,6 +9342,9 @@ Current user's request: ${currentInput}`;
9342
9342
  ...(pendingSkillMessages.length > 0
9343
9343
  ? { skillMessages: pendingSkillMessages }
9344
9344
  : {}),
9345
+ ...(enhancedOptions.modality
9346
+ ? { modality: enhancedOptions.modality }
9347
+ : {}),
9345
9348
  });
9346
9349
  this.recordMemorySpan("memory.store", { "memory.operation": "store", "memory.path": "stream" }, Date.now() - memStoreStart, SpanStatus.OK);
9347
9350
  logger.debug("[NeuroLink.stream] Stored conversation turn with events", {
@@ -12757,6 +12760,198 @@ Current user's request: ${currentInput}`;
12757
12760
  }
12758
12761
  return await this.conversationMemory.getSessionMessages(sessionId, userId);
12759
12762
  }
12763
+ /**
12764
+ * Record one finished turn from a transport that does not go through
12765
+ * generate()/stream() — a voice call — into the same conversation memory
12766
+ * typed chat uses: same key, same manager, same summarization and title
12767
+ * handling, same optional long-term memory write.
12768
+ *
12769
+ * Identity is never defaulted: `userId` is required, because typed chat falls
12770
+ * back to a shared placeholder user when it is missing and a spoken turn must
12771
+ * never be filed there. Recording the same `turnId` twice stores it once, so
12772
+ * a caller can retry after an ambiguous failure.
12773
+ *
12774
+ * A turn with a blank user message, or a blank response and no tool events,
12775
+ * is not stored (an empty assistant message makes some providers reject the
12776
+ * next request); the result says which. Storage failures throw — the caller
12777
+ * decides whether to retry or to carry on without the turn.
12778
+ *
12779
+ * @throws NeuroLinkError (INVALID_PARAMETERS) for a bad sessionId or userId
12780
+ * @throws NeuroLinkError (MISSING_CONFIGURATION) when conversation memory is not enabled
12781
+ * @throws ConversationMemoryError (STORAGE_ERROR) when the store rejects the write
12782
+ */
12783
+ async recordConversationTurn(options) {
12784
+ const { sessionId, userId, turnId } = options;
12785
+ NeuroLink.assertConversationIdentity(sessionId, userId);
12786
+ const identity = { sessionId, userId, ...(turnId ? { turnId } : {}) };
12787
+ if (!options.userMessage.trim()) {
12788
+ return { recorded: false, reason: "empty-user-message", ...identity };
12789
+ }
12790
+ const hasToolEvents = (options.events ?? []).some((event) => event.type === "tool:start" || event.type === "tool:end");
12791
+ if (!options.aiResponse.trim() && !hasToolEvents) {
12792
+ return { recorded: false, reason: "empty-response", ...identity };
12793
+ }
12794
+ await this.initializeConversationMemoryForGeneration(options.requestId ?? `record-turn-${Date.now()}`, Date.now(), process.hrtime.bigint());
12795
+ if (!this.conversationMemory) {
12796
+ throw new NeuroLinkError({
12797
+ code: ERROR_CODES.MISSING_CONFIGURATION,
12798
+ message: "Conversation memory is not enabled",
12799
+ category: ErrorCategory.VALIDATION,
12800
+ severity: ErrorSeverity.HIGH,
12801
+ retriable: false,
12802
+ });
12803
+ }
12804
+ if (await this.isOwnedByAnotherUser(sessionId, userId)) {
12805
+ throw new NeuroLinkError({
12806
+ code: ERROR_CODES.INVALID_PARAMETERS,
12807
+ message: "The conversation belongs to another user",
12808
+ category: ErrorCategory.VALIDATION,
12809
+ severity: ErrorSeverity.MEDIUM,
12810
+ retriable: false,
12811
+ });
12812
+ }
12813
+ const memStoreStart = Date.now();
12814
+ try {
12815
+ await this.conversationMemory.storeConversationTurn({
12816
+ sessionId,
12817
+ userId,
12818
+ userMessage: options.userMessage,
12819
+ aiResponse: options.aiResponse,
12820
+ startTimeStamp: options.startTime,
12821
+ ...(options.provider && options.model
12822
+ ? {
12823
+ providerDetails: {
12824
+ provider: options.provider,
12825
+ model: options.model,
12826
+ },
12827
+ }
12828
+ : {}),
12829
+ enableSummarization: options.enableSummarization,
12830
+ events: options.events?.length ? options.events : undefined,
12831
+ requestId: options.requestId,
12832
+ ...(options.modality ? { modality: options.modality } : {}),
12833
+ ...(turnId ? { turnId } : {}),
12834
+ ...(options.interrupted ? { interrupted: true } : {}),
12835
+ ...(options.callEnded ? { callEnded: true } : {}),
12836
+ });
12837
+ this.recordMemorySpan("memory.store", { "memory.operation": "store", "memory.path": "record" }, Date.now() - memStoreStart, SpanStatus.OK);
12838
+ }
12839
+ catch (error) {
12840
+ this.recordMemorySpan("memory.store", { "memory.operation": "store", "memory.path": "record" }, Date.now() - memStoreStart, SpanStatus.ERROR, error instanceof Error ? error.message : String(error));
12841
+ throw error;
12842
+ }
12843
+ if (this.shouldWriteMemory(options.memory, userId, options.aiResponse)) {
12844
+ this.storeMemoryInBackground({
12845
+ prompt: options.userMessage,
12846
+ response: options.aiResponse.trim(),
12847
+ userId,
12848
+ sessionId,
12849
+ provider: options.provider ?? "",
12850
+ model: options.model,
12851
+ }, options.memory, { sessionId });
12852
+ }
12853
+ return { recorded: true, ...identity };
12854
+ }
12855
+ /**
12856
+ * The most recent user/assistant messages of a conversation, bounded, for
12857
+ * seeding a model that has no history of its own — a voice model at call
12858
+ * start. Summaries, pinned skills, tool traffic and blank messages are left
12859
+ * out; the oldest messages are dropped first when `maxChars` would be
12860
+ * exceeded. Best effort: a missing conversation, disabled memory or a read
12861
+ * failure yields an empty seed and never throws, because a call must start
12862
+ * either way.
12863
+ */
12864
+ async getConversationSeed(options) {
12865
+ const { sessionId, userId } = options;
12866
+ const maxTurns = Math.max(0, Math.floor(options.maxTurns ?? 10));
12867
+ const maxChars = Math.max(0, Math.floor(options.maxChars ?? 6000));
12868
+ try {
12869
+ NeuroLink.assertConversationIdentity(sessionId, userId);
12870
+ const stored = await this.getSessionMessages(sessionId, userId);
12871
+ const spoken = [];
12872
+ for (const message of stored) {
12873
+ if ((message.role !== "user" && message.role !== "assistant") ||
12874
+ message.metadata?.isSummary ||
12875
+ message.metadata?.isSkill ||
12876
+ typeof message.content !== "string" ||
12877
+ !message.content.trim()) {
12878
+ continue;
12879
+ }
12880
+ spoken.push({
12881
+ role: message.role,
12882
+ content: message.content,
12883
+ ...(message.metadata?.modality
12884
+ ? { modality: message.metadata.modality }
12885
+ : {}),
12886
+ });
12887
+ }
12888
+ const recent = spoken.slice(-(maxTurns * 2));
12889
+ let used = 0;
12890
+ let firstKept = recent.length;
12891
+ for (let index = recent.length - 1; index >= 0; index -= 1) {
12892
+ used += recent[index].content.length;
12893
+ if (used > maxChars) {
12894
+ break;
12895
+ }
12896
+ firstKept = index;
12897
+ }
12898
+ return recent.slice(firstKept);
12899
+ }
12900
+ catch (error) {
12901
+ logger.warn("[NeuroLink] getConversationSeed failed, starting unseeded", {
12902
+ error: error instanceof Error ? error.message : String(error),
12903
+ });
12904
+ return [];
12905
+ }
12906
+ }
12907
+ /**
12908
+ * Whether a conversation already holds messages under this user. A client
12909
+ * may name the conversation a call appends to; the server accepts the name
12910
+ * only when this is true for the user the session is verified as.
12911
+ */
12912
+ async conversationExists(options) {
12913
+ NeuroLink.assertConversationIdentity(options.sessionId, options.userId);
12914
+ const stored = await this.getSessionMessages(options.sessionId, options.userId);
12915
+ if (stored.length === 0) {
12916
+ return false;
12917
+ }
12918
+ return !(await this.isOwnedByAnotherUser(options.sessionId, options.userId));
12919
+ }
12920
+ /**
12921
+ * Whether a stored conversation is owned by someone other than `userId`.
12922
+ *
12923
+ * The Redis manager includes the user in the key, so a different user simply
12924
+ * never finds the conversation. The in-process manager keys sessions by id
12925
+ * alone and only records the owner, so ownership is checked here: a session
12926
+ * that exists with no recorded owner, or another one, is not `userId`'s.
12927
+ */
12928
+ async isOwnedByAnotherUser(sessionId, userId) {
12929
+ const memory = this.conversationMemory;
12930
+ if (!memory || memory.isRedisConfigured) {
12931
+ return false;
12932
+ }
12933
+ const session = await memory.getSession(sessionId);
12934
+ return session !== undefined && session.userId !== userId;
12935
+ }
12936
+ /** Shared identity check for the voice-memory methods. */
12937
+ static assertConversationIdentity(sessionId, userId) {
12938
+ const problem = typeof sessionId !== "string" || !sessionId.trim()
12939
+ ? "sessionId must be a non-empty string"
12940
+ : sessionId.includes(":")
12941
+ ? 'sessionId must not contain ":"'
12942
+ : typeof userId !== "string" || !userId.trim()
12943
+ ? "userId must be a non-empty string"
12944
+ : undefined;
12945
+ if (problem) {
12946
+ throw new NeuroLinkError({
12947
+ code: ERROR_CODES.INVALID_PARAMETERS,
12948
+ message: problem,
12949
+ category: ErrorCategory.VALIDATION,
12950
+ severity: ErrorSeverity.MEDIUM,
12951
+ retriable: false,
12952
+ });
12953
+ }
12954
+ }
12760
12955
  /**
12761
12956
  * Replace the entire messages array for a session.
12762
12957
  * @param sessionId - The session ID to update
@@ -2008,6 +2008,7 @@ export class AnthropicProvider extends BaseProvider {
2008
2008
  steps: stepRecords,
2009
2009
  maxSteps,
2010
2010
  model: modelId,
2011
+ declaredTools: toolsRecord,
2011
2012
  ...(abortSignal ? { abortSignal } : {}),
2012
2013
  })))
2013
2014
  : undefined;
@@ -1741,6 +1741,7 @@ export class OpenAIChatCompletionsProvider extends BaseProvider {
1741
1741
  steps,
1742
1742
  maxSteps,
1743
1743
  model: modelId,
1744
+ declaredTools: toolsRecord,
1744
1745
  ...(abortSignal ? { abortSignal } : {}),
1745
1746
  }), wireNameMaps?.toWire)
1746
1747
  : undefined,
@@ -295,6 +295,8 @@ export type ToolResultData = {
295
295
  /** Error message if execution failed */
296
296
  error?: string;
297
297
  };
298
+ /** How a conversation turn was entered. */
299
+ export type ConversationModality = "text" | "voice";
298
300
  /** Metadata associated with a ChatMessage. */
299
301
  export type ChatMessageMetadata = {
300
302
  /** Is this a summary message? */
@@ -328,6 +330,22 @@ export type ChatMessageMetadata = {
328
330
  thinkingExpanded?: boolean;
329
331
  /** Step index for reconstructing parallel vs sequential tool calls */
330
332
  stepIndex?: number;
333
+ /**
334
+ * How the message was entered: typed (`"text"`) or spoken (`"voice"`).
335
+ * Absent on every message stored before this field existed, and on typed
336
+ * turns that did not set it; readers treat absence as `"text"`.
337
+ */
338
+ modality?: ConversationModality;
339
+ /**
340
+ * Stable id of the voice turn this message belongs to. The stored message
341
+ * ids are `${turnId}:user` and `${turnId}:assistant`, which is what makes
342
+ * recording the same turn twice a no-op.
343
+ */
344
+ turnId?: string;
345
+ /** True when the message was cut off (barge-in, or the call ended) before it finished. */
346
+ interrupted?: boolean;
347
+ /** True when the call ended while this turn was still open. */
348
+ callEnded?: boolean;
331
349
  /**
332
350
  * Head/tail preview of a large tool output.
333
351
  * Only present on tool_result messages where the output exceeded truncation limits.
@@ -515,6 +533,97 @@ export type StoreConversationTurnOptions = {
515
533
  * for the canonical partition-and-reanchor pattern).
516
534
  */
517
535
  skillMessages?: ChatMessage[];
536
+ /**
537
+ * How the turn was entered. Stamped on both stored messages' metadata when
538
+ * set; a typed turn that leaves it unset is stored exactly as before.
539
+ */
540
+ modality?: ConversationModality;
541
+ /**
542
+ * Idempotency key for the turn. When set, the user and assistant messages
543
+ * are stored with the ids `${turnId}:user` and `${turnId}:assistant`, and a
544
+ * turn whose assistant id is already in the session's recent messages is
545
+ * skipped, so a retry after an ambiguous failure cannot duplicate it.
546
+ */
547
+ turnId?: string;
548
+ /** The assistant message was cut off before it finished. */
549
+ interrupted?: boolean;
550
+ /** The call ended while this turn was open. */
551
+ callEnded?: boolean;
552
+ };
553
+ /** Id and metadata a memory manager gives one stored message of a turn. */
554
+ export type TurnMessageIdentity = {
555
+ /** Stable id for the message, or undefined to let the manager mint a random one. */
556
+ id: string | undefined;
557
+ /** Metadata to set on the message, or undefined when there is none to add. */
558
+ metadata: ChatMessageMetadata | undefined;
559
+ };
560
+ /**
561
+ * Input to `NeuroLink.recordConversationTurn`: one finished turn from a
562
+ * transport that does not go through generate()/stream() (a voice call),
563
+ * written to the same conversation memory typed chat uses.
564
+ */
565
+ export type RecordConversationTurnOptions = {
566
+ /** Conversation to append to. Must not contain ":" (it is part of the stored key). */
567
+ sessionId: string;
568
+ /**
569
+ * Owner of the conversation. Required: typed chat falls back to a shared
570
+ * placeholder user when it is missing, and a spoken turn must never land
571
+ * there.
572
+ */
573
+ userId: string;
574
+ /** What the person said. Must be non-blank. */
575
+ userMessage: string;
576
+ /** What the assistant said (or the closing note for a cut-off turn). Must be non-blank unless `events` carries tool activity. */
577
+ aiResponse: string;
578
+ /** How the turn was entered. Stamped on both stored messages. */
579
+ modality?: ConversationModality;
580
+ /** Idempotency key. Recording the same `turnId` again is a no-op. */
581
+ turnId?: string;
582
+ /** The assistant message was cut off before it finished. */
583
+ interrupted?: boolean;
584
+ /** The call ended while this turn was open. */
585
+ callEnded?: boolean;
586
+ /** Tool and UI events of the turn, in the shape typed chat stores. */
587
+ events?: StreamEventSequence[];
588
+ /** When the turn started. */
589
+ startTime?: Date;
590
+ /** Provider and model that answered, for token-threshold selection. */
591
+ provider?: string;
592
+ model?: string;
593
+ /** Observability request identifier. */
594
+ requestId?: string;
595
+ /** Per-call override of summarization, as on generate()/stream(). */
596
+ enableSummarization?: boolean;
597
+ /** Per-call long-term memory switches, as on generate()/stream(). */
598
+ memory?: {
599
+ enabled?: boolean;
600
+ write?: boolean;
601
+ };
602
+ };
603
+ /** Outcome of `NeuroLink.recordConversationTurn`. */
604
+ export type RecordConversationTurnResult = {
605
+ /** False when the turn was deliberately not stored; see `reason`. */
606
+ recorded: boolean;
607
+ /** Why nothing was stored. Only set when `recorded` is false. */
608
+ reason?: "empty-user-message" | "empty-response";
609
+ sessionId: string;
610
+ userId: string;
611
+ turnId?: string;
612
+ };
613
+ /** Options for `NeuroLink.getConversationSeed`. */
614
+ export type ConversationSeedOptions = {
615
+ sessionId: string;
616
+ userId: string;
617
+ /** Most recent user/assistant exchanges to return (default 10). */
618
+ maxTurns?: number;
619
+ /** Cap on the total characters returned; the oldest messages are dropped first (default 6000). */
620
+ maxChars?: number;
621
+ };
622
+ /** One message of a conversation seed. */
623
+ export type ConversationSeedMessage = {
624
+ role: "user" | "assistant";
625
+ content: string;
626
+ modality?: ConversationModality;
518
627
  };
519
628
  /**
520
629
  * Lightweight session metadata for efficient session listing