feat: add frontmatter preset system for one-hotkey context generation

- Parse ai-context block from frontmatter with validation
- Support fields: template, include-linked, link-depth, include-tags,
  exclude-paths, exclude-tags, max-tokens, include-active-note
- Add LinkTraverser for cycle-safe graph traversal of linked notes
- Add TagCollector for collecting files by tags (hierarchical support)
- Token budget enforcement with size-based file prioritization
- New command: "Copy context from frontmatter preset"
- Fallback to generator modal when no preset found
- Clear error messages for invalid preset values

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Luca G. Oelfke 2026-02-06 10:41:41 +01:00
parent c4b83140b5
commit 11fcce0f4c
No known key found for this signature in database
GPG key ID: E22BABF67200F864
2 changed files with 726 additions and 1 deletions

View file

@ -7,7 +7,13 @@ import { TemplateEngine } from './templates';
import { HistoryManager, HistoryMetadata } from './history'; import { HistoryManager, HistoryMetadata } from './history';
import { HistoryModal } from './history-modal'; import { HistoryModal } from './history-modal';
import { ContentSelector, FileSelection } from './content-selector'; import { ContentSelector, FileSelection } from './content-selector';
import { FileSelectorModal, FileSelectionResult } from './file-selector-modal'; import { FileSelectorModal } from './file-selector-modal';
import {
parsePresetFromFrontmatter,
PresetExecutor,
formatPresetErrors,
ContextPreset,
} from './presets';
export default class ClaudeContextPlugin extends Plugin { export default class ClaudeContextPlugin extends Plugin {
settings: ClaudeContextSettings; settings: ClaudeContextSettings;
@ -52,6 +58,12 @@ export default class ClaudeContextPlugin extends Plugin {
callback: () => this.openFileSelector() callback: () => this.openFileSelector()
}); });
this.addCommand({
id: 'copy-context-from-preset',
name: 'Copy context from frontmatter preset',
callback: () => this.copyContextFromPreset()
});
this.addSettingTab(new ClaudeContextSettingTab(this.app, this)); this.addSettingTab(new ClaudeContextSettingTab(this.app, this));
} }
@ -374,4 +386,206 @@ export default class ClaudeContextPlugin extends Plugin {
return checkAll(selection.headings); return checkAll(selection.headings);
} }
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;
// Execute preset
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();
}
}
} }

511
src/presets.ts Normal file
View file

