feat(directory-templates): v0.1 spike — apply Perplexity-driven profile templates per directory

Solves the immediate workload of populating ~1600 nearly-empty profile files
under Tooling/, Sources/, and Vocabulary/ with a consistent outline + body.

The user picks a template from a fuzzy palette (filtered by glob match against
the active file's path), and the plugin sends a single Perplexity Deep Research
request driven by the template's heading skeleton + per-section bullet
instructions. The response is buffered, then written into the empty body of the
target file. Frontmatter is byte-identical before and after.

Template anatomy (per context-v/specs/Per-Directory-Profile-Templates.md):
- Frontmatter — title, applies-to-paths globs, description.
- Pre-`cft` prose — explainer for humans, ignored at runtime.
- One `cft` codefence — YAML config + `system:` field. Config-only; no prompt
  body inside the fence.
- Post-`cft` skeleton — heading outline with model-facing bullet instructions.
  Sent as the user prompt with `{{title}}` and `{{frontmatter}}` interpolated.
- A `***` line below the skeleton terminates the user prompt; everything below
  is authoring scratch and never reaches the API.

New files:
- src/services/directoryTemplateService.ts — template loader, parser, glob
  matcher, frontmatter whitelist filter, prompt assembly, non-streaming
  Perplexity call, file writer.
- src/modals/DirectoryTemplatePickerModal.ts — FuzzySuggestModal wrapping the
  glob-matched template list.
- src/docs/templates/{tooling-profile,source-profile}.md — seed templates
  users can copy into their vault.

main.ts changes:
- New settings: directoryTemplatesRoot (default "zz-cf-lib/templates"),
  directoryTemplatesFrontmatterWhitelist (default ["title", "og_description",
  "tags", "og_image"]), directoryTemplatesRequestTimeoutMs (default 300000).
- New command: "Apply directory template to current file".
- New settings UI section under "Directory templates".

Out of scope for v0.1 — captured in the spec for later iterations:
- Folder-batch run with concurrency/progress/resume (v0.2).
- `cf` codefence runtime in user notes (v0.3).
- Section-aware writing + output modes (v0.4).
- Citation/backlink preservation (v0.5).
- Multi-provider, image generation, verb registry split (later).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
mpstaton 2026-05-09 15:27:09 -05:00
parent 4423052f04
commit 181a23e5e5
5 changed files with 583 additions and 1 deletions

123
main.ts
View file

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

56
src/docs/templates/source-profile.md vendored Normal file
View file

@ -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
- 36 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
- 35 sources that cover similar ground from different angles.
***
# User Notes
Authoring scratch zone — not sent to the model.

78
src/docs/templates/tooling-profile.md vendored Normal file
View file

@ -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 23 sentences each.
- Bullet 58 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
- 35 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.

View file

@ -0,0 +1,32 @@
import type { App } from 'obsidian';
import { FuzzySuggestModal } from 'obsidian';
import type { TemplateFile } from '../services/directoryTemplateService';
export class DirectoryTemplatePickerModal extends FuzzySuggestModal<TemplateFile> {
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);
}
}

View file

@ -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<string, unknown>;
title: string;
description: string;
appliesToPaths: string[];
}
export interface ParsedTemplate {
file: TFile;
cftConfig: Record<string, unknown>;
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<string, unknown> {
if (!yaml.trim()) return {};
try {
const parsed: unknown = parseYaml(yaml);
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
return parsed as Record<string, unknown>;
}
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<TemplateFile[]> {
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<string, unknown>;
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<ParsedTemplate | null> {
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<string, unknown>,
whitelist: string[],
): string {
const filtered: Record<string, unknown> = {};
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<string> {
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<never>((_, 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<void> {
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();
}
}