diff --git a/main.ts b/main.ts index d433acb..3716e19 100644 --- a/main.ts +++ b/main.ts @@ -17,6 +17,15 @@ import { URLUpdateModal } from './src/modals/URLUpdateModal'; import { ArticleGeneratorModal } from './src/modals/ArticleGeneratorModal'; import { TextEnhancementModal } from './src/modals/TextEnhancementModal'; import { TextEnhancementWithImagesModal } from './src/modals/TextEnhancementWithImagesModal'; +import { DirectoryTemplatePickerModal } from './src/modals/DirectoryTemplatePickerModal'; + +import { + applyTemplate as applyDirectoryTemplate, + listTemplates as listDirectoryTemplates, + loadTemplate as loadDirectoryTemplate, + pathMatchesGlobs, +} from './src/services/directoryTemplateService'; +import type { DirectoryTemplateSettings } from './src/services/directoryTemplateService'; interface PerplexedPluginSettings { @@ -68,6 +77,11 @@ interface PerplexedPluginSettings { // Text enhancement with images prompt enhanceWithImagesPrompt: string; }; + + // Directory templates (v0.1 spike — see context-v/specs/Per-Directory-Profile-Templates.md) + directoryTemplatesRoot: string; + directoryTemplatesFrontmatterWhitelist: string[]; + directoryTemplatesRequestTimeoutMs: number; } const DEFAULT_SETTINGS: PerplexedPluginSettings = { @@ -275,7 +289,12 @@ Structure the article as follows: // Text enhancement with images prompt enhanceWithImagesPrompt: "Please provide 1-3 relevant images for the following text. Return ONLY the image markers in the format [IMAGE 1: description], [IMAGE 2: description], etc. Each image should illustrate a key concept, example, or visual representation related to the text. Do not include any other text or explanation:\n\n{TEXT}" - } + }, + + // Directory templates (v0.1 spike defaults) + directoryTemplatesRoot: 'zz-cf-lib/templates', + directoryTemplatesFrontmatterWhitelist: ['title', 'og_description', 'tags', 'og_image'], + directoryTemplatesRequestTimeoutMs: 300000 }; export default class PerplexedPlugin extends Plugin { @@ -458,6 +477,15 @@ export default class PerplexedPlugin extends Plugin { await this.reinitializeServices(); } }); + + // Directory templates (v0.1 spike). See context-v/specs/Per-Directory-Profile-Templates.md + this.addCommand({ + id: 'apply-directory-template-to-current-file', + name: 'Apply directory template to current file', + callback: async () => { + await this.runApplyDirectoryTemplate(); + } + }); console.debug('Perplexed Plugin: Initialization completed successfully'); new Notice('Perplexed plugin loaded successfully'); @@ -999,6 +1027,50 @@ export default class PerplexedPlugin extends Plugin { new Notice('Failed to reinitialize services. Check console for details.'); } } + + private async runApplyDirectoryTemplate(): Promise { + const target = this.app.workspace.getActiveFile(); + if (!target) { + new Notice('No active file.'); + return; + } + if (!this.settings.perplexityApiKey) { + new Notice('Perplexity API key is not set. Configure it in perplexed settings.'); + return; + } + + const root = this.settings.directoryTemplatesRoot; + const all = await listDirectoryTemplates(this.app, root); + if (all.length === 0) { + new Notice(`No templates found under "${root}".`); + return; + } + + const matching = all.filter(t => pathMatchesGlobs(target.path, t.appliesToPaths)); + if (matching.length === 0) { + new Notice("No template's applies-to-paths matches this file."); + return; + } + + const dirSettings: DirectoryTemplateSettings = { + perplexityApiKey: this.settings.perplexityApiKey, + perplexityEndpoint: this.settings.perplexityEndpoint, + templatesRoot: this.settings.directoryTemplatesRoot, + frontmatterWhitelist: this.settings.directoryTemplatesFrontmatterWhitelist, + requestTimeoutMs: this.settings.directoryTemplatesRequestTimeoutMs, + }; + + new DirectoryTemplatePickerModal(this.app, matching, (chosen) => { + void (async () => { + const parsed = await loadDirectoryTemplate(this.app, chosen.file); + if (!parsed) { + new Notice('Template parse error: missing or malformed cft block.'); + return; + } + await applyDirectoryTemplate(this.app, dirSettings, target, parsed); + })(); + }).open(); + } } class PerplexedSettingTab extends PluginSettingTab { @@ -1510,5 +1582,54 @@ class PerplexedSettingTab extends PluginSettingTab { })()); enhanceWithImagesPromptSetting.settingEl.appendChild(enhanceWithImagesPromptTextArea); + + // Directory Templates Section (v0.1 spike) + new Setting(containerEl).setName('Directory templates').setHeading(); + containerEl.createEl('p', { + text: 'Apply a template (heading skeleton + per-section bullets) to fill a file via Perplexity deep research. Templates live in a vault folder and are matched to files by glob.', + cls: 'setting-item-description' + }); + + new Setting(containerEl) + .setName('Templates root') + .setDesc('Vault-relative folder where directory templates live.') + .addText(text => text + .setPlaceholder('Zz-cf-lib/templates') + .setValue(this.plugin.settings.directoryTemplatesRoot) + .onChange(async (value: string) => { + this.plugin.settings.directoryTemplatesRoot = value.trim(); + await this.plugin.saveSettings(); + }) + ); + + new Setting(containerEl) + .setName('Frontmatter whitelist') + .setDesc('Comma-separated list of frontmatter keys passed to the template as {{frontmatter}}. Other keys are filtered out.') + .addText(text => text + .setPlaceholder('Title, og_description, tags, og_image') + .setValue(this.plugin.settings.directoryTemplatesFrontmatterWhitelist.join(', ')) + .onChange(async (value: string) => { + this.plugin.settings.directoryTemplatesFrontmatterWhitelist = value + .split(',') + .map(s => s.trim()) + .filter(s => s.length > 0); + await this.plugin.saveSettings(); + }) + ); + + new Setting(containerEl) + .setName('Request timeout (ms)') + .setDesc('Maximum time to wait for the Perplexity deep research response. Default 300000 (5 min).') + .addText(text => text + .setPlaceholder('300000') + .setValue(String(this.plugin.settings.directoryTemplatesRequestTimeoutMs)) + .onChange(async (value: string) => { + const n = parseInt(value, 10); + if (!isNaN(n) && n > 0) { + this.plugin.settings.directoryTemplatesRequestTimeoutMs = n; + await this.plugin.saveSettings(); + } + }) + ); } } diff --git a/src/docs/templates/source-profile.md b/src/docs/templates/source-profile.md new file mode 100644 index 0000000..260d639 --- /dev/null +++ b/src/docs/templates/source-profile.md @@ -0,0 +1,56 @@ +--- +title: Source Profile (Person / Publication / Channel) +applies-to-paths: + - "Sources/**" +description: Generates a structured profile for a source — person, publication, or channel. +--- + +# About this template + +Use this for files under `Sources/` whose body is empty or near-empty. The bullets under each heading are model-facing instructions, not literal output. + +```cft +provider: perplexity +model: sonar-deep-research +search-recency: month +return-citations: true +return-images: false +system: | + You are filling out a structured profile for the source named "{{title}}". + Existing metadata: + {{frontmatter}} + Follow the heading skeleton and per-section bullet instructions in the + user prompt. Use inline citations. Prefer first-party sources. +``` + +# Identity +- One paragraph summarizing who or what this source is. + +## Type +- Person, publication, podcast, YouTube channel, newsletter, organization, etc. Pick one. + +## Reach / Audience +- Estimated audience size, geography, and primary platforms. Cite sources where possible. + +# Topical Coverage + +## Primary Beats +- 3–6 topics this source covers regularly. One bullet each. + +## Notable Recent Pieces +- 5 most-cited or most-relevant pieces from the past 12 months. Title, date, URL. + +# Track Record +- Brief assessment of credibility and editorial style. Two short paragraphs. + +## Known Biases or Editorial Stance +- Disclosed funding, ownership, or political stance if any. + +# Adjacent Sources +- 3–5 sources that cover similar ground from different angles. + +*** + +# User Notes + +Authoring scratch zone — not sent to the model. diff --git a/src/docs/templates/tooling-profile.md b/src/docs/templates/tooling-profile.md new file mode 100644 index 0000000..caf1851 --- /dev/null +++ b/src/docs/templates/tooling-profile.md @@ -0,0 +1,78 @@ +--- +title: Tooling Profile (Company / Service / App) +applies-to-paths: + - "Tooling/**" +description: Generates a structured profile for a company / service / app / open-source repo. +--- + +# About this template + +Use this for files under `Tooling/` whose body is empty or near-empty. Run "Apply directory template to current file" while viewing the target. The runtime sends the heading skeleton below as the user prompt to Perplexity Deep Research; the model returns markdown that follows the structure with inline citations. + +The bullets under each heading are *instructions to the model*, not literal content. The model fills each section based on those instructions. + +```cft +provider: perplexity +model: sonar-deep-research +search-recency: month +return-citations: true +return-images: false +system: | + You are filling out a structured profile for the entity named "{{title}}". + Existing metadata for this entity: + {{frontmatter}} + Produce markdown that follows the heading skeleton and per-section bullet + instructions in the user prompt. Every heading must be present, even if a + section reads "limited public information." Use inline citations. Prefer + first-party sources. +``` + +# Features +- Describe the core product features in 2–3 sentences each. +- Bullet 5–8 features in priority order. + +## Screenshots +- If three official screenshots are publicly available, list their URLs as bullets. +- For each, write a 1-sentence caption. If none are publicly available, write "No publicly available screenshots." + +## Product Roadmap / Announcements +- Public roadmap items and product announcements from the past 6 months. +- Use dated bullets, most recent first. Cite each item. + +## Recent Developments +- News and developments from the past 90 days. Cite sources inline. + +# History and Origin Story +- Founding story, founders, key inflection points. One short paragraph. + +## Fundraising History +- Search for Pre-Seed, Seed, Series A, etc. announcements. +- Produce a markdown table with columns: Round | Date | Amount | Lead investor. +- Add a Total row at the bottom with estimated or reported total funding. +- Below the table, list each investor in alphabetical order, one per line. + +## Notable Team Members +- Founders and notable leadership; one short paragraph each. + +# Market Sizing + +## Pricing +- Markdown table of pricing tiers if published. +- Note "no public pricing" if not. + +## Revenue Trajectory Estimates +- Estimated or reported revenue / ARR. Cite source per figure. + +# Competitive Landscape + +## Who it's for, who it's not for +- Two short paragraphs. Be concrete about ICP and anti-ICP. + +## Viable Alternatives +- 3–5 alternatives, one bullet each, with a brief rationale. + +*** + +# User Notes + +Anything below the `***` line is excluded from the request. Use this zone for tuning notes, prior outputs, or examples while iterating on the template. diff --git a/src/modals/DirectoryTemplatePickerModal.ts b/src/modals/DirectoryTemplatePickerModal.ts new file mode 100644 index 0000000..8d0b7cb --- /dev/null +++ b/src/modals/DirectoryTemplatePickerModal.ts @@ -0,0 +1,32 @@ +import type { App } from 'obsidian'; +import { FuzzySuggestModal } from 'obsidian'; +import type { TemplateFile } from '../services/directoryTemplateService'; + +export class DirectoryTemplatePickerModal extends FuzzySuggestModal { + private items: TemplateFile[]; + private callback: (chosen: TemplateFile) => void; + + constructor( + app: App, + items: TemplateFile[], + callback: (chosen: TemplateFile) => void, + ) { + super(app); + this.items = items; + this.callback = callback; + this.setPlaceholder('Pick a directory template…'); + } + + getItems(): TemplateFile[] { + return this.items; + } + + getItemText(item: TemplateFile): string { + const desc = item.description ? ` — ${item.description}` : ''; + return `${item.title}${desc}`; + } + + onChooseItem(item: TemplateFile): void { + this.callback(item); + } +} diff --git a/src/services/directoryTemplateService.ts b/src/services/directoryTemplateService.ts new file mode 100644 index 0000000..7f40612 --- /dev/null +++ b/src/services/directoryTemplateService.ts @@ -0,0 +1,295 @@ +import type { App, TFile } from 'obsidian'; +import { Notice, parseYaml, request, stringifyYaml } from 'obsidian'; + +export interface DirectoryTemplateSettings { + perplexityApiKey: string; + perplexityEndpoint: string; + templatesRoot: string; + frontmatterWhitelist: string[]; + requestTimeoutMs: number; +} + +export interface TemplateFile { + file: TFile; + frontmatter: Record; + title: string; + description: string; + appliesToPaths: string[]; +} + +export interface ParsedTemplate { + file: TFile; + cftConfig: Record; + cftSystem: string; + userSkeleton: string; +} + +interface PerplexityChoice { + message?: { content?: string }; +} + +interface PerplexityResponseShape { + choices?: PerplexityChoice[]; +} + +interface PerplexityPayload { + model: string; + messages: { role: string; content: string }[]; + stream: boolean; + return_citations: boolean; + return_images: boolean; + return_related_questions: boolean; + search_recency_filter?: string; +} + +const FRONTMATTER_FENCE = '---'; +const CFT_OPEN_RE = /^```cft\b\s*$/; +const FENCE_CLOSE_RE = /^```\s*$/; +const SCRATCH_TERMINATOR_RE = /^\*\*\*\s*$/; + +function splitFrontmatter(content: string): { frontmatter: string; body: string } { + const normalized = content.replace(/\r\n/g, '\n'); + const lines = normalized.split('\n'); + if (lines[0] !== FRONTMATTER_FENCE) { + return { frontmatter: '', body: normalized }; + } + let i = 1; + while (i < lines.length && lines[i] !== FRONTMATTER_FENCE) i++; + if (i >= lines.length) return { frontmatter: '', body: normalized }; + const fm = lines.slice(1, i).join('\n'); + const body = lines.slice(i + 1).join('\n'); + return { frontmatter: fm, body }; +} + +function safeParseYaml(yaml: string): Record { + if (!yaml.trim()) return {}; + try { + const parsed: unknown = parseYaml(yaml); + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + return parsed as Record; + } + return {}; + } catch { + return {}; + } +} + +function globToRegExp(glob: string): RegExp { + let re = '^'; + let i = 0; + while (i < glob.length) { + const c = glob[i] ?? ''; + if (c === '*' && glob[i + 1] === '*') { + if (glob[i + 2] === '/') { + re += '(?:.*/)?'; + i += 3; + } else { + re += '.*'; + i += 2; + } + } else if (c === '*') { + re += '[^/]*'; + i++; + } else if (c === '?') { + re += '[^/]'; + i++; + } else if ('.+^$|(){}[]\\'.includes(c)) { + re += '\\' + c; + i++; + } else { + re += c; + i++; + } + } + re += '$'; + return new RegExp(re); +} + +export function pathMatchesGlobs(path: string, globs: string[]): boolean { + if (!globs.length) return false; + return globs.some(g => globToRegExp(g).test(path)); +} + +export async function listTemplates(app: App, root: string): Promise { + const normalizedRoot = root.replace(/\/$/, ''); + if (!normalizedRoot) return []; + const all = app.vault.getMarkdownFiles(); + const results: TemplateFile[] = []; + for (const file of all) { + if (!file.path.startsWith(normalizedRoot + '/')) continue; + const cache = app.metadataCache.getFileCache(file); + const fm = (cache?.frontmatter ?? {}) as Record; + const appliesToRaw = fm['applies-to-paths']; + const appliesToPaths = Array.isArray(appliesToRaw) + ? appliesToRaw.filter((g): g is string => typeof g === 'string') + : typeof appliesToRaw === 'string' ? [appliesToRaw] : []; + results.push({ + file, + frontmatter: fm, + title: typeof fm.title === 'string' ? fm.title : file.basename, + description: typeof fm.description === 'string' ? fm.description : '', + appliesToPaths, + }); + } + return results; +} + +export async function loadTemplate(app: App, file: TFile): Promise { + const content = await app.vault.read(file); + const { body } = splitFrontmatter(content); + const lines = body.split('\n'); + + let cftStart = -1; + for (let i = 0; i < lines.length; i++) { + if (CFT_OPEN_RE.test(lines[i] ?? '')) { cftStart = i; break; } + } + if (cftStart < 0) return null; + + let cftEnd = -1; + for (let i = cftStart + 1; i < lines.length; i++) { + if (FENCE_CLOSE_RE.test(lines[i] ?? '')) { cftEnd = i; break; } + } + if (cftEnd < 0) return null; + + const cftYaml = lines.slice(cftStart + 1, cftEnd).join('\n'); + const cftParsed = safeParseYaml(cftYaml); + const systemPrompt = typeof cftParsed.system === 'string' ? cftParsed.system : ''; + const cftConfig = { ...cftParsed }; + delete cftConfig.system; + + let skeletonEnd = lines.length; + for (let i = cftEnd + 1; i < lines.length; i++) { + if (SCRATCH_TERMINATOR_RE.test(lines[i] ?? '')) { skeletonEnd = i; break; } + } + const userSkeleton = lines.slice(cftEnd + 1, skeletonEnd).join('\n').trim(); + + return { file, cftConfig, cftSystem: systemPrompt, userSkeleton }; +} + +export function buildFrontmatterPayload( + fm: Record, + whitelist: string[], +): string { + const filtered: Record = {}; + for (const key of whitelist) { + if (key in fm) filtered[key] = fm[key]; + } + if (Object.keys(filtered).length === 0) return '(none)'; + return stringifyYaml(filtered).trim(); +} + +export function interpolate(text: string, ctx: { title: string; frontmatter: string }): string { + return text.replace(/\{\{\s*(title|frontmatter)\s*\}\}/g, (_, key: string) => { + if (key === 'title') return ctx.title; + return ctx.frontmatter; + }); +} + +function buildPayload( + template: ParsedTemplate, + systemPrompt: string, + userPrompt: string, +): PerplexityPayload { + const cfg = template.cftConfig; + const model = typeof cfg.model === 'string' ? cfg.model : 'sonar-deep-research'; + const recency = typeof cfg['search-recency'] === 'string' + ? cfg['search-recency'] + : undefined; + const returnCitations = cfg['return-citations'] !== false; + const returnImages = cfg['return-images'] === true; + + const payload: PerplexityPayload = { + model, + messages: [ + { role: 'system', content: systemPrompt }, + { role: 'user', content: userPrompt }, + ], + stream: false, + return_citations: returnCitations, + return_images: returnImages, + return_related_questions: false, + }; + if (recency) payload.search_recency_filter = recency; + return payload; +} + +async function callPerplexity( + apiKey: string, + endpoint: string, + payload: PerplexityPayload, + timeoutMs: number, +): Promise { + const requestPromise = request({ + url: endpoint, + method: 'POST', + headers: { + 'Authorization': `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }, + body: JSON.stringify(payload), + }); + + const timeoutPromise = new Promise((_, reject) => { + activeWindow.setTimeout(() => reject(new Error(`Request timed out after ${timeoutMs}ms`)), timeoutMs); + }); + + const response = await Promise.race([requestPromise, timeoutPromise]); + const data = JSON.parse(response) as PerplexityResponseShape; + const content = data.choices?.[0]?.message?.content; + if (!content) throw new Error('Perplexity returned no content.'); + return content; +} + +export async function applyTemplate( + app: App, + settings: DirectoryTemplateSettings, + target: TFile, + template: ParsedTemplate, +): Promise { + if (!settings.perplexityApiKey) { + new Notice('Perplexity API key is not set.'); + return; + } + if (!template.userSkeleton) { + new Notice('Template has no skeleton (no content below the cft block).'); + return; + } + + const targetContent = await app.vault.read(target); + const { frontmatter: fmRaw, body } = splitFrontmatter(targetContent); + if (body.trim().length > 0) { + new Notice('File has existing body. Edit manually or delete body to re-run.'); + return; + } + + const fm = safeParseYaml(fmRaw); + const title = typeof fm.title === 'string' ? fm.title : target.basename; + const fmYaml = buildFrontmatterPayload(fm, settings.frontmatterWhitelist); + + const ctx = { title, frontmatter: fmYaml }; + const systemPrompt = interpolate(template.cftSystem, ctx); + const userPrompt = interpolate(template.userSkeleton, ctx); + + const payload = buildPayload(template, systemPrompt, userPrompt); + + const loadingNotice = new Notice('Applying template via Perplexity deep research…', 0); + try { + const content = await callPerplexity( + settings.perplexityApiKey, + settings.perplexityEndpoint, + payload, + settings.requestTimeoutMs, + ); + const trimmedContent = content.replace(/^\s+/, '').replace(/\s+$/, ''); + const fmBlock = fmRaw.length > 0 ? `---\n${fmRaw}\n---\n` : ''; + const newFile = `${fmBlock}\n${trimmedContent}\n`; + await app.vault.modify(target, newFile); + new Notice(`Applied "${template.file.basename}" to ${target.basename}`); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + new Notice(`Perplexity error: ${msg}`); + } finally { + loadingNotice.hide(); + } +}