/** * System prompt construction and project context loading */ import { getSystemMessageText } from "@earendil-works/pi-ai"; import { getDocsPath, getExamplesPath, getReadmePath } from "../config.ts"; import { formatSkillsForPrompt, type Skill } from "./skills.ts"; export interface BuildSystemPromptOptions { /** Custom system prompt (replaces the default prefix). */ customPrompt?: string; /** Exact full prompt replacement set by a before_agent_start handler. */ forceSystemPrompt?: string; /** Tools to include in prompt. Default: [read, bash, edit, write]. */ selectedTools?: string[]; /** Optional one-line tool snippets keyed by tool name. */ toolSnippets?: Record; /** Guideline bullets contributed by each tool, keyed by tool name. */ toolGuidelines?: Record; /** Additional guideline bullets appended to the default system prompt rules. */ promptGuidelines?: string[]; /** Text appended from user configuration before project context, skills, and cwd. */ appendSystemPrompt?: string; /** Additional XML-wrapped prompt sections keyed by tag name. */ sections?: Record; /** Working directory. */ cwd: string; /** Pre-loaded context files. */ contextFiles?: Array<{ path: string; content: string }>; /** Pre-loaded skills. */ skills?: Skill[]; } export type NormalizedBuildSystemPromptOptions = BuildSystemPromptOptions & { selectedTools: string[]; toolSnippets: Record; toolGuidelines: Record; promptGuidelines: string[]; appendSystemPrompt: string; sections: Record; contextFiles: Array<{ path: string; content: string }>; skills: Skill[]; }; /** * Ordered system prompt sections, keyed by name. `preamble` is untagged text; every other * section is wrapped in a tag of the same name so the model can match later updates to it. * These become `SystemMessage.sections` in the transcript. */ export type SystemPromptSections = Record; const SYSTEM_PROMPT_SECTION_NAME = /^[a-z][a-z0-9_-]*$/; /** Normalize prompt input into the mutable, collection-complete shape exposed to extensions. */ export function normalizeBuildSystemPromptOptions(input: BuildSystemPromptOptions): NormalizedBuildSystemPromptOptions { return { customPrompt: input.customPrompt, forceSystemPrompt: input.forceSystemPrompt, selectedTools: [...(input.selectedTools ?? ["read", "bash", "edit", "write"])], toolSnippets: { ...(input.toolSnippets ?? {}) }, toolGuidelines: Object.fromEntries( Object.entries(input.toolGuidelines ?? {}).map(([name, guidelines]) => [name, [...guidelines]]), ), promptGuidelines: [...(input.promptGuidelines ?? [])], appendSystemPrompt: input.appendSystemPrompt ?? "", sections: { ...(input.sections ?? {}) }, cwd: input.cwd, contextFiles: (input.contextFiles ?? []).map((file) => ({ ...file })), skills: (input.skills ?? []).map((skill) => ({ ...skill })), }; } function renderProjectContext(contextFiles: Array<{ path: string; content: string }>): string { return [ "Project-specific instructions and guidelines:", ...contextFiles.map( ({ path, content }) => `\n${content}\n`, ), ].join("\n\n"); } function buildRules( selectedTools: string[], toolGuidelines: Record, promptGuidelines: string[], ): string { const rules: string[] = []; const seen = new Set(); const addRule = (rule: string): void => { const normalized = rule.trim(); if (!normalized || seen.has(normalized)) return; seen.add(normalized); rules.push(normalized); }; const hasBash = selectedTools.includes("bash"); const hasPowerShell = selectedTools.includes("powershell"); const hasGrep = selectedTools.includes("grep"); const hasFind = selectedTools.includes("find"); const hasLs = selectedTools.includes("ls"); if ((hasBash || hasPowerShell) && !hasGrep && !hasFind && !hasLs) { if (hasBash && hasPowerShell) { addRule("Use bash or PowerShell for file operations like listing, searching, and finding files"); } else if (hasPowerShell) { addRule("Use PowerShell for file operations like listing, searching, and finding files"); } else { addRule("Use bash for file operations like ls, rg, find"); } } for (const name of selectedTools) { for (const rule of toolGuidelines[name] ?? []) addRule(rule); } for (const rule of promptGuidelines) addRule(rule); addRule("Be concise in your responses"); addRule("Show file paths clearly when working with files"); return rules.map((rule) => `- ${rule}`).join("\n"); } /** Build the ordered, independently replaceable sections of the structured system prompt. */ export function buildSystemPromptSections(input: BuildSystemPromptOptions): SystemPromptSections { const options = normalizeBuildSystemPromptOptions(input); const { customPrompt, selectedTools, toolSnippets, toolGuidelines, promptGuidelines, appendSystemPrompt, sections: customSections, cwd, contextFiles, skills, } = options; for (const name of Object.keys(customSections)) { if (!SYSTEM_PROMPT_SECTION_NAME.test(name) || name === "preamble") { throw new Error(`Invalid system prompt section name: ${name}`); } } const promptSections: Record = {}; if (customPrompt) { promptSections.preamble = customPrompt; } else { promptSections.preamble = "You are an expert coding assistant operating inside pi, a coding agent harness. You help users by reading files, executing commands, editing code, and writing new files."; const visibleTools = selectedTools.filter((name) => !!toolSnippets[name]); const tools = visibleTools.length > 0 ? visibleTools.map((name) => `- ${name}: ${toolSnippets[name]}`).join("\n") : "(none)"; promptSections.tools = `${tools}\n\nIn addition to the tools above, you may have access to other custom tools depending on the project.`; promptSections.rules = buildRules(selectedTools, toolGuidelines, promptGuidelines); promptSections.docs = `Pi documentation (read only when the user asks about pi itself, its SDK, extensions, themes, skills, or TUI): - Main documentation: ${getReadmePath()} - Additional docs: ${getDocsPath()} - Examples: ${getExamplesPath()} (extensions, custom tools, SDK) - When reading pi docs or examples, resolve docs/... under Additional docs and examples/... under Examples, not the current working directory - When asked about: extensions (docs/extensions.md, examples/extensions/), themes (docs/themes.md), skills (docs/skills.md), prompt templates (docs/prompt-templates.md), TUI components (docs/tui.md), keybindings (docs/keybindings.md), SDK integrations (docs/sdk.md), custom providers (docs/custom-provider.md), adding models (docs/models.md), pi packages (docs/packages.md), environment variables (docs/environment-variables.md) - When working on pi topics, read the docs and examples, and follow .md cross-references before implementing - Always read pi .md files completely and follow links to related docs (e.g., tui.md for TUI API details)`; } if (appendSystemPrompt) promptSections.addendum = appendSystemPrompt; if (contextFiles.length > 0) promptSections.project_context = renderProjectContext(contextFiles); const skillFileReadTool = (["read", "bash"] as const).find((tool) => selectedTools.includes(tool)); if (skillFileReadTool && skills.length > 0) { const skillsPrompt = formatSkillsForPrompt(skills, skillFileReadTool).trim(); if (skillsPrompt) promptSections.skills = skillsPrompt; } promptSections.cwd = cwd.replace(/\\/g, "/"); for (const [name, content] of Object.entries(customSections)) { if (content) promptSections[name] = content; } const sections: SystemPromptSections = { preamble: promptSections.preamble }; for (const [name, content] of Object.entries(promptSections)) { if (name !== "preamble") sections[name] = `<${name}>\n${content}\n`; } return sections; } /** * The complete prompt state for `input`. A forced prompt is opaque and lives in `content` * with no sections; otherwise `content` is empty and the structured sections carry the prompt. */ export function buildSystemPromptState(input: BuildSystemPromptOptions): { content: string; sections?: SystemPromptSections; } { if (input.forceSystemPrompt !== undefined) return { content: input.forceSystemPrompt }; return { content: "", sections: buildSystemPromptSections(input) }; } /** Build the system prompt text, rendered exactly as the transcript's system message replays it. */ export function buildSystemPrompt(input: BuildSystemPromptOptions): string { return getSystemMessageText({ role: "system", ...buildSystemPromptState(input), timestamp: 0 }); } /** * Diff the sections the model currently has (replayed from the transcript, so never null) * against the desired ones. Returns a `SystemMessage.sections` patch, or undefined when * nothing changed. */ export function diffSystemPromptSections( previous: Record, current: SystemPromptSections, ): Record | undefined { const patch: Record = {}; for (const [name, text] of Object.entries(current)) { if (previous[name] !== text) patch[name] = text; } for (const name of Object.keys(previous)) { if (current[name] === undefined) patch[name] = null; } return Object.keys(patch).length > 0 ? patch : undefined; }