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>
360 lines
8.9 KiB
TypeScript
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);
|
|
}
|