obsidian-promptfire/src/history.ts
luca-tty 563ea7accb feat: add context diff command to copy only changed files since last export
Stores per-file djb2 content hashes in history metadata on every export,
enabling a new "Copy context diff" command that compares against the most
recent baseline and copies only new/modified files with [NEW]/[MODIFIED] tags.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 14:43:23 +01:00

360 lines
8.9 KiB
TypeScript

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<string, string>; // 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<TFolder | null> {
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<HistoryMetadata, 'charCount' | 'estimatedTokens'>
): Promise<HistoryEntry | null> {
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<HistoryEntry[]> {
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<HistoryEntry | null> {
const entries = await this.loadEntries();
return entries.find(e => e.id === id) || null;
}
async deleteEntry(id: string): Promise<boolean> {
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<boolean> {
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<number> {
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<number> {
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<string, string>,
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);
}