//! Auto-memory harvest parser (Lot E1) — pure domain. //! //! When an agent ends a `Response` turn it may embed one or more fenced //! ` ```idea-memory ` blocks asking IdeA to persist a memory note. This module //! owns the **pure** parsing of that directive: it turns the raw turn text into a //! [`MemoryHarvestReport`] of validated [`MemoryHarvestDirective`]s plus per-block //! parse errors. It performs no I/O and never fails as a whole — invalid blocks //! are skipped individually so the valid ones can still be persisted by the //! application layer (best-effort harvest). //! //! ## Directive format //! //! ````markdown //! ```idea-memory //! slug: kebab-case-slug //! title: Human title //! type: project|reference|feedback|user //! description: One-line recall hook //! --- //! Markdown body to persist. //! ``` //! ```` //! //! `slug`, `title`, `type`, `description` are mandatory frontmatter keys; the //! `---` line separates them from a non-empty Markdown body (preserved verbatim //! save for outer whitespace). Fences with any other info string are ignored. use crate::markdown::MarkdownDoc; use crate::memory::{MemorySlug, MemoryType}; /// The fence info string that marks a harvest directive block. const FENCE_INFO: &str = "idea-memory"; /// Maximum number of directive blocks honoured per response. Extra blocks are /// reported as errors and skipped (a single response should not dump dozens of /// notes). pub const MAX_BLOCKS: usize = 3; /// Maximum raw size (bytes) of a single directive block's inner content. pub const MAX_BLOCK_BYTES: usize = 8 * 1024; /// Maximum length (characters) of a directive's `description` (the index hook). pub const MAX_DESCRIPTION_CHARS: usize = 200; /// A validated memory-harvest directive parsed from one ` ```idea-memory ` block. /// /// Pure value object. Note that `title` is captured (it is a mandatory key) even /// though the persisted [`crate::memory::Memory`] entity has no dedicated title /// field today — the application maps the rest onto a `Memory` and the title is /// kept here for callers/forward use. #[derive(Debug, Clone, PartialEq, Eq)] pub struct MemoryHarvestDirective { /// Validated kebab-case slug (the note identity / file stem). pub slug: MemorySlug, /// Human-readable title (mandatory in the directive). pub title: String, /// The note kind. pub r#type: MemoryType, /// One-line recall hook (bounded, non-empty). pub description: String, /// The Markdown body to persist (non-empty). pub body: MarkdownDoc, } /// Outcome of parsing a turn's text for harvest directives. /// /// `found` counts every ` ```idea-memory ` fence detected (valid or not), so the /// application can report how many directives the agent emitted versus how many /// were usable. `directives` are the valid ones (in document order); `errors` /// carries one human-readable message per skipped block. #[derive(Debug, Clone, PartialEq, Eq, Default)] pub struct MemoryHarvestReport { /// Number of `idea-memory` fences detected in the text. pub found: usize, /// Successfully parsed, validated directives (document order). pub directives: Vec, /// One message per skipped (invalid / over-limit) block. pub errors: Vec, } /// Parses `text` for ` ```idea-memory ` directive blocks, best-effort. /// /// Never fails: malformed blocks land in [`MemoryHarvestReport::errors`] and valid /// ones in [`MemoryHarvestReport::directives`]. Fences with a different info string /// are skipped entirely. At most [`MAX_BLOCKS`] blocks are honoured; further blocks /// are reported as errors. #[must_use] pub fn parse_memory_directives(text: &str) -> MemoryHarvestReport { let mut report = MemoryHarvestReport::default(); let lines: Vec<&str> = text.lines().collect(); let mut i = 0; while i < lines.len() { let line = lines[i]; let Some(info) = fence_info(line) else { i += 1; continue; }; // A fenced block opens here. Find its closing fence (a line whose trimmed // content is exactly ```), tracking the inner content lines. let mut j = i + 1; while j < lines.len() && lines[j].trim_end() != "```" { j += 1; } let inner = if j <= lines.len() { lines[i + 1..j.min(lines.len())].join("\n") } else { String::new() }; // Advance past the closing fence (or to EOF for an unterminated block). let next = if j < lines.len() { j + 1 } else { lines.len() }; if info == FENCE_INFO { report.found += 1; if report.found > MAX_BLOCKS { report.errors.push(format!( "skipped idea-memory block #{}: exceeds the max of {MAX_BLOCKS} blocks per response", report.found )); } else { match parse_block(&inner) { Ok(directive) => report.directives.push(directive), Err(err) => report.errors.push(err), } } } i = next; } report } /// Returns the (trimmed) info string of a fence opener line, or `None` if `line` /// is not a ```` ``` ```` fence opener. fn fence_info(line: &str) -> Option<&str> { let trimmed = line.trim(); let rest = trimmed.strip_prefix("```")?; // A bare ``` (no info string) is a generic fence opener, not ours. Some(rest.trim()) } /// Parses one block's inner content into a validated directive, or an error /// message describing why it was skipped. fn parse_block(inner: &str) -> Result { if inner.len() > MAX_BLOCK_BYTES { return Err(format!( "block exceeds the max size of {MAX_BLOCK_BYTES} bytes ({} bytes)", inner.len() )); } // Split frontmatter (until a `---` line) from the body. let mut sep = None; for (idx, line) in inner.lines().enumerate() { if line.trim() == "---" { sep = Some(idx); break; } } let Some(sep) = sep else { return Err("missing '---' separator between frontmatter and body".to_owned()); }; let block_lines: Vec<&str> = inner.lines().collect(); let frontmatter = &block_lines[..sep]; let body_raw = block_lines[sep + 1..].join("\n"); // Collect the known frontmatter keys (unknown keys are ignored leniently). let mut slug_raw = None; let mut title = None; let mut type_raw = None; let mut description = None; for line in frontmatter { let Some((key, value)) = line.split_once(':') else { continue; }; let value = value.trim().to_owned(); match key.trim() { "slug" => slug_raw = Some(value), "title" => title = Some(value), "type" => type_raw = Some(value), "description" => description = Some(value), _ => {} } } let slug_raw = required(slug_raw, "slug")?; let title = required(title, "title")?; let type_raw = required(type_raw, "type")?; let description = required(description, "description")?; let slug = MemorySlug::new(&slug_raw).map_err(|_| format!("invalid slug: {slug_raw:?}"))?; let r#type = parse_type(&type_raw)?; if description.chars().count() > MAX_DESCRIPTION_CHARS { return Err(format!( "description exceeds {MAX_DESCRIPTION_CHARS} characters" )); } let body = body_raw.trim(); if body.is_empty() { return Err("empty body".to_owned()); } Ok(MemoryHarvestDirective { slug, title, r#type, description, body: MarkdownDoc::new(body.to_owned()), }) } /// Requires a non-empty frontmatter value for `field`. fn required(value: Option, field: &str) -> Result { match value { Some(v) if !v.trim().is_empty() => Ok(v.trim().to_owned()), Some(_) => Err(format!("empty required field: {field}")), None => Err(format!("missing required field: {field}")), } } /// Maps the `type` value to a [`MemoryType`]. fn parse_type(raw: &str) -> Result { match raw { "project" => Ok(MemoryType::Project), "reference" => Ok(MemoryType::Reference), "feedback" => Ok(MemoryType::Feedback), "user" => Ok(MemoryType::User), other => Err(format!("invalid type: {other:?}")), } } #[cfg(test)] mod tests { use super::*; fn block(slug: &str, title: &str, ty: &str, desc: &str, body: &str) -> String { format!( "```idea-memory\nslug: {slug}\ntitle: {title}\ntype: {ty}\ndescription: {desc}\n---\n{body}\n```" ) } #[test] fn parses_a_single_valid_block() { let text = format!( "Here is what I learned.\n\n{}\n\nDone.", block( "api-base-url", "API base URL", "project", "Where the API lives", "Use `https://api`." ) ); let report = parse_memory_directives(&text); assert_eq!(report.found, 1); assert!(report.errors.is_empty(), "{:?}", report.errors); assert_eq!(report.directives.len(), 1); let d = &report.directives[0]; assert_eq!(d.slug.as_str(), "api-base-url"); assert_eq!(d.title, "API base URL"); assert_eq!(d.r#type, MemoryType::Project); assert_eq!(d.description, "Where the API lives"); assert_eq!(d.body.as_str(), "Use `https://api`."); } #[test] fn parses_multiple_valid_blocks_in_order() { let text = format!( "{}\n{}", block("first-note", "First", "project", "one", "Body one"), block("second-note", "Second", "reference", "two", "Body two") ); let report = parse_memory_directives(&text); assert_eq!(report.found, 2); assert_eq!(report.directives.len(), 2); assert_eq!(report.directives[0].slug.as_str(), "first-note"); assert_eq!(report.directives[1].slug.as_str(), "second-note"); } #[test] fn invalid_slug_is_skipped_with_error() { let text = block("Not Kebab!", "Bad", "project", "hook", "Body"); let report = parse_memory_directives(&text); assert_eq!(report.found, 1); assert!(report.directives.is_empty()); assert_eq!(report.errors.len(), 1); assert!( report.errors[0].contains("invalid slug"), "{:?}", report.errors ); } #[test] fn empty_body_is_skipped() { let text = block("ok-slug", "Title", "project", "hook", " "); let report = parse_memory_directives(&text); assert_eq!(report.found, 1); assert!(report.directives.is_empty()); assert!(report.errors[0].contains("empty body")); } #[test] fn missing_required_field_is_skipped() { // No `title:` key. let text = "```idea-memory\nslug: ok-slug\ntype: project\ndescription: hook\n---\nBody\n```"; let report = parse_memory_directives(text); assert_eq!(report.found, 1); assert!(report.directives.is_empty()); assert!(report.errors[0].contains("title"), "{:?}", report.errors); } #[test] fn invalid_type_is_skipped() { let text = block("ok-slug", "Title", "wisdom", "hook", "Body"); let report = parse_memory_directives(&text); assert!(report.directives.is_empty()); assert!(report.errors[0].contains("invalid type")); } #[test] fn missing_separator_is_skipped() { let text = "```idea-memory\nslug: ok-slug\ntitle: T\ntype: project\ndescription: hook\nBody without separator\n```"; let report = parse_memory_directives(text); assert_eq!(report.found, 1); assert!(report.directives.is_empty()); assert!( report.errors[0].contains("separator"), "{:?}", report.errors ); } #[test] fn caps_at_max_blocks() { let mut text = String::new(); for n in 0..(MAX_BLOCKS + 2) { text.push_str(&block(&format!("note-{n}"), "T", "project", "hook", "Body")); text.push('\n'); } let report = parse_memory_directives(&text); assert_eq!(report.found, MAX_BLOCKS + 2); assert_eq!( report.directives.len(), MAX_BLOCKS, "only the first MAX_BLOCKS honoured" ); assert_eq!(report.errors.len(), 2, "the surplus blocks are reported"); assert!(report.errors[0].contains("exceeds the max")); } #[test] fn oversize_block_is_skipped() { let huge_body = "x".repeat(MAX_BLOCK_BYTES + 1); let text = block("big-note", "T", "project", "hook", &huge_body); let report = parse_memory_directives(&text); assert_eq!(report.found, 1); assert!(report.directives.is_empty()); assert!(report.errors[0].contains("max size"), "{:?}", report.errors); } #[test] fn over_long_description_is_skipped() { let long_desc = "d".repeat(MAX_DESCRIPTION_CHARS + 1); let text = block("ok-slug", "T", "project", &long_desc, "Body"); let report = parse_memory_directives(&text); assert!(report.directives.is_empty()); assert!(report.errors[0].contains("description exceeds")); } #[test] fn non_idea_memory_fences_are_ignored() { let text = "```rust\nslug: looks-like-one\ntitle: T\ntype: project\ndescription: hook\n---\nfn main() {}\n```\n```\nplain\n```"; let report = parse_memory_directives(text); assert_eq!(report.found, 0); assert!(report.directives.is_empty()); assert!(report.errors.is_empty()); } #[test] fn markdown_body_is_preserved() { let body = "# Heading\n\n- bullet **bold**\n- second\n\n1. step\n\n`inline code`"; let text = block("md-note", "Markdown", "reference", "hook", body); let report = parse_memory_directives(&text); assert_eq!(report.directives.len(), 1, "{:?}", report.errors); assert_eq!(report.directives[0].body.as_str(), body); } #[test] fn empty_text_yields_empty_report() { let report = parse_memory_directives(""); assert_eq!(report.found, 0); assert!(report.directives.is_empty()); assert!(report.errors.is_empty()); } #[test] fn valid_and_invalid_blocks_coexist() { let text = format!( "{}\n{}", block("good-one", "Good", "project", "hook", "Body"), block("Bad Slug", "Bad", "project", "hook", "Body") ); let report = parse_memory_directives(&text); assert_eq!(report.found, 2); assert_eq!(report.directives.len(), 1); assert_eq!(report.directives[0].slug.as_str(), "good-one"); assert_eq!(report.errors.len(), 1); } }