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 { 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
|
||||||
|
|
|
||||||
96
src/main.ts
96
src/main.ts
|
|
@ -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,22 +241,85 @@ export default class ClaudeContextPlugin extends Plugin {
|
||||||
activeNote: activeNotePath,
|
activeNote: activeNotePath,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Copy and save to history
|
// Process targets if provided
|
||||||
const copyAndSave = async () => {
|
if (targets && targets.length > 0) {
|
||||||
await navigator.clipboard.writeText(combined);
|
await this.processTargets(combined, targets, historyMetadata, fileCount, sourceCount, templateName);
|
||||||
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 {
|
} 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<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;
|
||||||
let message = `Copied ${totalCount} items to clipboard (${fileCount} files, ${sourceCount} sources)`;
|
let message = `Copied ${totalCount} items to clipboard (${fileCount} files, ${sourceCount} sources)`;
|
||||||
|
|
|
||||||
175
src/settings.ts
175
src/settings.ts
|
|
@ -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
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