yapi-core 0.4.2

Coding agent core: sessions, compaction, settings, resources, built-in tools, system prompt, packages, extension runner and MCP. An internal library of yapi, with no stable API.
Documentation
//! The system prompt: named sections the transcript holds and later patches.
//!
//! Port of `packages/coding-agent/src/core/system-prompt.ts` in pi `v1.0.0`. The
//! preamble and the documentation section name yapi, and the section points
//! to the local docs, or to the published ones without a local copy.

use indexmap::IndexMap;
use yapi_types::system_prompt::SystemPromptOptions;

use crate::resources::format_skills;

fn rules(options: &SystemPromptOptions) -> String {
    let mut rules: Vec<String> = Vec::new();
    let mut add = |rule: &str| {
        let rule = rule.trim();
        if !rule.is_empty() && !rules.iter().any(|existing| existing == rule) {
            rules.push(rule.to_owned());
        }
    };
    let has = |name: &str| options.selected_tools.iter().any(|tool| tool == name);
    if (has("bash") || has("powershell")) && !has("grep") && !has("find") && !has("ls") {
        if has("bash") && has("powershell") {
            add(
                "Use bash or PowerShell for file operations like listing, searching, and finding files",
            );
        } else if has("powershell") {
            add("Use PowerShell for file operations like listing, searching, and finding files");
        } else {
            add("Use bash for file operations like ls, rg, find");
        }
    }
    for name in &options.selected_tools {
        for rule in options.tool_guidelines.get(name).into_iter().flatten() {
            add(rule);
        }
    }
    for rule in &options.prompt_guidelines {
        add(rule);
    }
    add("Be concise in your responses");
    add("Show file paths clearly when working with files");
    rules
        .iter()
        .map(|rule| format!("- {rule}"))
        .collect::<Vec<_>>()
        .join("\n")
}

/// pi's `docs` section, naming yapi, with where the docs are: yapi's main
/// page, and pi's docs and examples.
fn docs(docs: &crate::docs::Locations) -> String {
    format!(
        "yapi documentation (read only when the user asks about yapi itself, its SDK, extensions, themes, skills, or TUI):
- Main documentation: {}
- Additional docs: {}
- Examples: {} (extensions, custom tools, SDK)
- When reading yapi 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), codemode scripts and non-LLM models such as classifiers and image models (docs/codemode.md)
- When working on yapi topics, read the docs and examples, and follow .md cross-references before implementing
- Always read yapi .md files completely and follow links to related docs (e.g., tui.md for TUI API details)",
        docs.main, docs.pi_docs, docs.pi_examples,
    )
}

/// Whether a custom section name is valid: a lowercase letter, then lowercase
/// letters, digits, `_` or `-`, and not `preamble`.
pub fn valid_section_name(name: &str) -> bool {
    let mut chars = name.chars();
    name != "preamble"
        && chars.next().is_some_and(|c| c.is_ascii_lowercase())
        && chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-')
}

