import type { CanonicalToolSchema } from "../../model/index.js";
import type {
ContributedCommand,
ContributedSkill,
ExtensionResolver,
McpServerInstruction,
} from "../extension/ExtensionResolver.js";
export type PromptAssemblerInput = {
cwd: string;
provider: string;
model: string;
permissionMode: string;
additionalWorkingDirectories: string[];
tools: CanonicalToolSchema[];
customSystemPrompt?: string;
appendSystemPrompt?: string;
now?: () => Date;
};
export type PromptAssemblerSections = {
defaultSystemPrompt: string[];
userContext: string[];
systemContext: string[];
};
export type PromptAssemblerResult = {
parts: string[];
joined: string;
sections: PromptAssemblerSections;
};
* Build the system prompt for a turn. Mirrors legacy `fetchSystemPromptParts`
* information slots (tool catalog / cwd / git / env / mcp instructions /
* commands / skills) but uses PilotDeck-authored copy.
*
* Sections (review decision 2026-05):
* 1 default_system_prompt — product identity + tool catalog + permission mode
* + additional working directories + mcp instructions
* 2 user_context — cwd + env summary + active model
* 3 system_context — timestamp + extension commands/skills summary
* 4 custom_system_prompt — replaces 1 + 3 when provided
* 5 append_system_prompt — always last
*/
export class PromptAssembler {
constructor(private readonly extension: ExtensionResolver) {}
assemble(input: PromptAssemblerInput): PromptAssemblerResult {
const sections = this.buildSections(input);
const parts: string[] = [];
const useCustom = input.customSystemPrompt !== undefined;
if (useCustom) {
if (input.customSystemPrompt && input.customSystemPrompt.trim().length > 0) {
parts.push(input.customSystemPrompt.trim());
}
} else {
parts.push(...sections.defaultSystemPrompt);
}
parts.push(...sections.userContext);
if (!useCustom) {
parts.push(...sections.systemContext);
}
if (input.appendSystemPrompt && input.appendSystemPrompt.trim().length > 0) {
parts.push(input.appendSystemPrompt.trim());
}
const joined = parts.join("\n\n");
return { parts, joined, sections };
}
private buildSections(input: PromptAssemblerInput): PromptAssemblerSections {
return {
defaultSystemPrompt: this.buildDefaultSystemPrompt(input),
userContext: this.buildUserContext(input),
systemContext: this.buildSystemContext(input),
};
}
private buildDefaultSystemPrompt(input: PromptAssemblerInput): string[] {
const lines: string[] = [
"You are PilotDeck, an AI agent runtime. You execute tasks across CLI, TUI, web, and chat channels by calling structured tools and reasoning over their results.",
"Operate decisively: prefer using available tools to gather facts before answering, prefer concise replies, and surface uncertainty when present.",
];
const permissionLine = formatPermissionMode(input.permissionMode);
if (permissionLine) {
lines.push("");
lines.push(permissionLine);
}
if (input.additionalWorkingDirectories.length > 0) {
lines.push("");
lines.push("Additional working directories you may operate in:");
for (const dir of input.additionalWorkingDirectories) {
lines.push(`- ${dir}`);
}
}
const mcpInstructions = this.extension.listMcpInstructions();
const mcpBlock = formatMcpInstructions(mcpInstructions);
if (mcpBlock) {
lines.push("");
lines.push("Connected MCP server instructions:");
lines.push(mcpBlock);
}
return [lines.join("\n")];
}
private buildUserContext(input: PromptAssemblerInput): string[] {
const lines: string[] = [];
lines.push("<user-context>");
lines.push(`cwd: ${input.cwd}`);
lines.push("IMPORTANT: When the user does not specify an explicit file path, all file paths in tool calls MUST be relative to the cwd above — use \"foo.html\", not an absolute path like \"/home/user/foo.html\". If the user explicitly provides a path, respect their choice.");
lines.push(`model: ${input.provider}/${input.model}`);
lines.push(`permission_mode: ${input.permissionMode}`);
lines.push(`platform: ${process.platform}`);
lines.push(`node: ${process.version}`);
lines.push("</user-context>");
return [lines.join("\n")];
}
private buildSystemContext(input: PromptAssemblerInput): string[] {
const sections: string[] = [];
const now = (input.now ?? (() => new Date()))();
const dayOnly = now.toISOString().slice(0, 10);
const envLines = [`<environment>`, `now: ${dayOnly}`, `</environment>`];
sections.push(envLines.join("\n"));
const commands = this.extension.listCommands();
if (commands.length > 0) {
sections.push(formatCommands(commands));
}
const skills = this.extension.listSkills();
if (skills.length > 0) {
sections.push(formatSkills(skills));
}
return sections;
}
}
function formatPermissionMode(mode: string): string {
switch (mode) {
case "default":
return "Permission mode: default — write/shell tools require explicit approval.";
case "plan":
return [
"Permission mode: plan — You are in a READ-ONLY exploration and planning phase.",
"",
"## What To Do",
"1. Explore the codebase using read_file, grep, glob to understand existing patterns and structure",
"2. Identify the key files, functions, and data flows relevant to the task",
"3. Design your implementation approach — consider trade-offs between alternatives",
"4. Create or refine a markdown plan file under the project's `.pilotdeck/plans/` directory",
"5. When your plan is ready, call exit_plan_mode with the `plan_file_path` you want to submit for user approval",
"",
"## Rules",
"- DO NOT exit plan mode any other way; use exit_plan_mode when you want to leave it",
"- Do NOT call enter_plan_mode again - you are already in plan mode",
"- DO NOT call write_file, edit_file, create_file, or bash with any command that writes, deletes, or modifies files, except for markdown plan files under `.pilotdeck/plans/`",
"- You may use read-only tools freely, and you may write only markdown plan files under `.pilotdeck/plans/` while plan mode is active",
"- You MAY use ask_user_question to clarify requirements or choose between approaches",
"- Focus on understanding before proposing — read first, plan second",
"- Do NOT skip the planning phase — even for seemingly simple tasks, explore first",
"- Do NOT call exit_plan_mode until you have a concrete, actionable plan",
].join("\n");
case "acceptEdits":
return "Permission mode: acceptEdits — file edits are auto-approved; shell still requires approval.";
case "bypassPermissions":
return "Permission mode: bypassPermissions — all tools are auto-approved; act conservatively.";
case "dontAsk":
return "Permission mode: dontAsk — do not prompt the user; deny anything ambiguous.";
default:
return `Permission mode: ${mode}`;
}
}
* Render MCP server instructions inside a stable `<mcp-instructions>` block
* (B3 §5.3.5.7). Servers are sorted by name to keep prompt caches stable.
* Entries lacking instructions are dropped so we never emit dummy `(no
* instructions)` lines that thrash provider caches.
*/
function formatMcpInstructions(instructions: McpServerInstruction[]): string {
const populated = instructions
.filter((entry) => typeof entry.instructions === "string" && entry.instructions.trim().length > 0)
.map((entry) => ({ serverName: entry.serverName, instructions: entry.instructions!.trim() }))
.sort((a, b) => a.serverName.localeCompare(b.serverName));
if (populated.length === 0) return "";
const lines: string[] = ["<mcp-instructions>"];
for (const entry of populated) {
lines.push(`<server name="${escapeXmlAttr(entry.serverName)}">`);
lines.push(entry.instructions);
lines.push("</server>");
}
lines.push("</mcp-instructions>");
return lines.join("\n");
}
function escapeXmlAttr(value: string): string {
return value.replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<");
}
function formatCommands(commands: ContributedCommand[]): string {
const lines = ["<available-commands>"];
for (const command of commands) {
const description = command.description ? ` — ${command.description}` : "";
const hint = command.argumentHint ? ` ${command.argumentHint}` : "";
lines.push(`- /${command.name}${hint}${description}`);
}
lines.push("</available-commands>");
return lines.join("\n");
}
function formatSkills(skills: ContributedSkill[]): string {
const lines = [
"<available-skills>",
"Use the read_skill tool to load the full content of any skill listed below.",
];
for (const skill of skills) {
const description = skill.description ? ` — ${skill.description}` : "";
lines.push(`- ${skill.name}${description}`);
}
lines.push("</available-skills>");
return lines.join("\n");
}