Scores vault notes using 5 signals (forward links, backlinks, shared tags, folder proximity, shared properties) and presents a review modal with color-coded badges and token budget tracking. Adds "Copy smart context" command, Intelligence settings tab, and mode: auto preset support. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
859 lines
27 KiB
TypeScript
859 lines
27 KiB
TypeScript
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<number> {
|
|
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 = engine.processTemplate(template.content, context);
|
|
templateName = template.name;
|
|
}
|
|
}
|
|
|
|
// Prepare history metadata
|
|
const historyMetadata: Omit<HistoryMetadata, 'charCount' | 'estimatedTokens'> = {
|
|
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<HistoryMetadata, 'charCount' | 'estimatedTokens'>,
|
|
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 = engine.processTemplate(template.content, context);
|
|
templateName = template.name;
|
|
}
|
|
}
|
|
|
|
// Prepare history metadata
|
|
const historyMetadata: Omit<HistoryMetadata, 'charCount' | 'estimatedTokens'> = {
|
|
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 = engine.processTemplate(template.content, context);
|
|
templateName = template.name;
|
|
}
|
|
}
|
|
|
|
// Prepare history metadata
|
|
const historyMetadata: Omit<HistoryMetadata, 'charCount' | 'estimatedTokens'> = {
|
|
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 = 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 = 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<HistoryMetadata, 'charCount' | 'estimatedTokens'> = {
|
|
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();
|
|
}
|
|
}
|
|
}
|