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 <noreply@anthropic.com>
This commit is contained in:
Luca G. Oelfke 2026-02-06 10:48:55 +01:00
parent 11fcce0f4c
commit 66b1ac8ffa
No known key found for this signature in database
GPG key ID: E22BABF67200F864
5 changed files with 1079 additions and 15 deletions

View file

@ -1,6 +1,7 @@
import { App, Modal, Notice, Setting, TFolder } from 'obsidian'; import { App, Modal, Notice, Setting, TFolder } from 'obsidian';
import ClaudeContextPlugin from './main'; import ClaudeContextPlugin from './main';
import { createFreetextSource, SourcePosition } from './sources'; import { createFreetextSource, SourcePosition } from './sources';
import { OutputTarget, getTargetIcon, formatTokenCount } from './targets';
interface FolderConfig { interface FolderConfig {
name: string; name: string;
@ -83,6 +84,7 @@ export class ContextGeneratorModal extends Modal {
temporaryPosition: SourcePosition = 'prefix'; temporaryPosition: SourcePosition = 'prefix';
saveAsDefault: boolean = false; saveAsDefault: boolean = false;
selectedTemplateId: string | null = null; selectedTemplateId: string | null = null;
selectedTargetIds: Set<string> = new Set();
constructor(app: App, plugin: ClaudeContextPlugin) { constructor(app: App, plugin: ClaudeContextPlugin) {
super(app); super(app);
@ -91,6 +93,10 @@ export class ContextGeneratorModal extends Modal {
this.config.folders = this.scanVaultStructure().map(name => ({ name, purpose: '' })); this.config.folders = this.scanVaultStructure().map(name => ({ name, purpose: '' }));
// Initialize with default template // Initialize with default template
this.selectedTemplateId = this.plugin.settings.defaultTemplateId; 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() { onOpen() {
@ -342,6 +348,73 @@ export class ContextGeneratorModal extends Modal {
}); });
templateInfo.style.marginTop = '5px'; 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 // Copy buttons
const copyButtonContainer = contentEl.createDiv(); const copyButtonContainer = contentEl.createDiv();
copyButtonContainer.style.display = 'flex'; copyButtonContainer.style.display = 'flex';
@ -364,8 +437,18 @@ export class ContextGeneratorModal extends Modal {
new Notice('Freetext saved as default source'); new Notice('Freetext saved as default source');
} }
// Copy context with selected template // Get selected targets
await this.plugin.copyContextToClipboard(false, this.temporaryFreetext, this.selectedTemplateId); 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(); this.close();
})) }))
.addButton(btn => btn .addButton(btn => btn

View file

@ -14,6 +14,12 @@ import {
formatPresetErrors, formatPresetErrors,
ContextPreset, ContextPreset,
} from './presets'; } from './presets';
import {
OutputTarget,
TargetExecutor,
TargetResult,
saveTargetToFile,
} from './targets';
export default class ClaudeContextPlugin extends Plugin { export default class ClaudeContextPlugin extends Plugin {
settings: ClaudeContextSettings; settings: ClaudeContextSettings;
@ -101,7 +107,8 @@ export default class ClaudeContextPlugin extends Plugin {
async copyContextToClipboard( async copyContextToClipboard(
forceIncludeNote = false, forceIncludeNote = false,
temporaryFreetext?: string, temporaryFreetext?: string,
templateId?: string | null templateId?: string | null,
targets?: OutputTarget[]
) { ) {
const folder = this.app.vault.getAbstractFileByPath(this.settings.contextFolder); const folder = this.app.vault.getAbstractFileByPath(this.settings.contextFolder);
@ -234,7 +241,11 @@ export default class ClaudeContextPlugin extends Plugin {
activeNote: activeNotePath, activeNote: activeNotePath,
}; };
// Copy and save to history // 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 () => { const copyAndSave = async () => {
await navigator.clipboard.writeText(combined); await navigator.clipboard.writeText(combined);
this.showCopyNotice(fileCount, sourceCount, templateName); this.showCopyNotice(fileCount, sourceCount, templateName);
@ -249,6 +260,65 @@ export default class ClaudeContextPlugin extends Plugin {
await copyAndSave(); 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) { private showCopyNotice(fileCount: number, sourceCount: number, templateName: string | null) {
const totalCount = fileCount + sourceCount; const totalCount = fileCount + sourceCount;

View file

@ -5,6 +5,8 @@ import { SourceModal } from './source-modal';
import { PromptTemplate, STARTER_TEMPLATES } from './templates'; import { PromptTemplate, STARTER_TEMPLATES } from './templates';
import { TemplateModal, TemplateImportExportModal } from './template-modal'; import { TemplateModal, TemplateImportExportModal } from './template-modal';
import { HistorySettings, DEFAULT_HISTORY_SETTINGS } from './history'; import { HistorySettings, DEFAULT_HISTORY_SETTINGS } from './history';
import { OutputTarget, BUILTIN_TARGETS, getTargetIcon } from './targets';
import { TargetModal } from './target-modal';
export interface ClaudeContextSettings { export interface ClaudeContextSettings {
contextFolder: string; contextFolder: string;
@ -18,6 +20,9 @@ export interface ClaudeContextSettings {
promptTemplates: PromptTemplate[]; promptTemplates: PromptTemplate[];
defaultTemplateId: string | null; defaultTemplateId: string | null;
history: HistorySettings; history: HistorySettings;
targets: OutputTarget[];
primaryTargetId: string | null;
targetOutputFolder: string;
} }
export const DEFAULT_SETTINGS: ClaudeContextSettings = { export const DEFAULT_SETTINGS: ClaudeContextSettings = {
@ -32,6 +37,9 @@ export const DEFAULT_SETTINGS: ClaudeContextSettings = {
promptTemplates: [], promptTemplates: [],
defaultTemplateId: null, defaultTemplateId: null,
history: DEFAULT_HISTORY_SETTINGS, history: DEFAULT_HISTORY_SETTINGS,
targets: [],
primaryTargetId: null,
targetOutputFolder: '_claude/outputs',
}; };
export class ClaudeContextSettingTab extends PluginSettingTab { export class ClaudeContextSettingTab extends PluginSettingTab {
@ -304,6 +312,72 @@ export class ClaudeContextSettingTab extends PluginSettingTab {
new Notice(`Cleaned up ${deleted} old entries`); 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) { private renderSourcesList(container: HTMLElement) {
@ -496,4 +570,105 @@ export class ClaudeContextSettingTab extends PluginSettingTab {
lastRow.style.borderBottom = 'none'; 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';
}
}
} }

214
src/target-modal.ts Normal file
View file

@ -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. <context>)')
.addTextArea(text => {
text.setPlaceholder('<context>')
.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. </context>)')
.addTextArea(text => {
text.setPlaceholder('</context>')
.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();
}
}

522
src/targets.ts Normal file
View file

@ -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: '<context>\n',
suffix: '\n</context>',
},
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 === -> <file name="filename.md">
result = result.replace(
/^# === (.+?) ===/gm,
(_, filename) => `<file name="${this.escapeXml(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('<file name="')) {
if (inFile) {
outputLines.push('</file>');
}
outputLines.push(line);
inFile = true;
} else {
outputLines.push(line);
}
}
if (inFile) {
outputLines.push('</file>');
}
return outputLines.join('\n');
}
private toPlain(content: string): string {
let result = content;
// Remove markdown formatting
// Headers: # Header -> Header
result = result.replace(/^#{1,6}\s+/gm, '');
// Bold/Italic: **text** or *text* -> text
result = result.replace(/\*\*(.+?)\*\*/g, '$1');
result = result.replace(/\*(.+?)\*/g, '$1');
// Code blocks: ```lang\ncode\n``` -> code
result = result.replace(/```[\w]*\n([\s\S]*?)```/g, '$1');
// Inline code: `code` -> code
result = result.replace(/`(.+?)`/g, '$1');
// Links: [text](url) -> text
result = result.replace(/\[(.+?)\]\(.+?\)/g, '$1');
// Wikilinks: [[link]] -> link
result = result.replace(/\[\[(.+?)\]\]/g, '$1');
return result;
}
private escapeXml(str: string): string {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
}
// === Truncation Strategies ===
export interface ContentSection {
header: string;
content: string;
tokens: number;
priority: number; // Lower = more important
}
export class TruncationEngine {
/**
* Parse content into sections for truncation
*/
parseSections(content: string): ContentSection[] {
const sections: ContentSection[] = [];
const lines = content.split('\n');
let currentHeader = '';
let currentContent: string[] = [];
let sectionIndex = 0;
for (const line of lines) {
const headerMatch = line.match(/^# === (.+?) ===/);
if (headerMatch) {
// Save previous 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),
});
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<TFile | null> {
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}%)`;
}