Compare commits

..

3 commits

Author SHA1 Message Date
d39b0e621d docs: document context diff command in README, getting-started, and history docs
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 14:44:21 +01:00
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
660bf028ac chore: set author to tolvitty
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 14:34:22 +01:00
6 changed files with 282 additions and 2 deletions

View file

@ -27,6 +27,7 @@ When working with AI assistants on your Obsidian vault, you constantly re-explai
- **Prompt templates** with placeholders and conditionals for reusable workflows - **Prompt templates** with placeholders and conditionals for reusable workflows
- **Additional context sources** from freetext, external files, or shell commands - **Additional context sources** from freetext, external files, or shell commands
- **Frontmatter presets** to configure context per-note via `ai-context` YAML - **Frontmatter presets** to configure context per-note via `ai-context` YAML
- **Context diff** to copy only new and modified files since the last export
- **Context snapshots** for saving and replaying context recipes - **Context snapshots** for saving and replaying context recipes
- **Granular section selection** to include only the headings you need - **Granular section selection** to include only the headings you need
- **Context history** with diff, search, and one-click restore - **Context history** with diff, search, and one-click restore

View file

@ -77,6 +77,7 @@ The context is now in your clipboard. Paste it into Claude, ChatGPT, or any AI a
| **Copy context (select sections)** | Choose specific headings | | **Copy context (select sections)** | Choose specific headings |
| **Copy context from frontmatter preset** | Use note's ai-context config | | **Copy context from frontmatter preset** | Use note's ai-context config |
| **Copy smart context** | Auto-detect related notes via links, tags, and properties | | **Copy smart context** | Auto-detect related notes via links, tags, and properties |
| **Copy context diff** | Copy only files changed since last export |
| **Export context (multi-target)** | One-click export using the active export profile | | **Export context (multi-target)** | One-click export using the active export profile |
| **Generate context files** | Open the generator | | **Generate context files** | Open the generator |
| **View context history** | Browse past contexts | | **View context history** | Browse past contexts |

View file

@ -29,6 +29,40 @@ Ctrl+P > "Promptfire: View context history"
Browse, search, compare, and restore any previous context. Browse, search, compare, and restore any previous context.
## Context Diff
In iterative LLM workflows you don't want to paste the full vault context every time — only the files that changed. The context diff command compares per-file content hashes against the most recent export and copies only the delta.
### Usage
```
Ctrl+P > "Promptfire: Copy context diff (changes since last export)"
```
### How It Works
1. Every normal export stores a content hash per file in the history entry
2. The diff command finds the most recent history entry with hashes (the "baseline")
3. Current files are hashed and compared against the baseline
4. Only new and modified files are copied, tagged with `[NEW]` or `[MODIFIED]` in the file header
5. The diff entry stores all current hashes, so it becomes the next baseline — enabling chained iterative diffs
Sources (freetext, file, shell) are always included fully since they're external.
### Requirements
- History must be enabled (Settings > History > Enabled)
- At least one prior export with file hashes must exist (any normal copy after the update)
### Edge Cases
| Scenario | Behavior |
|----------|----------|
| No baseline exists | Notice: "Run a normal context copy first" |
| No changes detected | Notice: "No changes since last export" |
| File renamed | Shows as new file + removed file |
| File deleted | Listed in removed count in the notice |
## Snapshots ## Snapshots
Snapshots save a context "recipe" — the exact combination of notes, settings, and template — so you can replay it later. Snapshots save a context "recipe" — the exact combination of notes, settings, and template — so you can replay it later.

View file

@ -4,6 +4,6 @@
"version": "1.0.0", "version": "1.0.0",
"minAppVersion": "0.15.0", "minAppVersion": "0.15.0",
"description": "Copy vault context files to clipboard with one hotkey for any LLM.", "description": "Copy vault context files to clipboard with one hotkey for any LLM.",
"author": "Luca", "author": "tolvitty",
"isDesktopOnly": true "isDesktopOnly": true
} }

View file

