UNPKG

@posthog/agent

Version:

TypeScript agent framework wrapping Claude Agent SDK with Git-based task execution for PostHog

398 lines (395 loc) 17.4 kB
import { promises } from 'node:fs'; import { join } from 'node:path'; import { Logger } from './utils/logger.js'; class PromptBuilder { getTaskFiles; generatePlanTemplate; posthogClient; logger; constructor(deps) { this.getTaskFiles = deps.getTaskFiles; this.generatePlanTemplate = deps.generatePlanTemplate; this.posthogClient = deps.posthogClient; this.logger = deps.logger || new Logger({ debug: false, prefix: "[PromptBuilder]" }); } /** * Extract file paths from XML tags in description * Format: <file path="relative/path.ts" /> */ extractFilePaths(description) { const fileTagRegex = /<file\s+path="([^"]+)"\s*\/>/g; const paths = []; let match; match = fileTagRegex.exec(description); while (match !== null) { paths.push(match[1]); match = fileTagRegex.exec(description); } return paths; } /** * Read file contents from repository */ async readFileContent(repositoryPath, filePath) { try { const fullPath = join(repositoryPath, filePath); const content = await promises.readFile(fullPath, "utf8"); return content; } catch (error) { this.logger.warn(`Failed to read referenced file: ${filePath}`, { error, }); return null; } } /** * Extract URL mentions from XML tags in description * Formats: <error id="..." />, <experiment id="..." />, <url href="..." /> */ extractUrlMentions(description) { const mentions = []; // PostHog resource mentions: <error id="..." />, <experiment id="..." />, etc. const resourceRegex = /<(error|experiment|insight|feature_flag)\s+id="([^"]+)"\s*\/>/g; let match; match = resourceRegex.exec(description); while (match !== null) { const [, type, id] = match; mentions.push({ url: "", // Will be reconstructed if needed type: type, id, label: this.generateUrlLabel("", type), }); match = resourceRegex.exec(description); } // Generic URL mentions: <url href="..." /> const urlRegex = /<url\s+href="([^"]+)"\s*\/>/g; match = urlRegex.exec(description); while (match !== null) { const [, url] = match; mentions.push({ url, type: "generic", label: this.generateUrlLabel(url, "generic"), }); match = urlRegex.exec(description); } return mentions; } /** * Generate a display label for a URL mention */ generateUrlLabel(url, type) { try { const urlObj = new URL(url); switch (type) { case "error": { const errorMatch = url.match(/error_tracking\/([a-f0-9-]+)/); return errorMatch ? `Error ${errorMatch[1].slice(0, 8)}...` : "Error"; } case "experiment": { const expMatch = url.match(/experiments\/(\d+)/); return expMatch ? `Experiment #${expMatch[1]}` : "Experiment"; } case "insight": return "Insight"; case "feature_flag": return "Feature Flag"; default: return urlObj.hostname; } } catch { return "URL"; } } /** * Process URL references and fetch their content */ async processUrlReferences(description) { const urlMentions = this.extractUrlMentions(description); const referencedResources = []; if (urlMentions.length === 0 || !this.posthogClient) { return { description, referencedResources }; } // Fetch all referenced resources for (const mention of urlMentions) { try { const resource = await this.posthogClient.fetchResourceByUrl(mention); referencedResources.push(resource); } catch (error) { this.logger.warn(`Failed to fetch resource from URL: ${mention.url}`, { error, }); // Add a placeholder resource for failed fetches referencedResources.push({ type: mention.type, id: mention.id || "", url: mention.url, title: mention.label || "Unknown Resource", content: `Failed to fetch resource from ${mention.url}: ${error}`, metadata: {}, }); } } // Replace URL tags with just the label for readability let processedDescription = description; for (const mention of urlMentions) { if (mention.type === "generic") { // Generic URLs: <url href="..." /> const escapedUrl = mention.url.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); processedDescription = processedDescription.replace(new RegExp(`<url\\s+href="${escapedUrl}"\\s*/>`, "g"), `@${mention.label}`); } else { // PostHog resources: <error id="..." />, <experiment id="..." />, etc. const escapedType = mention.type.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); const escapedId = mention.id ? mention.id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") : ""; processedDescription = processedDescription.replace(new RegExp(`<${escapedType}\\s+id="${escapedId}"\\s*/>`, "g"), `@${mention.label}`); } } return { description: processedDescription, referencedResources }; } /** * Process description to extract file tags and read contents * Returns processed description and referenced file contents */ async processFileReferences(description, repositoryPath) { const filePaths = this.extractFilePaths(description); const referencedFiles = []; if (filePaths.length === 0 || !repositoryPath) { return { description, referencedFiles }; } // Read all referenced files for (const filePath of filePaths) { const content = await this.readFileContent(repositoryPath, filePath); if (content !== null) { referencedFiles.push({ path: filePath, content }); } } // Replace file tags with just the filename for readability let processedDescription = description; for (const filePath of filePaths) { const fileName = filePath.split("/").pop() || filePath; processedDescription = processedDescription.replace(new RegExp(`<file\\s+path="${filePath.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}"\\s*/>`, "g"), `@${fileName}`); } return { description: processedDescription, referencedFiles }; } async buildResearchPrompt(task, repositoryPath) { // Process file references in description const { description: descriptionAfterFiles, referencedFiles } = await this.processFileReferences(task.description, repositoryPath); // Process URL references in description const { description: processedDescription, referencedResources } = await this.processUrlReferences(descriptionAfterFiles); let prompt = "<task>\n"; prompt += `<title>${task.title}</title>\n`; prompt += `<description>${processedDescription}</description>\n`; if (task.repository) { prompt += `<repository>${task.repository}</repository>\n`; } prompt += "</task>\n"; // Add referenced files from @ mentions if (referencedFiles.length > 0) { prompt += "\n<referenced_files>\n"; for (const file of referencedFiles) { prompt += `<file path="${file.path}">\n\`\`\`\n${file.content}\n\`\`\`\n</file>\n`; } prompt += "</referenced_files>\n"; } // Add referenced resources from URL mentions if (referencedResources.length > 0) { prompt += "\n<referenced_resources>\n"; for (const resource of referencedResources) { prompt += `<resource type="${resource.type}" url="${resource.url}">\n`; prompt += `<title>${resource.title}</title>\n`; prompt += `<content>${resource.content}</content>\n`; prompt += "</resource>\n"; } prompt += "</referenced_resources>\n"; } try { const taskFiles = await this.getTaskFiles(task.id); const contextFiles = taskFiles.filter((f) => f.type === "context" || f.type === "reference"); if (contextFiles.length > 0) { prompt += "\n<supporting_files>\n"; for (const file of contextFiles) { prompt += `<file name="${file.name}" type="${file.type}">\n${file.content}\n</file>\n`; } prompt += "</supporting_files>\n"; } } catch (_error) { this.logger.debug("No existing task files found for research", { taskId: task.id, }); } return prompt; } async buildPlanningPrompt(task, repositoryPath) { // Process file references in description const { description: descriptionAfterFiles, referencedFiles } = await this.processFileReferences(task.description, repositoryPath); // Process URL references in description const { description: processedDescription, referencedResources } = await this.processUrlReferences(descriptionAfterFiles); let prompt = "<task>\n"; prompt += `<title>${task.title}</title>\n`; prompt += `<description>${processedDescription}</description>\n`; if (task.repository) { prompt += `<repository>${task.repository}</repository>\n`; } prompt += "</task>\n"; // Add referenced files from @ mentions if (referencedFiles.length > 0) { prompt += "\n<referenced_files>\n"; for (const file of referencedFiles) { prompt += `<file path="${file.path}">\n\`\`\`\n${file.content}\n\`\`\`\n</file>\n`; } prompt += "</referenced_files>\n"; } // Add referenced resources from URL mentions if (referencedResources.length > 0) { prompt += "\n<referenced_resources>\n"; for (const resource of referencedResources) { prompt += `<resource type="${resource.type}" url="${resource.url}">\n`; prompt += `<title>${resource.title}</title>\n`; prompt += `<content>${resource.content}</content>\n`; prompt += "</resource>\n"; } prompt += "</referenced_resources>\n"; } try { const taskFiles = await this.getTaskFiles(task.id); const contextFiles = taskFiles.filter((f) => f.type === "context" || f.type === "reference"); if (contextFiles.length > 0) { prompt += "\n<supporting_files>\n"; for (const file of contextFiles) { prompt += `<file name="${file.name}" type="${file.type}">\n${file.content}\n</file>\n`; } prompt += "</supporting_files>\n"; } } catch (_error) { this.logger.debug("No existing task files found for planning", { taskId: task.id, }); } const templateVariables = { task_id: task.id, task_title: task.title, task_description: processedDescription, date: new Date().toISOString().split("T")[0], repository: task.repository || "", }; const planTemplate = await this.generatePlanTemplate(templateVariables); prompt += "\n<instructions>\n"; prompt += "Analyze the codebase and create a detailed implementation plan. Use the template structure below, filling each section with specific, actionable information.\n"; prompt += "</instructions>\n\n"; prompt += "<plan_template>\n"; prompt += planTemplate; prompt += "\n</plan_template>"; return prompt; } async buildExecutionPrompt(task, repositoryPath) { // Process file references in description const { description: descriptionAfterFiles, referencedFiles } = await this.processFileReferences(task.description, repositoryPath); // Process URL references in description const { description: processedDescription, referencedResources } = await this.processUrlReferences(descriptionAfterFiles); let prompt = "<task>\n"; prompt += `<title>${task.title}</title>\n`; prompt += `<description>${processedDescription}</description>\n`; if (task.repository) { prompt += `<repository>${task.repository}</repository>\n`; } prompt += "</task>\n"; // Add referenced files from @ mentions if (referencedFiles.length > 0) { prompt += "\n<referenced_files>\n"; for (const file of referencedFiles) { prompt += `<file path="${file.path}">\n\`\`\`\n${file.content}\n\`\`\`\n</file>\n`; } prompt += "</referenced_files>\n"; } // Add referenced resources from URL mentions if (referencedResources.length > 0) { prompt += "\n<referenced_resources>\n"; for (const resource of referencedResources) { prompt += `<resource type="${resource.type}" url="${resource.url}">\n`; prompt += `<title>${resource.title}</title>\n`; prompt += `<content>${resource.content}</content>\n`; prompt += "</resource>\n"; } prompt += "</referenced_resources>\n"; } try { const taskFiles = await this.getTaskFiles(task.id); const hasPlan = taskFiles.some((f) => f.type === "plan"); const todosFile = taskFiles.find((f) => f.name === "todos.json"); if (taskFiles.length > 0) { prompt += "\n<context>\n"; for (const file of taskFiles) { if (file.type === "plan") { prompt += `<plan>\n${file.content}\n</plan>\n`; } else if (file.name === "todos.json") { } else { prompt += `<file name="${file.name}" type="${file.type}">\n${file.content}\n</file>\n`; } } prompt += "</context>\n"; } // Add todos context if resuming work if (todosFile) { try { const todos = JSON.parse(todosFile.content); if (todos.items && todos.items.length > 0) { prompt += "\n<previous_todos>\n"; prompt += "You previously created the following todo list for this task:\n\n"; for (const item of todos.items) { const statusIcon = item.status === "completed" ? "✓" : item.status === "in_progress" ? "▶" : "○"; prompt += `${statusIcon} [${item.status}] ${item.content}\n`; } prompt += `\nProgress: ${todos.metadata.completed}/${todos.metadata.total} completed\n`; prompt += "\nYou can reference this list when resuming work or create an updated list as needed.\n"; prompt += "</previous_todos>\n"; } } catch (error) { this.logger.debug("Failed to parse todos.json for context", { error, }); } } prompt += "\n<instructions>\n"; if (hasPlan) { prompt += "Implement the changes described in the execution plan. Follow the plan step-by-step and make the necessary file modifications.\n"; } else { prompt += "Implement the changes described in the task. Make the necessary file modifications to complete the task.\n"; } prompt += "</instructions>"; } catch (_error) { this.logger.debug("No supporting files found for execution", { taskId: task.id, }); prompt += "\n<instructions>\n"; prompt += "Implement the changes described in the task.\n"; prompt += "</instructions>"; } return prompt; } } export { PromptBuilder }; //# sourceMappingURL=prompt-builder.js.map