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 <noreply@anthropic.com>
This commit is contained in:
Luca Oelfke 2026-02-11 13:25:25 +01:00
parent e7243e3cc1
commit acb82971b4
6 changed files with 1164 additions and 3 deletions

435
src/context-intelligence.ts Normal file
View file

@ -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<string>();
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<string, number>,
backlinks: Set<string>,
activeTags: string[],
activeProperties: Record<string, unknown>,
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<string, unknown> {
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<IntelligenceResult> {
// 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<string, number>();
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<string>(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<string, unknown> {
const cache = this.app.metadataCache.getFileCache(file);
return cache?.frontmatter ?? {};
}
}
// === Signal Display Helpers ===
export const SIGNAL_LABELS: Record<SignalType, string> = {
forwardLink: 'forward-link',
backlink: 'backlink',
sharedTag: 'shared-tag',
folderProximity: 'folder',
sharedProperty: 'property',
};
export const SIGNAL_CSS_CLASSES: Record<SignalType, string> = {
forwardLink: 'pf-signal-forward-link',
backlink: 'pf-signal-backlink',
sharedTag: 'pf-signal-shared-tag',
folderProximity: 'pf-signal-folder',
sharedProperty: 'pf-signal-property',
};

View file

@ -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<HistoryMetadata, 'charCount' | 'estimatedTokens'> = {
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);
}

View file

@ -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)) {

View file

@ -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();

167
src/smart-context-modal.ts Normal file
View file

@ -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);
}
}

View file

@ -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;
}
}