@ -18,6 +18,7 @@ export interface HistoryMetadata {
charCount: number; charCount: number;
estimatedTokens: number; estimatedTokens: number;
userNote?: string; userNote?: string;
fileHashes?: Record<string, string>; // path -> djb2 hash
} }
export interface HistorySettings { export interface HistorySettings {
@ -48,6 +49,16 @@ export function estimateTokens(text: string): number {
return Math.ceil(text.length / 4); 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 === // === History Manager ===
export class HistoryManager { 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<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 === // === Formatting ===
export function formatTimestamp(timestamp: number): string { export function formatTimestamp(timestamp: number): string {

View file

@ -4,7 +4,7 @@ import { ContextGeneratorModal } from './generator';
import { PreviewModal } from './preview'; import { PreviewModal } from './preview';
import { SourceRegistry, formatSourceOutput } from './sources'; import { SourceRegistry, formatSourceOutput } from './sources';
import { TemplateEngine } from './templates'; import { TemplateEngine } from './templates';
import { HistoryManager, HistoryMetadata } from './history'; import { HistoryManager, HistoryMetadata, djb2Hash, ContextDiffResult, computeContextDiff, findBaselineWithHashes } from './history';
import { HistoryModal } from './history-modal'; import { HistoryModal } from './history-modal';
import { SnapshotManager, ContextSnapshot } from './snapshots'; import { SnapshotManager, ContextSnapshot } from './snapshots';
import { SnapshotListModal } from './snapshot-modal'; import { SnapshotListModal } from './snapshot-modal';
@ -99,6 +99,12 @@ export default class PromptfirePlugin extends Plugin {
callback: () => this.exportMultiTarget() 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)); this.addSettingTab(new PromptfireSettingTab(this.app, this));
} }
@ -174,12 +180,14 @@ export default class PromptfirePlugin extends Plugin {
// Read files and track missing ones // Read files and track missing ones
const missingFiles: string[] = []; const missingFiles: string[] = [];
const vaultParts: string[] = []; const vaultParts: string[] = [];
const readFiles: TFile[] = [];
for (const notePath of snapshot.notePaths) { for (const notePath of snapshot.notePaths) {
// Strip (partial) suffix — replay always includes full file // Strip (partial) suffix — replay always includes full file
const cleanPath = notePath.replace(/ \(partial\)$/, ''); const cleanPath = notePath.replace(/ \(partial\)$/, '');
const file = this.app.vault.getAbstractFileByPath(cleanPath); const file = this.app.vault.getAbstractFileByPath(cleanPath);
if (file instanceof TFile) { if (file instanceof TFile) {
readFiles.push(file);
const content = await this.app.vault.read(file); const content = await this.app.vault.read(file);
if (snapshot.includeFilenames) { if (snapshot.includeFilenames) {
vaultParts.push(`# === ${file.name} ===\n\n${content}`); vaultParts.push(`# === ${file.name} ===\n\n${content}`);
@ -258,6 +266,7 @@ export default class PromptfirePlugin extends Plugin {
], ],
activeNote: activeNotePath, activeNote: activeNotePath,
userNote: `Replayed snapshot: ${snapshot.name}`, userNote: `Replayed snapshot: ${snapshot.name}`,
fileHashes: await this.computeFileHashes(readFiles),
}; };
// Copy and save to history // Copy and save to history
@ -422,6 +431,7 @@ export default class PromptfirePlugin extends Plugin {
...(temporaryFreetext?.trim() ? ['Session Context'] : []), ...(temporaryFreetext?.trim() ? ['Session Context'] : []),
], ],
activeNote: activeNotePath, activeNote: activeNotePath,
fileHashes: await this.computeFileHashes(files),
}; };
// Process targets if provided // Process targets if provided
@ -612,6 +622,7 @@ export default class PromptfirePlugin extends Plugin {
...suffixSources.map(r => r.source.name), ...suffixSources.map(r => r.source.name),
], ],
activeNote: null, activeNote: null,
fileHashes: await this.computeFileHashes(selectedFiles.map(s => s.file)),
}; };
// Copy and save // Copy and save
@ -642,6 +653,177 @@ export default class PromptfirePlugin extends Plugin {
return checkAll(selection.headings); return checkAll(selection.headings);
} }
private async computeFileHashes(files: TFile[]): Promise<Record<string, string>> {
const hashes: Record<string, string> = {};
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<string, string> = {};
const contentCache: Record<string, string> = {};
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<HistoryMetadata, 'charCount' | 'estimatedTokens'> = {
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() { async exportMultiTarget() {
const profileId = this.settings.activeProfileId; const profileId = this.settings.activeProfileId;
if (!profileId) { if (!profileId) {
@ -814,6 +996,7 @@ export default class PromptfirePlugin extends Plugin {
], ],
activeNote: this.settings.intelligence.includeActiveNote ? activeFile.path : null, activeNote: this.settings.intelligence.includeActiveNote ? activeFile.path : null,
userNote: `Smart context from: ${activeFile.basename}`, userNote: `Smart context from: ${activeFile.basename}`,
fileHashes: await this.computeFileHashes(selectedNotes.map(n => n.file)),
}; };
// Copy and save // Copy and save
@ -1024,6 +1207,7 @@ export default class PromptfirePlugin extends Plugin {
], ],
activeNote: includeActive ? activeFile.path : null, activeNote: includeActive ? activeFile.path : null,
userNote: `Preset from: ${activeFile.basename}`, userNote: `Preset from: ${activeFile.basename}`,
fileHashes: await this.computeFileHashes(result.files),
}; };
// Copy and save // Copy and save