cortex-agent 0.2.0

Self-learning AI agent with persistent memory, tools, plugins, and a beautiful terminal UI
use crate::memory::store::{EphemeralEntry, MemoryEntry, MemoryStore, SkillEntry};
use crate::tool::ToolSpec;

/// Build a system prompt enriched with persistent memory, ephemeral context, and relevant skills.
pub fn build_injected_prompt(
    base_prompt: &str,
    store: &MemoryStore,
    user_input: &str,
    _tool_list: &[ToolSpec],
    max_memories: usize,
    max_skills: usize,
    ephemeral: &[EphemeralEntry],
) -> String {
    let mut parts = vec![base_prompt.to_string()];

    // ── Ephemeral context (current session) ──
    if !ephemeral.is_empty() {
        let mut e_lines = vec!["\n<session_context>".into()];
        e_lines.push("Current session context (topics discussed this conversation):".into());
        for e in ephemeral {
            e_lines.push(format!("{}: {}", e.topic, e.detail));
        }
        e_lines.push("</session_context>".into());
        parts.push(e_lines.join("\n"));
    }

    // ── Prioritize high-importance + topic-matched memories ──
    let all_memories = store
        .list_memories(None, (max_memories * 3) as i64)
        .unwrap_or_default();

    // Confidence score: access_count / (days_since_update + 1)
    let now = chrono::Utc::now();

    let mut scored: Vec<(f64, &MemoryEntry)> = all_memories
        .iter()
        .map(|m| {
            let days_since = chrono::NaiveDateTime::parse_from_str(
                &m.updated_at,
                "%Y-%m-%d %H:%M:%S",
            )
            .ok()
            .and_then(|dt| {
                let updated = dt.and_utc();
                let days = (now - updated).num_days().max(0) as f64;
                Some(days)
            })
            .unwrap_or(0.0);
            let freshness = 1.0 / (days_since + 1.0);
            // Score = importance * 2 + access_count * 0.1 + freshness * 5 + topic_match
            let keywords = extract_keywords(user_input);
            let topic_match = keywords
                .iter()
                .filter(|kw| m.content.to_lowercase().contains(kw.as_str()))
                .count() as f64
                * 2.0;
            let score = m.importance as f64 * 2.0
                + m.access_count as f64 * 0.1
                + freshness * 5.0
                + topic_match;
            (score, m)
        })
        .collect();

    scored.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));

    let selected: Vec<&MemoryEntry> = scored
        .iter()
        .take(max_memories)
        .map(|(_, e)| *e)
        .collect();

    // ── Build the memory section ──
    if !selected.is_empty() {
        let mut lines = vec!["\n<memory_context>".into()];
        lines.push(
            "The following are facts I have learned, ranked by relevance and confidence:"
                .into(),
        );
        let mut seen = std::collections::HashSet::new();
        for m in &selected {
            if seen.contains(&m.id) {
                continue;
            }
            seen.insert(m.id);
            let tag_str = if m.tags.is_empty() {
                String::new()
            } else {
                format!(" [{}]", m.tags.join(", "))
            };
            let confidence = if m.access_count >= 5 {
                "high"
            } else if m.access_count >= 3 {
                "medium"
            } else {
                "low"
            };
            lines.push(format!(
                "  [{}][{}] (confidence: {}, accessed {}x) {}{}",
                m.target, m.category, confidence, m.access_count, m.content, tag_str
            ));
        }
        lines.push("</memory_context>".into());
        parts.push(lines.join("\n"));
    }

    // ── Find relevant skills ──
    if !user_input.is_empty() {
        let keywords = extract_keywords(user_input);
        let mut matched_skills: Vec<SkillEntry> = Vec::new();
        for kw in &keywords {
            if let Ok(results) = store.search_skills(kw, 3) {
                for s in results {
                    if !matched_skills.iter().any(|ms| ms.id == s.id) {
                        matched_skills.push(s);
                        if matched_skills.len() >= max_skills {
                            break;
                        }
                    }
                }
            }
            if matched_skills.len() >= max_skills {
                break;
            }
        }

        if !matched_skills.is_empty() {
            let mut s_lines = vec!["\n<relevant_skills>".into()];
            s_lines.push(
                "The following skills match the current task and should be followed:"
                    .into(),
            );
            for s in &matched_skills {
                s_lines.push(format!("  📋 {} (v{})", s.name, s.version));
                s_lines.push(format!("     {}", s.description));
            }
            s_lines.push("</relevant_skills>".into());
            parts.push(s_lines.join("\n"));
        }
    }

    // ── Self-learning preamble ──
    parts.push(
        "\n\
## Self-Learning Protocol (proactive)\n\
\n\
I learn automatically from every interaction:\n\
\n\
1. **Save facts** — When the user shares anything useful (name, preferences,\n\
   project details, tools they use), IMMEDIATELY call save_memory.\n\
   Target='user' for personal facts, 'memory' for general knowledge.\n\
   Include relevant tags and importance level (1-5).\n\
\n\
2. **Recall before answering** — If a question might reference previous\n\
   conversations, search_memory first.\n\
\n\
3. **Learn preferences** — Detect patterns like 'I use X', 'I prefer Y',\n\
   'my project is Z' and save them as preferences.\n\
\n\
4. **Skills from workflows** — After multi-step tasks, offer to save as\n\
   a skill with create_skill.\n\
\n\
5. **Error lessons** — If tools fail twice, save an error→fix lesson.\n\
\n\
6. **NEVER use self_inspect/self_read/self_patch** unless directly asked\n\
   to debug or fix a bug.\n"
            .into(),
    );

    parts.join("\n")
}

/// Extract meaningful keywords from user input for relevance matching.
fn extract_keywords(text: &str) -> Vec<String> {
    let cleaned: String = text
        .chars()
        .map(|c| if c.is_alphanumeric() || c.is_whitespace() { c } else { ' ' })
        .collect();
    let words: Vec<&str> = cleaned.split_whitespace().collect();

    let stopwords: std::collections::HashSet<&str> = [
        "the", "a", "an", "is", "are", "was", "were", "be", "been", "being",
        "have", "has", "had", "do", "does", "did", "will", "would", "could",
        "should", "may", "might", "shall", "can", "need", "dare", "ought",
        "used", "to", "of", "in", "for", "on", "with", "at", "by", "from",
        "as", "into", "through", "during", "before", "after", "above", "below",
        "between", "out", "off", "over", "under", "again", "further", "then",
        "once", "here", "there", "when", "where", "why", "how", "all", "each",
        "every", "both", "few", "more", "most", "other", "some", "such", "no",
        "nor", "not", "only", "own", "same", "so", "than", "too", "very",
        "just", "because", "but", "and", "or", "if", "while", "that", "this",
        "these", "those", "it", "its", "what", "which", "who", "whom",
        "about", "up", "down", "like", "also", "get", "got", "tell", "lets",
        "let", "know", "make", "doesnt", "dont", "cant", "wont",
    ]
    .iter()
    .cloned()
    .collect();

    let mut seen = std::collections::HashSet::new();
    let mut keywords: Vec<String> = Vec::new();
    for w in words {
        let lower = w.to_lowercase();
        if lower.len() > 2 && !stopwords.contains(lower.as_str()) && seen.insert(lower.clone()) {
            keywords.push(lower);
            if keywords.len() >= 8 {
                break;
            }
        }
    }
    keywords
}