@ -0,0 +1,511 @@
import { App, TFile, CachedMetadata } from 'obsidian';
import { estimateTokens } from './history';
// === Types ===
export interface ContextPreset {
template?: string;
includeLinked?: boolean;
linkDepth?: number;
includeTags?: string[];
excludePaths?: string[];
excludeTags?: string[];
maxTokens?: number;
includeActiveNote?: boolean;
}
export interface PresetValidationResult {
valid: boolean;
preset: ContextPreset | null;
errors: string[];
warnings: string[];
}
export interface LinkedNoteResult {
file: TFile;
depth: number;
linkPath: string[];
}
// === Frontmatter Parsing ===
/**
* Extract and validate ai-context preset from file's frontmatter
*/
export function parsePresetFromFrontmatter(
cache: CachedMetadata | null
): PresetValidationResult {
const errors: string[] = [];
const warnings: string[] = [];
if (!cache?.frontmatter) {
return { valid: false, preset: null, errors: ['No frontmatter found'], warnings };
}
const aiContext = cache.frontmatter['ai-context'];
if (!aiContext) {
return { valid: false, preset: null, errors: ['No ai-context block in frontmatter'], warnings };
}
if (typeof aiContext !== 'object' || Array.isArray(aiContext)) {
return { valid: false, preset: null, errors: ['ai-context must be an object'], warnings };
}
const preset: ContextPreset = {};
// Parse template
if (aiContext.template !== undefined) {
if (typeof aiContext.template === 'string') {
preset.template = aiContext.template;
} else {
errors.push('template must be a string');
}
}
// Parse includeLinked
if (aiContext['include-linked'] !== undefined) {
if (typeof aiContext['include-linked'] === 'boolean') {
preset.includeLinked = aiContext['include-linked'];
} else {
errors.push('include-linked must be a boolean');
}
}
// Parse linkDepth
if (aiContext['link-depth'] !== undefined) {
const depth = Number(aiContext['link-depth']);
if (!isNaN(depth) && depth >= 0 && depth <= 10) {
preset.linkDepth = depth;
} else {
errors.push('link-depth must be a number between 0 and 10');
}
}
// Parse includeTags
if (aiContext['include-tags'] !== undefined) {
if (Array.isArray(aiContext['include-tags'])) {
const tags = aiContext['include-tags'].filter((t: unknown) => typeof t === 'string');
if (tags.length > 0) {
preset.includeTags = tags.map((t: string) => t.startsWith('#') ? t : `#${t}`);
}
} else {
errors.push('include-tags must be an array of strings');
}
}
// Parse excludePaths
if (aiContext['exclude-paths'] !== undefined) {
if (Array.isArray(aiContext['exclude-paths'])) {
const paths = aiContext['exclude-paths'].filter((p: unknown) => typeof p === 'string');
if (paths.length > 0) {
preset.excludePaths = paths;
}
} else {
errors.push('exclude-paths must be an array of strings');
}
}
// Parse excludeTags
if (aiContext['exclude-tags'] !== undefined) {
if (Array.isArray(aiContext['exclude-tags'])) {
const tags = aiContext['exclude-tags'].filter((t: unknown) => typeof t === 'string');
if (tags.length > 0) {
preset.excludeTags = tags.map((t: string) => t.startsWith('#') ? t : `#${t}`);
}
} else {
errors.push('exclude-tags must be an array of strings');
}
}
// Parse maxTokens
if (aiContext['max-tokens'] !== undefined) {
const tokens = Number(aiContext['max-tokens']);
if (!isNaN(tokens) && tokens > 0) {
preset.maxTokens = tokens;
} else {
errors.push('max-tokens must be a positive number');
}
}
// Parse includeActiveNote
if (aiContext['include-active-note'] !== undefined) {
if (typeof aiContext['include-active-note'] === 'boolean') {
preset.includeActiveNote = aiContext['include-active-note'];
} else {
errors.push('include-active-note must be a boolean');
}
}
// Warn about unknown fields
const knownFields = [
'template', 'include-linked', 'link-depth', 'include-tags',
'exclude-paths', 'exclude-tags', 'max-tokens', 'include-active-note'
];
for (const key of Object.keys(aiContext)) {
if (!knownFields.includes(key)) {
warnings.push(`Unknown field: ${key}`);
}
}
return {
valid: errors.length === 0,
preset: errors.length === 0 ? preset : null,
errors,
warnings,
};
}
// === Link Traversal ===
export class LinkTraverser {
private app: App;
private visited: Set<string> = new Set();
private results: LinkedNoteResult[] = [];
constructor(app: App) {
this.app = app;
}
/**
* Traverse links from a starting file up to a given depth
*/
async traverseLinks(
startFile: TFile,
maxDepth: number,
excludePaths: string[] = [],
excludeTags: string[] = []
): Promise<LinkedNoteResult[]> {
this.visited.clear();
this.results = [];
await this.traverse(startFile, 0, maxDepth, [], excludePaths, excludeTags);
return this.results;
}
private async traverse(
file: TFile,
currentDepth: number,
maxDepth: number,
linkPath: string[],
excludePaths: string[],
excludeTags: string[]
): Promise<void> {
// Check if already visited
if (this.visited.has(file.path)) {
return;
}
// Check excluded paths
if (this.isPathExcluded(file.path, excludePaths)) {
return;
}
// Check excluded tags
if (this.hasExcludedTag(file, excludeTags)) {
return;
}
this.visited.add(file.path);
// Add to results (except starting file at depth 0)
if (currentDepth > 0) {
this.results.push({
file,
depth: currentDepth,
linkPath: [...linkPath, file.basename],
});
}
// Stop if max depth reached
if (currentDepth >= maxDepth) {
return;
}
// Get outgoing links
const cache = this.app.metadataCache.getFileCache(file);
if (!cache?.links) {
return;
}
for (const link of cache.links) {
const linkedFile = this.app.metadataCache.getFirstLinkpathDest(
link.link,
file.path
);
if (linkedFile instanceof TFile) {
await this.traverse(
linkedFile,
currentDepth + 1,
maxDepth,
[...linkPath, file.basename],
excludePaths,
excludeTags
);
}
}
}
private isPathExcluded(path: string, excludePaths: string[]): boolean {
for (const excludePath of excludePaths) {
if (path.startsWith(excludePath) || path.includes(`/${excludePath}`)) {
return true;
}
}
return false;
}
private hasExcludedTag(file: TFile, excludeTags: string[]): boolean {
if (excludeTags.length === 0) {
return false;
}
const cache = this.app.metadataCache.getFileCache(file);
if (!cache?.frontmatter?.tags) {
return false;
}
const fileTags: string[] = Array.isArray(cache.frontmatter.tags)
? cache.frontmatter.tags
: [cache.frontmatter.tags];
for (const fileTag of fileTags) {
const normalizedTag = fileTag.startsWith('#') ? fileTag : `#${fileTag}`;
if (excludeTags.includes(normalizedTag)) {
return true;
}
}
return false;
}
}
// === Tag-based File Collection ===
export class TagCollector {
private app: App;
constructor(app: App) {
this.app = app;
}
/**
* Collect all files that have any of the specified tags
*/
collectFilesWithTags(
tags: string[],
excludePaths: string[] = []
): TFile[] {
const files: TFile[] = [];
const normalizedTags = tags.map(t => t.startsWith('#') ? t.substring(1) : t);
for (const file of this.app.vault.getMarkdownFiles()) {
// Check excluded paths
let excluded = false;
for (const excludePath of excludePaths) {
if (file.path.startsWith(excludePath) || file.path.includes(`/${excludePath}`)) {
excluded = true;
break;
}
}
if (excluded) continue;
// Check tags
const cache = this.app.metadataCache.getFileCache(file);
if (!cache) continue;
// Check frontmatter tags
const frontmatterTags = this.getFrontmatterTags(cache);
// Check inline tags
const inlineTags = cache.tags?.map(t => t.tag.substring(1)) || [];
const allTags = [...frontmatterTags, ...inlineTags];
for (const tag of allTags) {
// Support hierarchical tag matching
for (const searchTag of normalizedTags) {
if (tag === searchTag || tag.startsWith(`${searchTag}/`)) {
files.push(file);
break;
}
}
}
}
return files;
}
private getFrontmatterTags(cache: CachedMetadata): string[] {
if (!cache.frontmatter?.tags) {
return [];
}
const tags = cache.frontmatter.tags;
if (Array.isArray(tags)) {
return tags.map(t => typeof t === 'string' ? t : String(t));
}
if (typeof tags === 'string') {
return [tags];
}
return [];
}
}
// === Preset Executor ===
export interface PresetExecutionResult {
files: TFile[];
linkedFiles: LinkedNoteResult[];
taggedFiles: TFile[];
truncated: boolean;
totalTokens: number;
}
export class PresetExecutor {
private app: App;
private linkTraverser: LinkTraverser;
private tagCollector: TagCollector;
constructor(app: App) {
this.app = app;
this.linkTraverser = new LinkTraverser(app);
this.tagCollector = new TagCollector(app);
}
/**
* Execute a preset and collect all files to include
*/
async execute(
activeFile: TFile,
preset: ContextPreset,
contextFiles: TFile[]
): Promise<PresetExecutionResult> {
const allFiles = new Set<TFile>(contextFiles);
let linkedFiles: LinkedNoteResult[] = [];
let taggedFiles: TFile[] = [];
// Include linked notes
if (preset.includeLinked) {
const depth = preset.linkDepth ?? 1;
linkedFiles = await this.linkTraverser.traverseLinks(
activeFile,
depth,
preset.excludePaths || [],
preset.excludeTags || []
);
for (const result of linkedFiles) {
allFiles.add(result.file);
}
}
// Include notes with specified tags
if (preset.includeTags && preset.includeTags.length > 0) {
taggedFiles = this.tagCollector.collectFilesWithTags(
preset.includeTags,
preset.excludePaths || []
);
for (const file of taggedFiles) {
allFiles.add(file);
}
}
// Convert to array and apply exclusions
let files = Array.from(allFiles);
// Apply path exclusions
if (preset.excludePaths && preset.excludePaths.length > 0) {
files = files.filter(f => {
for (const excludePath of preset.excludePaths!) {
if (f.path.startsWith(excludePath) || f.path.includes(`/${excludePath}`)) {
return false;
}
}
return true;
});
}
// Apply tag exclusions
if (preset.excludeTags && preset.excludeTags.length > 0) {
files = files.filter(f => {
const cache = this.app.metadataCache.getFileCache(f);
if (!cache?.frontmatter?.tags) return true;
const fileTags: string[] = Array.isArray(cache.frontmatter.tags)
? cache.frontmatter.tags
: [cache.frontmatter.tags];
for (const fileTag of fileTags) {
const normalizedTag = fileTag.startsWith('#') ? fileTag : `#${fileTag}`;
if (preset.excludeTags!.includes(normalizedTag)) {
return false;
}
}
return true;
});
}
// Check token budget
let totalTokens = 0;
let truncated = false;
if (preset.maxTokens) {
const filesWithTokens: { file: TFile; tokens: number }[] = [];
for (const file of files) {
const content = await this.app.vault.read(file);
const tokens = estimateTokens(content);
filesWithTokens.push({ file, tokens });
}
// Sort by tokens (smallest first) to maximize included files
filesWithTokens.sort((a, b) => a.tokens - b.tokens);
const includedFiles: TFile[] = [];
for (const { file, tokens } of filesWithTokens) {
if (totalTokens + tokens <= preset.maxTokens) {
includedFiles.push(file);
totalTokens += tokens;
} else {
truncated = true;
}
}
files = includedFiles;
} else {
// Calculate total tokens without truncation
for (const file of files) {
const content = await this.app.vault.read(file);
totalTokens += estimateTokens(content);
}
}
return {
files,
linkedFiles,
taggedFiles,
truncated,
totalTokens,
};
}
}
// === Helper ===
export function formatPresetErrors(result: PresetValidationResult): string {
let message = '';
if (result.errors.length > 0) {
message += 'Errors:\n' + result.errors.map(e => `${e}`).join('\n');
}
if (result.warnings.length > 0) {
if (message) message += '\n\n';
message += 'Warnings:\n' + result.warnings.map(w => `${w}`).join('\n');
}
return message;
}