Compare commits
3 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d39b0e621d | |||
| 563ea7accb | |||
| 660bf028ac |
6 changed files with 282 additions and 2 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 |
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
186
src/main.ts
186
src/main.ts
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue