From 66b1ac8ffacfcf720b0569c22284e3605ffdeea8 Mon Sep 17 00:00:00 2001 From: "Luca G. Oelfke" Date: Fri, 6 Feb 2026 10:48:55 +0100 Subject: [PATCH] feat: add multi-target output system for different LLMs - Add OutputTarget interface with format, tokens, truncation strategy - Add built-in targets: Claude (XML, 200k), GPT-4o (MD, 128k), Compact (8k) - Add ContentTransformer for markdown/xml/plain conversion - Add TruncationEngine with smart section prioritization - Add TargetExecutor for processing content per target - Add target configuration UI in settings with CRUD operations - Add target selection checkboxes in generator modal - Primary target goes to clipboard, secondary targets saved as files Co-Authored-By: Claude Opus 4.5 --- src/generator.ts | 87 +++++++- src/main.ts | 96 ++++++-- src/settings.ts | 175 +++++++++++++++ src/target-modal.ts | 214 ++++++++++++++++++ src/targets.ts | 522 ++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 1079 insertions(+), 15 deletions(-) create mode 100644 src/target-modal.ts create mode 100644 src/targets.ts diff --git a/src/generator.ts b/src/generator.ts index a579262..45b3d9c 100644 --- a/src/generator.ts +++ b/src/generator.ts @@ -1,6 +1,7 @@ import { App, Modal, Notice, Setting, TFolder } from 'obsidian'; import ClaudeContextPlugin from './main'; import { createFreetextSource, SourcePosition } from './sources'; +import { OutputTarget, getTargetIcon, formatTokenCount } from './targets'; interface FolderConfig { name: string; @@ -83,6 +84,7 @@ export class ContextGeneratorModal extends Modal { temporaryPosition: SourcePosition = 'prefix'; saveAsDefault: boolean = false; selectedTemplateId: string | null = null; + selectedTargetIds: Set = new Set(); constructor(app: App, plugin: ClaudeContextPlugin) { super(app); @@ -91,6 +93,10 @@ export class ContextGeneratorModal extends Modal { this.config.folders = this.scanVaultStructure().map(name => ({ name, purpose: '' })); // Initialize with default template this.selectedTemplateId = this.plugin.settings.defaultTemplateId; + // Initialize with enabled targets + this.selectedTargetIds = new Set( + this.plugin.settings.targets.filter(t => t.enabled).map(t => t.id) + ); } onOpen() { @@ -342,6 +348,73 @@ export class ContextGeneratorModal extends Modal { }); templateInfo.style.marginTop = '5px'; + // === OUTPUT TARGETS SECTION === + if (this.plugin.settings.targets.length > 0) { + contentEl.createEl('h3', { text: 'Output Targets' }); + + const targetsContainer = contentEl.createDiv({ cls: 'targets-checkboxes' }); + targetsContainer.style.display = 'flex'; + targetsContainer.style.flexDirection = 'column'; + targetsContainer.style.gap = '8px'; + targetsContainer.style.padding = '10px'; + targetsContainer.style.backgroundColor = 'var(--background-secondary)'; + targetsContainer.style.borderRadius = '4px'; + targetsContainer.style.marginBottom = '15px'; + + const primaryId = this.plugin.settings.primaryTargetId || + (this.plugin.settings.targets.find(t => t.enabled)?.id ?? null); + + for (const target of this.plugin.settings.targets) { + const row = targetsContainer.createDiv({ cls: 'target-checkbox-row' }); + row.style.display = 'flex'; + row.style.alignItems = 'center'; + row.style.gap = '10px'; + + const checkbox = row.createEl('input', { type: 'checkbox' }); + checkbox.checked = this.selectedTargetIds.has(target.id); + checkbox.addEventListener('change', () => { + if (checkbox.checked) { + this.selectedTargetIds.add(target.id); + } else { + this.selectedTargetIds.delete(target.id); + } + }); + + const icon = row.createEl('span', { text: getTargetIcon(target.format) }); + + const label = row.createEl('span', { text: target.name }); + label.style.flex = '1'; + + // Primary indicator + if (target.id === primaryId) { + const badge = row.createEl('span', { text: 'clipboard' }); + badge.style.padding = '2px 6px'; + badge.style.borderRadius = '3px'; + badge.style.fontSize = '11px'; + badge.style.backgroundColor = 'var(--interactive-accent)'; + badge.style.color = 'var(--text-on-accent)'; + } else { + const badge = row.createEl('span', { text: 'file' }); + badge.style.padding = '2px 6px'; + badge.style.borderRadius = '3px'; + badge.style.fontSize = '11px'; + badge.style.backgroundColor = 'var(--background-modifier-hover)'; + } + + const tokenInfo = row.createEl('span', { + text: `${target.maxTokens.toLocaleString()} tokens` + }); + tokenInfo.style.fontSize = '11px'; + tokenInfo.style.color = 'var(--text-muted)'; + } + + const targetsInfo = contentEl.createEl('p', { + text: 'Primary target is copied to clipboard. Secondary targets are saved as files in the output folder.', + cls: 'setting-item-description' + }); + targetsInfo.style.marginTop = '5px'; + } + // Copy buttons const copyButtonContainer = contentEl.createDiv(); copyButtonContainer.style.display = 'flex'; @@ -364,8 +437,18 @@ export class ContextGeneratorModal extends Modal { new Notice('Freetext saved as default source'); } - // Copy context with selected template - await this.plugin.copyContextToClipboard(false, this.temporaryFreetext, this.selectedTemplateId); + // Get selected targets + const selectedTargets = this.plugin.settings.targets.filter( + t => this.selectedTargetIds.has(t.id) + ); + + // Copy context with selected template and targets + await this.plugin.copyContextToClipboard( + false, + this.temporaryFreetext, + this.selectedTemplateId, + selectedTargets.length > 0 ? selectedTargets : undefined + ); this.close(); })) .addButton(btn => btn diff --git a/src/main.ts b/src/main.ts index e356f9f..4897a84 100644 --- a/src/main.ts +++ b/src/main.ts @@ -14,6 +14,12 @@ import { formatPresetErrors, ContextPreset, } from './presets'; +import { + OutputTarget, + TargetExecutor, + TargetResult, + saveTargetToFile, +} from './targets'; export default class ClaudeContextPlugin extends Plugin { settings: ClaudeContextSettings; @@ -101,7 +107,8 @@ export default class ClaudeContextPlugin extends Plugin { async copyContextToClipboard( forceIncludeNote = false, temporaryFreetext?: string, - templateId?: string | null + templateId?: string | null, + targets?: OutputTarget[] ) { const folder = this.app.vault.getAbstractFileByPath(this.settings.contextFolder); @@ -234,22 +241,85 @@ export default class ClaudeContextPlugin extends Plugin { activeNote: activeNotePath, }; - // Copy and save to history - 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(); + // Process targets if provided + if (targets && targets.length > 0) { + await this.processTargets(combined, targets, historyMetadata, fileCount, sourceCount, templateName); } else { - await copyAndSave(); + // 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)`; diff --git a/src/settings.ts b/src/settings.ts index d6055a5..d3d2480 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -5,6 +5,8 @@ import { SourceModal } from './source-modal'; import { PromptTemplate, STARTER_TEMPLATES } from './templates'; import { TemplateModal, TemplateImportExportModal } from './template-modal'; import { HistorySettings, DEFAULT_HISTORY_SETTINGS } from './history'; +import { OutputTarget, BUILTIN_TARGETS, getTargetIcon } from './targets'; +import { TargetModal } from './target-modal'; export interface ClaudeContextSettings { contextFolder: string; @@ -18,6 +20,9 @@ export interface ClaudeContextSettings { promptTemplates: PromptTemplate[]; defaultTemplateId: string | null; history: HistorySettings; + targets: OutputTarget[]; + primaryTargetId: string | null; + targetOutputFolder: string; } export const DEFAULT_SETTINGS: ClaudeContextSettings = { @@ -32,6 +37,9 @@ export const DEFAULT_SETTINGS: ClaudeContextSettings = { promptTemplates: [], defaultTemplateId: null, history: DEFAULT_HISTORY_SETTINGS, + targets: [], + primaryTargetId: null, + targetOutputFolder: '_claude/outputs', }; export class ClaudeContextSettingTab extends PluginSettingTab { @@ -304,6 +312,72 @@ export class ClaudeContextSettingTab extends PluginSettingTab { new Notice(`Cleaned up ${deleted} old entries`); }); } + + // === OUTPUT TARGETS SECTION === + containerEl.createEl('h3', { text: 'Output Targets' }); + + const targetsDesc = containerEl.createEl('p', { + text: 'Configure multiple output formats for different LLMs. The primary target is copied to clipboard, secondary targets are saved as files.', + cls: 'setting-item-description' + }); + targetsDesc.style.marginBottom = '10px'; + + const targetButtonContainer = containerEl.createDiv({ cls: 'targets-button-container' }); + targetButtonContainer.style.display = 'flex'; + targetButtonContainer.style.gap = '8px'; + targetButtonContainer.style.marginBottom = '15px'; + + const addTargetBtn = targetButtonContainer.createEl('button', { text: '+ New Target' }); + addTargetBtn.addEventListener('click', () => { + new TargetModal(this.app, this.plugin, null, () => this.display()).open(); + }); + + const addBuiltinsBtn = targetButtonContainer.createEl('button', { text: 'Add Built-in Targets' }); + addBuiltinsBtn.addEventListener('click', async () => { + const { BUILTIN_TARGETS } = await import('./targets'); + const existingIds = this.plugin.settings.targets.map(t => t.id); + const newTargets = BUILTIN_TARGETS.filter(t => !existingIds.includes(t.id)); + if (newTargets.length === 0) { + new Notice('All built-in targets already added'); + return; + } + this.plugin.settings.targets.push(...newTargets); + await this.plugin.saveSettings(); + new Notice(`Added ${newTargets.length} built-in target(s)`); + this.display(); + }); + + // Targets list + const targetsContainer = containerEl.createDiv({ cls: 'targets-list-container' }); + this.renderTargetsList(targetsContainer); + + // Primary target setting + new Setting(containerEl) + .setName('Primary target') + .setDesc('This target\'s output is copied to clipboard') + .addDropdown(dropdown => { + dropdown.addOption('', 'First enabled target'); + for (const target of this.plugin.settings.targets) { + dropdown.addOption(target.id, target.name); + } + dropdown.setValue(this.plugin.settings.primaryTargetId || ''); + dropdown.onChange(async (value) => { + this.plugin.settings.primaryTargetId = value || null; + await this.plugin.saveSettings(); + }); + }); + + // Output folder for secondary targets + new Setting(containerEl) + .setName('Output folder') + .setDesc('Folder for secondary target files') + .addText(text => text + .setPlaceholder('_claude/outputs') + .setValue(this.plugin.settings.targetOutputFolder) + .onChange(async (value) => { + this.plugin.settings.targetOutputFolder = value || '_claude/outputs'; + await this.plugin.saveSettings(); + })); } private renderSourcesList(container: HTMLElement) { @@ -496,4 +570,105 @@ export class ClaudeContextSettingTab extends PluginSettingTab { lastRow.style.borderBottom = 'none'; } } + + private renderTargetsList(container: HTMLElement) { + container.empty(); + + if (this.plugin.settings.targets.length === 0) { + const emptyMsg = container.createEl('p', { + text: 'No targets configured yet. Add built-in targets or create a custom one.', + cls: 'setting-item-description' + }); + emptyMsg.style.fontStyle = 'italic'; + return; + } + + const list = container.createDiv({ cls: 'targets-list' }); + list.style.border = '1px solid var(--background-modifier-border)'; + list.style.borderRadius = '4px'; + list.style.marginBottom = '15px'; + + for (const target of this.plugin.settings.targets) { + const row = list.createDiv({ cls: 'target-row' }); + row.style.display = 'flex'; + row.style.alignItems = 'center'; + row.style.padding = '8px 12px'; + row.style.borderBottom = '1px solid var(--background-modifier-border)'; + row.style.gap = '10px'; + + // Icon + const icon = row.createEl('span', { text: getTargetIcon(target.format) }); + icon.style.fontSize = '16px'; + + // Name and info + const textContainer = row.createDiv(); + textContainer.style.flex = '1'; + + const name = textContainer.createEl('span', { text: target.name }); + name.style.fontWeight = '500'; + + const info = textContainer.createEl('div', { + text: `${target.maxTokens.toLocaleString()} tokens | ${target.format} | ${target.strategy}` + }); + info.style.fontSize = '11px'; + info.style.color = 'var(--text-muted)'; + + // Primary badge + const isPrimary = this.plugin.settings.primaryTargetId === target.id || + (!this.plugin.settings.primaryTargetId && this.plugin.settings.targets.indexOf(target) === 0 && target.enabled); + if (isPrimary) { + const badge = row.createEl('span', { text: 'primary' }); + badge.style.padding = '2px 6px'; + badge.style.borderRadius = '3px'; + badge.style.fontSize = '11px'; + badge.style.backgroundColor = 'var(--interactive-accent)'; + badge.style.color = 'var(--text-on-accent)'; + } + + // Builtin badge + if (target.isBuiltin) { + const badge = row.createEl('span', { text: 'builtin' }); + badge.style.padding = '2px 6px'; + badge.style.borderRadius = '3px'; + badge.style.fontSize = '11px'; + badge.style.backgroundColor = 'var(--background-modifier-hover)'; + } + + // Enabled toggle + const toggleContainer = row.createDiv(); + const toggle = toggleContainer.createEl('input', { type: 'checkbox' }); + toggle.checked = target.enabled; + toggle.addEventListener('change', async () => { + target.enabled = toggle.checked; + await this.plugin.saveSettings(); + this.display(); + }); + + // Edit button + const editBtn = row.createEl('button', { text: '✎' }); + editBtn.style.padding = '2px 8px'; + editBtn.addEventListener('click', () => { + new TargetModal(this.app, this.plugin, target, () => this.display()).open(); + }); + + // Delete button + const deleteBtn = row.createEl('button', { text: '✕' }); + deleteBtn.style.padding = '2px 8px'; + deleteBtn.addEventListener('click', async () => { + this.plugin.settings.targets = this.plugin.settings.targets.filter(t => t.id !== target.id); + // Clear primary if this was it + if (this.plugin.settings.primaryTargetId === target.id) { + this.plugin.settings.primaryTargetId = null; + } + await this.plugin.saveSettings(); + this.display(); + }); + } + + // Remove bottom border from last item + const lastTargetRow = list.lastElementChild as HTMLElement; + if (lastTargetRow) { + lastTargetRow.style.borderBottom = 'none'; + } + } } diff --git a/src/target-modal.ts b/src/target-modal.ts new file mode 100644 index 0000000..9ebff4e --- /dev/null +++ b/src/target-modal.ts @@ -0,0 +1,214 @@ +import { App, Modal, Notice, Setting } from 'obsidian'; +import ClaudeContextPlugin from './main'; +import { + OutputTarget, + OutputFormat, + TruncationStrategy, + generateTargetId, +} from './targets'; + +export class TargetModal extends Modal { + plugin: ClaudeContextPlugin; + target: OutputTarget | null; + isNew: boolean; + onSave: () => void; + + // Form state + name: string = ''; + maxTokens: number = 100000; + format: OutputFormat = 'markdown'; + strategy: TruncationStrategy = 'summarize-headers'; + wrapperPrefix: string = ''; + wrapperSuffix: string = ''; + separator: string = '\n\n---\n\n'; + enabled: boolean = true; + + constructor( + app: App, + plugin: ClaudeContextPlugin, + target: OutputTarget | null, + onSave: () => void + ) { + super(app); + this.plugin = plugin; + this.target = target; + this.isNew = target === null; + this.onSave = onSave; + + // Initialize form state from existing target + if (target) { + this.name = target.name; + this.maxTokens = target.maxTokens; + this.format = target.format; + this.strategy = target.strategy; + this.wrapperPrefix = target.wrapper?.prefix || ''; + this.wrapperSuffix = target.wrapper?.suffix || ''; + this.separator = target.separator || '\n\n---\n\n'; + this.enabled = target.enabled; + } + } + + onOpen() { + const { contentEl } = this; + contentEl.empty(); + contentEl.addClass('target-modal'); + + contentEl.createEl('h2', { text: this.isNew ? 'Add Output Target' : 'Edit Output Target' }); + + // Name + new Setting(contentEl) + .setName('Name') + .setDesc('Display name for this target') + .addText(text => text + .setPlaceholder('e.g. Claude, GPT-4, Gemini') + .setValue(this.name) + .onChange(v => this.name = v)); + + // Max tokens + new Setting(contentEl) + .setName('Max tokens') + .setDesc('Maximum token limit for this LLM') + .addText(text => text + .setPlaceholder('100000') + .setValue(String(this.maxTokens)) + .onChange(v => { + const num = parseInt(v, 10); + if (!isNaN(num) && num > 0) { + this.maxTokens = num; + } + })); + + // Format + new Setting(contentEl) + .setName('Output format') + .setDesc('How to format the context output') + .addDropdown(dropdown => dropdown + .addOption('markdown', 'Markdown (default)') + .addOption('xml', 'XML (better for Claude)') + .addOption('plain', 'Plain text') + .setValue(this.format) + .onChange(v => this.format = v as OutputFormat)); + + // Truncation strategy + new Setting(contentEl) + .setName('Truncation strategy') + .setDesc('How to handle content that exceeds token limit') + .addDropdown(dropdown => dropdown + .addOption('summarize-headers', 'Summarize to headers (preserve structure)') + .addOption('drop-sections', 'Drop low-priority sections') + .addOption('truncate', 'Simple truncation (cut off)') + .setValue(this.strategy) + .onChange(v => this.strategy = v as TruncationStrategy)); + + // Separator + new Setting(contentEl) + .setName('Section separator') + .setDesc('Text between content sections') + .addText(text => text + .setPlaceholder('\\n\\n---\\n\\n') + .setValue(this.separator.replace(/\n/g, '\\n')) + .onChange(v => this.separator = v.replace(/\\n/g, '\n'))); + + // Wrapper section + contentEl.createEl('h3', { text: 'Content Wrapper' }); + contentEl.createEl('p', { + text: 'Optional text to wrap around the entire output', + cls: 'setting-item-description' + }); + + new Setting(contentEl) + .setName('Prefix') + .setDesc('Text before content (e.g. )') + .addTextArea(text => { + text.setPlaceholder('') + .setValue(this.wrapperPrefix) + .onChange(v => this.wrapperPrefix = v); + text.inputEl.rows = 2; + text.inputEl.style.width = '100%'; + }); + + new Setting(contentEl) + .setName('Suffix') + .setDesc('Text after content (e.g. )') + .addTextArea(text => { + text.setPlaceholder('') + .setValue(this.wrapperSuffix) + .onChange(v => this.wrapperSuffix = v); + text.inputEl.rows = 2; + text.inputEl.style.width = '100%'; + }); + + // Enabled toggle + new Setting(contentEl) + .setName('Enabled') + .setDesc('Include this target when generating context') + .addToggle(toggle => toggle + .setValue(this.enabled) + .onChange(v => this.enabled = v)); + + // Buttons + const buttonContainer = contentEl.createDiv(); + buttonContainer.style.display = 'flex'; + buttonContainer.style.justifyContent = 'flex-end'; + buttonContainer.style.gap = '10px'; + buttonContainer.style.marginTop = '20px'; + + const cancelBtn = buttonContainer.createEl('button', { text: 'Cancel' }); + cancelBtn.addEventListener('click', () => this.close()); + + const saveBtn = buttonContainer.createEl('button', { text: 'Save', cls: 'mod-cta' }); + saveBtn.addEventListener('click', () => this.save()); + } + + async save() { + // Validate + if (!this.name.trim()) { + new Notice('Name is required'); + return; + } + + if (this.maxTokens <= 0) { + new Notice('Max tokens must be positive'); + return; + } + + // Create or update target + const targetData: OutputTarget = { + id: this.target?.id || generateTargetId(), + name: this.name.trim(), + maxTokens: this.maxTokens, + format: this.format, + strategy: this.strategy, + separator: this.separator, + enabled: this.enabled, + isBuiltin: false, + }; + + // Only add wrapper if values are set + if (this.wrapperPrefix || this.wrapperSuffix) { + targetData.wrapper = { + prefix: this.wrapperPrefix || undefined, + suffix: this.wrapperSuffix || undefined, + }; + } + + if (this.isNew) { + this.plugin.settings.targets.push(targetData); + } else { + const index = this.plugin.settings.targets.findIndex(t => t.id === this.target!.id); + if (index >= 0) { + this.plugin.settings.targets[index] = targetData; + } + } + + await this.plugin.saveSettings(); + new Notice(this.isNew ? 'Target added' : 'Target updated'); + this.onSave(); + this.close(); + } + + onClose() { + const { contentEl } = this; + contentEl.empty(); + } +} diff --git a/src/targets.ts b/src/targets.ts new file mode 100644 index 0000000..904864a --- /dev/null +++ b/src/targets.ts @@ -0,0 +1,522 @@ +import { App, TFile } from 'obsidian'; +import { estimateTokens } from './history'; + +// === Types === + +export type OutputFormat = 'markdown' | 'xml' | 'plain'; +export type TruncationStrategy = 'truncate' | 'summarize-headers' | 'drop-sections'; + +export interface OutputTarget { + id: string; + name: string; + maxTokens: number; + format: OutputFormat; + strategy: TruncationStrategy; + wrapper?: { + prefix?: string; + suffix?: string; + }; + separator?: string; + enabled: boolean; + isBuiltin?: boolean; +} + +export interface TargetResult { + target: OutputTarget; + content: string; + tokens: number; + truncated: boolean; + sectionsDropped: number; +} + +// === ID Generation === + +export function generateTargetId(): string { + return 'target_' + Date.now().toString(36) + Math.random().toString(36).substring(2, 9); +} + +// === Built-in Targets === + +export const BUILTIN_TARGETS: OutputTarget[] = [ + { + id: 'builtin_claude', + name: 'Claude', + maxTokens: 200000, + format: 'xml', + strategy: 'summarize-headers', + wrapper: { + prefix: '\n', + suffix: '\n', + }, + separator: '\n\n', + enabled: true, + isBuiltin: true, + }, + { + id: 'builtin_gpt4', + name: 'GPT-4o', + maxTokens: 128000, + format: 'markdown', + strategy: 'summarize-headers', + separator: '\n\n---\n\n', + enabled: false, + isBuiltin: true, + }, + { + id: 'builtin_compact', + name: 'Compact (8k)', + maxTokens: 8000, + format: 'plain', + strategy: 'drop-sections', + separator: '\n\n', + enabled: false, + isBuiltin: true, + }, +]; + +// === Content Transformer === + +export class ContentTransformer { + /** + * Transform content to target format + */ + transform(content: string, format: OutputFormat): string { + switch (format) { + case 'xml': + return this.toXml(content); + case 'plain': + return this.toPlain(content); + case 'markdown': + default: + return content; + } + } + + private toXml(content: string): string { + // Convert markdown headers to XML tags + let result = content; + + // Convert file headers: # === filename.md === -> + result = result.replace( + /^# === (.+?) ===/gm, + (_, filename) => `` + ); + + // Add closing tags before next file or at end + const lines = result.split('\n'); + const outputLines: string[] = []; + let inFile = false; + + for (const line of lines) { + if (line.startsWith(' 0) { + const sectionContent = currentContent.join('\n'); + sections.push({ + header: currentHeader, + content: sectionContent, + tokens: estimateTokens(sectionContent), + priority: this.calculatePriority(currentHeader, sectionIndex), + }); + sectionIndex++; + } + + currentHeader = headerMatch[1] || ''; + currentContent = [line]; + } else { + currentContent.push(line); + } + } + + // Save last section + if (currentHeader || currentContent.length > 0) { + const sectionContent = currentContent.join('\n'); + sections.push({ + header: currentHeader, + content: sectionContent, + tokens: estimateTokens(sectionContent), + priority: this.calculatePriority(currentHeader, sectionIndex), + }); + } + + return sections; + } + + private calculatePriority(header: string, index: number): number { + // VAULT.md is highest priority (lowest number) + if (header.toLowerCase().includes('vault')) return 0; + + // Context files are high priority + if (header.toLowerCase().includes('context')) return 1; + if (header.toLowerCase().includes('convention')) return 2; + if (header.toLowerCase().includes('structure')) return 3; + + // Active note is important + if (header.toLowerCase().includes('active')) return 4; + + // Examples and templates are lower priority + if (header.toLowerCase().includes('example')) return 100; + if (header.toLowerCase().includes('template')) return 99; + + // Default priority based on order + return 10 + index; + } + + /** + * Apply truncation strategy to fit within token budget + */ + truncate( + sections: ContentSection[], + maxTokens: number, + strategy: TruncationStrategy + ): { sections: ContentSection[]; truncated: boolean; dropped: number } { + const totalTokens = sections.reduce((sum, s) => sum + s.tokens, 0); + + if (totalTokens <= maxTokens) { + return { sections, truncated: false, dropped: 0 }; + } + + switch (strategy) { + case 'truncate': + return this.applyTruncate(sections, maxTokens); + case 'summarize-headers': + return this.applySummarizeHeaders(sections, maxTokens); + case 'drop-sections': + return this.applyDropSections(sections, maxTokens); + default: + return { sections, truncated: false, dropped: 0 }; + } + } + + private applyTruncate( + sections: ContentSection[], + maxTokens: number + ): { sections: ContentSection[]; truncated: boolean; dropped: number } { + let currentTokens = 0; + const result: ContentSection[] = []; + + for (const section of sections) { + if (currentTokens + section.tokens <= maxTokens) { + result.push(section); + currentTokens += section.tokens; + } else { + // Truncate this section to fit + const remainingTokens = maxTokens - currentTokens; + if (remainingTokens > 100) { // Only include if we have reasonable space + const truncatedContent = this.truncateContent(section.content, remainingTokens); + result.push({ + ...section, + content: truncatedContent + '\n\n[... truncated ...]', + tokens: remainingTokens, + }); + } + break; + } + } + + return { + sections: result, + truncated: true, + dropped: sections.length - result.length, + }; + } + + private applySummarizeHeaders( + sections: ContentSection[], + maxTokens: number + ): { sections: ContentSection[]; truncated: boolean; dropped: number } { + // Sort by priority (lowest = most important) + const sorted = [...sections].sort((a, b) => a.priority - b.priority); + + let currentTokens = 0; + const fullSections: ContentSection[] = []; + const summarizedSections: ContentSection[] = []; + + // First pass: include full content for high-priority sections + for (const section of sorted) { + if (currentTokens + section.tokens <= maxTokens * 0.7) { // Reserve 30% for summaries + fullSections.push(section); + currentTokens += section.tokens; + } else { + summarizedSections.push(section); + } + } + + // Second pass: summarize remaining sections + for (const section of summarizedSections) { + const summary = this.summarizeToHeaders(section.content); + const summaryTokens = estimateTokens(summary); + + if (currentTokens + summaryTokens <= maxTokens) { + fullSections.push({ + ...section, + content: summary, + tokens: summaryTokens, + }); + currentTokens += summaryTokens; + } + } + + // Restore original order + const result = fullSections.sort((a, b) => { + const aIndex = sections.findIndex(s => s.header === a.header); + const bIndex = sections.findIndex(s => s.header === b.header); + return aIndex - bIndex; + }); + + return { + sections: result, + truncated: summarizedSections.length > 0, + dropped: sections.length - result.length, + }; + } + + private applyDropSections( + sections: ContentSection[], + maxTokens: number + ): { sections: ContentSection[]; truncated: boolean; dropped: number } { + // Sort by priority (highest priority = lowest number = keep) + const sorted = [...sections].sort((a, b) => a.priority - b.priority); + + let currentTokens = 0; + const kept: ContentSection[] = []; + + for (const section of sorted) { + if (currentTokens + section.tokens <= maxTokens) { + kept.push(section); + currentTokens += section.tokens; + } + } + + // Restore original order + const result = kept.sort((a, b) => { + const aIndex = sections.findIndex(s => s.header === a.header); + const bIndex = sections.findIndex(s => s.header === b.header); + return aIndex - bIndex; + }); + + return { + sections: result, + truncated: kept.length < sections.length, + dropped: sections.length - kept.length, + }; + } + + private truncateContent(content: string, maxTokens: number): string { + // Rough character limit (4 chars per token) + const maxChars = maxTokens * 4; + if (content.length <= maxChars) { + return content; + } + return content.substring(0, maxChars); + } + + private summarizeToHeaders(content: string): string { + const lines = content.split('\n'); + const headers: string[] = []; + + for (const line of lines) { + if (line.match(/^#{1,6}\s+/) || line.match(/^# === /)) { + headers.push(line); + } + } + + if (headers.length === 0) { + // No headers found, return first few lines + return lines.slice(0, 5).join('\n') + '\n[... content summarized ...]'; + } + + return headers.join('\n') + '\n[... content summarized to headers only ...]'; + } + + /** + * Reconstruct content from sections + */ + sectionsToContent(sections: ContentSection[], separator: string): string { + return sections.map(s => s.content).join(separator); + } +} + +// === Target Executor === + +export class TargetExecutor { + private transformer: ContentTransformer; + private truncationEngine: TruncationEngine; + + constructor() { + this.transformer = new ContentTransformer(); + this.truncationEngine = new TruncationEngine(); + } + + /** + * Process content for a specific target + */ + processForTarget(content: string, target: OutputTarget): TargetResult { + // Parse into sections + const sections = this.truncationEngine.parseSections(content); + + // Apply truncation if needed + const { sections: truncatedSections, truncated, dropped } = this.truncationEngine.truncate( + sections, + target.maxTokens, + target.strategy + ); + + // Reconstruct content + const separator = target.separator || '\n\n---\n\n'; + let processedContent = this.truncationEngine.sectionsToContent(truncatedSections, separator); + + // Transform to target format + processedContent = this.transformer.transform(processedContent, target.format); + + // Apply wrapper + if (target.wrapper?.prefix) { + processedContent = target.wrapper.prefix + processedContent; + } + if (target.wrapper?.suffix) { + processedContent = processedContent + target.wrapper.suffix; + } + + const tokens = estimateTokens(processedContent); + + return { + target, + content: processedContent, + tokens, + truncated, + sectionsDropped: dropped, + }; + } + + /** + * Process content for multiple targets + */ + processForTargets(content: string, targets: OutputTarget[]): TargetResult[] { + return targets.map(target => this.processForTarget(content, target)); + } +} + +// === File Output === + +export async function saveTargetToFile( + app: App, + result: TargetResult, + outputFolder: string +): Promise { + const filename = `context-${result.target.name.toLowerCase().replace(/\s+/g, '-')}.md`; + const path = `${outputFolder}/${filename}`; + + try { + // Ensure folder exists + const folder = app.vault.getAbstractFileByPath(outputFolder); + if (!folder) { + await app.vault.createFolder(outputFolder); + } + + // Create or update file + const existing = app.vault.getAbstractFileByPath(path); + if (existing instanceof TFile) { + await app.vault.modify(existing, result.content); + return existing; + } else { + return await app.vault.create(path, result.content); + } + } catch (error) { + console.error(`Failed to save target output: ${error}`); + return null; + } +} + +// === Helpers === + +export function getTargetIcon(format: OutputFormat): string { + switch (format) { + case 'xml': return '📐'; + case 'plain': return '📄'; + case 'markdown': + default: return '📝'; + } +} + +export function formatTokenCount(tokens: number, maxTokens: number): string { + const percentage = Math.round((tokens / maxTokens) * 100); + const status = percentage > 100 ? '⚠️' : percentage > 80 ? '⚡' : '✓'; + return `${status} ${tokens.toLocaleString()} / ${maxTokens.toLocaleString()} (${percentage}%)`; +}