/// The prompt's sections in order, with `docs` in the documentation section.
/// `preamble` is plain text. Every other section is wrapped in a tag of its
/// name.
pub fn build_sections(
    options: &SystemPromptOptions,
    docs: &crate::docs::Locations,
) -> Result<IndexMap<String, String>, String> {
    for name in options.sections.keys() {
        if !valid_section_name(name) {
            return Err(format!("Invalid system prompt section name: {name}"));
        }
    }
    let mut raw: IndexMap<String, String> = IndexMap::new();
    match &options.custom_prompt {
        Some(custom) if !custom.is_empty() => {
            raw.insert("preamble".into(), custom.clone());
        }
        _ => {
            raw.insert(
                "preamble".into(),
                "You are an expert coding assistant operating inside yapi, a coding agent harness. You help users by reading files, executing commands, editing code, and writing new files.".into(),
            );
            let visible: Vec<String> = options
                .selected_tools
                .iter()
                .filter_map(|name| {
                    options
                        .tool_snippets
                        .get(name)
                        .filter(|snippet| !snippet.is_empty())
                        .map(|snippet| format!("- {name}: {snippet}"))
                })
                .collect();
            let tools = if visible.is_empty() {
                "(none)".to_owned()
            } else {
                visible.join("\n")
            };
            raw.insert(
                "tools".into(),
                format!("{tools}\n\nIn addition to the tools above, you may have access to other custom tools depending on the project."),
            );
            raw.insert("rules".into(), rules(options));
            raw.insert("docs".into(), self::docs(docs));
        }
    }
    if !options.append_system_prompt.is_empty() {
        raw.insert("addendum".into(), options.append_system_prompt.clone());
    }
    if !options.context_files.is_empty() {
        let mut parts = vec!["Project-specific instructions and guidelines:".to_owned()];
        for file in &options.context_files {
            parts.push(format!(
                "<project_instructions path=\"{}\">\n{}\n</project_instructions>",
                file.path.display(),
                file.content
            ));
        }
        raw.insert("project_context".into(), parts.join("\n\n"));
    }
    let read_tool = ["read", "bash"].into_iter().find(|tool| {
        options
            .selected_tools
            .iter()
            .any(|selected| selected == tool)
    });
    if let Some(read_tool) = read_tool
        && !options.skills.is_empty()
    {
        let skills = format_skills(&options.skills, read_tool).trim().to_owned();
        if !skills.is_empty() {
            raw.insert("skills".into(), skills);
        }
    }
    raw.insert(
        "cwd".into(),
        options.cwd.to_string_lossy().replace('\\', "/"),
    );
    for (name, content) in &options.sections {
        if !content.is_empty() {
            raw.insert(name.clone(), content.clone());
        }
    }
    let mut sections = IndexMap::new();
    for (name, content) in raw {
        if name == "preamble" {
            sections.insert(name, content);
        } else {
            let wrapped = format!("<{name}>\n{content}\n</{name}>");
            sections.insert(name, wrapped);
        }
    }
    Ok(sections)
}

/// The section patch from what the model has to what it should have: changed or
/// new sections, and `None` for removed ones. `None` when nothing changed.
pub fn diff_sections(
    previous: &IndexMap<String, Option<String>>,
    current: &IndexMap<String, String>,
) -> Option<IndexMap<String, Option<String>>> {
    let mut patch = IndexMap::new();
    for (name, text) in current {
        if previous.get(name).and_then(Option::as_ref) != Some(text) {
            patch.insert(name.clone(), Some(text.clone()));
        }
    }
    for name in previous.keys() {
        if !current.contains_key(name) {
            patch.insert(name.clone(), None);
        }
    }
    (!patch.is_empty()).then_some(patch)
}

/// The prompt as plain text, as the transcript renders it.
pub fn render(sections: &IndexMap<String, String>) -> String {
    sections
        .values()
        .filter(|text| !text.is_empty())
        .cloned()
        .collect::<Vec<_>>()
        .join("\n\n")
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn builds_pi_sections() {
        let mut options = SystemPromptOptions {
            selected_tools: vec!["read".into(), "bash".into()],
            cwd: "/work".into(),
            ..SystemPromptOptions::default()
        };
        let docs = crate::docs::Locations {
            main: "/agent/docs/index.md".into(),
            pi_docs: "/agent/docs/pi/docs".into(),
            pi_examples: "/agent/docs/pi/examples".into(),
        };
        options
            .tool_snippets
            .insert("read".into(), "Read file contents".into());
        options.tool_snippets.insert(
            "bash".into(),
            "Execute bash commands (ls, grep, find, etc.)".into(),
        );
        options.tool_guidelines.insert(
            "read".into(),
            vec!["Use read to examine files instead of cat or sed.".into()],
        );
        let sections = build_sections(&options, &docs).unwrap();
        assert_eq!(
            sections.keys().cloned().collect::<Vec<_>>(),
            ["preamble", "tools", "rules", "docs", "cwd"]
        );
        assert_eq!(
            sections["rules"],
            "<rules>\n- Use bash for file operations like ls, rg, find\n- Use read to examine files instead of cat or sed.\n- Be concise in your responses\n- Show file paths clearly when working with files\n</rules>"
        );
        assert!(sections["docs"].starts_with(
            "<docs>\nyapi documentation (read only when the user asks about yapi itself, its SDK, extensions, themes, skills, or TUI):\n- Main documentation: /agent/docs/index.md\n- Additional docs: /agent/docs/pi/docs\n- Examples: /agent/docs/pi/examples (extensions, custom tools, SDK)\n"
        ));
        assert_eq!(sections["cwd"], "<cwd>\n/work\n</cwd>");
    }
}