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:
parent
c4b83140b5
commit
11fcce0f4c
2 changed files with 726 additions and 1 deletions
216
src/main.ts
216
src/main.ts
|
|
@ -7,7 +7,13 @@ import { TemplateEngine } from './templates';
|
|||
import { HistoryManager, HistoryMetadata } from './history';
|
||||
import { HistoryModal } from './history-modal';
|
||||
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 {
|
||||
settings: ClaudeContextSettings;
|
||||
|
|
@ -52,6 +58,12 @@ export default class ClaudeContextPlugin extends Plugin {
|
|||
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));
|
||||
}
|
||||
|
||||
|
|
@ -374,4 +386,206 @@ export default class ClaudeContextPlugin extends Plugin {
|
|||
|
||||
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
511
src/presets.ts
Normal 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;
|
||||
}
|
||||
Loading…
Reference in a new issue