daat-locus 0.4.0

A long-running local agent runtime with memory, workflows, apps, and sleep-time self-improvement.
use crate::{context::Context, preturn_state::PreTurnState};

use super::{
    prompt_doc::PromptDocument,
    prompt_parts::{
        AfterClaimContextInput, AfterClaimContextPart, AfterClaimInputPart, PreTurnContextPart,
        PreTurnPlanPart, PreTurnProjectInstructionsPart, PreTurnSensoryPart,
    },
    prompts::{SYSTEM_CORE, build_app_docs_prompt},
    turn_compile::{
        PromptPersonaSpec, load_or_create_prompt_persona_spec_sync, resolve_prompt_persona_language,
    },
};

const WORKSPACE_PATH_PLACEHOLDER: &str = "{{workspace_path}}";
const PERSONA_SECTION_PLACEHOLDER: &str = "{{persona_section}}";
const PERSONA_NAME_PLACEHOLDER: &str = "{{name}}";
const SKILLS_SECTION_PLACEHOLDER: &str = "{{skills_section}}";
const APP_DOCS_SECTION_PLACEHOLDER: &str = "{{app_docs_section}}";
const COMPILED_ADDITIONS_SECTION_PLACEHOLDER: &str = "{{compiled_additions_section}}";

pub struct PreTurnContextAssembler {
    parts: Vec<Box<dyn PreTurnContextPart>>,
}

pub struct AfterClaimContextAssembler {
    parts: Vec<Box<dyn AfterClaimContextPart>>,
}

struct RuntimeSystemPromptSections {
    workspace_path: String,
    persona_section: String,
    skills_section: String,
    app_docs_section: String,
    compiled_additions_section: String,
}

pub fn runtime_system_prompt_text(ctx: &Context) -> String {
    let configured_locale = ctx.config.locale.as_str();
    let persona = load_or_create_prompt_persona_spec_sync(configured_locale);
    render_runtime_system_prompt(&RuntimeSystemPromptSections {
        workspace_path: format!(
            "Your absolute workspace path is `{}`.",
            ctx.execution_cwd.display()
        ),
        persona_section: render_persona_section(
            &persona,
            &resolve_prompt_persona_language(&persona, configured_locale),
            configured_locale,
        ),
        skills_section: ctx.openskills.render_prompt_block().unwrap_or_default(),
        app_docs_section: render_app_docs_section(ctx),
        compiled_additions_section: render_compiled_additions_section(
            ctx.compiled_prompts.runtime_system_additions(),
        ),
    })
}

#[cfg(test)]
pub fn runtime_system_prompt_text_from_additions(additions: &[String]) -> String {
    let persona = crate::reasoning::turn_compile::load_prompt_persona_spec_sync();
    render_runtime_system_prompt(&RuntimeSystemPromptSections {
        workspace_path:
            "The absolute runtime workspace path is injected into the real system prompt."
                .to_string(),
        persona_section: render_persona_section(
            &persona,
            persona.language.trim(),
            "injected in live runtime prompt",
        ),
        skills_section: String::new(),
        app_docs_section: String::new(),
        compiled_additions_section: render_compiled_additions_section(additions),
    })
}

fn render_runtime_system_prompt(sections: &RuntimeSystemPromptSections) -> String {
    let rendered = SYSTEM_CORE
        .replace(WORKSPACE_PATH_PLACEHOLDER, sections.workspace_path.trim())
        .replace(PERSONA_SECTION_PLACEHOLDER, sections.persona_section.trim())
        .replace(SKILLS_SECTION_PLACEHOLDER, sections.skills_section.trim())
        .replace(
            APP_DOCS_SECTION_PLACEHOLDER,
            sections.app_docs_section.trim(),
        )
        .replace(
            COMPILED_ADDITIONS_SECTION_PLACEHOLDER,
            sections.compiled_additions_section.trim(),
        );
    compact_blank_lines(&rendered)
}

fn render_persona_section(
    persona: &PromptPersonaSpec,
    language: &str,
    configured_locale: &str,
) -> String {
    let target_language = render_target_language(language);
    let identity_summary = render_persona_template(persona.identity_summary.trim(), persona);
    format!(
        "# Persona\n\nname: {}\nlanguage: {}\nconfigured_locale: {}\n\n{}\n\n## Communication Language\n\n- Use {} for all user-visible assistant prose, including intermediate progress messages, status updates, and final replies.\n- If the user explicitly requests another language, follow that requested language until they change it.\n- Preserve code, commands, file paths, identifiers, tool names, schema keys, logs, errors, direct quotes, and source text in their original language.\n- Do not switch to English merely because tool names, system text, retrieved sources, or internal runtime context are English.",
        persona.name.trim(),
        language.trim(),
        configured_locale.trim(),
        identity_summary.trim(),
        target_language
    )
}

fn render_persona_template(template: &str, persona: &PromptPersonaSpec) -> String {
    template.replace(PERSONA_NAME_PLACEHOLDER, persona.name.trim())
}

