/** * Prompt runner — executes a PromptTemplate against note content via @bytelyst/llm. */ import { llm } from '../../lib/llm.js'; import { config } from '../../lib/config.js'; import { trackEvent } from '../../lib/telemetry.js'; import { buildVisionMessage, hasVisionContent, type ChatMessage, } from '@bytelyst/llm'; import type { PromptTemplateDoc, RunPromptInput, RunPromptOutput } from './types.js'; function withTimeout(promise: Promise, ms: number): Promise { return Promise.race([ promise, new Promise((_, reject) => setTimeout(() => reject(new Error('LLM request timed out')), ms)), ]); } /** * Interpolate {{variable}} placeholders in a template string. */ function interpolate(template: string, vars: Record): string { return template.replace(/\{\{(\w+)\}\}/g, (_, key) => vars[key] ?? `{{${key}}}`); } /** * Run a prompt template against provided input. * Handles text-only, image-only, and text+image inputs. */ export async function executePrompt( template: PromptTemplateDoc, input: RunPromptInput, noteBody: string, ): Promise { const provider = llm(); if (!provider.isConfigured()) { throw new Error('LLM provider is not configured. Set LLM_PROVIDER and the required API key.'); } // Build variables map const vars: Record = { ...input.variables, noteBody, noteId: input.noteId, workspaceId: input.workspaceId, }; if (input.inputText) vars.inputText = input.inputText; const userPrompt = interpolate(template.userPromptTemplate, vars); // Build messages const messages: ChatMessage[] = [ { role: 'system', content: template.systemPrompt }, ]; if (input.imageUrl && (template.inputType === 'image' || template.inputType === 'text+image')) { messages.push(buildVisionMessage(userPrompt, input.imageUrl)); } else { messages.push({ role: 'user', content: userPrompt }); } // Select model: vision model for image content, custom model, or default const req = { messages }; let model = template.model || config.LLM_DEFAULT_MODEL; if (hasVisionContent(req)) { model = config.LLM_VISION_MODEL; } const maxRetries = 3; const baseDelayMs = 1000; let lastError: unknown; for (let attempt = 0; attempt < maxRetries; attempt++) { try { const result = await withTimeout(provider.chatCompletion({ messages, model, temperature: template.temperature ?? 0.7, maxTokens: template.maxTokens ?? 4096, }), 60_000); if (!result.content || result.content.trim().length === 0) { throw new Error('LLM returned empty response'); } const output: RunPromptOutput = { content: result.content, model: result.model, usage: result.usage, templateSlug: template.slug, outputType: template.outputType, }; // F27: Approval-gated actions — produce proposed state instead of applied if (template.requiresApproval) { output.approvalState = 'proposed'; } else { output.approvalState = 'applied'; } trackEvent('smart_action_run', 'system', { templateSlug: template.slug, inputType: template.inputType, model: result.model, totalTokens: String(result.usage.totalTokens), }); trackEvent('smart_action_result_saved', 'system', { outputAction: template.outputType, resultType: output.approvalState ?? 'applied', }); return output; } catch (err: unknown) { lastError = err; const errorType = err instanceof Error ? (err.message.includes('429') ? 'rate_limit' : err.message.includes('timed out') ? 'timeout' : 'llm_error') : 'unknown'; trackEvent('smart_action_error', 'system', { errorType, templateSlug: template.slug }); const isRateLimit = errorType === 'rate_limit'; if (isRateLimit && attempt < maxRetries - 1) { await new Promise((r) => setTimeout(r, baseDelayMs * Math.pow(2, attempt))); continue; } break; } } throw lastError instanceof Error ? lastError : new Error('LLM call failed after retries'); }