Skip to main content
Glama
knowledgeBaseHistoryService.ts22.4 kB
import { Repository } from '../core/repository'; import { RepositoryManager } from '../core/repositoryManager'; import { TimelineService, ApiHistoryEntry, TimelineFilterOptions } from '../core/timelineService'; export interface KnowledgeBaseChange { id: string; timestamp: string; description: string; // Human-friendly description operation: 'added' | 'updated' | 'removed' | 'organized'; changeType: 'file_upload' | 'knowledge_base_generation'; // Key distinction! filesAffected: string[]; userFriendlyDate: string; canRevert: boolean; internalCommitId: string; relatedCommitId?: string; // Links file upload to its KB generation details?: { title?: string; user?: string; category?: string; sourceFile?: string; // For KB changes, which file triggered them }; } export interface RevertOptions { repositoryId: string; // Human-friendly targeting filename?: string; // "undo changes for test.txt" changeId?: string; // Specific change from list lastNChanges?: number; // "undo last 3 changes" // Granular control revertType?: 'file_upload' | 'knowledge_base_generation' | 'both'; // Default: 'both' regenerateAfterRevert?: boolean; // For KB-only reverts, trigger new processing } export interface RevertResult { success: boolean; message: string; revertCommitIds: string[]; changesReverted: KnowledgeBaseChange[]; regenerationTriggered?: boolean; } /** * Service for managing knowledge base history with file upload vs KB generation distinction */ export class KnowledgeBaseHistoryService { constructor( private repositoryManager: RepositoryManager, private timelineService: TimelineService ) {} /** * List knowledge base changes showing both file uploads and KB generations */ async listKnowledgeBaseChanges( repositoryId: string, options: { limit?: number; includeDetails?: boolean; changeType?: 'file_upload' | 'knowledge_base_generation' | 'both'; } = {} ): Promise<KnowledgeBaseChange[]> { const repository = this.repositoryManager.getRepository(repositoryId); const filterOptions: TimelineFilterOptions = { limit: options.limit || 20, offset: 0 }; const historyEntries = await this.timelineService.getDetailedHistoryEntries(repository, filterOptions); const changes = historyEntries.flatMap(entry => this.convertToKnowledgeBaseChanges(entry)); // Filter by change type if specified if (options.changeType && options.changeType !== 'both') { return changes.filter(change => change.changeType === options.changeType); } return changes; } /** * Revert knowledge base changes with granular control over file vs KB */ async revertKnowledgeBaseChanges(options: RevertOptions): Promise<RevertResult> { const repository = this.repositoryManager.getRepository(options.repositoryId); try { // Sync with remote before reverting await this.repositoryManager.syncWithRemote(options.repositoryId); let changesToRevert: KnowledgeBaseChange[] = []; const revertType = options.revertType || 'both'; // Special handling for reverting to initial state if (options.lastNChanges && options.lastNChanges > 10) { console.log(`KnowledgeBaseHistoryService: Large revert requested (${options.lastNChanges} changes). Using reset-to-initial approach.`); return this.revertToInitialState(options.repositoryId); } // Find changes to revert based on criteria if (options.filename) { changesToRevert = await this.findChangesForFile(repository, options.filename, revertType); } else if (options.changeId) { const change = await this.getChangeById(repository, options.changeId); if (change) { changesToRevert = [change]; // If reverting a file upload, might also want to revert associated KB if (revertType === 'both' && change.changeType === 'file_upload' && change.relatedCommitId) { const kbChange = await this.getChangeByCommitId(repository, change.relatedCommitId); if (kbChange) changesToRevert.push(kbChange); } } } else if (options.lastNChanges) { const recentChanges = await this.listKnowledgeBaseChanges(options.repositoryId, { limit: options.lastNChanges * 2, // Get more to account for filtering changeType: revertType === 'both' ? 'both' : revertType }); changesToRevert = recentChanges.slice(0, options.lastNChanges); } if (changesToRevert.length === 0) { return { success: false, message: 'No changes found to revert based on the criteria provided.', revertCommitIds: [], changesReverted: [] }; } // Group changes by type for processing const fileChanges = changesToRevert.filter(c => c.changeType === 'file_upload'); const kbChanges = changesToRevert.filter(c => c.changeType === 'knowledge_base_generation'); const revertCommitIds: string[] = []; let regenerationTriggered = false; // Revert KB changes first (to maintain dependency order) if (kbChanges.length > 0 && (revertType === 'knowledge_base_generation' || revertType === 'both')) { const kbRevertCommitId = await this.performRevertCommits( repository, kbChanges.map(c => c.internalCommitId), `Revert knowledge base changes: ${kbChanges.map(c => c.details?.sourceFile || c.description).join(', ')}` ); revertCommitIds.push(kbRevertCommitId); } // Revert file uploads if (fileChanges.length > 0 && (revertType === 'file_upload' || revertType === 'both')) { const fileRevertCommitId = await this.performRevertCommits( repository, fileChanges.map(c => c.internalCommitId), `Revert file uploads: ${fileChanges.map(c => c.description).join(', ')}` ); revertCommitIds.push(fileRevertCommitId); } // Trigger regeneration if requested (only for KB-only reverts) if (options.regenerateAfterRevert && revertType === 'knowledge_base_generation' && fileChanges.length === 0) { // TODO: Trigger orchestrator to regenerate KB for remaining files regenerationTriggered = true; } // Push changes to remote try { await this.repositoryManager.pushToRemote(options.repositoryId); } catch (pushError: any) { // Handle the known isomorphic-git protocol parsing error if (pushError.message && pushError.message.includes('Expected "Two strings separated by')) { console.warn(`KnowledgeBaseHistoryService: Git protocol parsing error during revert push (likely cosmetic): ${pushError.message}`); // Continue - the revert likely succeeded despite the protocol error } else { throw pushError; // Re-throw other errors } } return { success: true, message: this.generateSuccessMessage(changesToRevert, revertType, regenerationTriggered), revertCommitIds, changesReverted: changesToRevert, regenerationTriggered }; } catch (error: any) { return { success: false, message: `Failed to revert changes: ${error.message}`, revertCommitIds: [], changesReverted: [] }; } } /** * Convenience methods for common operations */ async revertKnowledgeBaseOnly(repositoryId: string, filename: string, regenerate = false): Promise<RevertResult> { return this.revertKnowledgeBaseChanges({ repositoryId, filename, revertType: 'knowledge_base_generation', regenerateAfterRevert: regenerate }); } async revertFileAndKnowledgeBase(repositoryId: string, filename: string): Promise<RevertResult> { return this.revertKnowledgeBaseChanges({ repositoryId, filename, revertType: 'both' }); } async revertLastFileUpload(repositoryId: string): Promise<RevertResult> { return this.revertKnowledgeBaseChanges({ repositoryId, lastNChanges: 1, revertType: 'file_upload' }); } async revertLastKnowledgeBaseUpdate(repositoryId: string): Promise<RevertResult> { return this.revertKnowledgeBaseChanges({ repositoryId, lastNChanges: 1, revertType: 'knowledge_base_generation' }); } async regenerateKnowledgeBase(repositoryId: string, filename: string): Promise<RevertResult> { return this.revertKnowledgeBaseChanges({ repositoryId, filename, revertType: 'knowledge_base_generation', regenerateAfterRevert: true }); } /** * Revert repository to initial state (first commit with just README.md) */ private async revertToInitialState(repositoryId: string): Promise<RevertResult> { const repository = this.repositoryManager.getRepository(repositoryId); try { console.log(`KnowledgeBaseHistoryService: Reverting repository to initial state...`); // Find the initial commit using git log directly as fallback let initialCommitId: string; try { // Try timeline service first const historyEntries = await this.timelineService.getDetailedHistoryEntries(repository, { limit: 1000 }); if (historyEntries.length > 0) { // Get the first commit (oldest) const initialEntry = historyEntries[historyEntries.length - 1]; initialCommitId = initialEntry.commit?.id; if (!initialCommitId) { throw new Error('Timeline entry found but no commit ID'); } } else { throw new Error('No timeline entries found, trying git log fallback'); } } catch (timelineError: any) { console.warn(`KnowledgeBaseHistoryService: Timeline service failed: ${timelineError.message}`); console.log(`KnowledgeBaseHistoryService: Using git log fallback to find initial commit...`); // Fallback: Use git log to find the very first commit // We'll use the repository's git functionality directly try { // Import git modules dynamically const git = await import('isomorphic-git'); const fs = require('fs'); // Get all commits in reverse chronological order const commits = await git.default.log({ fs, dir: repository.path, depth: 1000 // Get all commits }); if (commits.length === 0) { throw new Error('No commits found in repository'); } // The last commit in the array is the oldest (initial commit) initialCommitId = commits[commits.length - 1].oid; console.log(`KnowledgeBaseHistoryService: Found initial commit via git log: ${initialCommitId.slice(0, 8)}`); } catch (gitError: any) { throw new Error(`Failed to find initial commit: ${gitError.message}`); } } console.log(`KnowledgeBaseHistoryService: Initial commit found: ${initialCommitId.slice(0, 8)}`); // Use git reset to go back to initial commit await repository.rollbackToCommit(initialCommitId); console.log(`KnowledgeBaseHistoryService: Rollback completed. Checking repository state...`); // Check what files exist after rollback const currentFiles = await repository.listFiles('.'); console.log(`KnowledgeBaseHistoryService: Files after rollback:`, currentFiles.map(f => f.path)); // Get what files should exist in the initial commit const initialFiles = await repository.getChangedFilesInCommit(initialCommitId); const initialFilePaths = initialFiles.map(f => f.path); console.log(`KnowledgeBaseHistoryService: Files that should exist:`, initialFilePaths); // Restore missing files from initial commit for (const expectedPath of initialFilePaths) { const fileExists = await repository.fileExists(expectedPath); if (!fileExists) { try { console.log(`KnowledgeBaseHistoryService: Restoring missing file: ${expectedPath}`); const initialContent = await repository.getFileContentAtCommit(initialCommitId, expectedPath); if (initialContent !== null) { await repository.writeFile(expectedPath, initialContent); console.log(`KnowledgeBaseHistoryService: Restored ${expectedPath}`); } } catch (error) { console.warn(`KnowledgeBaseHistoryService: Could not restore file ${expectedPath}: ${error}`); } } } // Remove files that weren't in the initial commit for (const file of currentFiles) { if (file.type === 'file' && !initialFilePaths.includes(file.path) && !file.path.startsWith('.git')) { try { await repository.deleteFile(file.path); console.log(`KnowledgeBaseHistoryService: Removed extra file: ${file.path}`); } catch (error) { console.warn(`KnowledgeBaseHistoryService: Could not remove file ${file.path}: ${error}`); } } } // Check if there are any changes to commit const unstagedFiles = await repository.getUnstagedFiles(); console.log(`KnowledgeBaseHistoryService: Unstaged files after cleanup:`, unstagedFiles); if (unstagedFiles.length > 0) { await repository.add(unstagedFiles); const cleanupCommit = await repository.commit({ message: 'Reset repository to initial state\n\nRemoved all files except initial README.md', author: { name: 'Lspace Revert Service', email: 'revert@lspace.local' } }); if (!cleanupCommit.success) { throw new Error(`Failed to create cleanup commit: ${cleanupCommit.message}`); } console.log(`KnowledgeBaseHistoryService: Created cleanup commit: ${cleanupCommit.hash}`); } else { console.log(`KnowledgeBaseHistoryService: No changes to commit after reset`); } // Push changes to remote try { await this.repositoryManager.pushToRemote(repositoryId); } catch (pushError: any) { // Handle the known isomorphic-git protocol parsing error if (pushError.message && pushError.message.includes('Expected "Two strings separated by')) { console.warn(`KnowledgeBaseHistoryService: Git protocol parsing error during reset push (likely cosmetic): ${pushError.message}`); // Continue - the push likely succeeded despite the protocol error } else { throw pushError; // Re-throw other errors } } return { success: true, message: 'Successfully reverted repository to initial state (README.md only)', revertCommitIds: [initialCommitId], changesReverted: [] }; } catch (error: any) { console.error(`KnowledgeBaseHistoryService: Failed to revert to initial state: ${error.message}`); return { success: false, message: `Failed to revert to initial state: ${error.message}`, revertCommitIds: [], changesReverted: [] }; } } /** * Convert timeline entry to knowledge base changes (potentially 2: file + KB) */ private convertToKnowledgeBaseChanges(entry: ApiHistoryEntry): KnowledgeBaseChange[] { const changes: KnowledgeBaseChange[] = []; const date = new Date(entry.timestamp); const userFriendlyDate = date.toLocaleDateString() + ' at ' + date.toLocaleTimeString(); // File upload change if (entry.commit) { const fileChange: KnowledgeBaseChange = { id: `${entry.id}_file`, timestamp: entry.timestamp, description: this.generateFileDescription(entry), operation: this.mapFileOperation(entry.fileOperation), changeType: 'file_upload', filesAffected: [entry.path], userFriendlyDate, canRevert: true, internalCommitId: entry.commit.id, relatedCommitId: entry.kbCommit?.id, details: { title: entry.title, user: entry.user, category: entry.operationType } }; changes.push(fileChange); } // Knowledge base generation change if (entry.kbCommit) { const kbChange: KnowledgeBaseChange = { id: `${entry.id}_kb`, timestamp: entry.timestamp, description: this.generateKBDescription(entry), operation: 'updated', // KB generation is typically an update changeType: 'knowledge_base_generation', filesAffected: entry.kbCommit.changedKbFiles?.map(f => f.path) || [], userFriendlyDate, canRevert: true, internalCommitId: entry.kbCommit.id, relatedCommitId: entry.commit?.id, details: { title: entry.title, user: entry.user, category: 'knowledge_base_generation', sourceFile: entry.path } }; changes.push(kbChange); } return changes; } private generateFileDescription(entry: ApiHistoryEntry): string { const fileName = entry.title || entry.path; switch (entry.fileOperation) { case 'add': return `Uploaded ${fileName}`; case 'modify': return `Updated ${fileName}`; case 'delete': return `Removed ${fileName}`; default: return `Modified ${fileName}`; } } private generateKBDescription(entry: ApiHistoryEntry): string { const fileName = entry.title || entry.path; const kbFiles = entry.kbCommit?.changedKbFiles?.length || 0; return `Generated knowledge base from ${fileName} (${kbFiles} KB files updated)`; } private mapFileOperation(operation?: string): KnowledgeBaseChange['operation'] { switch (operation) { case 'add': return 'added'; case 'modify': return 'updated'; case 'delete': return 'removed'; default: return 'updated'; } } private async findChangesForFile( repository: Repository, filename: string, revertType: string ): Promise<KnowledgeBaseChange[]> { const entries = await this.timelineService.getDetailedHistoryEntries(repository, { path: filename, limit: 50 }); const allChanges = entries.flatMap(entry => this.convertToKnowledgeBaseChanges(entry)); // Filter by revert type if (revertType === 'file_upload') { return allChanges.filter(c => c.changeType === 'file_upload'); } else if (revertType === 'knowledge_base_generation') { return allChanges.filter(c => c.changeType === 'knowledge_base_generation'); } return allChanges; // both } private async getChangeById(repository: Repository, changeId: string): Promise<KnowledgeBaseChange | null> { const entries = await this.timelineService.getDetailedHistoryEntries(repository, { limit: 100 }); const allChanges = entries.flatMap(entry => this.convertToKnowledgeBaseChanges(entry)); return allChanges.find(c => c.id === changeId) || null; } private async getChangeByCommitId(repository: Repository, commitId: string): Promise<KnowledgeBaseChange | null> { const entries = await this.timelineService.getDetailedHistoryEntries(repository, { limit: 100 }); const allChanges = entries.flatMap(entry => this.convertToKnowledgeBaseChanges(entry)); return allChanges.find(c => c.internalCommitId === commitId) || null; } private async performRevertCommits( repository: Repository, commitIds: string[], message: string ): Promise<string> { // Perform actual git revert for each commit (in reverse order for proper dependency handling) const reversedCommitIds = [...commitIds].reverse(); for (const commitId of reversedCommitIds) { try { console.log(`KnowledgeBaseHistoryService: Reverting commit ${commitId.slice(0, 8)}...`); // Use the repository's underlying git functionality const revertResult = await repository.revertCommit(commitId, { message: `Revert commit ${commitId.slice(0, 8)}`, author: { name: 'Lspace Revert Service', email: 'revert@lspace.local' } }); if (!revertResult.success) { throw new Error(`Failed to revert commit ${commitId}: ${revertResult.message || 'Unknown error'}`); } console.log(`KnowledgeBaseHistoryService: Successfully reverted commit ${commitId.slice(0, 8)}`); } catch (error: any) { console.error(`KnowledgeBaseHistoryService: Error reverting commit ${commitId}: ${error.message}`); throw new Error(`Failed to revert commit ${commitId.slice(0, 8)}: ${error.message}`); } } // Create a summary commit documenting the batch revert operation const summaryCommitResult = await repository.commit({ message: `${message}\n\nReverted commits: ${commitIds.map(id => id.slice(0, 8)).join(', ')}`, author: { name: 'Lspace Revert Service', email: 'revert@lspace.local' } }); if (!summaryCommitResult.success || !summaryCommitResult.hash) { throw new Error('Failed to create revert summary commit'); } return summaryCommitResult.hash; } private generateSuccessMessage( changes: KnowledgeBaseChange[], revertType: string, regenerationTriggered: boolean ): string { const fileChanges = changes.filter(c => c.changeType === 'file_upload').length; const kbChanges = changes.filter(c => c.changeType === 'knowledge_base_generation').length; let message = 'Successfully reverted '; if (revertType === 'file_upload') { message += `${fileChanges} file upload(s)`; } else if (revertType === 'knowledge_base_generation') { message += `${kbChanges} knowledge base update(s)`; } else { message += `${fileChanges} file upload(s) and ${kbChanges} knowledge base update(s)`; } if (regenerationTriggered) { message += '. Knowledge base regeneration has been triggered.'; } return message; } }

Implementation Reference

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/Lspace-io/lspace-server'

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