mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-06 23:44:36 +00:00
This adds codemode and MCP support to pi. Codemode (packages/codemode) runs model-written JavaScript in a QuickJS wasm VM inside a worker. From there the script calls pi's tools as async functions, uses a per-session store, and can read the model catalog and run classifiers. The codemode tool in coding-agent is compatible with Codex's exec tool, and codemode.mode works like Codex's tool modes (on or only). Large results are written to a JSON temp file. MCP (packages/mcp) is a standalone client for stdio and streamable HTTP servers, with OAuth. The core only gets general mechanisms; codemode, tool_search and MCP are built-in extensions that use them. Other extensions can replace these built-ins. Tools declare an exposure: direct, model-only, codemode, deferred or hidden. prepareLoadout() lets an orchestrating tool change which tool declarations the model sees. ctx.executeTool() runs nested calls through validation and the tool_call/tool_result hooks. Those calls emit events with parentToolCallId and are recorded as bounded nestedCalls on the parent result, which compaction and HTML export use. Tools can also return structuredContent with an outputSchema, and can report errors with isError instead of throwing. MCP servers come from mcp.json (global, or per project after the project is trusted) or from pi.registerMcpServer(). They are managed with /mcp and pi mcp list|login|logout, and their runtimes load on first use. Closes #10040
216 lines
9.2 KiB
TypeScript
216 lines
9.2 KiB
TypeScript
/**
|
|
* 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<string, string>;
|
|
/** Guideline bullets contributed by each tool, keyed by tool name. */
|
|
toolGuidelines?: Record<string, string[]>;
|
|
/** 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<string, string>;
|
|
/** 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<string, string>;
|
|
toolGuidelines: Record<string, string[]>;
|
|
promptGuidelines: string[];
|
|
appendSystemPrompt: string;
|
|
sections: Record<string, string>;
|
|
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<string, string>;
|
|
|
|
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 }) => `<project_instructions path="${path}">\n${content}\n</project_instructions>`,
|
|
),
|
|
].join("\n\n");
|
|
}
|
|
|
|
function buildRules(
|
|
selectedTools: string[],
|
|
toolGuidelines: Record<string, string[]>,
|
|
promptGuidelines: string[],
|
|
): string {
|
|
const rules: string[] = [];
|
|
const seen = new Set<string>();
|
|
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<string, string> = {};
|
|
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), MCP servers (docs/mcp.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</${name}>`;
|
|
}
|
|
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<string, string | null>,
|
|
current: SystemPromptSections,
|
|
): Record<string, string | null> | undefined {
|
|
const patch: Record<string, string | null> = {};
|
|
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;
|
|
}
|