cel-summarizer 0.2.0

LLM-backed Summarizer implementations (Anthropic + Ollama) for cel-memory.
Documentation
//! Prompt assembly for memory summarization calls.

use cel_memory::{MemoryChunk, SummaryContext};

/// Default cap on summary length, in words.
pub(crate) const DEFAULT_MAX_WORDS: u32 = 200;

/// Build the system prompt for a summarization call.
pub(crate) fn build_system_prompt(ctx: &SummaryContext) -> String {
    let max_words = ctx.max_words.unwrap_or(DEFAULT_MAX_WORDS);
    let unit = ctx.kind_label.as_deref().unwrap_or("session");
    format!(
        "You are a memory summarizer for an AI agent.\n\
         Produce concise, high-recall summaries that the agent will later search for\n\
         the user's situation, decisions, and outcomes — not verbose paraphrases.\n\
         \n\
         Produce a {unit} summary covering:\n\
         - What the user was trying to do\n\
         - What the agent or rules actually did\n\
         - Any user corrections or surprises\n\
         - Outcome (success/failure/ongoing)\n\
         - Concrete nouns: file paths, app names, URLs, named entities\n\
         \n\
         Maximum {max_words} words. Use neutral past tense. Begin with the most important sentence."
    )
}

/// Render chunks plus optional caller note as a single user message.
pub(crate) fn build_user_prompt(chunks: &[MemoryChunk], ctx: &SummaryContext) -> String {
    let mut s = String::with_capacity(chunks.len() * 200);
    s.push_str("Memory chunks (oldest first):\n\n");
    for (i, c) in chunks.iter().enumerate() {
        let ts = c.created_at.to_rfc3339();
        s.push_str(&format!(
            "[{idx}] {ts} kind={kind:?} caller={caller}\n{content}\n\n",
            idx = i + 1,
            ts = ts,
            kind = c.kind,
            caller = c.caller_id,
            content = c.content.trim()
        ));
    }
    if let Some(note) = &ctx.note {
        s.push_str("Note: ");
        s.push_str(note);
        s.push('\n');
    }
    s
}

/// Estimate output token budget from requested word cap.
pub(crate) fn max_tokens_for_context(ctx: &SummaryContext) -> u32 {
    let max_words = ctx.max_words.unwrap_or(DEFAULT_MAX_WORDS);
    ((max_words as f32 * 1.4).ceil() as u32 + 64).max(256)
}

#[cfg(test)]
mod tests {
    use super::*;
    use cel_memory::{ChunkKind, ChunkSource, MemoryTier};
    use chrono::Utc;
    use serde_json::Value;

    fn chunk(content: &str) -> MemoryChunk {
        MemoryChunk {
            id: "c1".into(),
            created_at: Utc::now(),
            kind: ChunkKind::Chat,
            tier: MemoryTier::Session,
            source: ChunkSource::Embedded,
            session_id: Some("s1".into()),
            project_root: None,
            caller_id: "embedded".into(),
            content: content.into(),
            metadata: Value::Null,
            importance: 0.5,
            pinned: false,
            shareable: false,
            superseded_by: None,
            embedding_model: "mock".into(),
            embedding_dim: 0,
        }
    }

    #[test]
    fn system_prompt_includes_kind_label_and_word_cap() {
        let sys = build_system_prompt(&SummaryContext {
            kind_label: Some("day 2026-05-23".into()),
            max_words: Some(120),
            ..Default::default()
        });
        assert!(sys.contains("day 2026-05-23 summary"));
        assert!(sys.contains("Maximum 120 words"));
    }

    #[test]
    fn user_prompt_includes_chunks_and_note() {
        let user = build_user_prompt(
            &[chunk("alpha")],
            &SummaryContext {
                note: Some("first daily rollup".into()),
                ..Default::default()
            },
        );
        assert!(user.contains("alpha"));
        assert!(user.contains("first daily rollup"));
    }
}