lean-ctx-engine 3.12.2

LeanCTX | OSS library: the open-source runtime for the Context Gateway for AI agents behind the lean-ctx CLI, MCP server and proxy integrations.
Documentation
// SPDX-License-Identifier: Apache-2.0
//! Project knowledge → a section of `AGENTS.md` (K1 export).
//!
//! The section lives between two markers, so an export replaces exactly its
//! own earlier output and nothing the people of the project wrote around it.
//! It lists the current, public facts of the categories an agent acts on —
//! conventions, procedures, decisions, architecture, gotchas, dead ends and
//! experiences — one bullet per fact, under one heading per category. The
//! headings are the ones `core::import::markdown` maps back to these
//! categories, so importing an export yields the same `(category, value)`
//! pairs (round trip, tested).
//!
//! Only `Public` facts that are not machine-derived are written: the file is
//! committed and leaves the machine with the repository.

use super::{ProjectKnowledge, is_machine_derived};
use crate::core::sensitivity::SensitivityLevel;

/// Opening marker of the generated section.
pub const BEGIN: &str = "<!-- lean-ctx:knowledge:begin (generated by `lean-ctx knowledge export-agents-md`; edits inside are replaced) -->";
/// Closing marker of the generated section.
pub const END: &str = "<!-- lean-ctx:knowledge:end -->";

/// The categories written, their heading, and the category names a fact may
/// carry for each (singular and plural forms).
pub const SECTIONS: [(&str, &str, &[&str]); 7] = [
    (
        "convention",
        "Conventions",
        &["convention", "conventions", "style"],
    ),
    (
        "procedure",
        "Procedures",
        &["procedure", "procedures", "workflow", "workflows"],
    ),
    ("decision", "Decisions", &["decision", "decisions"]),
    ("architecture", "Architecture", &["architecture", "arch"]),
    (
        "gotcha",
        "Gotchas",
        &["gotcha", "gotchas", "known_issues", "known_bugs"],
    ),
    (
        "dead_end",
        "Dead ends",
        &["dead_end", "dead_ends", "dead-end", "dead-ends"],
    ),
    ("experience", "Experiences", &["experience", "experiences"]),
];

fn one_line(value: &str) -> String {
    value.split_whitespace().collect::<Vec<_>>().join(" ")
}

/// The generated section, markers included; empty sections are left out.
#[must_use]
pub fn render(knowledge: &ProjectKnowledge) -> String {
    render_with_hive(knowledge, false)
}

/// [`render`], with `include_hive` also a clearly marked section of the
/// team's knowledge pulled from Hive. AGENTS.md instructs agents, and
/// other members' text is data (X5) everywhere else; only a person's
/// explicit export (`--include-hive`, owner decision 22.10.) republishes
/// it, and only reviewed knowledge: pull loads the delivered head revision
/// (merged, released from quarantine), a swarm never reaches the store, and
/// a fact under review (to verify, contradicted) is left out.
#[must_use]
pub fn render_with_hive(knowledge: &ProjectKnowledge, include_hive: bool) -> String {
    let mut out = format!("{BEGIN}\n## Project knowledge (lean-ctx)\n");
    for (_, heading, categories) in SECTIONS {
        let mut values: Vec<String> = knowledge
            .facts
            .iter()
            .filter(|fact| {
                // Pulled knowledge only ever in its own section below.
                fact.is_current()
                    && fact.hive_origin().is_none()
                    && fact.sensitivity == SensitivityLevel::Public
                    && !is_machine_derived(&fact.category, &fact.key)
                    && categories.contains(&fact.category.to_lowercase().as_str())
            })
            .map(|fact| one_line(&fact.value))
            .filter(|value| !value.is_empty())
            .collect();
        values.sort();
        values.dedup();
        if values.is_empty() {
            continue;
        }
        out.push_str(&format!("\n### {heading}\n\n"));
        for value in values {
            out.push_str(&format!("- {value}\n"));
        }
    }
    if include_hive {
        let mut team: Vec<String> = knowledge
            .facts
            .iter()
            .filter(|fact| {
                fact.is_current()
                    && fact.review.is_none()
                    && fact.sensitivity == SensitivityLevel::Public
            })
            .filter_map(|fact| {
                let origin = fact.hive_origin()?;
                let text = crate::core::untrusted::body_of(&fact.value)
                    .unwrap_or_else(|| fact.value.clone());
                let text = one_line(&text);
                // `hive-repo:@ns/name@r4` → `@ns/name r4`.
                let source = super::import_tag::import_tag_source(origin)
                    .and_then(|rest| rest.rsplit_once("@r"))
                    .map_or_else(
                        || origin.to_string(),
                        |(repo, rev)| format!("{repo} r{rev}"),
                    );
                (!text.is_empty()).then(|| format!("{text} (Hive {source})"))
            })
            .collect();
        team.sort();
        team.dedup();
        if !team.is_empty() {
            out.push_str("\n### Team knowledge from Hive (reviewed)\n\n");
            for line in team {
                out.push_str(&format!("- {line}\n"));
            }
        }
    }
    out.push('\n');
    out.push_str(END);
    out.push('\n');
    out
}

/// `existing` with its generated section replaced by `section`, or with
/// `section` appended (after one blank line) when it has none yet.
#[must_use]
pub fn merge_into(existing: Option<&str>, section: &str) -> String {
    let Some(existing) = existing.filter(|text| !text.trim().is_empty()) else {
        return section.to_owned();
    };
    if let (Some(start), Some(end)) = (existing.find(BEGIN), existing.find(END))
        && start < end
    {
        let tail = &existing[end + END.len()..];
        let tail = tail.strip_prefix('\n').unwrap_or(tail);
        return format!("{}{section}{tail}", &existing[..start]);
    }
    let mut out = existing.trim_end().to_owned();
    out.push_str("\n\n");
    out.push_str(section);
    out
}

#[cfg(test)]
#[path = "agents_md_tests.rs"]
mod tests;