//! Auto-memory harvest use case (Lot E1). //! //! [`HarvestMemoryFromTurn`] turns the text of a finished agent turn into persisted //! memory notes when it embeds ` ```idea-memory ` directives (parsed by the pure //! [`domain::memory_harvest`] module). It is **best-effort strict**: it only acts on //! a `Response` turn, parses **only the current turn's text** (never the full log), //! and never fails — parse/store errors are collected into the returned //! [`MemoryHarvestOutcome`] so the orchestrator can drop them without ever turning a //! successful reply/ticket/handoff into an error. use std::sync::Arc; use domain::conversation_log::TurnRole; use domain::ports::{EventBus, MemoryStore}; use domain::{ parse_memory_directives, DomainEvent, MarkdownDoc, Memory, MemoryFrontmatter, MemoryHarvestDirective, MemorySlug, ProjectPath, }; /// The outcome of harvesting one turn: a report the caller may log or ignore. /// /// `found` is the number of `idea-memory` directives the agent emitted; `saved` /// the slugs successfully persisted; `skipped` how many were dropped (parse or /// store failures); `errors` the human-readable reasons (parse + store). #[derive(Debug, Clone, PartialEq, Eq, Default)] pub struct MemoryHarvestOutcome { /// Number of `idea-memory` directives detected in the turn text. pub found: usize, /// Slugs of the notes that were saved (and announced). pub saved: Vec, /// Number of directives dropped (invalid, over-limit, or store failure). pub skipped: usize, /// One message per dropped directive (parse + persistence reasons). pub errors: Vec, } /// Validates and persists the memory directives embedded in a finished turn. /// /// Talks only to ports ([`MemoryStore`], [`EventBus`]); announces /// [`DomainEvent::MemorySaved`] per saved note like the manual CRUD use cases. pub struct HarvestMemoryFromTurn { memories: Arc, events: Arc, } impl HarvestMemoryFromTurn { /// Builds the use case from its ports. #[must_use] pub fn new(memories: Arc, events: Arc) -> Self { Self { memories, events } } /// Harvests `text` (the current turn's content) into `root`'s memory store. /// /// Best-effort: returns a [`MemoryHarvestOutcome`] and **never** an error. A /// non-`Response` turn is a no-op (empty outcome) — prompts and tool activity /// never carry harvest directives. pub async fn harvest( &self, root: &ProjectPath, role: TurnRole, text: &str, ) -> MemoryHarvestOutcome { if role != TurnRole::Response { return MemoryHarvestOutcome::default(); } let report = parse_memory_directives(text); let mut outcome = MemoryHarvestOutcome { found: report.found, // Parse-level skips are already final; store-level skips are added below. skipped: report.errors.len(), errors: report.errors, saved: Vec::new(), }; for directive in report.directives { match self.persist(root, directive).await { Ok(slug) => outcome.saved.push(slug), Err(err) => { outcome.skipped += 1; outcome.errors.push(err); } } } outcome } /// Persists one validated directive and announces it, mapping the (today /// title-less) [`Memory`] entity from the directive. Returns the saved slug or /// a message on a build/store failure (never panics). async fn persist( &self, root: &ProjectPath, directive: MemoryHarvestDirective, ) -> Result { let slug = directive.slug.clone(); // The persisted `Memory` has no title field today; the directive's `title` // is intentionally not stored separately (the index derives its title from // the slug). See the Lot E1 report's écart note. let frontmatter = MemoryFrontmatter { name: slug.clone(), description: directive.description, r#type: directive.r#type, }; let memory = Memory::new(frontmatter, MarkdownDoc::new(directive.body.as_str())) .map_err(|e| format!("invalid memory {slug}: {e}"))?; self.memories .save(root, &memory) .await .map_err(|e| format!("store failed for {slug}: {e}"))?; self.events .publish(DomainEvent::MemorySaved { slug: slug.clone() }); Ok(slug) } }