@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
JavaScript
/**
* 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;
}
}