UNPKG

@nanocollective/nanocoder

Version:

A local-first CLI coding agent that brings the power of agentic coding tools like Claude Code and Gemini CLI to local models or controlled APIs like OpenRouter

356 lines 14 kB
import { existsSync } from 'fs'; import * as fs from 'fs/promises'; import * as path from 'path'; import { TRUNCATION_DESCRIPTION_LENGTH } from '../constants.js'; import { validateCheckpointName } from '../utils/checkpoint-utils.js'; import { logWarning } from '../utils/message-queue.js'; import { FileSnapshotService } from './file-snapshot.js'; /** * Service for managing conversation checkpoints. * Checkpoints are stored in .nanocoder/checkpoints/ within the workspace root. */ export class CheckpointManager { checkpointsDir; fileSnapshotService; constructor(workspaceRoot = process.cwd()) { // nosemgrep this.checkpointsDir = path.join(workspaceRoot, '.nanocoder', 'checkpoints'); // nosemgrep this.fileSnapshotService = new FileSnapshotService(workspaceRoot); } /** * Initialize the checkpoints directory */ async ensureCheckpointsDir() { if (!existsSync(this.checkpointsDir)) { await fs.mkdir(this.checkpointsDir, { recursive: true }); } } /** * Generate a checkpoint name based on timestamp */ generateCheckpointName() { const now = new Date(); const timestamp = now .toISOString() .replace(/[:.]/g, '-') .replace('T', '-') .split('.')[0]; return `checkpoint-${timestamp}`; } /** * Get the directory path for a specific checkpoint */ // nosemgrep getCheckpointDir(name) { return path.join(this.checkpointsDir, name); // nosemgrep } /** * Validate checkpoint name using shared utility */ validateName(name) { const result = validateCheckpointName(name); if (!result.valid) { throw new Error(result.error || 'Invalid checkpoint name'); } } /** * Generate metadata description from messages */ generateDescription(messages) { const userMessages = messages.filter(m => m.role === 'user'); if (userMessages.length === 0) { return 'Empty conversation'; } const firstMessage = userMessages[0].content; // Take first characters and add ellipsis if longer return firstMessage.length > TRUNCATION_DESCRIPTION_LENGTH ? `${firstMessage.substring(0, TRUNCATION_DESCRIPTION_LENGTH)}...` : firstMessage; } /** * Save a checkpoint */ async saveCheckpoint(name, messages, provider, model, modifiedFiles) { await this.ensureCheckpointsDir(); // Generate name if not provided const checkpointName = name || this.generateCheckpointName(); this.validateName(checkpointName); const checkpointDir = this.getCheckpointDir(checkpointName); // Check if checkpoint already exists if (existsSync(checkpointDir)) { throw new Error(`Checkpoint '${checkpointName}' already exists`); } // Get modified files if not provided const filesToSnapshot = modifiedFiles || this.fileSnapshotService.getModifiedFiles(); // Capture file snapshots const fileSnapshots = await this.fileSnapshotService.captureFiles(filesToSnapshot); // Create metadata const metadata = { name: checkpointName, timestamp: new Date().toISOString(), messageCount: messages.length, filesChanged: Array.from(fileSnapshots.keys()), provider: { name: provider, model }, description: this.generateDescription(messages), }; // Create conversation data const conversation = { messages: messages.map(msg => ({ ...msg })), // Deep copy }; // Create checkpoint directory and files await fs.mkdir(checkpointDir, { recursive: true }); // nosemgrep // Save metadata await fs.writeFile(path.join(checkpointDir, 'metadata.json'), // nosemgrep JSON.stringify(metadata, null, 2), 'utf-8'); // nosemgrep // Save conversation await fs.writeFile(path.join(checkpointDir, 'conversation.json'), // nosemgrep JSON.stringify(conversation, null, 2), 'utf-8'); // nosemgrep // Save file snapshots if (fileSnapshots.size > 0) { const filesDir = path.join(checkpointDir, 'files'); // nosemgrep await fs.mkdir(filesDir, { recursive: true }); for (const [relativePath, content] of fileSnapshots) { const filePath = path.join(filesDir, relativePath); // nosemgrep const fileDir = path.dirname(filePath); await fs.mkdir(fileDir, { recursive: true }); await fs.writeFile(filePath, content, 'utf-8'); } } return metadata; } /** * Load a checkpoint */ async loadCheckpoint(name, options = {}) { const checkpointDir = this.getCheckpointDir(name); if (!existsSync(checkpointDir)) { throw new Error(`Checkpoint '${name}' does not exist`); } // Validate checkpoint if requested if (options.validateIntegrity) { const validation = await this.validateCheckpoint(name); if (!validation.valid) { throw new Error(`Checkpoint validation failed: ${validation.errors.join(', ')}`); } } // nosemgrep // Load metadata const metadataPath = path.join(checkpointDir, 'metadata.json'); // nosemgrep const metadataContent = await fs.readFile(metadataPath, 'utf-8'); const metadata = JSON.parse(metadataContent); // nosemgrep // Load conversation const conversationPath = path.join(checkpointDir, 'conversation.json'); // nosemgrep const conversationContent = await fs.readFile(conversationPath, 'utf-8'); const conversation = JSON.parse(conversationContent); // nosemgrep // Load file snapshots const fileSnapshots = new Map(); const filesDir = path.join(checkpointDir, 'files'); // nosemgrep if (existsSync(filesDir)) { for (const relativePath of metadata.filesChanged) { try { const filePath = path.join(filesDir, relativePath); // nosemgrep const content = await fs.readFile(filePath, 'utf-8'); fileSnapshots.set(relativePath, content); } catch (error) { logWarning('Could not load file snapshot', true, { context: { relativePath, error: error instanceof Error ? error.message : 'Unknown error', }, }); } } } return { metadata, conversation, fileSnapshots, }; } /** * List all available checkpoints */ async listCheckpoints() { await this.ensureCheckpointsDir(); try { const entries = await fs.readdir(this.checkpointsDir); const checkpoints = []; for (const entry of entries) { try { const checkpointDir = path.join(this.checkpointsDir, entry); // nosemgrep const stat = await fs.stat(checkpointDir); if (stat.isDirectory()) { const metadataPath = path.join(checkpointDir, 'metadata.json'); // nosemgrep if (existsSync(metadataPath)) { const metadataContent = await fs.readFile(metadataPath, 'utf-8'); const metadata = JSON.parse(metadataContent); // Calculate directory size const sizeBytes = await this.calculateDirectorySize(checkpointDir); checkpoints.push({ name: entry, metadata, sizeBytes, }); } } } catch (error) { logWarning('Could not read checkpoint', true, { context: { checkpointName: entry, error: error instanceof Error ? error.message : 'Unknown error', }, }); } } // Sort by timestamp (newest first) checkpoints.sort((a, b) => new Date(b.metadata.timestamp).getTime() - new Date(a.metadata.timestamp).getTime()); return checkpoints; } catch (error) { throw new Error(`Failed to list checkpoints: ${error instanceof Error ? error.message : 'Unknown error'}`); } } /** * Delete a checkpoint */ async deleteCheckpoint(name) { const checkpointDir = this.getCheckpointDir(name); if (!existsSync(checkpointDir)) { throw new Error(`Checkpoint '${name}' does not exist`); } try { await fs.rm(checkpointDir, { recursive: true, force: true }); } catch (error) { throw new Error(`Failed to delete checkpoint '${name}': ${error instanceof Error ? error.message : 'Unknown error'}`); } } /** * Validate checkpoint integrity */ async validateCheckpoint(name) { const checkpointDir = this.getCheckpointDir(name); const errors = []; const warnings = []; // Check if checkpoint directory exists if (!existsSync(checkpointDir)) { errors.push('Checkpoint directory does not exist'); return { valid: false, errors, warnings }; } // Check metadata file const metadataPath = path.join(checkpointDir, 'metadata.json'); // nosemgrep if (!existsSync(metadataPath)) { errors.push('Missing metadata.json file'); } else { try { const metadataContent = await fs.readFile(metadataPath, 'utf-8'); const metadata = JSON.parse(metadataContent); // Validate metadata structure if (!metadata.name || !metadata.timestamp || typeof metadata.messageCount !== 'number') { errors.push('Invalid metadata structure'); } } catch (error) { errors.push(`Invalid metadata.json: ${error instanceof Error ? error.message : 'Unknown error'}`); } } // Check conversation file const conversationPath = path.join(checkpointDir, 'conversation.json'); // nosemgrep if (!existsSync(conversationPath)) { errors.push('Missing conversation.json file'); } else { try { const conversationContent = await fs.readFile(conversationPath, 'utf-8'); const conversation = JSON.parse(conversationContent); // Validate conversation structure if (!Array.isArray(conversation.messages)) { errors.push('Invalid conversation structure'); } } catch (error) { errors.push(`Invalid conversation.json: ${error instanceof Error ? error.message : 'Unknown error'}`); } } return { valid: errors.length === 0, errors, warnings, }; } /** * Restore files from a checkpoint */ async restoreFiles(checkpointData) { if (checkpointData.fileSnapshots.size === 0) { return; // No files to restore } // Validate restore paths const validation = await this.fileSnapshotService.validateRestorePath(checkpointData.fileSnapshots); if (!validation.valid) { throw new Error(`Cannot restore files: ${validation.errors.join(', ')}`); } // Restore files await this.fileSnapshotService.restoreFiles(checkpointData.fileSnapshots); } /** * Calculate the total size of a directory */ async calculateDirectorySize(dirPath) { let totalSize = 0; try { const entries = await fs.readdir(dirPath, { withFileTypes: true }); for (const entry of entries) { const fullPath = path.join(dirPath, entry.name); // nosemgrep if (entry.isDirectory()) { totalSize += await this.calculateDirectorySize(fullPath); } else { const stat = await fs.stat(fullPath); totalSize += stat.size; } } } catch (error) { // If we can't read the directory, just return 0 logWarning('Could not calculate directory size', true, { context: { dirPath, error: error instanceof Error ? error.message : 'Unknown error', }, }); } return totalSize; } /** * Check if a checkpoint exists */ checkpointExists(name) { const checkpointDir = this.getCheckpointDir(name); return existsSync(checkpointDir); } /** * Get checkpoint metadata without loading full data */ async getCheckpointMetadata(name) { const checkpointDir = this.getCheckpointDir(name); if (!existsSync(checkpointDir)) { throw new Error(`Checkpoint '${name}' does not exist`); } const metadataPath = path.join(checkpointDir, 'metadata.json'); // nosemgrep const metadataContent = await fs.readFile(metadataPath, 'utf-8'); return JSON.parse(metadataContent); } } //# sourceMappingURL=checkpoint-manager.js.map