From 563ea7accbac3638e4113afae6cbc14d553d6180 Mon Sep 17 00:00:00 2001 From: luca-tty Date: Wed, 11 Feb 2026 14:43:23 +0100 Subject: [PATCH] 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 --- src/history.ts | 60 ++++++++++++++++ src/main.ts | 186 ++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 245 insertions(+), 1 deletion(-) diff --git a/src/history.ts b/src/history.ts index cc29dd4..34bce5a 100644 --- a/src/history.ts +++ b/src/history.ts @@ -18,6 +18,7 @@ export interface HistoryMetadata { charCount: number; estimatedTokens: number; userNote?: string; + fileHashes?: Record; // path -> djb2 hash } export interface HistorySettings { @@ -48,6 +49,16 @@ export function estimateTokens(text: string): number { 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 { @@ -276,6 +287,55 @@ export function diffEntries(older: HistoryEntry, newer: HistoryEntry): DiffResul }; } +// === 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 { diff --git a/src/main.ts b/src/main.ts index 74684ea..6cf8366 100644 --- a/src/main.ts +++ b/src/main.ts @@ -4,7 +4,7 @@ import { ContextGeneratorModal } from './generator'; import { PreviewModal } from './preview'; import { SourceRegistry, formatSourceOutput } from './sources'; import { TemplateEngine } from './templates'; -import { HistoryManager, HistoryMetadata } from './history'; +import { HistoryManager, HistoryMetadata, djb2Hash, ContextDiffResult, computeContextDiff, findBaselineWithHashes } from './history'; import { HistoryModal } from './history-modal'; import { SnapshotManager, ContextSnapshot } from './snapshots'; import { SnapshotListModal } from './snapshot-modal'; @@ -99,6 +99,12 @@ export default class PromptfirePlugin extends Plugin { callback: () => this.exportMultiTarget() }); + this.addCommand({ + id: 'copy-context-diff', + name: 'Copy context diff (changes since last export)', + callback: () => this.copyContextDiff() + }); + this.addSettingTab(new PromptfireSettingTab(this.app, this)); } @@ -174,12 +180,14 @@ export default class PromptfirePlugin extends Plugin { // Read files and track missing ones const missingFiles: string[] = []; const vaultParts: string[] = []; + const readFiles: TFile[] = []; for (const notePath of snapshot.notePaths) { // Strip (partial) suffix — replay always includes full file const cleanPath = notePath.replace(/ \(partial\)$/, ''); const file = this.app.vault.getAbstractFileByPath(cleanPath); if (file instanceof TFile) { + readFiles.push(file); const content = await this.app.vault.read(file); if (snapshot.includeFilenames) { vaultParts.push(`# === ${file.name} ===\n\n${content}`); @@ -258,6 +266,7 @@ export default class PromptfirePlugin extends Plugin { ], activeNote: activeNotePath, userNote: `Replayed snapshot: ${snapshot.name}`, + fileHashes: await this.computeFileHashes(readFiles), }; // Copy and save to history @@ -422,6 +431,7 @@ export default class PromptfirePlugin extends Plugin { ...(temporaryFreetext?.trim() ? ['Session Context'] : []), ], activeNote: activeNotePath, + fileHashes: await this.computeFileHashes(files), }; // Process targets if provided @@ -612,6 +622,7 @@ export default class PromptfirePlugin extends Plugin { ...suffixSources.map(r => r.source.name), ], activeNote: null, + fileHashes: await this.computeFileHashes(selectedFiles.map(s => s.file)), }; // Copy and save @@ -642,6 +653,177 @@ export default class PromptfirePlugin extends Plugin { return checkAll(selection.headings); } + private async computeFileHashes(files: TFile[]): Promise> { + const hashes: Record = {}; + for (const file of files) { + const content = await this.app.vault.read(file); + hashes[file.path] = djb2Hash(content); + } + return hashes; + } + + async copyContextDiff() { + // Verify history is enabled + if (!this.settings.history.enabled) { + new Notice('Context diff requires history to be enabled. Enable it in Settings > History.'); + return; + } + + // Find baseline + const entries = await this.historyManager.loadEntries(); + const baseline = findBaselineWithHashes(entries); + if (!baseline) { + new Notice('No baseline found. Run a normal context copy first to establish a baseline.'); + return; + } + + // Read current context files + const folder = this.app.vault.getAbstractFileByPath(this.settings.contextFolder); + if (!folder || !(folder instanceof TFolder)) { + new Notice(`Folder "${this.settings.contextFolder}" not found`); + return; + } + + const excludedFiles = this.settings.excludedFiles.map(f => f.toLowerCase()); + const files = folder.children + .filter((f): f is TFile => + f instanceof TFile && + f.extension === 'md' && + !excludedFiles.includes(f.name.toLowerCase()) + ) + .sort((a, b) => { + if (a.basename === 'VAULT') return -1; + if (b.basename === 'VAULT') return 1; + return a.basename.localeCompare(b.basename); + }); + + // Compute current hashes and cache content + const currentHashes: Record = {}; + const contentCache: Record = {}; + for (const file of files) { + const content = await this.app.vault.read(file); + currentHashes[file.path] = djb2Hash(content); + contentCache[file.path] = content; + } + + // Compute diff + const diff = computeContextDiff(currentHashes, baseline); + if (!diff) { + new Notice('Baseline entry has no file hashes. Run a normal context copy first.'); + return; + } + + // Check if there are any changes + if (diff.newFiles.length === 0 && diff.modifiedFiles.length === 0) { + let msg = 'No changes since last export.'; + if (diff.removedFiles.length > 0) { + msg += ` (${diff.removedFiles.length} file(s) removed)`; + } + new Notice(msg); + return; + } + + // Resolve additional sources (included fully) + const registry = new SourceRegistry(); + const enabledSources = this.settings.sources.filter(s => s.enabled); + const resolvedSources = await registry.resolveAll(enabledSources); + + const errors = resolvedSources.filter(r => r.error); + if (errors.length > 0) { + const errorNames = errors.map(e => e.source.name).join(', '); + new Notice(`Some sources failed: ${errorNames}`, 5000); + } + + const prefixSources = resolvedSources.filter(r => r.source.position === 'prefix' && !r.error); + const suffixSources = resolvedSources.filter(r => r.source.position === 'suffix' && !r.error); + + // Build output with only delta files + const outputParts: string[] = []; + + // Add prefix sources (fully) + for (const resolved of prefixSources) { + const formatted = formatSourceOutput(resolved, this.settings.showSourceLabels); + if (formatted) outputParts.push(formatted); + } + + // Add delta files + const deltaFiles = [...diff.newFiles, ...diff.modifiedFiles]; + const deltaFileSet = new Set(deltaFiles); + const vaultParts: string[] = []; + + for (const file of files) { + if (!deltaFileSet.has(file.path)) continue; + const content = contentCache[file.path] ?? ''; + if (this.settings.includeFilenames) { + const tag = diff.newFiles.includes(file.path) ? ' [NEW]' : ' [MODIFIED]'; + vaultParts.push(`# === ${file.name}${tag} ===\n\n${content}`); + } else { + vaultParts.push(content); + } + } + + if (vaultParts.length > 0) { + outputParts.push(vaultParts.join(`\n\n${this.settings.separator}\n\n`)); + } + + // Add suffix sources (fully) + for (const resolved of suffixSources) { + const formatted = formatSourceOutput(resolved, this.settings.showSourceLabels); + if (formatted) outputParts.push(formatted); + } + + let combined = outputParts.join(`\n\n${this.settings.separator}\n\n`); + + // Apply default template if set + const effectiveTemplateId = this.settings.defaultTemplateId; + let templateName: string | null = null; + + if (effectiveTemplateId) { + const template = this.settings.promptTemplates.find(t => t.id === effectiveTemplateId); + if (template) { + const engine = new TemplateEngine(this.app); + const context = await engine.buildContext(combined); + combined = await engine.processTemplate(template.content, context); + templateName = template.name; + } + } + + // Build summary + const summary = `Diff: ${diff.newFiles.length} new, ${diff.modifiedFiles.length} modified, ${diff.removedFiles.length} removed (${diff.unchangedFiles.length} unchanged)`; + + // Prepare history metadata with ALL current hashes (becomes next baseline) + const historyMetadata: Omit = { + templateId: effectiveTemplateId || null, + templateName, + includedFiles: deltaFiles, + includedSources: [ + ...prefixSources.map(r => r.source.name), + ...suffixSources.map(r => r.source.name), + ], + activeNote: null, + userNote: summary, + fileHashes: currentHashes, + }; + + // Copy and save + const copyAndSave = async () => { + await navigator.clipboard.writeText(combined); + + let message = `Context diff: ${diff.newFiles.length} new, ${diff.modifiedFiles.length} modified, ${diff.removedFiles.length} removed (${diff.unchangedFiles.length} unchanged)`; + if (templateName) message += ` using "${templateName}"`; + new Notice(message, 5000); + + await this.historyManager.saveEntry(combined, historyMetadata); + }; + + if (this.settings.showPreview) { + const totalCount = deltaFiles.length + prefixSources.length + suffixSources.length; + new PreviewModal(this.app, combined, totalCount, copyAndSave).open(); + } else { + await copyAndSave(); + } + } + async exportMultiTarget() { const profileId = this.settings.activeProfileId; if (!profileId) { @@ -814,6 +996,7 @@ export default class PromptfirePlugin extends Plugin { ], activeNote: this.settings.intelligence.includeActiveNote ? activeFile.path : null, userNote: `Smart context from: ${activeFile.basename}`, + fileHashes: await this.computeFileHashes(selectedNotes.map(n => n.file)), }; // Copy and save @@ -1024,6 +1207,7 @@ export default class PromptfirePlugin extends Plugin { ], activeNote: includeActive ? activeFile.path : null, userNote: `Preset from: ${activeFile.basename}`, + fileHashes: await this.computeFileHashes(result.files), }; // Copy and save