import { App, TFile, TFolder } from 'obsidian'; // === Types === export interface HistoryEntry { id: string; timestamp: number; content: string; metadata: HistoryMetadata; } export interface HistoryMetadata { templateId: string | null; templateName: string | null; includedFiles: string[]; includedSources: string[]; activeNote: string | null; charCount: number; estimatedTokens: number; userNote?: string; fileHashes?: Record; // path -> djb2 hash } export interface HistorySettings { enabled: boolean; storageFolder: string; maxEntries: number; autoCleanupDays: number; } export const DEFAULT_HISTORY_SETTINGS: HistorySettings = { enabled: false, storageFolder: '.context-history', maxEntries: 50, autoCleanupDays: 30, }; // === ID Generation === export function generateHistoryId(): string { return 'hist_' + Date.now().toString(36) + Math.random().toString(36).substring(2, 9); } // === Token Estimation === export function estimateTokens(text: string): number { // Rough estimation: ~4 characters per token for English text // This is a simplified heuristic, actual tokenization varies by model return Math.ceil(text.length / 4); } // === Hashing === export function djb2Hash(str: string): string { let hash = 5381; for (let i = 0; i < str.length; i++) { hash = ((hash << 5) + hash + str.charCodeAt(i)) & 0xFFFFFFFF; } return (hash >>> 0).toString(16).padStart(8, '0'); } // === History Manager === export class HistoryManager { private app: App; private settings: HistorySettings; constructor(app: App, settings: HistorySettings) { this.app = app; this.settings = settings; } updateSettings(settings: HistorySettings) { this.settings = settings; } private get folderPath(): string { return this.settings.storageFolder; } private async ensureFolder(): Promise { const existing = this.app.vault.getAbstractFileByPath(this.folderPath); if (existing instanceof TFolder) { return existing; } try { await this.app.vault.createFolder(this.folderPath); return this.app.vault.getAbstractFileByPath(this.folderPath) as TFolder; } catch { return null; } } private getEntryFilename(entry: HistoryEntry): string { const date = new Date(entry.timestamp); const dateStr = date.toISOString().replace(/[:.]/g, '-').substring(0, 19); return `${dateStr}_${entry.id}.json`; } async saveEntry( content: string, metadata: Omit ): Promise { if (!this.settings.enabled) { return null; } const folder = await this.ensureFolder(); if (!folder) { return null; } const entry: HistoryEntry = { id: generateHistoryId(), timestamp: Date.now(), content, metadata: { ...metadata, charCount: content.length, estimatedTokens: estimateTokens(content), }, }; const filename = this.getEntryFilename(entry); const filePath = `${this.folderPath}/${filename}`; try { await this.app.vault.create(filePath, JSON.stringify(entry, null, 2)); await this.cleanup(); return entry; } catch { return null; } } async loadEntries(): Promise { const folder = this.app.vault.getAbstractFileByPath(this.folderPath); if (!folder || !(folder instanceof TFolder)) { return []; } const entries: HistoryEntry[] = []; for (const file of folder.children) { if (file instanceof TFile && file.extension === 'json') { try { const content = await this.app.vault.read(file); const entry = JSON.parse(content) as HistoryEntry; entries.push(entry); } catch { // Skip invalid entries } } } // Sort by timestamp descending (newest first) entries.sort((a, b) => b.timestamp - a.timestamp); return entries; } async loadEntry(id: string): Promise { const entries = await this.loadEntries(); return entries.find(e => e.id === id) || null; } async deleteEntry(id: string): Promise { const folder = this.app.vault.getAbstractFileByPath(this.folderPath); if (!folder || !(folder instanceof TFolder)) { return false; } for (const file of folder.children) { if (file instanceof TFile && file.name.includes(id)) { try { await this.app.vault.delete(file); return true; } catch { return false; } } } return false; } async updateEntryNote(id: string, userNote: string): Promise { const folder = this.app.vault.getAbstractFileByPath(this.folderPath); if (!folder || !(folder instanceof TFolder)) { return false; } for (const file of folder.children) { if (file instanceof TFile && file.name.includes(id)) { try { const content = await this.app.vault.read(file); const entry = JSON.parse(content) as HistoryEntry; entry.metadata.userNote = userNote; await this.app.vault.modify(file, JSON.stringify(entry, null, 2)); return true; } catch { return false; } } } return false; } async cleanup(): Promise { const entries = await this.loadEntries(); let deletedCount = 0; // Delete entries exceeding max count if (entries.length > this.settings.maxEntries) { const toDelete = entries.slice(this.settings.maxEntries); for (const entry of toDelete) { if (await this.deleteEntry(entry.id)) { deletedCount++; } } } // Delete entries older than autoCleanupDays if (this.settings.autoCleanupDays > 0) { const cutoff = Date.now() - (this.settings.autoCleanupDays * 24 * 60 * 60 * 1000); const remainingEntries = await this.loadEntries(); for (const entry of remainingEntries) { if (entry.timestamp < cutoff) { if (await this.deleteEntry(entry.id)) { deletedCount++; } } } } return deletedCount; } async clearAll(): Promise { const entries = await this.loadEntries(); let deletedCount = 0; for (const entry of entries) { if (await this.deleteEntry(entry.id)) { deletedCount++; } } return deletedCount; } } // === Diff Utilities === export interface DiffResult { addedFiles: string[]; removedFiles: string[]; addedSources: string[]; removedSources: string[]; templateChanged: boolean; oldTemplate: string | null; newTemplate: string | null; charDiff: number; tokenDiff: number; } export function diffEntries(older: HistoryEntry, newer: HistoryEntry): DiffResult { const oldFiles = new Set(older.metadata.includedFiles); const newFiles = new Set(newer.metadata.includedFiles); const oldSources = new Set(older.metadata.includedSources); const newSources = new Set(newer.metadata.includedSources); return { addedFiles: newer.metadata.includedFiles.filter(f => !oldFiles.has(f)), removedFiles: older.metadata.includedFiles.filter(f => !newFiles.has(f)), addedSources: newer.metadata.includedSources.filter(s => !oldSources.has(s)), removedSources: older.metadata.includedSources.filter(s => !newSources.has(s)), templateChanged: older.metadata.templateId !== newer.metadata.templateId, oldTemplate: older.metadata.templateName, newTemplate: newer.metadata.templateName, charDiff: newer.metadata.charCount - older.metadata.charCount, tokenDiff: newer.metadata.estimatedTokens - older.metadata.estimatedTokens, }; } // === Context Diff === export interface ContextDiffResult { newFiles: string[]; modifiedFiles: string[]; removedFiles: string[]; unchangedFiles: string[]; baselineTimestamp: number; baselineId: string; } export function computeContextDiff( currentHashes: Record, baseline: HistoryEntry ): ContextDiffResult | null { const baseHashes = baseline.metadata.fileHashes; if (!baseHashes) return null; const newFiles: string[] = []; const modifiedFiles: string[] = []; const unchangedFiles: string[] = []; for (const [path, hash] of Object.entries(currentHashes)) { if (!(path in baseHashes)) { newFiles.push(path); } else if (baseHashes[path] !== hash) { modifiedFiles.push(path); } else { unchangedFiles.push(path); } } const currentPaths = new Set(Object.keys(currentHashes)); const removedFiles = Object.keys(baseHashes).filter(p => !currentPaths.has(p)); return { newFiles, modifiedFiles, removedFiles, unchangedFiles, baselineTimestamp: baseline.timestamp, baselineId: baseline.id, }; } export function findBaselineWithHashes(entries: HistoryEntry[]): HistoryEntry | null { return entries.find(e => e.metadata.fileHashes != null) ?? null; } // === Formatting === export function formatTimestamp(timestamp: number): string { const date = new Date(timestamp); return date.toLocaleString(); } export function formatRelativeTime(timestamp: number): string { const now = Date.now(); const diff = now - timestamp; const minutes = Math.floor(diff / 60000); const hours = Math.floor(diff / 3600000); const days = Math.floor(diff / 86400000); if (minutes < 1) return 'just now'; if (minutes < 60) return `${minutes}m ago`; if (hours < 24) return `${hours}h ago`; if (days < 7) return `${days}d ago`; return formatTimestamp(timestamp); }