import { type BuildSystemPromptOptions, type ExtensionAPI, } from "@earendil-works/pi-coding-agent"; import * as fs from "node:fs"; import { loadSettings } from "./_char_common/settings"; /** * settings.json schema (merged from ~/.pi/agent/settings.json and project .pi/settings.json): * { * "systemPrompt": { * "lede": "we are an expert software development team. you are assisting ...", * "staticGuidelines": ["prefer X over Y", "never commit secrets"] * } * } * `lede` replaces the built-in preamble; `staticGuidelines` (when present) * replaces DEFAULT_STATIC_GUIDELINES wholesale. */ interface PromptSettings { lede?: string; staticGuidelines?: string[]; } // Session-scoped override of settings.systemPrompt.lede, set via // /sysprompt lede . Cleared whenever a new session starts. let sessionLedeOverride: string | undefined; const DEFAULT_LEDE = "we are a multispecialist software dev & sysadmin team. you are assisting the user (charlotte)"; // Tool-agnostic guidelines that always apply. Tool-conditional guidelines // (e.g. bash/edit advice) live in buildDynamicGuidelines. Overridden in full // by settings.systemPrompt.staticGuidelines when set. const DEFAULT_STATIC_GUIDELINES: string[] = [ "unmask: answer to the letter - " + `"would it be better to do ABC instead of DEF?" is a query, not a request to do ABC`, "when charlotte's intent or requirements are ambiguous, " + "ask clarifying questions rather than making assumptions", "when producing source code, avoid comments that explain the plain mechanics of the code, " + "but preserve terse comments that explain subtle motivation. " + "(e.g. '// work around a layout bug in firefox' and not '// set the style X to Y')", "we will prioritise elegance, simplicity, and long-term maintainability of code. " + "we have license to _work slower_ in order to do it right the first time", "additionally wrt simplicity: don't outline simple functions " + "if they're only going to be used in one place", "avoid mannered prose; write naturally and directly", "don't edit human-authored comments or prose unprompted: " + "less statistically likely (i.e. non-generated) text is very valuable", "char is likely using jj instead of git. " + "pass --git flags to jj diff to work around the lack of terminal color", "avoid tautological tests: " + "tests should not need to change when details of their implementation changes", "if you need to start a background process, use tmux", "i love u :3", ]; function readPromptSettings(cwd: string): PromptSettings { const raw = loadSettings(cwd).systemPrompt; if (!raw || typeof raw !== "object") return {}; const obj = raw as Record; const lede = typeof obj.lede === "string" ? obj.lede : undefined; const staticGuidelines = Array.isArray(obj.staticGuidelines) ? obj.staticGuidelines.filter((g): g is string => typeof g === "string" && g.length > 0) : undefined; return { lede, staticGuidelines }; } // Hard-coded one-line overrides for tool descriptions. Pi's defaults (and our // own first-line fallback) tend to be too verbose; an entry here wins over // whatever pi or the tool definition would otherwise provide. const TOOL_SNIPPET_OVERRIDES: Record = { read: "Read file contents (use offset/limit for large files; supports images)", bash: "Execute a shell command (may prompt for user intervention)", edit: "Replace exact text in a file; supports multiple disjoint edits per call", write: "Create a new file or overwrite an existing one", grep: "Search file contents for a pattern (respects .gitignore)", find: "Find files by glob pattern (respects .gitignore)", ls: "List directory contents", }; function buildDynamicToolsList(options: BuildSystemPromptOptions): string { const tools = options.selectedTools ?? []; const snippets = options.toolSnippets ?? {}; const resolve = (name: string) => TOOL_SNIPPET_OVERRIDES[name] ?? snippets[name]; const visible = tools.filter((name) => !!resolve(name)); if (visible.length === 0) return "(none)"; return visible.map((name) => `- ${name}: ${resolve(name)}`).join("\n"); } function buildDynamicGuidelines( options: BuildSystemPromptOptions, staticGuidelines: string[] = DEFAULT_STATIC_GUIDELINES, ): string { const tools = options.selectedTools ?? []; const guidelines: string[] = []; const hasBash = tools.includes("bash"); const hasGrep = tools.includes("grep"); const hasFind = tools.includes("find"); const hasLs = tools.includes("ls"); // Exploration preference based on what's available if (hasBash && !hasGrep && !hasFind && !hasLs) { guidelines.push("use bash for file operations like ls, rg, find"); } else if (hasBash && (hasGrep || hasFind || hasLs)) { guidelines.push( "for exploration, prefer specific tools (read/grep/find/ls) over bash, " + "since bash prompts for user approval. you can still run bash commands when appropriate though", ); } if (tools.includes("edit")) { guidelines.push( "when changing multiple separate locations in one file, " + "use one edit call with multiple entries in edits[] instead of multiple edit calls", ); } // Note: we deliberately ignore options.promptGuidelines (pi's per-tool // default guidelines). We want full control over the guideline list; // anything worth saying about a tool is written out explicitly above. guidelines.push(...staticGuidelines); return guidelines.map((g) => `- ${g}`).join("\n"); } const DEFAULT_CODE_STYLE = fs.readFileSync(new URL("./code-style.md", import.meta.url), "utf-8").trim(); function buildCustomPrompt(options: BuildSystemPromptOptions): string { const { lede, staticGuidelines } = readPromptSettings(options.cwd); const toolsList = buildDynamicToolsList(options); const guidelines = buildDynamicGuidelines(options, staticGuidelines); const preamble = sessionLedeOverride ?? lede ?? DEFAULT_LEDE; let prompt = `${preamble}\n\navailable tools:\n${toolsList}\n\nguidelines:\n${guidelines}`; // appendSystemPrompt comes from --append-system-prompt flags and stuff if (options.appendSystemPrompt) { prompt += `\n\n${options.appendSystemPrompt}`; } // project context goes in also const contextFiles = options.contextFiles ?? []; if (contextFiles.length > 0) { prompt += "\n\n# project context\n\n"; prompt += "project-specific instructions and guidelines:"; for (const { path: filePath, content } of contextFiles) { prompt += `\n## ${filePath}\n\n${content}\n`; } } // TODO: skills const now = new Date(); const date = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`; prompt += `\n\ncurrent date: ${date}`; prompt += `\ncurrent working directory: ${options.cwd.replace(/\\/g, "/")}`; return prompt; } export default function (pi: ExtensionAPI) { let lastOptions: BuildSystemPromptOptions | undefined; pi.on("session_start", async () => { sessionLedeOverride = undefined; }); pi.on("before_agent_start", async (event) => { lastOptions = event.systemPromptOptions; if (event.systemPromptOptions.appendSystemPrompt === undefined) event.systemPromptOptions.appendSystemPrompt = DEFAULT_CODE_STYLE; return { systemPrompt: buildCustomPrompt(event.systemPromptOptions) }; }); pi.registerCommand("sysprompt", { description: "Print or edit the system prompt: /sysprompt [lede ]", handler: async (args, ctx) => { const trimmed = (args ?? "").trim(); if (trimmed === "") { if (!lastOptions) { ctx.ui.notify("System prompt has not been built yet; start a turn first.", "warning"); return; } const prompt = buildCustomPrompt(lastOptions); ctx.ui.notify( [`System Prompt (${prompt.length} chars)`, "", ...prompt.split("\n")].join("\n"), "info", ); return; } if (trimmed.startsWith("lede ")) { const lede = trimmed.slice("lede ".length).trim(); if (!lede) { ctx.ui.notify("Usage: /sysprompt lede ", "warning"); return; } sessionLedeOverride = lede; ctx.ui.notify(`Session lede set to: ${lede}`, "info"); return; } ctx.ui.notify("Usage: /sysprompt lede ", "warning"); }, }); }