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
},
};
}
}