Skip to main content
Glama
SummaryMerger.ts10.5 kB
import { GoogleGenerativeAI } from '@google/generative-ai'; import Anthropic from '@anthropic-ai/sdk'; import { ProjectState, ProjectStateSchema, RecentChange } from '../types/schema.js'; import { logger } from '../utils/logger.js'; import { getCurrentTimestamp } from '../utils/time.js'; const DEFAULT_GEMINI_MODEL = 'gemini-3-pro-preview'; // Latest Gemini 3 Pro (Nov 2025) const ANTHROPIC_MODEL = 'claude-3-5-sonnet-20241022'; const MAX_TOKENS = 8000; const TEMPERATURE = 0.3; type Provider = 'gemini' | 'anthropic'; /** * LLM-based summary merger that combines old state with new context */ export class SummaryMerger { private geminiClient: GoogleGenerativeAI; private anthropicClient?: Anthropic; private geminiModel: string; private anthropicApiKey: string; constructor(geminiApiKey: string, anthropicApiKey: string, geminiModel?: string) { this.geminiClient = new GoogleGenerativeAI(geminiApiKey); this.anthropicApiKey = anthropicApiKey; this.geminiModel = geminiModel || process.env.GEMINI_MODEL || DEFAULT_GEMINI_MODEL; } private getAnthropicClient(): Anthropic { if (!this.anthropicClient) { if (!this.anthropicApiKey) { throw new Error('Anthropic API key not configured'); } this.anthropicClient = new Anthropic({ apiKey: this.anthropicApiKey }); } return this.anthropicClient; } /** * Merges old project state with new context using the preferred provider */ async merge( oldState: ProjectState, newContext: string, tokenCount: number, preferredProvider: Provider = 'gemini' ): Promise<ProjectState> { const startTime = Date.now(); try { const prompt = this.buildMergePrompt(oldState, newContext, tokenCount); logger.info('Invoking LLM for state merge', { provider: preferredProvider, oldVersion: oldState.meta.version, contextLength: newContext.length, tokenCount, geminiModel: this.geminiModel }); let responseText: string; if (preferredProvider === 'anthropic') { responseText = await this.mergeWithRetry(() => this.mergeWithAnthropic(prompt)); } else { try { responseText = await this.mergeWithRetry(() => this.mergeWithGemini(prompt)); } catch (error) { logger.warn('Gemini merge failed, falling back to Anthropic', { error }); responseText = await this.mergeWithRetry(() => this.mergeWithAnthropic(prompt)); } } // Parse and validate the response const mergedState = this.parseAndValidate(responseText, oldState); const duration = Date.now() - startTime; logger.info('State merge completed successfully', { duration, newVersion: mergedState.meta.version, provider: preferredProvider }); return mergedState; } catch (error) { logger.error('Failed to merge state with LLM', { error }); // Fallback: return old state with just the new context appended logger.warn('Falling back to simple context append'); return this.fallbackMerge(oldState, newContext, tokenCount); } } private async mergeWithRetry<T>( fn: () => Promise<T>, maxRetries: number = 2, retryDelay: number = 1000 ): Promise<T> { let lastError: Error | undefined; for (let attempt = 0; attempt <= maxRetries; attempt++) { try { return await fn(); } catch (error) { lastError = error as Error; // Don't retry on auth errors or invalid requests (simplified check) if (this.isNonRetryableError(error)) { throw error; } if (attempt < maxRetries) { const delay = retryDelay * (attempt + 1); logger.warn(`Merge attempt ${attempt + 1} failed, retrying in ${delay}ms`, { error }); await new Promise(resolve => setTimeout(resolve, delay)); } } } throw lastError; } private isNonRetryableError(error: unknown): boolean { const msg = String(error).toLowerCase(); return msg.includes('api key') || msg.includes('unauthorized') || msg.includes('invalid request'); } private async mergeWithGemini(prompt: string): Promise<string> { const model = this.geminiClient.getGenerativeModel({ model: this.geminiModel, generationConfig: { maxOutputTokens: MAX_TOKENS, temperature: TEMPERATURE, }, }); const result = await model.generateContent(prompt); const response = await result.response; const text = response.text(); if (!text) { throw new Error('Empty response from Gemini'); } return text; } private async mergeWithAnthropic(prompt: string): Promise<string> { const client = this.getAnthropicClient(); const message = await client.messages.create({ model: ANTHROPIC_MODEL, max_tokens: MAX_TOKENS, temperature: TEMPERATURE, messages: [ { role: 'user', content: prompt, }, ], }); if (message.content[0].type !== 'text') { throw new Error('Unexpected response type from Anthropic'); } return message.content[0].text; } /** * Builds the structured prompt for the LLM */ private buildMergePrompt( oldState: ProjectState, newContext: string, tokenCount: number ): string { const schemaExample = { meta: { version: 'number (preserved from old state)', last_checkpoint: 'ISO timestamp (current time)', last_access: 'ISO timestamp (current time)', session_id: 'UUID (preserved from old state)', token_budget_used: 'number (provided token count)', }, project_context: { overview: 'string (comprehensive project summary - be detailed, include key information, findings, and context)', architecture: 'string (detailed architectural decisions, patterns, tech stack, design choices, and rationale)', recent_changes: [ { timestamp: 'ISO timestamp', summary: 'string (comprehensive description of what was done - include specifics, details, outcomes, and important information)', files: ['array of file paths'], }, ], }, active_context: { current_task: 'string (detailed description of current work, goals, progress, and full context)', active_files: ['array of currently relevant file paths'], active_decisions: [ { question: 'string', status: 'pending | decided', decision: 'optional string', }, ], }, }; return `You are updating a project state summary for a long-term memory system. Your task is to merge the OLD STATE with NEW CONTEXT to create an updated state. CRITICAL RULES: 1. Preserve ALL critical data: version, session_id, file paths, timestamps 2. Move completed tasks from active_context.current_task to project_context.recent_changes 3. Update active_files based on files mentioned in the new context 4. Be DETAILED and COMPREHENSIVE in overview, architecture, and recent_changes - preserve specifics, findings, numbers, and important details from the new context 5. Maintain a ring buffer of last 10 items in recent_changes (oldest get dropped) 6. Update active_decisions: mark as "decided" if resolved in new context 7. Return ONLY valid JSON matching the schema - no explanation text 8. If new context is empty/minimal, preserve old state mostly unchanged 9. When merging, COMBINE information intelligently - don't just replace, add new details to existing context OLD STATE: ${JSON.stringify(oldState, null, 2)} NEW CONTEXT (from recent work): ${newContext} TOKEN COUNT: ${tokenCount} REQUIRED SCHEMA: ${JSON.stringify(schemaExample, null, 2)} Return the updated state as valid JSON:`; } /** * Parses LLM response and validates against schema */ private parseAndValidate(responseText: string, oldState: ProjectState): ProjectState { try { // Extract JSON from response (handles cases where LLM adds explanation) const jsonMatch = responseText.match(/\{[\s\S]*\}/); if (!jsonMatch) { throw new Error('No JSON found in response'); } const parsed = JSON.parse(jsonMatch[0]); // Validate with Zod const validated = ProjectStateSchema.parse(parsed); // Data integrity checks this.validateDataIntegrity(validated, oldState); return validated; } catch (error) { logger.error('Failed to parse/validate LLM response', { error, response: responseText.substring(0, 500), }); throw error; } } /** * Validates that critical fields weren't dropped */ private validateDataIntegrity(newState: ProjectState, oldState: ProjectState): void { // Ensure session_id is preserved if (newState.meta.session_id !== oldState.meta.session_id) { throw new Error('Session ID was modified during merge'); } // Ensure version is numeric if (typeof newState.meta.version !== 'number') { throw new Error('Version must be a number'); } // Warn if all active files were dropped unexpectedly if ( oldState.active_context.active_files.length > 0 && newState.active_context.active_files.length === 0 ) { logger.warn('All active files were removed during merge', { oldFiles: oldState.active_context.active_files, }); } } /** * Fallback merge strategy when LLM fails */ private fallbackMerge( oldState: ProjectState, newContext: string, tokenCount: number ): ProjectState { const now = getCurrentTimestamp(); // Create a new recent change entry from the new context const newChange: RecentChange = { timestamp: now, summary: newContext.substring(0, 500), // Truncate if too long files: [], // Can't infer files in fallback mode }; // Add to recent changes and maintain ring buffer const recentChanges = [newChange, ...oldState.project_context.recent_changes].slice(0, 10); return { meta: { ...oldState.meta, last_checkpoint: now, last_access: now, token_budget_used: tokenCount, }, project_context: { ...oldState.project_context, recent_changes: recentChanges, }, active_context: { ...oldState.active_context, current_task: newContext.substring(0, 200), // Update task with truncated context }, }; } }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/coderdeep11/claude-memory-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server