From acb82971b46f487aeb0111675dd1bad88e7363f1 Mon Sep 17 00:00:00 2001 From: luca-tty Date: Wed, 11 Feb 2026 13:25:25 +0100 Subject: [PATCH] feat: add context intelligence for auto-detecting related notes Scores vault notes using 5 signals (forward links, backlinks, shared tags, folder proximity, shared properties) and presents a review modal with color-coded badges and token budget tracking. Adds "Copy smart context" command, Intelligence settings tab, and mode: auto preset support. Co-Authored-By: Claude Opus 4.6 --- src/context-intelligence.ts | 435 ++++++++++++++++++++++++++++++++++++ src/main.ts | 200 ++++++++++++++++- src/presets.ts | 14 +- src/settings.ts | 184 ++++++++++++++- src/smart-context-modal.ts | 167 ++++++++++++++ styles.css | 167 ++++++++++++++ 6 files changed, 1164 insertions(+), 3 deletions(-) create mode 100644 src/context-intelligence.ts create mode 100644 src/smart-context-modal.ts diff --git a/src/context-intelligence.ts b/src/context-intelligence.ts new file mode 100644 index 0000000..59b5293 --- /dev/null +++ b/src/context-intelligence.ts @@ -0,0 +1,435 @@ +import { App, TFile } from 'obsidian'; +import { estimateTokens } from './history'; + +// === Types === + +export type SignalType = 'forwardLink' | 'backlink' | 'sharedTag' | 'folderProximity' | 'sharedProperty'; + +export interface SignalWeights { + forwardLink: number; + backlink: number; + sharedTag: number; + folderProximity: number; + sharedProperty: number; +} + +export interface IntelligenceSettings { + enabled: boolean; + maxTokens: number; + linkDepth: number; + weights: SignalWeights; + excludePaths: string[]; + excludeTags: string[]; + includeActiveNote: boolean; + minScore: number; + maxNotes: number; + propertyKeys: string[]; +} + +export const DEFAULT_INTELLIGENCE_SETTINGS: IntelligenceSettings = { + enabled: true, + maxTokens: 50000, + linkDepth: 1, + weights: { + forwardLink: 1.0, + backlink: 0.8, + sharedTag: 0.6, + folderProximity: 0.3, + sharedProperty: 0.4, + }, + excludePaths: ['_context', '.obsidian'], + excludeTags: [], + includeActiveNote: true, + minScore: 0.1, + maxNotes: 50, + propertyKeys: ['project', 'area', 'type'], +}; + +export interface SignalDetail { + type: SignalType; + score: number; + detail: string; +} + +export interface ScoredNote { + file: TFile; + score: number; + signals: SignalDetail[]; + tokens: number; + withinBudget: boolean; + selected: boolean; +} + +export interface IntelligenceResult { + activeFile: TFile; + scoredNotes: ScoredNote[]; + totalTokens: number; + budgetTokens: number; +} + +// === Backlink Resolution === + +export class BacklinkResolver { + private app: App; + + constructor(app: App) { + this.app = app; + } + + /** + * Find all files that link TO the given file by inverting resolvedLinks. + */ + getBacklinks(targetPath: string): string[] { + const backlinks: string[] = []; + const resolvedLinks = this.app.metadataCache.resolvedLinks; + + for (const sourcePath in resolvedLinks) { + const destinations = resolvedLinks[sourcePath]; + if (destinations && targetPath in destinations) { + backlinks.push(sourcePath); + } + } + + return backlinks; + } +} + +// === Forward Link Resolution (multi-depth) === + +interface ForwardLinkResult { + path: string; + depth: number; +} + +function resolveForwardLinks( + app: App, + startPath: string, + maxDepth: number +): ForwardLinkResult[] { + const results: ForwardLinkResult[] = []; + const visited = new Set(); + visited.add(startPath); + + let frontier = [startPath]; + + for (let depth = 1; depth <= maxDepth; depth++) { + const nextFrontier: string[] = []; + + for (const sourcePath of frontier) { + const destinations = app.metadataCache.resolvedLinks[sourcePath]; + if (!destinations) continue; + + for (const destPath in destinations) { + if (visited.has(destPath)) continue; + visited.add(destPath); + + const file = app.vault.getAbstractFileByPath(destPath); + if (file instanceof TFile && file.extension === 'md') { + results.push({ path: destPath, depth }); + nextFrontier.push(destPath); + } + } + } + + frontier = nextFrontier; + } + + return results; +} + +// === Relevance Scorer === + +export class RelevanceScorer { + private app: App; + private backlinkResolver: BacklinkResolver; + + constructor(app: App) { + this.app = app; + this.backlinkResolver = new BacklinkResolver(app); + } + + /** + * Score a candidate note against the active file using 5 signals. + */ + scoreNote( + candidate: TFile, + activeFile: TFile, + weights: SignalWeights, + forwardLinks: Map, + backlinks: Set, + activeTags: string[], + activeProperties: Record, + propertyKeys: string[] + ): { score: number; signals: SignalDetail[] } { + const signals: SignalDetail[] = []; + + // 1. Forward Links + const linkDepth = forwardLinks.get(candidate.path); + if (linkDepth !== undefined) { + const score = weights.forwardLink * (1 / linkDepth); + signals.push({ + type: 'forwardLink', + score, + detail: linkDepth === 1 ? 'direct link' : `${linkDepth} hops`, + }); + } + + // 2. Backlinks + if (backlinks.has(candidate.path)) { + const score = weights.backlink * 1.0; + signals.push({ + type: 'backlink', + score, + detail: 'links to active note', + }); + } + + // 3. Shared Tags + if (activeTags.length > 0) { + const candidateTags = this.getFileTags(candidate); + const shared = activeTags.filter(t => candidateTags.includes(t)); + if (shared.length > 0) { + const score = weights.sharedTag * (shared.length / activeTags.length); + signals.push({ + type: 'sharedTag', + score, + detail: shared.map(t => `#${t}`).join(', '), + }); + } + } + + // 4. Folder Proximity + { + const distance = this.pathDistance(activeFile.path, candidate.path); + if (distance < 5) { + const score = weights.folderProximity * (1 - distance / 5); + if (score > 0) { + signals.push({ + type: 'folderProximity', + score, + detail: distance === 0 ? 'same folder' : `${distance} folders apart`, + }); + } + } + } + + // 5. Shared Properties + if (propertyKeys.length > 0) { + const candidateProps = this.getFileProperties(candidate); + let matches = 0; + let checked = 0; + for (const key of propertyKeys) { + if (key in activeProperties) { + checked++; + if (key in candidateProps && activeProperties[key] === candidateProps[key]) { + matches++; + } + } + } + if (checked > 0 && matches > 0) { + const score = weights.sharedProperty * (matches / checked); + signals.push({ + type: 'sharedProperty', + score, + detail: `${matches}/${checked} properties match`, + }); + } + } + + const totalScore = signals.reduce((sum, s) => sum + s.score, 0); + return { score: Math.round(totalScore * 100) / 100, signals }; + } + + private getFileTags(file: TFile): string[] { + const cache = this.app.metadataCache.getFileCache(file); + if (!cache) return []; + + const tags: string[] = []; + + // Frontmatter tags + if (cache.frontmatter?.tags) { + const fmTags = cache.frontmatter.tags; + if (Array.isArray(fmTags)) { + tags.push(...fmTags.map(t => typeof t === 'string' ? t : String(t))); + } else if (typeof fmTags === 'string') { + tags.push(fmTags); + } + } + + // Inline tags + if (cache.tags) { + tags.push(...cache.tags.map(t => t.tag.startsWith('#') ? t.tag.substring(1) : t.tag)); + } + + return tags; + } + + private getFileProperties(file: TFile): Record { + const cache = this.app.metadataCache.getFileCache(file); + return cache?.frontmatter ?? {}; + } + + private pathDistance(pathA: string, pathB: string): number { + const partsA = pathA.split('/').slice(0, -1); // directory parts + const partsB = pathB.split('/').slice(0, -1); + + // Find common prefix length + let common = 0; + while (common < partsA.length && common < partsB.length && partsA[common] === partsB[common]) { + common++; + } + + // Distance = steps up from A + steps down to B + return (partsA.length - common) + (partsB.length - common); + } +} + +// === Context Intelligence Orchestrator === + +export class ContextIntelligence { + private app: App; + private scorer: RelevanceScorer; + private backlinkResolver: BacklinkResolver; + + constructor(app: App) { + this.app = app; + this.scorer = new RelevanceScorer(app); + this.backlinkResolver = new BacklinkResolver(app); + } + + /** + * Analyze the active file and score all candidate notes. + */ + async analyze( + activeFile: TFile, + settings: IntelligenceSettings + ): Promise { + // 1. Gather active note's metadata + const activeTags = this.getFileTags(activeFile); + const activeProperties = this.getFileProperties(activeFile); + + // 2. Resolve forward links (multi-depth) + const forwardLinkResults = resolveForwardLinks(this.app, activeFile.path, settings.linkDepth); + const forwardLinks = new Map(); + for (const r of forwardLinkResults) { + forwardLinks.set(r.path, r.depth); + } + + // 3. Resolve backlinks + const backlinkPaths = this.backlinkResolver.getBacklinks(activeFile.path); + const backlinks = new Set(backlinkPaths); + + // 4. Build candidate set: all markdown files except active + excluded + const candidates = this.app.vault.getMarkdownFiles().filter(f => { + if (f.path === activeFile.path) return false; + for (const exclude of settings.excludePaths) { + if (f.path.startsWith(exclude + '/') || f.path.startsWith(exclude)) return false; + } + if (settings.excludeTags.length > 0) { + const tags = this.getFileTags(f); + const normalizedExclude = settings.excludeTags.map(t => t.startsWith('#') ? t.substring(1) : t); + for (const tag of tags) { + if (normalizedExclude.includes(tag)) return false; + } + } + return true; + }); + + // 5. Score each candidate + const scored: ScoredNote[] = []; + for (const candidate of candidates) { + const { score, signals } = this.scorer.scoreNote( + candidate, + activeFile, + settings.weights, + forwardLinks, + backlinks, + activeTags, + activeProperties, + settings.propertyKeys + ); + + if (score < settings.minScore) continue; + + const content = await this.app.vault.cachedRead(candidate); + const tokens = estimateTokens(content); + + scored.push({ + file: candidate, + score, + signals, + tokens, + withinBudget: true, // calculated below + selected: true, // calculated below + }); + } + + // 6. Sort by score descending + scored.sort((a, b) => b.score - a.score); + + // 7. Limit to maxNotes + const limited = scored.slice(0, settings.maxNotes); + + // 8. Apply token budget (greedy: highest score first) + let runningTokens = 0; + for (const note of limited) { + if (runningTokens + note.tokens <= settings.maxTokens) { + note.withinBudget = true; + note.selected = true; + runningTokens += note.tokens; + } else { + note.withinBudget = false; + note.selected = false; + } + } + + return { + activeFile, + scoredNotes: limited, + totalTokens: runningTokens, + budgetTokens: settings.maxTokens, + }; + } + + private getFileTags(file: TFile): string[] { + const cache = this.app.metadataCache.getFileCache(file); + if (!cache) return []; + + const tags: string[] = []; + if (cache.frontmatter?.tags) { + const fmTags = cache.frontmatter.tags; + if (Array.isArray(fmTags)) { + tags.push(...fmTags.map(t => typeof t === 'string' ? t : String(t))); + } else if (typeof fmTags === 'string') { + tags.push(fmTags); + } + } + if (cache.tags) { + tags.push(...cache.tags.map(t => t.tag.startsWith('#') ? t.tag.substring(1) : t.tag)); + } + return tags; + } + + private getFileProperties(file: TFile): Record { + const cache = this.app.metadataCache.getFileCache(file); + return cache?.frontmatter ?? {}; + } +} + +// === Signal Display Helpers === + +export const SIGNAL_LABELS: Record = { + forwardLink: 'forward-link', + backlink: 'backlink', + sharedTag: 'shared-tag', + folderProximity: 'folder', + sharedProperty: 'property', +}; + +export const SIGNAL_CSS_CLASSES: Record = { + forwardLink: 'pf-signal-forward-link', + backlink: 'pf-signal-backlink', + sharedTag: 'pf-signal-shared-tag', + folderProximity: 'pf-signal-folder', + sharedProperty: 'pf-signal-property', +}; diff --git a/src/main.ts b/src/main.ts index 9ba2817..b09ea85 100644 --- a/src/main.ts +++ b/src/main.ts @@ -20,6 +20,12 @@ import { TargetResult, saveTargetToFile, } from './targets'; +import { + ContextIntelligence, + ScoredNote, + DEFAULT_INTELLIGENCE_SETTINGS, +} from './context-intelligence'; +import { SmartContextModal } from './smart-context-modal'; export default class PromptfirePlugin extends Plugin { settings: PromptfireSettings; @@ -70,6 +76,12 @@ export default class PromptfirePlugin extends Plugin { callback: () => this.copyContextFromPreset() }); + this.addCommand({ + id: 'copy-smart-context', + name: 'Copy smart context (auto-detect related notes)', + callback: () => this.copySmartContext() + }); + this.addSettingTab(new PromptfireSettingTab(this.app, this)); } @@ -86,6 +98,14 @@ export default class PromptfirePlugin extends Plugin { if (loaded?.history) { this.settings.history = Object.assign({}, DEFAULT_SETTINGS.history, loaded.history); } + if (loaded?.intelligence) { + this.settings.intelligence = Object.assign({}, DEFAULT_INTELLIGENCE_SETTINGS, loaded.intelligence); + if (loaded.intelligence.weights) { + this.settings.intelligence.weights = Object.assign( + {}, DEFAULT_INTELLIGENCE_SETTINGS.weights, loaded.intelligence.weights + ); + } + } } async saveSettings() { @@ -457,6 +477,163 @@ export default class PromptfirePlugin extends Plugin { return checkAll(selection.headings); } + async copySmartContext() { + if (!this.settings.intelligence.enabled) { + new Notice('Context intelligence is disabled. Enable it in Settings > Intelligence.'); + return; + } + + const activeView = this.app.workspace.getActiveViewOfType(MarkdownView); + if (!activeView?.file) { + new Notice('No active note'); + return; + } + + const activeFile = activeView.file; + const intelligence = new ContextIntelligence(this.app); + const result = await intelligence.analyze(activeFile, this.settings.intelligence); + + if (result.scoredNotes.length === 0) { + new Notice('No related notes found for this note'); + return; + } + + new SmartContextModal(this.app, result, async (selectedNotes) => { + await this.assembleAndCopySmartContext(activeFile, selectedNotes); + }).open(); + } + + private async assembleAndCopySmartContext(activeFile: TFile, selectedNotes: ScoredNote[]) { + if (selectedNotes.length === 0) { + new Notice('No notes selected'); + return; + } + + // Resolve additional sources + const registry = new SourceRegistry(); + const enabledSources = this.settings.sources.filter(s => s.enabled); + const resolvedSources = await registry.resolveAll(enabledSources); + + const errors = resolvedSources.filter(r => r.error); + if (errors.length > 0) { + const errorNames = errors.map(e => e.source.name).join(', '); + new Notice(`Some sources failed: ${errorNames}`, 5000); + } + + const prefixSources = resolvedSources.filter(r => r.source.position === 'prefix' && !r.error); + const suffixSources = resolvedSources.filter(r => r.source.position === 'suffix' && !r.error); + + // Build output + const outputParts: string[] = []; + + // Prefix sources + for (const resolved of prefixSources) { + const formatted = formatSourceOutput(resolved, this.settings.showSourceLabels); + if (formatted) outputParts.push(formatted); + } + + // Context folder files + const folder = this.app.vault.getAbstractFileByPath(this.settings.contextFolder); + if (folder instanceof TFolder) { + const excludedFiles = this.settings.excludedFiles.map(f => f.toLowerCase()); + const contextFiles = folder.children + .filter((f): f is TFile => + f instanceof TFile && + f.extension === 'md' && + !excludedFiles.includes(f.name.toLowerCase()) + ) + .sort((a, b) => { + if (a.basename === 'VAULT') return -1; + if (b.basename === 'VAULT') return 1; + return a.basename.localeCompare(b.basename); + }); + + if (contextFiles.length > 0) { + const vaultParts: string[] = []; + for (const file of contextFiles) { + const content = await this.app.vault.read(file); + if (this.settings.includeFilenames) { + vaultParts.push(`# === ${file.name} ===\n\n${content}`); + } else { + vaultParts.push(content); + } + } + outputParts.push(vaultParts.join(`\n\n${this.settings.separator}\n\n`)); + } + } + + // Smart context notes (sorted by score) + const smartParts: string[] = []; + for (const note of selectedNotes) { + const content = await this.app.vault.read(note.file); + if (this.settings.includeFilenames) { + smartParts.push(`# === SMART: ${note.file.name} (${note.score} pts) ===\n\n${content}`); + } else { + smartParts.push(content); + } + } + if (smartParts.length > 0) { + outputParts.push(smartParts.join(`\n\n${this.settings.separator}\n\n`)); + } + + // Active note + if (this.settings.intelligence.includeActiveNote) { + const content = await this.app.vault.read(activeFile); + if (this.settings.includeFilenames) { + outputParts.push(`# === ACTIVE: ${activeFile.name} ===\n\n${content}`); + } else { + outputParts.push(`--- ACTIVE NOTE ---\n\n${content}`); + } + } + + // Suffix sources + for (const resolved of suffixSources) { + const formatted = formatSourceOutput(resolved, this.settings.showSourceLabels); + if (formatted) outputParts.push(formatted); + } + + let combined = outputParts.join(`\n\n${this.settings.separator}\n\n`); + + // Apply template + const effectiveTemplateId = this.settings.defaultTemplateId; + let templateName: string | null = null; + + if (effectiveTemplateId) { + const template = this.settings.promptTemplates.find(t => t.id === effectiveTemplateId); + if (template) { + const engine = new TemplateEngine(this.app); + const context = await engine.buildContext(combined); + combined = engine.processTemplate(template.content, context); + templateName = template.name; + } + } + + // Prepare history metadata + const historyMetadata: Omit = { + templateId: effectiveTemplateId || null, + templateName, + includedFiles: selectedNotes.map(n => n.file.path), + includedSources: [ + ...prefixSources.map(r => r.source.name), + ...suffixSources.map(r => r.source.name), + ], + activeNote: this.settings.intelligence.includeActiveNote ? activeFile.path : null, + userNote: `Smart context from: ${activeFile.basename}`, + }; + + // Copy and save + await navigator.clipboard.writeText(combined); + + const noteCount = selectedNotes.length + (this.settings.intelligence.includeActiveNote ? 1 : 0); + let message = `Copied smart context: ${noteCount} notes`; + if (templateName) { + message += ` using "${templateName}"`; + } + new Notice(message); + + await this.historyManager.saveEntry(combined, historyMetadata); + } + async copyContextFromPreset() { // Get active file const activeView = this.app.workspace.getActiveViewOfType(MarkdownView); @@ -492,7 +669,28 @@ export default class PromptfirePlugin extends Plugin { const preset = parseResult.preset; - // Execute preset + // Route based on mode + if (preset.mode === 'auto') { + // Use intelligence engine + const intelligence = new ContextIntelligence(this.app); + const intelSettings = { + ...this.settings.intelligence, + ...(preset.maxTokens ? { maxTokens: preset.maxTokens } : {}), + }; + const result = await intelligence.analyze(activeFile, intelSettings); + + if (result.scoredNotes.length === 0) { + new Notice('No related notes found for this note'); + return; + } + + new SmartContextModal(this.app, result, async (selectedNotes) => { + await this.assembleAndCopySmartContext(activeFile, selectedNotes); + }).open(); + return; + } + + // Execute preset (manual mode) await this.executePreset(activeFile, preset); } diff --git a/src/presets.ts b/src/presets.ts index 7fe0510..ca10d63 100644 --- a/src/presets.ts +++ b/src/presets.ts @@ -3,7 +3,10 @@ import { estimateTokens } from './history'; // === Types === +export type PresetMode = 'manual' | 'auto'; + export interface ContextPreset { + mode?: PresetMode; template?: string; includeLinked?: boolean; linkDepth?: number; @@ -54,6 +57,15 @@ export function parsePresetFromFrontmatter( const preset: ContextPreset = {}; + // Parse mode + if (aiContext.mode !== undefined) { + if (aiContext.mode === 'auto' || aiContext.mode === 'manual') { + preset.mode = aiContext.mode; + } else { + errors.push('mode must be "auto" or "manual"'); + } + } + // Parse template if (aiContext.template !== undefined) { if (typeof aiContext.template === 'string') { @@ -139,7 +151,7 @@ export function parsePresetFromFrontmatter( // Warn about unknown fields const knownFields = [ - 'template', 'include-linked', 'link-depth', 'include-tags', + 'mode', 'template', 'include-linked', 'link-depth', 'include-tags', 'exclude-paths', 'exclude-tags', 'max-tokens', 'include-active-note' ]; for (const key of Object.keys(aiContext)) { diff --git a/src/settings.ts b/src/settings.ts index d95b127..192c0af 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -7,6 +7,7 @@ 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'; +import { IntelligenceSettings, DEFAULT_INTELLIGENCE_SETTINGS } from './context-intelligence'; export interface PromptfireSettings { contextFolder: string; @@ -26,6 +27,7 @@ export interface PromptfireSettings { lastSettingsTab: string; collapsedSections: string[]; generatorPreviewOpen: boolean; + intelligence: IntelligenceSettings; } export const DEFAULT_SETTINGS: PromptfireSettings = { @@ -46,9 +48,10 @@ export const DEFAULT_SETTINGS: PromptfireSettings = { lastSettingsTab: 'general', collapsedSections: [], generatorPreviewOpen: false, + intelligence: DEFAULT_INTELLIGENCE_SETTINGS, }; -export type SettingsTabId = 'general' | 'sources' | 'templates' | 'output' | 'history'; +export type SettingsTabId = 'general' | 'sources' | 'templates' | 'output' | 'history' | 'intelligence'; const SETTINGS_TABS: { id: SettingsTabId; label: string }[] = [ { id: 'general', label: 'General' }, @@ -56,6 +59,7 @@ const SETTINGS_TABS: { id: SettingsTabId; label: string }[] = [ { id: 'templates', label: 'Templates' }, { id: 'output', label: 'Output' }, { id: 'history', label: 'History' }, + { id: 'intelligence', label: 'Intelligence' }, ]; class CollapsibleSection { @@ -151,6 +155,7 @@ export class PromptfireSettingTab extends PluginSettingTab { { label: 'Templates', render: el => this.renderTemplatesTab(el) }, { label: 'Output', render: el => this.renderOutputTab(el) }, { label: 'History', render: el => this.renderHistoryTab(el) }, + { label: 'Intelligence', render: el => this.renderIntelligenceTab(el) }, ]; for (const tab of tabRenderers) { @@ -187,6 +192,7 @@ export class PromptfireSettingTab extends PluginSettingTab { case 'templates': this.renderTemplatesTab(content); break; case 'output': this.renderOutputTab(content); break; case 'history': this.renderHistoryTab(content); break; + case 'intelligence': this.renderIntelligenceTab(content); break; } } } @@ -625,6 +631,182 @@ export class PromptfireSettingTab extends PluginSettingTab { } } + // === TAB: Intelligence === + + private renderIntelligenceTab(el: HTMLElement) { + const desc = el.createEl('p', { + text: 'Auto-detect related notes based on links, tags, folder proximity, and shared properties.', + cls: 'setting-item-description' + }); + desc.style.marginBottom = '10px'; + + new Setting(el) + .setName('Enable context intelligence') + .setDesc('Show the "Copy smart context" command') + .addToggle(toggle => toggle + .setValue(this.plugin.settings.intelligence.enabled) + .onChange(async (value) => { + this.plugin.settings.intelligence.enabled = value; + await this.plugin.saveSettings(); + this.display(); + })); + + if (!this.plugin.settings.intelligence.enabled) return; + + // Section: Budget + const budgetSection = new CollapsibleSection(el, 'intel-budget', 'Budget', this.plugin); + const bc = budgetSection.contentEl; + + new Setting(bc) + .setName('Token budget') + .setDesc('Maximum tokens for smart context output') + .addText(text => text + .setPlaceholder('50000') + .setValue(String(this.plugin.settings.intelligence.maxTokens)) + .onChange(async (value) => { + const num = parseInt(value, 10); + if (!isNaN(num) && num > 0) { + this.plugin.settings.intelligence.maxTokens = num; + await this.plugin.saveSettings(); + } + })); + + new Setting(bc) + .setName('Max notes') + .setDesc('Maximum number of related notes to consider') + .addText(text => text + .setPlaceholder('50') + .setValue(String(this.plugin.settings.intelligence.maxNotes)) + .onChange(async (value) => { + const num = parseInt(value, 10); + if (!isNaN(num) && num > 0) { + this.plugin.settings.intelligence.maxNotes = num; + await this.plugin.saveSettings(); + } + })); + + new Setting(bc) + .setName('Min score') + .setDesc('Notes below this score are excluded') + .addText(text => text + .setPlaceholder('0.1') + .setValue(String(this.plugin.settings.intelligence.minScore)) + .onChange(async (value) => { + const num = parseFloat(value); + if (!isNaN(num) && num >= 0) { + this.plugin.settings.intelligence.minScore = num; + await this.plugin.saveSettings(); + } + })); + + new Setting(bc) + .setName('Include active note') + .setDesc('Include the active note in the output') + .addToggle(toggle => toggle + .setValue(this.plugin.settings.intelligence.includeActiveNote) + .onChange(async (value) => { + this.plugin.settings.intelligence.includeActiveNote = value; + await this.plugin.saveSettings(); + })); + + // Section: Signal Weights + const weightsSection = new CollapsibleSection(el, 'intel-weights', 'Signal weights', this.plugin); + const wc = weightsSection.contentEl; + + const weightEntries: { key: keyof IntelligenceSettings['weights']; name: string; desc: string }[] = [ + { key: 'forwardLink', name: 'Forward links', desc: 'Weight for notes linked from the active note' }, + { key: 'backlink', name: 'Backlinks', desc: 'Weight for notes that link to the active note' }, + { key: 'sharedTag', name: 'Shared tags', desc: 'Weight for notes sharing tags with the active note' }, + { key: 'folderProximity', name: 'Folder proximity', desc: 'Weight for notes in nearby folders' }, + { key: 'sharedProperty', name: 'Shared properties', desc: 'Weight for matching frontmatter properties' }, + ]; + + for (const entry of weightEntries) { + new Setting(wc) + .setName(entry.name) + .setDesc(entry.desc) + .addText(text => text + .setPlaceholder('0.0') + .setValue(String(this.plugin.settings.intelligence.weights[entry.key])) + .onChange(async (value) => { + const num = parseFloat(value); + if (!isNaN(num) && num >= 0) { + this.plugin.settings.intelligence.weights[entry.key] = num; + await this.plugin.saveSettings(); + } + })); + } + + new Setting(wc) + .setName('Link depth') + .setDesc('How many hops to follow forward links (1-3)') + .addDropdown(dropdown => { + dropdown.addOption('1', '1 (direct links only)'); + dropdown.addOption('2', '2 (links of links)'); + dropdown.addOption('3', '3 (deep)'); + dropdown.setValue(String(this.plugin.settings.intelligence.linkDepth)); + dropdown.onChange(async (value) => { + this.plugin.settings.intelligence.linkDepth = parseInt(value, 10); + await this.plugin.saveSettings(); + }); + }); + + // Section: Scope + const scopeSection = new CollapsibleSection(el, 'intel-scope', 'Scope', this.plugin); + const sc = scopeSection.contentEl; + + const excludePathsSetting = new Setting(sc) + .setName('Exclude paths') + .addText(text => text + .setPlaceholder('_context, .obsidian') + .setValue(this.plugin.settings.intelligence.excludePaths.join(', ')) + .onChange(async (value) => { + this.plugin.settings.intelligence.excludePaths = value + .split(',') + .map(s => s.trim()) + .filter(s => s.length > 0); + await this.plugin.saveSettings(); + const n = this.plugin.settings.intelligence.excludePaths.length; + this.setDynamicDesc(excludePathsSetting, + 'Comma-separated folder paths to exclude from analysis', + n > 0 ? `${n} excluded` : ''); + })); + { + const n = this.plugin.settings.intelligence.excludePaths.length; + this.setDynamicDesc(excludePathsSetting, + 'Comma-separated folder paths to exclude from analysis', + n > 0 ? `${n} excluded` : ''); + } + + new Setting(sc) + .setName('Exclude tags') + .setDesc('Comma-separated tags to exclude (notes with these tags are skipped)') + .addText(text => text + .setPlaceholder('#draft, #archive') + .setValue(this.plugin.settings.intelligence.excludeTags.join(', ')) + .onChange(async (value) => { + this.plugin.settings.intelligence.excludeTags = value + .split(',') + .map(s => s.trim()) + .filter(s => s.length > 0); + await this.plugin.saveSettings(); + })); + + new Setting(sc) + .setName('Property keys') + .setDesc('Comma-separated frontmatter keys to compare (e.g. project, area, type)') + .addText(text => text + .setPlaceholder('project, area, type') + .setValue(this.plugin.settings.intelligence.propertyKeys.join(', ')) + .onChange(async (value) => { + this.plugin.settings.intelligence.propertyKeys = value + .split(',') + .map(s => s.trim()) + .filter(s => s.length > 0); + await this.plugin.saveSettings(); + })); + } + private renderSourcesList(container: HTMLElement) { container.empty(); diff --git a/src/smart-context-modal.ts b/src/smart-context-modal.ts new file mode 100644 index 0000000..7888379 --- /dev/null +++ b/src/smart-context-modal.ts @@ -0,0 +1,167 @@ +import { App, Modal } from 'obsidian'; +import { + IntelligenceResult, + ScoredNote, + SIGNAL_LABELS, + SIGNAL_CSS_CLASSES, +} from './context-intelligence'; +import { estimateTokens } from './history'; + +export class SmartContextModal extends Modal { + private result: IntelligenceResult; + private onConfirm: (selectedNotes: ScoredNote[]) => void; + private tokenDisplay: HTMLElement | null = null; + private countDisplay: HTMLElement | null = null; + + constructor( + app: App, + result: IntelligenceResult, + onConfirm: (selectedNotes: ScoredNote[]) => void, + ) { + super(app); + this.result = result; + this.onConfirm = onConfirm; + } + + onOpen() { + const { contentEl } = this; + contentEl.empty(); + contentEl.addClass('pf-smart-modal'); + + // Header + contentEl.createEl('h2', { + text: `Smart Context for "${this.result.activeFile.basename}"`, + }); + + // Stats bar + const statsBar = contentEl.createDiv({ cls: 'pf-smart-stats' }); + this.countDisplay = statsBar.createSpan({ cls: 'pf-smart-stat-item' }); + statsBar.createSpan({ text: ' | ', cls: 'pf-smart-stat-sep' }); + this.tokenDisplay = statsBar.createSpan({ cls: 'pf-smart-stat-item' }); + statsBar.createSpan({ text: ' | ', cls: 'pf-smart-stat-sep' }); + statsBar.createSpan({ + text: `Budget: ${this.formatTokenCount(this.result.budgetTokens)}`, + cls: 'pf-smart-stat-item', + }); + + this.updateStats(); + + // Controls + const controls = contentEl.createDiv({ cls: 'pf-smart-controls' }); + const selectAllBtn = controls.createEl('button', { text: 'Select All' }); + selectAllBtn.addEventListener('click', () => { + for (const note of this.result.scoredNotes) { + note.selected = true; + } + this.refreshList(); + }); + + const deselectAllBtn = controls.createEl('button', { text: 'Deselect All' }); + deselectAllBtn.addEventListener('click', () => { + for (const note of this.result.scoredNotes) { + note.selected = false; + } + this.refreshList(); + }); + + // Note list + contentEl.createDiv({ cls: 'pf-smart-list' }); + this.refreshList(); + + // Footer + const footer = contentEl.createDiv({ cls: 'pf-smart-footer' }); + + const cancelBtn = footer.createEl('button', { text: 'Cancel' }); + cancelBtn.addEventListener('click', () => this.close()); + + const confirmBtn = footer.createEl('button', { + text: 'Copy Smart Context', + cls: 'mod-cta', + }); + confirmBtn.addEventListener('click', () => { + const selected = this.result.scoredNotes.filter(n => n.selected); + this.onConfirm(selected); + this.close(); + }); + } + + onClose() { + this.contentEl.empty(); + } + + private refreshList() { + const listEl = this.contentEl.querySelector('.pf-smart-list') as HTMLElement; + if (!listEl) return; + listEl.empty(); + + for (const note of this.result.scoredNotes) { + const row = listEl.createDiv({ + cls: `pf-smart-row${!note.withinBudget && !note.selected ? ' is-over-budget' : ''}`, + }); + + // Checkbox + const checkbox = row.createEl('input', { type: 'checkbox' }); + checkbox.checked = note.selected; + checkbox.addEventListener('change', () => { + note.selected = checkbox.checked; + this.updateStats(); + }); + + // Info area + const info = row.createDiv({ cls: 'pf-smart-row-info' }); + + // Top line: name, score, tokens + const topLine = info.createDiv({ cls: 'pf-smart-row-top' }); + const nameEl = topLine.createSpan({ cls: 'pf-smart-row-name' }); + nameEl.setText(note.file.basename); + if (!note.withinBudget) { + nameEl.appendText(' (budget)'); + } + + topLine.createSpan({ + text: `${note.score} pts`, + cls: 'pf-smart-row-score', + }); + + topLine.createSpan({ + text: `~${this.formatTokenCount(note.tokens)} tok`, + cls: 'pf-smart-row-tokens', + }); + + // Bottom line: signal badges + const badges = info.createDiv({ cls: 'pf-smart-row-badges' }); + for (const signal of note.signals) { + const badge = badges.createSpan({ + cls: `pf-signal-badge ${SIGNAL_CSS_CLASSES[signal.type]}`, + }); + badge.setText(SIGNAL_LABELS[signal.type]); + badge.title = `${signal.detail} (+${signal.score.toFixed(2)})`; + } + } + + this.updateStats(); + } + + private updateStats() { + const selected = this.result.scoredNotes.filter(n => n.selected); + const totalTokens = selected.reduce((sum, n) => sum + n.tokens, 0); + + if (this.countDisplay) { + this.countDisplay.setText(`${selected.length} notes`); + } + if (this.tokenDisplay) { + this.tokenDisplay.setText(`~${this.formatTokenCount(totalTokens)} tokens`); + this.tokenDisplay.toggleClass( + 'is-over-budget', + totalTokens > this.result.budgetTokens + ); + } + } + + private formatTokenCount(tokens: number): string { + if (tokens >= 1000) { + return `${(tokens / 1000).toFixed(1)}k`; + } + return String(tokens); + } +} diff --git a/styles.css b/styles.css index a726be6..7945a3b 100644 --- a/styles.css +++ b/styles.css @@ -249,6 +249,170 @@ margin-top: 6px; } +/* Smart context modal */ +.pf-smart-modal { + width: 60vw; + max-width: 700px; +} + +.pf-smart-modal .modal-content { + max-height: 80vh; + overflow-y: auto; + display: flex; + flex-direction: column; +} + +.pf-smart-modal h2 { + margin-top: 0; + margin-bottom: 8px; +} + +.pf-smart-stats { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 10px; + background: var(--background-secondary); + border: 1px solid var(--background-modifier-border); + border-radius: 4px; + font-size: 12px; + color: var(--text-muted); + margin-bottom: 10px; +} + +.pf-smart-stat-sep { + color: var(--text-faint); + user-select: none; +} + +.pf-smart-stat-item.is-over-budget { + color: var(--text-error, #e03e3e); + font-weight: 600; +} + +.pf-smart-controls { + display: flex; + gap: 8px; + margin-bottom: 10px; +} + +.pf-smart-controls button { + font-size: 12px; + padding: 4px 10px; +} + +.pf-smart-list { + border: 1px solid var(--background-modifier-border); + border-radius: 4px; + max-height: 50vh; + overflow-y: auto; + flex: 1; +} + +.pf-smart-row { + display: flex; + align-items: flex-start; + gap: 10px; + padding: 8px 12px; + border-bottom: 1px solid var(--background-modifier-border); +} + +.pf-smart-row:last-child { + border-bottom: none; +} + +.pf-smart-row.is-over-budget { + opacity: 0.5; +} + +.pf-smart-row input[type="checkbox"] { + margin-top: 3px; + flex-shrink: 0; +} + +.pf-smart-row-info { + flex: 1; + min-width: 0; +} + +.pf-smart-row-top { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 4px; +} + +.pf-smart-row-name { + flex: 1; + font-weight: 500; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.pf-smart-row-score { + font-size: 12px; + font-weight: 600; + color: var(--text-accent); + white-space: nowrap; +} + +.pf-smart-row-tokens { + font-size: 11px; + color: var(--text-muted); + white-space: nowrap; +} + +.pf-smart-row-badges { + display: flex; + flex-wrap: wrap; + gap: 4px; +} + +/* Signal badges */ +.pf-signal-badge { + display: inline-block; + font-size: 10px; + padding: 1px 6px; + border-radius: 3px; + font-weight: 500; + line-height: 1.4; +} + +.pf-signal-forward-link { + background: rgba(59, 130, 246, 0.15); + color: rgb(59, 130, 246); +} + +.pf-signal-backlink { + background: rgba(34, 197, 94, 0.15); + color: rgb(34, 197, 94); +} + +.pf-signal-shared-tag { + background: rgba(168, 85, 247, 0.15); + color: rgb(168, 85, 247); +} + +.pf-signal-folder { + background: rgba(249, 115, 22, 0.15); + color: rgb(249, 115, 22); +} + +.pf-signal-property { + background: rgba(20, 184, 166, 0.15); + color: rgb(20, 184, 166); +} + +.pf-smart-footer { + display: flex; + justify-content: flex-end; + gap: 8px; + padding-top: 12px; + margin-top: 12px; + border-top: 1px solid var(--background-modifier-border); +} + /* Stack on narrow viewports */ @media (max-width: 768px) { .pf-gen-modal { @@ -267,4 +431,7 @@ .pf-gen-zone { max-height: none; } + .pf-smart-modal { + width: 90vw; + } }