ai-agent-sdk 0.4.0

Idiomatic agent sdk inspired by the claude code source leak
Documentation
//! Memory type taxonomy and structures.
//!
//! Memories are constrained to four types capturing context NOT derivable
//! from the current project state. Code patterns, architecture, git history,
//! and file structure are derivable and should NOT be saved as memories.

use serde::{Deserialize, Serialize};

/// Memory types supported by the memory system
pub const MEMORY_TYPES: &[&str] = &["user", "feedback", "project", "reference"];

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum MemoryType {
    User,
    Feedback,
    Project,
    Reference,
}

impl MemoryType {
    /// Parse a string into a MemoryType
    pub fn from_str(s: &str) -> Option<Self> {
        match s.to_lowercase().as_str() {
            "user" => Some(Self::User),
            "feedback" => Some(Self::Feedback),
            "project" => Some(Self::Project),
            "reference" => Some(Self::Reference),
            _ => None,
        }
    }

    /// Get the type name as string
    pub fn as_str(&self) -> &'static str {
        match self {
            Self::User => "user",
            Self::Feedback => "feedback",
            Self::Project => "project",
            Self::Reference => "reference",
        }
    }
}

impl std::fmt::Display for MemoryType {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "{}", self.as_str())
    }
}

/// A single memory entry
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Memory {
    pub name: String,
    pub description: String,
    #[serde(rename = "type")]
    pub memory_type: MemoryType,
    pub content: String,
}

/// Frontmatter for memory files
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MemoryFrontmatter {
    pub name: String,
    pub description: String,
    #[serde(rename = "type")]
    pub memory_type: MemoryType,
}

/// Parse frontmatter from markdown content
pub fn parse_frontmatter(content: &str) -> Option<MemoryFrontmatter> {
    let trimmed = content.trim();

    // Check for frontmatter delimiters
    if !trimmed.starts_with("---") {
        return None;
    }

    // Find the closing delimiter
    let end_idx = trimmed[3..].find("---")? + 3;
    let frontmatter = &trimmed[3..end_idx];

    let mut name = String::new();
    let mut description = String::new();
    let mut memory_type = MemoryType::User; // Default

    for line in frontmatter.lines() {
        let line = line.trim();
        if let Some((key, value)) = line.split_once(':') {
            let key = key.trim();
            let value = value.trim();

            match key {
                "name" => name = value.to_string(),
                "description" => description = value.to_string(),
                "type" => {
                    if let Some(t) = MemoryType::from_str(value) {
                        memory_type = t;
                    }
                }
                _ => {}
            }
        }
    }

    if name.is_empty() {
        return None;
    }

    Some(MemoryFrontmatter {
        name,
        description,
        memory_type,
    })
}

/// Extract content after frontmatter
pub fn extract_content(content: &str) -> String {
    let trimmed = content.trim();

    if !trimmed.starts_with("---") {
        return trimmed.to_string();
    }

    if let Some(end_idx) = trimmed[3..].find("---") {
        let after_frontmatter = &trimmed[3 + end_idx + 3..];
        after_frontmatter.trim().to_string()
    } else {
        trimmed.to_string()
    }
}

/// Entrypoint truncation result
#[derive(Debug, Clone)]
pub struct EntrypointTruncation {
    pub content: String,
    pub line_count: usize,
    pub byte_count: usize,
    pub was_line_truncated: bool,
    pub was_byte_truncated: bool,
}

/// Maximum lines in MEMORY.md entrypoint
pub const MAX_ENTRYPOINT_LINES: usize = 200;
/// Maximum bytes in MEMORY.md entrypoint (~125 chars/line * 200 lines)
pub const MAX_ENTRYPOINT_BYTES: usize = 25_000;

/// Truncate MEMORY.md content to line and byte caps
pub fn truncate_entrypoint(raw: &str) -> EntrypointTruncation {
    let trimmed = raw.trim();
    let content_lines: Vec<&str> = trimmed.lines().collect();
    let line_count = content_lines.len();
    let byte_count = trimmed.len();

    let was_line_truncated = line_count > MAX_ENTRYPOINT_LINES;
    let was_byte_truncated = byte_count > MAX_ENTRYPOINT_BYTES;

    if !was_line_truncated && !byte_count <= MAX_ENTRYPOINT_BYTES {
        return EntrypointTruncation {
            content: trimmed.to_string(),
            line_count,
            byte_count,
            was_line_truncated: false,
            was_byte_truncated: false,
        };
    }

    let truncated = if was_line_truncated {
        content_lines[..MAX_ENTRYPOINT_LINES].join("\n")
    } else {
        trimmed.to_string()
    };

    let truncated = if truncated.len() > MAX_ENTRYPOINT_BYTES {
        if let Some(cut_at) = truncated.rfind('\n') {
            if cut_at > MAX_ENTRYPOINT_BYTES {
                truncated[..cut_at].to_string()
            } else {
                truncated[..MAX_ENTRYPOINT_BYTES].to_string()
            }
        } else {
            truncated[..MAX_ENTRYPOINT_BYTES].to_string()
        }
    } else {
        truncated
    };

    let reason = if was_byte_truncated && !was_line_truncated {
        format!("{} (limit: {} bytes)", byte_count, MAX_ENTRYPOINT_BYTES)
    } else if was_line_truncated && !was_byte_truncated {
        format!("{} lines (limit: {})", line_count, MAX_ENTRYPOINT_LINES)
    } else {
        format!("{} lines and {} bytes", line_count, byte_count)
    };

    let content = format!(
        "{}\n\n> WARNING: MEMORY.md is {}. Only part of it was loaded. Keep index entries to one line under ~200 chars; move detail into topic files.",
        truncated, reason
    );

    EntrypointTruncation {
        content,
        line_count,
        byte_count,
        was_line_truncated,
        was_byte_truncated,
    }
}

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

    #[test]
    fn test_memory_type_from_str() {
        assert_eq!(MemoryType::from_str("user"), Some(MemoryType::User));
        assert_eq!(MemoryType::from_str("feedback"), Some(MemoryType::Feedback));
        assert_eq!(MemoryType::from_str("project"), Some(MemoryType::Project));
        assert_eq!(MemoryType::from_str("reference"), Some(MemoryType::Reference));
        assert_eq!(MemoryType::from_str("unknown"), None);
    }

    #[test]
    fn test_parse_frontmatter() {
        let content = r#"---
name: test_memory
description: A test memory
type: user
---

This is the content."#;

        let fm = parse_frontmatter(content).unwrap();
        assert_eq!(fm.name, "test_memory");
        assert_eq!(fm.description, "A test memory");
        assert_eq!(fm.memory_type, MemoryType::User);
    }

    #[test]
    fn test_extract_content() {
        let content = r#"---
name: test
description: test
type: user
---

This is the actual content."#;

        let extracted = extract_content(content);
        assert_eq!(extracted, "This is the actual content.");
    }
}