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:
parent
e7243e3cc1
commit
acb82971b4
6 changed files with 1164 additions and 3 deletions
435
src/context-intelligence.ts
Normal file
435
src/context-intelligence.ts
Normal 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',
|
||||
};
|
||||
200
src/main.ts
200
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<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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
184
src/settings.ts
184
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();
|
||||
|
||||
|
|
|
|||
167
src/smart-context-modal.ts
Normal file
167
src/smart-context-modal.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
167
styles.css
167
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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue