voyageai-cli
Version:
CLI for Voyage AI embeddings, reranking, and MongoDB Atlas Vector Search
264 lines (237 loc) • 9.3 kB
JavaScript
;
const { estimateTokens } = require('./turn-state.js');
/**
* Sliding window strategy: selects the most recent turns that fit
* within a given token budget. Works backwards from the newest turn,
* accumulating token costs until the budget is exhausted.
*/
class SlidingWindowStrategy {
/**
* Select the most recent turns fitting within budgetTokens.
* @param {Array<{role: string, content: string}>} turns - Conversation turns (oldest first)
* @param {number} budgetTokens - Maximum tokens available for history
* @returns {Array<{role: string, content: string}>} Selected turns (oldest first)
*/
static select(turnsOrOpts, budgetTokens) {
// Support both: select(turns, budget) and select({ turns, budgetTokens })
let turns, budget;
if (Array.isArray(turnsOrOpts)) {
turns = turnsOrOpts;
budget = budgetTokens;
} else {
turns = turnsOrOpts.turns;
budget = turnsOrOpts.budgetTokens;
}
if (!turns || turns.length === 0 || budget <= 0) {
return [];
}
let used = 0;
let startIndex = turns.length; // exclusive start (we walk backwards)
for (let i = turns.length - 1; i >= 0; i--) {
const cost = estimateTokens(turns[i].content);
if (used + cost > budget) {
break;
}
used += cost;
startIndex = i;
}
return turns.slice(startIndex);
}
}
/**
* MemoryManager dispatches history selection to a named strategy.
* Ships with 'sliding_window' registered by default; additional strategies
* (e.g., summarization, hierarchical) can be added via registerStrategy().
*/
class MemoryManager {
/**
* @param {object} [options]
* @param {Record<string, {select: Function}>} [options.strategies] - Strategy map
* @param {string} [options.defaultStrategy='sliding_window'] - Default strategy name
*/
constructor({ strategies = {}, defaultStrategy = 'sliding_window', llm, recall, currentSessionId, query } = {}) {
this._strategies = new Map();
this._defaultStrategy = defaultStrategy;
this._extraOpts = {};
this._optionsStyleStrategies = new Set(['sliding_window']);
if (llm) this._extraOpts.llm = llm;
if (recall) this._extraOpts.recall = recall;
if (currentSessionId) this._extraOpts.currentSessionId = currentSessionId;
if (query) this._extraOpts.query = query;
// Register built-in strategy
this._strategies.set('sliding_window', SlidingWindowStrategy);
// Register any user-provided strategies
for (const [name, strategy] of Object.entries(strategies)) {
this._strategies.set(name, strategy);
}
}
/**
* Set or update extra options passed to strategies (e.g. llm, recall, query).
* @param {object} opts - Key-value pairs to merge into extra options
*/
setOpts(opts) {
Object.assign(this._extraOpts, opts);
}
/**
* Build history by selecting turns via the named (or default) strategy.
* @param {object} options
* @param {Array<{role: string, content: string}>} options.turns - All conversation turns
* @param {number} options.budget - Token budget for history
* @param {string} [options.strategy] - Strategy name (defaults to constructor default)
* @returns {Array<{role: string, content: string}>} Selected turns
*/
buildHistory({ turns, budget, strategy } = {}) {
const name = strategy || this._defaultStrategy;
const strat = this._strategies.get(name);
if (!strat) {
throw new Error(`Unknown strategy: ${name}`);
}
// Known built-in strategies accept an options-object form.
// Custom/legacy strategies that expect positional (turns, budget) get the old form
// for backward compatibility.
let result;
if (this._optionsStyleStrategies.has(name)) {
result = strat.select({ turns, budgetTokens: budget, ...this._extraOpts });
} else {
result = strat.select(turns, budget);
}
// Return as-is: synchronous strategies return arrays, async ones return Promises.
// Callers can safely `await` either (await on a non-Promise is harmless).
return result;
}
/**
* Get names of all registered strategies.
* @returns {string[]}
*/
getStrategyNames() {
return Array.from(this._strategies.keys());
}
/**
* Register a new strategy for extensibility.
* @param {string} name - Strategy name
* @param {{select: Function}} strategy - Strategy object with select() method
*/
registerStrategy(name, strategy) {
this._strategies.set(name, strategy);
}
}
/**
* Hierarchical strategy: layers verbatim recent turns, tiered summaries
* of older turns, and vector-recalled context from past sessions.
*
* Budget allocation:
* - 20% for cross-session recall context
* - 20% for summarized older turns
* - 60% for recent verbatim turns
*
* Degrades gracefully when components are unavailable:
* - No recall: skips cross-session context
* - No LLM: skips summarization, uses sliding window for old turns
*/
class HierarchicalStrategy {
/**
* Select turns using hierarchical strategy.
*
* @param {object} options
* @param {Array<{role: string, content: string}>} options.turns - All conversation turns
* @param {number} options.budgetTokens - Maximum tokens for history
* @param {{chat: Function}|null} options.llm - LLM for summarization
* @param {{recall: Function}|null} options.recall - CrossSessionRecall instance
* @param {string} options.currentSessionId - Current session to exclude from recall
* @param {string} options.query - Current query for recall search
* @returns {Promise<Array<{role: string, content: string}>>}
*/
static async select({ turns, budgetTokens, llm, recall, currentSessionId, query }) {
if (!turns || turns.length === 0 || budgetTokens <= 0) {
return [];
}
const result = [];
// Budget allocation
const recallBudget = Math.floor(budgetTokens * 0.2);
const summaryBudget = Math.floor(budgetTokens * 0.2);
const recentBudget = Math.floor(budgetTokens * 0.6);
// Step 1: Cross-session recall
if (recall && query) {
try {
const recallResults = await recall.recall(query, currentSessionId);
if (recallResults.length > 0) {
const contextParts = recallResults.map(
(r) => `[Session ${r.sessionId}] ${r.summary}`
);
let contextText = contextParts.join('\n\n');
// Trim to recallBudget tokens
const maxChars = recallBudget * 4; // estimateTokens uses ceil(len/4)
if (contextText.length > maxChars) {
contextText = contextText.slice(0, maxChars);
}
result.push({
role: 'system',
content: `--- Past Session Context ---\n${contextText}\n--- End Past Context ---`,
});
}
} catch {
// Graceful degradation: skip cross-session context
}
}
// Step 2+3: Split turns into old and recent, summarize old if LLM available
// Use remaining budget (subtract recall tokens used)
let recallTokensUsed = 0;
for (const msg of result) {
recallTokensUsed += estimateTokens(msg.content);
}
const remainingBudget = budgetTokens - recallTokensUsed;
const effectiveRecentBudget = Math.min(recentBudget, remainingBudget);
// Split: allocate recent turns
let recentTokens = 0;
let splitIndex = turns.length;
for (let i = turns.length - 1; i >= 0; i--) {
const cost = estimateTokens(turns[i].content);
if (recentTokens + cost > effectiveRecentBudget) {
break;
}
recentTokens += cost;
splitIndex = i;
}
const oldTurns = turns.slice(0, splitIndex);
const recentTurns = turns.slice(splitIndex);
// Step 3: Summarize old turns if LLM available
if (oldTurns.length > 0 && llm) {
try {
const { summarizeTurns } = require('./memory-summarizer.js');
const summary = await summarizeTurns(oldTurns, llm);
if (summary) {
result.push({
role: 'system',
content: `[Summary of earlier conversation]\n${summary}`,
});
}
} catch {
// Summarization failed -- just include what fits via sliding window
}
}
// If no LLM and no recall, fall back entirely to sliding window
if (result.length === 0 && !llm) {
return SlidingWindowStrategy.select(turns, budgetTokens);
}
result.push(...recentTurns);
return result;
}
}
/**
* Create a MemoryManager with all built-in strategies registered:
* sliding_window, summarization, hierarchical.
*
* @param {object} [options] - MemoryManager constructor options
* @returns {MemoryManager}
*/
function createFullMemoryManager(options = {}) {
const { SummarizationStrategy } = require('./memory-summarizer.js');
const mm = new MemoryManager(options);
mm.registerStrategy('summarization', SummarizationStrategy);
mm.registerStrategy('hierarchical', HierarchicalStrategy);
// Mark strategies that accept options-object form (for extra opts like llm, recall)
mm._optionsStyleStrategies.add('summarization');
mm._optionsStyleStrategies.add('hierarchical');
return mm;
}
module.exports = { SlidingWindowStrategy, MemoryManager, HierarchicalStrategy, createFullMemoryManager };