lossless-group_perplexed-pl.../src/services/directoryTemplateService.ts
mpstaton 22b26a271e fix(sources-footer): use canonical [N]: form per Lossless citation spec
Previous fix used [N] without the colon. Lossless-Citation-Spec.md mandates
the colon for reference-section identifiers (cite-wide reminders/spec line
68: "always use a `: ` after the citation identifier"). Cite-wide's parser
accepts both shapes, but the colon form is canonical and what other
Lossless tooling expects to see in checked-in files.

Verified the parser flow against cite-wide:
- REFDEF_NUM_RE (llmCitationParserService.ts:91) matches both `[N]` and `[N]:`
- reformatRefDefBodyAsMarkdownLink (line 547) skips wrapping when the body
  already contains a markdown link, so `[N]: [Title](URL)` passes through
  untouched into the hex-conversion stage.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 16:35:32 -05:00

538 lines
19 KiB
TypeScript

import type { App, TFile } from 'obsidian';
import { Notice, parseYaml, 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 PerplexityPayload {
model: string;
messages: { role: string; content: string }[];
stream: boolean;
return_citations: boolean;
return_images: boolean;
return_related_questions: boolean;
search_recency_filter?: string;
}
export interface PerplexitySource {
title?: string;
url?: string;
date?: string;
last_updated?: string;
}
const INLINE_CITATION_DIRECTIVE = "When you make any factual claim that comes from a web search result, append a numeric citation marker like [1], [2], etc. immediately after the claim. The numbers MUST correspond 1:1 to the order of the search results returned by the search tool (first result = [1], second = [2], and so on). You may cite the same source multiple times. Do not list the sources at the end — only inline markers. Do not invent sources; only cite results actually used.";
function buildResearchFraming(title: string, fmYaml: string): string {
return `Research the entity "${title}" using web search. Use the metadata below as context, then produce a structured profile that follows the markdown skeleton at the end of this prompt. Every factual claim in your output must be immediately followed by an inline [N] citation marker corresponding to a returned search result. Quote phrasing from sources where useful.
Metadata for "${title}":
${fmYaml}
Skeleton (follow this structure; bullets under each heading describe what the section requires):
`;
}
function wrapThinkBlocks(text: string): string {
return text.replace(/<think>([\s\S]*?)<\/think>/gi, (_match, inner: string) => {
const trimmed = inner.replace(/^\s+/, '').replace(/\s+$/, '');
return '```think-output\n' + trimmed + '\n```';
});
}
function buildSourcesFooter(sources: PerplexitySource[]): string {
if (!sources.length) return '';
// Canonical Lossless reference-section format per
// cite-wide/context-v/reminders/Lossless-Citation-Spec.md:
// "always use a `: ` after the citation identifier".
// cite-wide's parser (REFDEF_NUM_RE, line 91 of llmCitationParserService.ts)
// accepts both `[N]` and `[N]:` thanks to the `(:?)` group, but the colon
// form matches the spec, so emit it.
const lines = sources.map((s, i) => {
const n = i + 1;
const title = (typeof s.title === 'string' && s.title) ? s.title : (s.url ?? 'Source');
const url = s.url ?? '';
return url ? `[${n.toString()}]: [${title}](${url})` : `[${n.toString()}]: ${title}`;
});
return '\n\n## Sources\n\n' + lines.join('\n') + '\n';
}
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();
}
function frontmatterValueToString(value: unknown): string {
if (value == null) return '';
if (typeof value === 'string') return value;
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
if (Array.isArray(value)) return value.map(v => frontmatterValueToString(v)).join(', ');
return stringifyYaml(value).trim();
}
export interface InterpolationContext {
title: string;
frontmatter: string;
frontmatterObj: Record<string, unknown>;
}
export function interpolate(text: string, ctx: InterpolationContext): string {
return text.replace(/\{\{\s*([\w.-]+)\s*\}\}/g, (full, key: string) => {
if (key === 'title') return ctx.title;
if (key === 'frontmatter') return ctx.frontmatter;
if (key === 'today') return new Date().toISOString().slice(0, 10);
const fmKey = key.startsWith('frontmatter.') ? key.slice('frontmatter.'.length) : key;
if (fmKey in ctx.frontmatterObj) {
return frontmatterValueToString(ctx.frontmatterObj[fmKey]);
}
return full;
});
}
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 streamPerplexityToFile(
app: App,
apiKey: string,
endpoint: string,
payload: PerplexityPayload,
timeoutMs: number,
file: TFile,
initialContent: string,
isCancelled: () => boolean,
): Promise<{ streamed: string; sources: PerplexitySource[] }> {
payload.stream = true;
const controller = new AbortController();
const timer = activeWindow.setTimeout(() => controller.abort(), timeoutMs);
let response: Response;
try {
// eslint-disable-next-line no-restricted-globals
response = await fetch(endpoint, {
method: 'POST',
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json',
'Accept': 'text/event-stream',
},
body: JSON.stringify(payload),
signal: controller.signal,
cache: 'no-store',
});
} catch (err) {
activeWindow.clearTimeout(timer);
throw err;
}
if (!response.ok) {
activeWindow.clearTimeout(timer);
throw new Error(`Perplexity HTTP ${response.status.toString()}`);
}
const reader = response.body?.getReader();
if (!reader) {
activeWindow.clearTimeout(timer);
throw new Error('Perplexity returned no response body');
}
const decoder = new TextDecoder();
let sseBuffer = '';
let streamed = '';
let sources: PerplexitySource[] = [];
let lastFlush = 0;
const FLUSH_MS = 500;
try {
while (true) {
if (isCancelled()) {
controller.abort();
break;
}
const { value, done } = await reader.read();
if (done) break;
sseBuffer += decoder.decode(value, { stream: true });
const lines = sseBuffer.split('\n');
sseBuffer = lines.pop() ?? '';
for (const rawLine of lines) {
const line = rawLine.trim();
if (!line.startsWith('data:')) continue;
const data = line.slice(5).trim();
if (!data || data === '[DONE]') continue;
try {
const parsed: unknown = JSON.parse(data);
if (typeof parsed !== 'object' || parsed === null) continue;
const obj = parsed as Record<string, unknown>;
const choices = obj['choices'];
if (Array.isArray(choices) && choices.length > 0) {
const first = choices[0] as Record<string, unknown> | undefined;
const delta = first?.['delta'] as Record<string, unknown> | undefined;
const content = delta?.['content'];
if (typeof content === 'string') {
streamed += content;
}
}
const sr = obj['search_results'];
if (Array.isArray(sr)) {
sources = sr.filter((x): x is PerplexitySource =>
typeof x === 'object' && x !== null);
}
} catch {
// partial JSON; skip
}
}
const now = Date.now();
if (now - lastFlush >= FLUSH_MS) {
await app.vault.modify(file, initialContent + streamed);
lastFlush = now;
}
}
} finally {
activeWindow.clearTimeout(timer);
try {
reader.releaseLock();
} catch {
// already released
}
}
// Final flush of raw stream content before post-processing
await app.vault.modify(file, initialContent + streamed);
return { streamed, sources };
}
export type ApplyOutcome =
| { status: 'applied'; mode: 'fill' | 'append'; sourceCount: number }
| { status: 'skipped'; reason: string }
| { status: 'error'; error: string };
export interface ApplyOptions {
quiet?: boolean;
isCancelled?: () => boolean;
}
export async function applyTemplate(
app: App,
settings: DirectoryTemplateSettings,
target: TFile,
template: ParsedTemplate,
options: ApplyOptions = {},
): Promise<ApplyOutcome> {
const quiet = options.quiet === true;
const isCancelled = options.isCancelled ?? (() => false);
if (!settings.perplexityApiKey) {
if (!quiet) new Notice('Perplexity API key is not set.');
return { status: 'error', error: 'Perplexity API key not set' };
}
if (!template.userSkeleton) {
if (!quiet) new Notice('Template has no skeleton (no content below the cft block).');
return { status: 'error', error: 'Template has no skeleton' };
}
const targetContent = await app.vault.read(target);
const { frontmatter: fmRaw, body } = splitFrontmatter(targetContent);
const existingBody = body.replace(/\s+$/, '');
const mode: 'fill' | 'append' = existingBody.trim().length === 0 ? 'fill' : 'append';
const fm = safeParseYaml(fmRaw);
const title = typeof fm.title === 'string' ? fm.title : target.basename;
const fmYaml = buildFrontmatterPayload(fm, settings.frontmatterWhitelist);
const ctx: InterpolationContext = { title, frontmatter: fmYaml, frontmatterObj: fm };
const templateSystem = interpolate(template.cftSystem, ctx);
const interpolatedSkeleton = interpolate(template.userSkeleton, ctx);
// Combined system prompt: citation-enforcement directive first, then template's role framing.
const systemPrompt = templateSystem
? `${INLINE_CITATION_DIRECTIVE}\n\n${templateSystem}`
: INLINE_CITATION_DIRECTIVE;
// User prompt: research framing prepended to the skeleton so the model treats this as a
// research task, not a writing brief.
const userPrompt = buildResearchFraming(title, fmYaml) + interpolatedSkeleton;
// Initial file content the stream will append to.
const fmBlock = fmRaw.length > 0 ? `---\n${fmRaw}\n---\n` : '';
const initialContent = mode === 'fill'
? `${fmBlock}\n`
: `${fmBlock}\n${existingBody}\n\n`;
const payload = buildPayload(template, systemPrompt, userPrompt);
let loadingNotice: Notice | null = null;
if (!quiet) {
loadingNotice = new Notice('Streaming Perplexity deep research…', 0);
}
try {
// Set initial state before streaming begins.
await app.vault.modify(target, initialContent);
const { streamed, sources } = await streamPerplexityToFile(
app,
settings.perplexityApiKey,
settings.perplexityEndpoint,
payload,
settings.requestTimeoutMs,
target,
initialContent,
isCancelled,
);
// Post-write cleanup: wrap <think> blocks, append sources footer.
const trimmedStreamed = streamed.replace(/^\s+/, '').replace(/\s+$/, '');
const cleanedStreamed = wrapThinkBlocks(trimmedStreamed);
const sourcesFooter = buildSourcesFooter(sources);
const finalContent = `${initialContent}${cleanedStreamed}\n${sourcesFooter}`;
await app.vault.modify(target, finalContent);
if (!quiet) {
const verb = mode === 'fill' ? 'Filled' : 'Appended to';
new Notice(`${verb} "${target.basename}" using ${template.file.basename} (${sources.length.toString()} sources)`);
}
return { status: 'applied', mode, sourceCount: sources.length };
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
if (!quiet) new Notice(`Perplexity error: ${msg}`);
return { status: 'error', error: msg };
} finally {
if (loadingNotice) loadingNotice.hide();
}
}
export interface BatchProgress {
current: number;
total: number;
file: TFile;
}
export interface BatchResult {
appliedFill: number;
appliedAppend: number;
errored: number;
cancelled: boolean;
errors: { path: string; error: string }[];
}
export async function applyTemplateBatch(
app: App,
settings: DirectoryTemplateSettings,
files: TFile[],
template: ParsedTemplate,
onProgress: (p: BatchProgress) => void,
isCancelled: () => boolean,
): Promise<BatchResult> {
const result: BatchResult = {
appliedFill: 0,
appliedAppend: 0,
errored: 0,
cancelled: false,
errors: [],
};
for (let i = 0; i < files.length; i++) {
if (isCancelled()) {
result.cancelled = true;
return result;
}
const file = files[i];
if (!file) continue;
onProgress({ current: i + 1, total: files.length, file });
const outcome = await applyTemplate(app, settings, file, template, { quiet: true });
if (outcome.status === 'applied') {
if (outcome.mode === 'fill') result.appliedFill++;
else result.appliedAppend++;
} else if (outcome.status === 'error') {
result.errored++;
result.errors.push({ path: file.path, error: outcome.error });
}
}
return result;
}
export function listMarkdownFilesInFolder(app: App, folderPath: string): TFile[] {
const normalized = folderPath.replace(/\/$/, '');
return app.vault.getMarkdownFiles().filter(f => {
if (normalized === '') return true;
return f.path === normalized || f.path.startsWith(normalized + '/');
});
}