1
/**2
* System prompt construction and project context loading3
*/5
import { getSystemMessageText } from "@earendil-works/pi-ai";6
import { getDocsPath, getExamplesPath, getReadmePath } from "../config.ts";7
import { formatSkillsForPrompt, type Skill } from "./skills.ts";9
export interface BuildSystemPromptOptions {10
/** Custom system prompt (replaces the default prefix). */11
customPrompt?: string;12
/** Exact full prompt replacement set by a before_agent_start handler. */13
forceSystemPrompt?: string;14
/** Tools to include in prompt. Default: [read, bash, edit, write]. */15
selectedTools?: string[];16
/** Optional one-line tool snippets keyed by tool name. */17
toolSnippets?: Record<string, string>;18
/** Guideline bullets contributed by each tool, keyed by tool name. */19
toolGuidelines?: Record<string, string[]>;20
/** Additional guideline bullets appended to the default system prompt rules. */21
promptGuidelines?: string[];22
/** Text appended from user configuration before project context, skills, and cwd. */23
appendSystemPrompt?: string;24
/** Additional XML-wrapped prompt sections keyed by tag name. */25
sections?: Record<string, string>;26
/** Working directory. */27
cwd: string;28
/** Pre-loaded context files. */29
contextFiles?: Array<{ path: string; content: string }>;30
/** Pre-loaded skills. */31
skills?: Skill[];32
}34
export type NormalizedBuildSystemPromptOptions = BuildSystemPromptOptions & {35
selectedTools: string[];36
toolSnippets: Record<string, string>;37
toolGuidelines: Record<string, string[]>;38
promptGuidelines: string[];39
appendSystemPrompt: string;40
sections: Record<string, string>;41
contextFiles: Array<{ path: string; content: string }>;42
skills: Skill[];43
};45
/**46
* Ordered system prompt sections, keyed by name. `preamble` is untagged text; every other47
* section is wrapped in a tag of the same name so the model can match later updates to it.48
* These become `SystemMessage.sections` in the transcript.49
*/50
export type SystemPromptSections = Record<string, string>;52
const SYSTEM_PROMPT_SECTION_NAME = /^[a-z][a-z0-9_-]*$/;53
/** Normalize prompt input into the mutable, collection-complete shape exposed to extensions. */54
export function normalizeBuildSystemPromptOptions(input: BuildSystemPromptOptions): NormalizedBuildSystemPromptOptions {55
return {56
customPrompt: input.customPrompt,57
forceSystemPrompt: input.forceSystemPrompt,58
selectedTools: [...(input.selectedTools ?? ["read", "bash", "edit", "write"])],59
toolSnippets: { ...(input.toolSnippets ?? {}) },60
toolGuidelines: Object.fromEntries(61
Object.entries(input.toolGuidelines ?? {}).map(([name, guidelines]) => [name, [...guidelines]]),62
),63
promptGuidelines: [...(input.promptGuidelines ?? [])],64
appendSystemPrompt: input.appendSystemPrompt ?? "",65
sections: { ...(input.sections ?? {}) },66
cwd: input.cwd,67
contextFiles: (input.contextFiles ?? []).map((file) => ({ ...file })),68
skills: (input.skills ?? []).map((skill) => ({ ...skill })),69
};70
}72
function renderProjectContext(contextFiles: Array<{ path: string; content: string }>): string {73
return [74
"Project-specific instructions and guidelines:",75
...contextFiles.map(76
({ path, content }) => `<project_instructions path="${path}">\n${content}\n</project_instructions>`,77
),78
].join("\n\n");79
}81
function buildRules(82
selectedTools: string[],83
toolGuidelines: Record<string, string[]>,84
promptGuidelines: string[],85
): string {86
const rules: string[] = [];87
const seen = new Set<string>();88
const addRule = (rule: string): void => {89
const normalized = rule.trim();90
if (!normalized || seen.has(normalized)) return;91
seen.add(normalized);92
rules.push(normalized);93
};95
const hasBash = selectedTools.includes("bash");96
const hasPowerShell = selectedTools.includes("powershell");97
const hasGrep = selectedTools.includes("grep");98
const hasFind = selectedTools.includes("find");99
const hasLs = selectedTools.includes("ls");101
if ((hasBash || hasPowerShell) && !hasGrep && !hasFind && !hasLs) {102
if (hasBash && hasPowerShell) {103
addRule("Use bash or PowerShell for file operations like listing, searching, and finding files");104
} else if (hasPowerShell) {105
addRule("Use PowerShell for file operations like listing, searching, and finding files");106
} else {107
addRule("Use bash for file operations like ls, rg, find");108
}109
}111
for (const name of selectedTools) {112
for (const rule of toolGuidelines[name] ?? []) addRule(rule);113
}114
for (const rule of promptGuidelines) addRule(rule);115
addRule("Be concise in your responses");116
addRule("Show file paths clearly when working with files");117
return rules.map((rule) => `- ${rule}`).join("\n");118
}120
/** Build the ordered, independently replaceable sections of the structured system prompt. */121
export function buildSystemPromptSections(input: BuildSystemPromptOptions): SystemPromptSections {122
const options = normalizeBuildSystemPromptOptions(input);123
const {124
customPrompt,125
selectedTools,126
toolSnippets,127
toolGuidelines,128
promptGuidelines,129
appendSystemPrompt,130
sections: customSections,131
cwd,132
contextFiles,133
skills,134
} = options;136
for (const name of Object.keys(customSections)) {137
if (!SYSTEM_PROMPT_SECTION_NAME.test(name) || name === "preamble") {138
throw new Error(`Invalid system prompt section name: ${name}`);139
}140
}142
const promptSections: Record<string, string> = {};143
if (customPrompt) {144
promptSections.preamble = customPrompt;145
} else {146
promptSections.preamble =147
"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.";148
const visibleTools = selectedTools.filter((name) => !!toolSnippets[name]);149
const tools =150
visibleTools.length > 0 ? visibleTools.map((name) => `- ${name}: ${toolSnippets[name]}`).join("\n") : "(none)";151
promptSections.tools = `${tools}\n\nIn addition to the tools above, you may have access to other custom tools depending on the project.`;152
promptSections.rules = buildRules(selectedTools, toolGuidelines, promptGuidelines);153
promptSections.docs = `Pi documentation (read only when the user asks about pi itself, its SDK, extensions, themes, skills, or TUI):154
- Main documentation: ${getReadmePath()}155
- Additional docs: ${getDocsPath()}156
- Examples: ${getExamplesPath()} (extensions, custom tools, SDK)157
- When reading pi docs or examples, resolve docs/... under Additional docs and examples/... under Examples, not the current working directory158
- 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), MCP servers (docs/mcp.md), codemode scripts and non-LLM models such as classifiers and image models (docs/codemode.md)159
- When working on pi topics, read the docs and examples, and follow .md cross-references before implementing160
- Always read pi .md files completely and follow links to related docs (e.g., tui.md for TUI API details)`;161
}163
if (appendSystemPrompt) promptSections.addendum = appendSystemPrompt;164
if (contextFiles.length > 0) promptSections.project_context = renderProjectContext(contextFiles);165
const skillFileReadTool = (["read", "bash"] as const).find((tool) => selectedTools.includes(tool));166
if (skillFileReadTool && skills.length > 0) {167
const skillsPrompt = formatSkillsForPrompt(skills, skillFileReadTool).trim();168
if (skillsPrompt) promptSections.skills = skillsPrompt;169
}170
promptSections.cwd = cwd.replace(/\\/g, "/");171
for (const [name, content] of Object.entries(customSections)) {172
if (content) promptSections[name] = content;173
}175
const sections: SystemPromptSections = { preamble: promptSections.preamble };176
for (const [name, content] of Object.entries(promptSections)) {177
if (name !== "preamble") sections[name] = `<${name}>\n${content}\n</${name}>`;178
}179
return sections;180
}182
/**183
* The complete prompt state for `input`. A forced prompt is opaque and lives in `content`184
* with no sections; otherwise `content` is empty and the structured sections carry the prompt.185
*/186
export function buildSystemPromptState(input: BuildSystemPromptOptions): {187
content: string;188
sections?: SystemPromptSections;189
} {190
if (input.forceSystemPrompt !== undefined) return { content: input.forceSystemPrompt };191
return { content: "", sections: buildSystemPromptSections(input) };192
}194
/** Build the system prompt text, rendered exactly as the transcript's system message replays it. */195
export function buildSystemPrompt(input: BuildSystemPromptOptions): string {196
return getSystemMessageText({ role: "system", ...buildSystemPromptState(input), timestamp: 0 });197
}199
/**200
* Diff the sections the model currently has (replayed from the transcript, so never null)201
* against the desired ones. Returns a `SystemMessage.sections` patch, or undefined when202
* nothing changed.203
*/204
export function diffSystemPromptSections(205
previous: Record<string, string | null>,206
current: SystemPromptSections,207
): Record<string, string | null> | undefined {208
const patch: Record<string, string | null> = {};209
for (const [name, text] of Object.entries(current)) {210
if (previous[name] !== text) patch[name] = text;211
}212
for (const name of Object.keys(previous)) {213
if (current[name] === undefined) patch[name] = null;214
}215
return Object.keys(patch).length > 0 ? patch : undefined;216
}