UNPKG

@juspay/neurolink

Version:

The pipe layer of an AI nervous system: one interface connecting provider neurons to your application, across three inference types — generate, stream and decide. `decide` returns typed, calibrated judgements from a non-generative model (~400ms, ~$0.00002

162 lines (161 loc) • 7.53 kB
/** * Shared Summarization Engine * * Extracted from ConversationMemoryManager and RedisConversationMemoryManager * to eliminate code duplication. Both managers delegate to this engine. */ import { buildContextFromPointer, generateSummary, } from "../utils/conversationMemory.js"; import { RECENT_MESSAGES_RATIO } from "../config/conversationMemory.js"; import { snapSplitToBatchBoundary } from "./toolPairRepair.js"; import { estimateMessageTokens, estimateMessagesTokens, } from "../utils/tokenEstimation.js"; import { withSpan } from "../telemetry/withSpan.js"; import { tracers } from "../telemetry/tracers.js"; import { logger } from "../utils/logger.js"; /** * Centralized summarization engine for conversation memory. * Handles token counting, threshold checking, and summary generation. */ export class SummarizationEngine { /** * Check if a session needs summarization and perform it if so. * @param session - Session memory to check and potentially summarize * @param threshold - Token threshold that triggers summarization * @param config - Conversation memory configuration (partial allowed) * @param logPrefix - Prefix for log messages * @returns True if summarization was performed */ async checkAndSummarize(session, threshold, config, logPrefix = "[SummarizationEngine]", requestId) { return withSpan({ name: "neurolink.memory.summarize", tracer: tracers.memory, attributes: { "memory.session_id": session.sessionId ?? "unknown", "memory.threshold": threshold, }, }, async (span) => { const contextMessages = buildContextFromPointer(session, requestId); const tokenCount = this.estimateTokens(contextMessages); session.lastTokenCount = tokenCount; session.lastCountedAt = Date.now(); logger.info("[Summarization] Check", { requestId, sessionId: session.sessionId, tokenCount, threshold, willSummarize: tokenCount >= threshold, }); if (tokenCount >= threshold) { await this.summarizeSession(session, threshold, config, logPrefix, requestId); span.setAttribute("memory.summarized", true); return true; } span.setAttribute("memory.summarized", false); return false; }); // end withSpan } /** * Perform token-based summarization on a session. * Uses pointer-based, non-destructive approach. * @param session - Session memory to summarize * @param threshold - Token threshold for calculating split point * @param config - Conversation memory configuration (partial allowed) * @param logPrefix - Prefix for log messages */ async summarizeSession(session, threshold, config, logPrefix = "[SummarizationEngine]", requestId) { const startTime = Date.now(); const startIndex = session.summarizedUpToMessageId ? session.messages.findIndex((m) => m.id === session.summarizedUpToMessageId) + 1 : 0; const recentMessages = session.messages.slice(startIndex); if (recentMessages.length === 0) { return; } const targetRecentTokens = threshold * RECENT_MESSAGES_RATIO; const splitIndex = this.findSplitIndexByTokens(recentMessages, targetRecentTokens); const messagesToSummarize = recentMessages.slice(0, splitIndex); if (messagesToSummarize.length === 0) { return; } const recentToKeep = recentMessages.length - messagesToSummarize.length; logger.info("[Summarization] Starting", { requestId, sessionId: session.sessionId, messagesToSummarize: messagesToSummarize.length, recentToKeep, hasPreviousSummary: !!session.summarizedMessage, }); try { const summary = await generateSummary(messagesToSummarize, config, logPrefix, session.summarizedMessage, requestId); if (!summary) { logger.warn(`${logPrefix} Summary generation failed`, { requestId, sessionId: session.sessionId, durationMs: Date.now() - startTime, }); return; } const lastSummarized = messagesToSummarize[messagesToSummarize.length - 1]; session.summarizedUpToMessageId = lastSummarized.id; session.summarizedMessage = summary; logger.info("[Summarization] Complete", { requestId, sessionId: session.sessionId, summaryChars: summary.length, newPointerId: lastSummarized.id, durationMs: Date.now() - startTime, }); } catch (err) { const errorMessage = err instanceof Error ? err.message : String(err); logger.error("[Summarization] Error", { requestId, sessionId: session.sessionId, error: errorMessage, durationMs: Date.now() - startTime, }); throw err; } } /** * Estimate total tokens for a message array. * @param messages - Array of chat messages * @returns Estimated token count */ estimateTokens(messages) { // Delegates to the shared estimator so this threshold agrees with the // budget checker and the compactor. The previous content-only sum scored // tool calls (whose payload lives in `args`) at zero, so a Write/Edit-heavy // session never reached the summarization threshold at all. return estimateMessagesTokens(messages); } /** * Find split index to keep recent messages within target token count. * Works backwards from the most recent message to find the split point. * @param messages - Array of messages to analyze * @param targetRecentTokens - Target token count for recent messages * @returns Index at which to split (messages before this index will be summarized) */ findSplitIndexByTokens(messages, targetRecentTokens) { let recentTokens = 0; let splitIndex = messages.length; for (let i = messages.length - 1; i >= 0; i--) { const msgTokens = estimateMessageTokens(messages[i]); if (recentTokens + msgTokens > targetRecentTokens) { splitIndex = i + 1; break; } recentTokens += msgTokens; } // Ensure at least one message is summarized, then snap off any tool batch // the boundary would otherwise cut in half (which would leave the recent // window opening on an orphaned tool_result). const snapped = snapSplitToBatchBoundary(messages, Math.max(1, splitIndex)); // A forward snap can consume the ENTIRE array when the batch starts at // index 0. Summarizing everything would leave no recent window at all and // move the pointer to the last message, so the next read returns a summary // and nothing else. Decline instead — `summarizeSession` skips a round on // an empty slice. `structuredSummarizer` already guards this case; this is // the same guard for the pointer-based path. return snapped >= messages.length ? 0 : snapped; } }