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:
parent
11fcce0f4c
commit
66b1ac8ffa
5 changed files with 1079 additions and 15 deletions
|
|
@ -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<string> = 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
|
||||
|
|
|
|||
74
src/main.ts
74
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,7 +241,11 @@ export default class ClaudeContextPlugin extends Plugin {
|
|||
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 () => {
|
||||
await navigator.clipboard.writeText(combined);
|
||||
this.showCopyNotice(fileCount, sourceCount, templateName);
|
||||
|
|
@ -249,6 +260,65 @@ export default class ClaudeContextPlugin extends Plugin {
|
|||
await copyAndSave();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async processTargets(
|
||||
rawContent: string,
|
||||
targets: OutputTarget[],
|
||||
historyMetadata: Omit<HistoryMetadata, 'charCount' | 'estimatedTokens'>,
|
||||
fileCount: number,
|
||||
sourceCount: number,
|
||||
templateName: string | null
|
||||
) {
|
||||
const executor = new TargetExecutor();
|
||||
const results: TargetResult[] = [];
|
||||
|
||||
// Process each target
|
||||
for (const target of targets) {
|
||||
const result = executor.processForTarget(rawContent, target);
|
||||
results.push(result);
|
||||
}
|
||||
|
||||
// Determine primary target
|
||||
const primaryId = this.settings.primaryTargetId ||
|
||||
(targets.find(t => t.enabled)?.id ?? targets[0]?.id);
|
||||
const primaryResult = results.find(r => r.target.id === primaryId) || results[0];
|
||||
|
||||
if (!primaryResult) {
|
||||
new Notice('No targets processed');
|
||||
return;
|
||||
}
|
||||
|
||||
const secondaryResults = results.filter(r => r !== primaryResult);
|
||||
|
||||
// Copy primary to clipboard
|
||||
await navigator.clipboard.writeText(primaryResult.content);
|
||||
|
||||
// Save secondary results as files
|
||||
const savedFiles: string[] = [];
|
||||
for (const result of secondaryResults) {
|
||||
const file = await saveTargetToFile(this.app, result, this.settings.targetOutputFolder);
|
||||
if (file) {
|
||||
savedFiles.push(file.path);
|
||||
}
|
||||
}
|
||||
|
||||
// Show notice
|
||||
let message = `Copied to clipboard: ${primaryResult.target.name}`;
|
||||
if (primaryResult.truncated) {
|
||||
message += ` (truncated, ${primaryResult.sectionsDropped} sections dropped)`;
|
||||
}
|
||||
if (savedFiles.length > 0) {
|
||||
message += `. Saved ${savedFiles.length} file(s)`;
|
||||
}
|
||||
new Notice(message, 5000);
|
||||
|
||||
// Save primary to history
|
||||
await this.historyManager.saveEntry(primaryResult.content, {
|
||||
...historyMetadata,
|
||||
userNote: `Target: ${primaryResult.target.name}${primaryResult.truncated ? ' (truncated)' : ''}`,
|
||||
});
|
||||
}
|
||||
|
||||
private showCopyNotice(fileCount: number, sourceCount: number, templateName: string | null) {
|
||||
const totalCount = fileCount + sourceCount;
|
||||
|
|
|
|||
175
src/settings.ts
175
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';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
214
src/target-modal.ts
Normal file
214
src/target-modal.ts
Normal 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
522
src/targets.ts
Normal 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
}
|
||||
|
||||
// === 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}%)`;
|
||||
}
|
||||
Loading…
Reference in a new issue