import { MarkdownView, Notice, Plugin, TFile, TFolder } from 'obsidian'; import { PromptfireSettings, PromptfireSettingTab, DEFAULT_SETTINGS } from './settings'; import { ContextGeneratorModal } from './generator'; import { PreviewModal } from './preview'; import { SourceRegistry, formatSourceOutput } from './sources'; import { TemplateEngine } from './templates'; import { HistoryManager, HistoryMetadata } from './history'; import { HistoryModal } from './history-modal'; import { ContentSelector, FileSelection } from './content-selector'; import { FileSelectorModal } from './file-selector-modal'; import { parsePresetFromFrontmatter, PresetExecutor, formatPresetErrors, ContextPreset, } from './presets'; import { OutputTarget, TargetExecutor, TargetResult, saveTargetToFile, } from './targets'; import { ContextIntelligence, ScoredNote, DEFAULT_INTELLIGENCE_SETTINGS, } from './context-intelligence'; import { SmartContextModal } from './smart-context-modal'; export default class PromptfirePlugin extends Plugin { settings: PromptfireSettings; historyManager: HistoryManager; async onload() { await this.loadSettings(); this.historyManager = new HistoryManager(this.app, this.settings.history); // Ribbon icon this.addRibbonIcon('clipboard-copy', 'Copy Promptfire context', () => { this.copyContextToClipboard(); }); this.addCommand({ id: 'copy-context', name: 'Copy context to clipboard', callback: () => this.copyContextToClipboard() }); this.addCommand({ id: 'copy-context-with-note', name: 'Copy context with current note', callback: () => this.copyContextToClipboard(true) }); this.addCommand({ id: 'generate-context', name: 'Generate context files', callback: () => new ContextGeneratorModal(this.app, this).open() }); this.addCommand({ id: 'view-history', name: 'View context history', callback: () => this.openHistory() }); this.addCommand({ id: 'copy-context-selective', name: 'Copy context (select sections)', callback: () => this.openFileSelector() }); this.addCommand({ id: 'copy-context-from-preset', name: 'Copy context from frontmatter preset', callback: () => this.copyContextFromPreset() }); this.addCommand({ id: 'copy-smart-context', name: 'Copy smart context (auto-detect related notes)', callback: () => this.copySmartContext() }); this.addSettingTab(new PromptfireSettingTab(this.app, this)); } openFileSelector() { new FileSelectorModal(this.app, this, async (result) => { await this.copyContextWithSelections(result.selections); }).open(); } async loadSettings() { const loaded = await this.loadData(); this.settings = Object.assign({}, DEFAULT_SETTINGS, loaded); // Ensure nested objects are properly merged if (loaded?.history) { this.settings.history = Object.assign({}, DEFAULT_SETTINGS.history, loaded.history); } if (loaded?.intelligence) { this.settings.intelligence = Object.assign({}, DEFAULT_INTELLIGENCE_SETTINGS, loaded.intelligence); if (loaded.intelligence.weights) { this.settings.intelligence.weights = Object.assign( {}, DEFAULT_INTELLIGENCE_SETTINGS.weights, loaded.intelligence.weights ); } } } async saveSettings() { await this.saveData(this.settings); // Update history manager with new settings if (this.historyManager) { this.historyManager.updateSettings(this.settings.history); } } openHistory() { new HistoryModal(this.app, this, this.historyManager).open(); } async runHistoryCleanup(): Promise { return await this.historyManager.cleanup(); } async copyContextToClipboard( forceIncludeNote = false, temporaryFreetext?: string, templateId?: string | null, targets?: OutputTarget[] ) { 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); }); if (files.length === 0) { new Notice(`No markdown files in "${this.settings.contextFolder}"`); return; } // Resolve additional sources const registry = new SourceRegistry(); const enabledSources = this.settings.sources.filter(s => s.enabled); const resolvedSources = await registry.resolveAll(enabledSources); // Check for source errors 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); } // Separate prefix and suffix sources const prefixSources = resolvedSources.filter(r => r.source.position === 'prefix' && !r.error); const suffixSources = resolvedSources.filter(r => r.source.position === 'suffix' && !r.error); // Build output parts const outputParts: string[] = []; // Add prefix sources for (const resolved of prefixSources) { const formatted = formatSourceOutput(resolved, this.settings.showSourceLabels); if (formatted) { outputParts.push(formatted); } } // Add temporary freetext if provided (as prefix) if (temporaryFreetext?.trim()) { if (this.settings.showSourceLabels) { outputParts.push(`# === PREFIX: Session Context ===\n\n${temporaryFreetext}`); } else { outputParts.push(temporaryFreetext); } } // Add vault content const vaultParts: string[] = []; for (const file of files) { const content = await this.app.vault.read(file); if (this.settings.includeFilenames) { vaultParts.push(`# === ${file.name} ===\n\n${content}`); } else { vaultParts.push(content); } } outputParts.push(vaultParts.join(`\n\n${this.settings.separator}\n\n`)); // Track active note for history let activeNotePath: string | null = null; // Include active note if (forceIncludeNote || this.settings.includeActiveNote) { const activeView = this.app.workspace.getActiveViewOfType(MarkdownView); if (activeView?.file) { activeNotePath = activeView.file.path; const content = await this.app.vault.read(activeView.file); if (this.settings.includeFilenames) { outputParts.push(`# === ACTIVE: ${activeView.file.name} ===\n\n${content}`); } else { outputParts.push(`--- ACTIVE NOTE ---\n\n${content}`); } } } // Add suffix sources 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`); const sourceCount = prefixSources.length + suffixSources.length + (temporaryFreetext?.trim() ? 1 : 0); const fileCount = files.length + (forceIncludeNote || this.settings.includeActiveNote ? 1 : 0); const totalCount = fileCount + sourceCount; // Apply template if specified const effectiveTemplateId = templateId !== undefined ? templateId : 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; } } // Prepare history metadata const historyMetadata: Omit = { templateId: effectiveTemplateId || null, templateName, includedFiles: files.map(f => f.path), includedSources: [ ...prefixSources.map(r => r.source.name), ...suffixSources.map(r => r.source.name), ...(temporaryFreetext?.trim() ? ['Session Context'] : []), ], activeNote: activeNotePath, }; // Process targets if provided if (targets && targets.length > 0) { await this.processTargets(combined, targets, historyMetadata, fileCount, sourceCount, templateName); } else { // Copy and save to history (legacy mode) const copyAndSave = async () => { await navigator.clipboard.writeText(combined); this.showCopyNotice(fileCount, sourceCount, templateName); // Save to history await this.historyManager.saveEntry(combined, historyMetadata); }; if (this.settings.showPreview) { new PreviewModal(this.app, combined, totalCount, copyAndSave).open(); } else { await copyAndSave(); } } } private async processTargets( rawContent: string, targets: OutputTarget[], historyMetadata: Omit, fileCount: number, sourceCount: number, templateName: string | null ) { const executor = new TargetExecutor(); const results: TargetResult[] = []; // Process each target for (const target of targets) { const result = executor.processForTarget(rawContent, target); results.push(result); } // Determine primary target const primaryId = this.settings.primaryTargetId || (targets.find(t => t.enabled)?.id ?? targets[0]?.id); const primaryResult = results.find(r => r.target.id === primaryId) || results[0]; if (!primaryResult) { new Notice('No targets processed'); return; } const secondaryResults = results.filter(r => r !== primaryResult); // Copy primary to clipboard await navigator.clipboard.writeText(primaryResult.content); // Save secondary results as files const savedFiles: string[] = []; for (const result of secondaryResults) { const file = await saveTargetToFile(this.app, result, this.settings.targetOutputFolder); if (file) { savedFiles.push(file.path); } } // Show notice let message = `Copied to clipboard: ${primaryResult.target.name}`; if (primaryResult.truncated) { message += ` (truncated, ${primaryResult.sectionsDropped} sections dropped)`; } if (savedFiles.length > 0) { message += `. Saved ${savedFiles.length} file(s)`; } new Notice(message, 5000); // Save primary to history await this.historyManager.saveEntry(primaryResult.content, { ...historyMetadata, userNote: `Target: ${primaryResult.target.name}${primaryResult.truncated ? ' (truncated)' : ''}`, }); } private showCopyNotice(fileCount: number, sourceCount: number, templateName: string | null) { const totalCount = fileCount + sourceCount; let message = `Copied ${totalCount} items to clipboard (${fileCount} files, ${sourceCount} sources)`; if (templateName) { message += ` using template "${templateName}"`; } new Notice(message); } async copyContextWithSelections( selections: FileSelection[], templateId?: string | null ) { const selector = new ContentSelector(this.app); // Filter to selected files only const selectedFiles = selections.filter(s => s.selected); if (selectedFiles.length === 0) { new Notice('No files selected'); return; } // Resolve additional sources const registry = new SourceRegistry(); const enabledSources = this.settings.sources.filter(s => s.enabled); const resolvedSources = await registry.resolveAll(enabledSources); // Check for source errors 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); } // Separate prefix and suffix sources const prefixSources = resolvedSources.filter(r => r.source.position === 'prefix' && !r.error); const suffixSources = resolvedSources.filter(r => r.source.position === 'suffix' && !r.error); // Build output parts const outputParts: string[] = []; // Add prefix sources for (const resolved of prefixSources) { const formatted = formatSourceOutput(resolved, this.settings.showSourceLabels); if (formatted) { outputParts.push(formatted); } } // Add selected vault content const vaultParts: string[] = []; for (const selection of selectedFiles) { const content = selector.extractSelectedContent(selection); if (content) { if (this.settings.includeFilenames) { // Include heading info if partial selection const isPartial = !this.isFullFileSelected(selection); const suffix = isPartial ? ' (partial)' : ''; vaultParts.push(`# === ${selection.file.name}${suffix} ===\n\n${content}`); } else { vaultParts.push(content); } } } outputParts.push(vaultParts.join(`\n\n${this.settings.separator}\n\n`)); // Add suffix sources 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`); const sourceCount = prefixSources.length + suffixSources.length; const fileCount = selectedFiles.length; const totalCount = fileCount + sourceCount; // Apply template if specified const effectiveTemplateId = templateId !== undefined ? templateId : 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; } } // Prepare history metadata const historyMetadata: Omit = { templateId: effectiveTemplateId || null, templateName, includedFiles: selectedFiles.map(s => { const isPartial = !this.isFullFileSelected(s); return isPartial ? `${s.file.path} (partial)` : s.file.path; }), includedSources: [ ...prefixSources.map(r => r.source.name), ...suffixSources.map(r => r.source.name), ], activeNote: null, }; // Copy and save const copyAndSave = async () => { await navigator.clipboard.writeText(combined); this.showCopyNotice(fileCount, sourceCount, templateName); await this.historyManager.saveEntry(combined, historyMetadata); }; if (this.settings.showPreview) { new PreviewModal(this.app, combined, totalCount, copyAndSave).open(); } else { await copyAndSave(); } } private isFullFileSelected(selection: FileSelection): boolean { if (selection.headings.length === 0) return true; const checkAll = (headings: import('./content-selector').HeadingNode[]): boolean => { for (const h of headings) { if (!h.selected) return false; if (!checkAll(h.children)) return false; } return true; }; return checkAll(selection.headings); } async copySmartContext() { if (!this.settings.intelligence.enabled) { new Notice('Context intelligence is disabled. Enable it in Settings > Intelligence.'); return; } const activeView = this.app.workspace.getActiveViewOfType(MarkdownView); if (!activeView?.file) { new Notice('No active note'); return; } const activeFile = activeView.file; const intelligence = new ContextIntelligence(this.app); const result = await intelligence.analyze(activeFile, this.settings.intelligence); if (result.scoredNotes.length === 0) { new Notice('No related notes found for this note'); return; } new SmartContextModal(this.app, result, async (selectedNotes) => { await this.assembleAndCopySmartContext(activeFile, selectedNotes); }).open(); } private async assembleAndCopySmartContext(activeFile: TFile, selectedNotes: ScoredNote[]) { if (selectedNotes.length === 0) { new Notice('No notes selected'); return; } // Resolve additional sources 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 const outputParts: string[] = []; // Prefix sources for (const resolved of prefixSources) { const formatted = formatSourceOutput(resolved, this.settings.showSourceLabels); if (formatted) outputParts.push(formatted); } // Context folder files const folder = this.app.vault.getAbstractFileByPath(this.settings.contextFolder); if (folder instanceof TFolder) { const excludedFiles = this.settings.excludedFiles.map(f => f.toLowerCase()); const contextFiles = 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); }); if (contextFiles.length > 0) { const vaultParts: string[] = []; for (const file of contextFiles) { const content = await this.app.vault.read(file); if (this.settings.includeFilenames) { vaultParts.push(`# === ${file.name} ===\n\n${content}`); } else { vaultParts.push(content); } } outputParts.push(vaultParts.join(`\n\n${this.settings.separator}\n\n`)); } } // Smart context notes (sorted by score) const smartParts: string[] = []; for (const note of selectedNotes) { const content = await this.app.vault.read(note.file); if (this.settings.includeFilenames) { smartParts.push(`# === SMART: ${note.file.name} (${note.score} pts) ===\n\n${content}`); } else { smartParts.push(content); } } if (smartParts.length > 0) { outputParts.push(smartParts.join(`\n\n${this.settings.separator}\n\n`)); } // Active note if (this.settings.intelligence.includeActiveNote) { const content = await this.app.vault.read(activeFile); if (this.settings.includeFilenames) { outputParts.push(`# === ACTIVE: ${activeFile.name} ===\n\n${content}`); } else { outputParts.push(`--- ACTIVE NOTE ---\n\n${content}`); } } // Suffix sources 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 template 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; } } // Prepare history metadata const historyMetadata: Omit = { templateId: effectiveTemplateId || null, templateName, includedFiles: selectedNotes.map(n => n.file.path), includedSources: [ ...prefixSources.map(r => r.source.name), ...suffixSources.map(r => r.source.name), ], activeNote: this.settings.intelligence.includeActiveNote ? activeFile.path : null, userNote: `Smart context from: ${activeFile.basename}`, }; // Copy and save await navigator.clipboard.writeText(combined); const noteCount = selectedNotes.length + (this.settings.intelligence.includeActiveNote ? 1 : 0); let message = `Copied smart context: ${noteCount} notes`; if (templateName) { message += ` using "${templateName}"`; } new Notice(message); await this.historyManager.saveEntry(combined, historyMetadata); } async copyContextFromPreset() { // Get active file const activeView = this.app.workspace.getActiveViewOfType(MarkdownView); if (!activeView?.file) { new Notice('No active note'); return; } const activeFile = activeView.file; // Parse preset from frontmatter const cache = this.app.metadataCache.getFileCache(activeFile); const parseResult = parsePresetFromFrontmatter(cache); if (!parseResult.valid || !parseResult.preset) { // No preset found, fall back to generator modal if (parseResult.errors.includes('No ai-context block in frontmatter') || parseResult.errors.includes('No frontmatter found')) { new Notice('No ai-context preset found. Opening generator...'); new ContextGeneratorModal(this.app, this).open(); return; } // Show validation errors new Notice(`Invalid ai-context preset:\n${formatPresetErrors(parseResult)}`, 10000); return; } // Show warnings if any if (parseResult.warnings.length > 0) { new Notice(`Preset warnings:\n${parseResult.warnings.join('\n')}`, 5000); } const preset = parseResult.preset; // Route based on mode if (preset.mode === 'auto') { // Use intelligence engine const intelligence = new ContextIntelligence(this.app); const intelSettings = { ...this.settings.intelligence, ...(preset.maxTokens ? { maxTokens: preset.maxTokens } : {}), }; const result = await intelligence.analyze(activeFile, intelSettings); if (result.scoredNotes.length === 0) { new Notice('No related notes found for this note'); return; } new SmartContextModal(this.app, result, async (selectedNotes) => { await this.assembleAndCopySmartContext(activeFile, selectedNotes); }).open(); return; } // Execute preset (manual mode) await this.executePreset(activeFile, preset); } private async executePreset(activeFile: TFile, preset: ContextPreset) { // Get base context files const folder = this.app.vault.getAbstractFileByPath(this.settings.contextFolder); let contextFiles: TFile[] = []; if (folder instanceof TFolder) { const excludedFiles = this.settings.excludedFiles.map(f => f.toLowerCase()); contextFiles = 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); }); } // Execute preset to collect files const executor = new PresetExecutor(this.app); const result = await executor.execute(activeFile, preset, contextFiles); if (result.files.length === 0) { new Notice('No files matched the preset criteria'); return; } // Show truncation warning if (result.truncated) { new Notice(`Token budget exceeded. Some files were excluded. (~${result.totalTokens} tokens)`, 5000); } // Resolve additional sources 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 const outputParts: string[] = []; // Add prefix sources for (const resolved of prefixSources) { const formatted = formatSourceOutput(resolved, this.settings.showSourceLabels); if (formatted) { outputParts.push(formatted); } } // Add context files const vaultParts: string[] = []; for (const file of result.files) { const content = await this.app.vault.read(file); if (this.settings.includeFilenames) { vaultParts.push(`# === ${file.name} ===\n\n${content}`); } else { vaultParts.push(content); } } outputParts.push(vaultParts.join(`\n\n${this.settings.separator}\n\n`)); // Include active note if specified const includeActive = preset.includeActiveNote ?? this.settings.includeActiveNote; if (includeActive) { const content = await this.app.vault.read(activeFile); if (this.settings.includeFilenames) { outputParts.push(`# === ACTIVE: ${activeFile.name} ===\n\n${content}`); } else { outputParts.push(`--- ACTIVE NOTE ---\n\n${content}`); } } // Add suffix sources 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 template let templateId: string | null = null; let templateName: string | null = null; if (preset.template) { // Find template by name const template = this.settings.promptTemplates.find( t => t.name.toLowerCase() === preset.template!.toLowerCase() ); if (template) { templateId = template.id; templateName = template.name; const engine = new TemplateEngine(this.app); const context = await engine.buildContext(combined); combined = await engine.processTemplate(template.content, context); } else { new Notice(`Template "${preset.template}" not found`, 5000); } } else if (this.settings.defaultTemplateId) { const template = this.settings.promptTemplates.find(t => t.id === this.settings.defaultTemplateId); if (template) { templateId = template.id; templateName = template.name; const engine = new TemplateEngine(this.app); const context = await engine.buildContext(combined); combined = await engine.processTemplate(template.content, context); } } // Prepare history metadata const sourceCount = prefixSources.length + suffixSources.length; const fileCount = result.files.length + (includeActive ? 1 : 0); const historyMetadata: Omit = { templateId, templateName, includedFiles: result.files.map(f => f.path), includedSources: [ ...prefixSources.map(r => r.source.name), ...suffixSources.map(r => r.source.name), ], activeNote: includeActive ? activeFile.path : null, userNote: `Preset from: ${activeFile.basename}`, }; // Copy and save const copyAndSave = async () => { await navigator.clipboard.writeText(combined); let message = `Copied ${fileCount} files`; if (result.linkedFiles.length > 0) { message += ` (${result.linkedFiles.length} linked)`; } if (result.taggedFiles.length > 0) { message += ` (${result.taggedFiles.length} by tag)`; } if (templateName) { message += ` using "${templateName}"`; } new Notice(message); await this.historyManager.saveEntry(combined, historyMetadata); }; if (this.settings.showPreview) { new PreviewModal(this.app, combined, fileCount + sourceCount, copyAndSave).open(); } else { await copyAndSave(); } } }