fn render_target_language(language: &str) -> String {
    match language.trim() {
        "en-US" => "English (en-US)".to_string(),
        "zh-CN" => "Simplified Chinese (zh-CN)".to_string(),
        "configured-locale" | "" => "the configured locale".to_string(),
        language => language.to_string(),
    }
}

fn render_app_docs_section(ctx: &Context) -> String {
    let mut sections = Vec::new();
    for app_id in ctx.apps.app_ids() {
        if let Some(docs) = ctx.apps.docs(&app_id) {
            sections.push(format!("## {app_id}\n\n{}", build_app_docs_prompt(&docs)));
        }
    }
    if sections.is_empty() {
        String::new()
    } else {
        format!("# App Documentation\n\n{}", sections.join("\n\n"))
    }
}

fn render_compiled_additions_section(additions: &[String]) -> String {
    let items = additions
        .iter()
        .map(|line| line.trim())
        .filter(|line| !line.is_empty())
        .map(|line| format!("- {line}"))
        .collect::<Vec<_>>();
    if items.is_empty() {
        String::new()
    } else {
        format!("# Runtime Prompt Additions\n\n{}", items.join("\n"))
    }
}

fn compact_blank_lines(input: &str) -> String {
    let mut lines = Vec::new();
    let mut blank_count = 0usize;
    for line in input.lines() {
        if line.trim().is_empty() {
            blank_count += 1;
            if blank_count <= 2 {
                lines.push(String::new());
            }
        } else {
            blank_count = 0;
            lines.push(line.trim_end().to_string());
        }
    }
    lines.join("\n").trim().to_string()
}

impl PreTurnContextAssembler {
    pub fn new(parts: Vec<Box<dyn PreTurnContextPart>>) -> Self {
        Self { parts }
    }

    pub fn default_runtime() -> Self {
        Self::new(vec![
            Box::new(PreTurnSensoryPart),
            Box::new(PreTurnProjectInstructionsPart),
            Box::new(PreTurnPlanPart),
        ])
    }

    pub fn assemble(&self, ctx: &mut Context, state: &PreTurnState) -> PromptDocument {
        PromptDocument::new(
            self.parts
                .iter()
                .filter_map(|part| part.build(ctx, state))
                .collect(),
        )
    }
}

impl AfterClaimContextAssembler {
    pub fn new(parts: Vec<Box<dyn AfterClaimContextPart>>) -> Self {
        Self { parts }
    }

    pub fn default_runtime() -> Self {
        Self::new(vec![Box::new(AfterClaimInputPart)])
    }

    pub fn assemble(&self, ctx: &Context, input: &AfterClaimContextInput) -> PromptDocument {
        PromptDocument::new(
            self.parts
                .iter()
                .filter_map(|part| part.build(ctx, input))
                .collect(),
        )
    }
}

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

    #[test]
    fn runtime_system_prompt_from_additions_is_single_markdown_document() {
        let text = runtime_system_prompt_text_from_additions(&["extra rule".to_string()]);

        assert!(text.starts_with("# Runtime Identity"));
        assert!(text.contains("# Event Handling"));
        assert!(text.contains("# Planning"));
        assert!(text.contains("# Tool Selection"));
        assert!(text.contains("# Runtime Prompt Additions\n\n- extra rule"));
        assert!(text.contains("for all user-visible assistant prose"));
        assert!(!text.contains("<core>"));
        assert!(!text.contains("<event>"));
        assert!(!text.contains("{{"));
    }

    #[test]
    fn persona_section_has_explicit_language_rules() {
        let persona = PromptPersonaSpec {
            name: "Daat Locus".to_string(),
            language: "configured-locale".to_string(),
            identity_summary: "{{name}} follows the configured locale.".to_string(),
        };
        let text = render_persona_section(&persona, "zh-CN", "zh-CN");

        assert!(text.contains("Daat Locus follows the configured locale."));
        assert!(!text.contains("{{name}}"));
        assert!(text.contains("## Communication Language"));
        assert!(text.contains("Simplified Chinese (zh-CN)"));
        assert!(text.contains("intermediate progress messages"));
        assert!(text.contains("final replies"));
        assert!(text.contains("Preserve code, commands, file paths"));
        assert!(text.contains("Do not switch to English"));
    }

    #[test]
    fn target_language_placeholder_renders_as_configured_locale() {
        assert_eq!(
            render_target_language("configured-locale"),
            "the configured locale"
        );
    }

    #[test]
    fn persona_template_only_renders_at_prompt_boundary() {
        let persona = PromptPersonaSpec {
            name: "Custom Agent".to_string(),
            language: "configured-locale".to_string(),
            identity_summary: "{{name}} answers directly.".to_string(),
        };

        assert_eq!(
            render_persona_template(&persona.identity_summary, &persona),
            "Custom Agent answers directly."
        );
    }
}