@juspay/neurolink 12.49.20 → 12.50.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +3 -3
- package/dist/browser/neurolink.min.js +371 -371
- package/dist/core/conversationMemoryManager.js +10 -4
- package/dist/core/redisConversationMemoryManager.js +6 -5
- package/dist/neurolink.d.ts +52 -1
- package/dist/neurolink.js +195 -0
- package/dist/types/conversation.d.ts +109 -0
- package/dist/types/livekit.d.ts +246 -3
- package/dist/types/sdk.d.ts +1 -1
- package/dist/types/stream.d.ts +8 -1
- package/dist/utils/conversationTurnIdentity.d.ts +24 -0
- package/dist/utils/conversationTurnIdentity.js +81 -0
- package/dist/voice/livekit/brain.js +10 -7
- package/dist/voice/livekit/index.d.ts +2 -0
- package/dist/voice/livekit/index.js +2 -0
- package/dist/voice/livekit/memoryBinding.d.ts +47 -0
- package/dist/voice/livekit/memoryBinding.js +152 -0
- package/dist/voice/livekit/realtimeMcpTools.js +20 -1
- package/dist/voice/livekit/realtimeMemory.d.ts +22 -0
- package/dist/voice/livekit/realtimeMemory.js +111 -0
- package/dist/voice/livekit/realtimeVoiceAgent.js +40 -1
- package/dist/voice/livekit/turnRecorder.d.ts +38 -0
- package/dist/voice/livekit/turnRecorder.js +362 -0
- package/dist/voice/livekit/voiceAgent.js +43 -5
- package/docs-site/static/search-index.json +1 -0
- package/package.json +2 -1
|
@@ -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
|
/**
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
package/dist/neurolink.d.ts
CHANGED
|
@@ -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
|
|
@@ -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
|