//! Memory entity — the persistent, model-agnostic knowledge base of a project //! (LOT A, étage 1: `.md` files as the single source of truth). //! //! A [`Memory`] is one Markdown note stored under `.ideai/memory/.md`. Its //! frontmatter carries the structured metadata (a kebab-case [`MemorySlug`], a //! human-readable description, and a [`MemoryType`]); its body is opaque //! [`MarkdownDoc`] text. Notes cross-reference one another via `[[slug]]` wiki //! links, scanned by [`Memory::outgoing_links`]. //! //! The aggregated `.ideai/memory/MEMORY.md` index (one [`MemoryIndexEntry`] line //! per note) is derived data: [`Memory::index_entry`] produces a note's row. The //! adapter (`FsMemoryStore`) owns the on-disk YAML frontmatter and index file //! formats; the domain stays I/O-free and format-neutral. use serde::{Deserialize, Serialize}; use crate::error::DomainError; use crate::markdown::MarkdownDoc; /// A kebab-case identifier for a memory note (`[a-z0-9]` plus `-`), used both as /// the on-disk file stem (`.md`) and as the `[[slug]]` link target. /// /// Invariants enforced by [`MemorySlug::new`]: /// - non-empty, /// - only lowercase ASCII letters, ASCII digits, and `-`, /// - therefore no uppercase, no whitespace, and no `.` (so no `..` traversal). #[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] #[serde(transparent)] pub struct MemorySlug(String); impl MemorySlug { /// Builds a validated kebab-case slug. /// /// # Errors /// [`DomainError::InvalidSlug`] if `raw` is empty or contains any character /// outside `[a-z0-9-]`. pub fn new(raw: impl Into) -> Result { let raw = raw.into(); let invalid = || DomainError::InvalidSlug { value: raw.clone() }; if raw.is_empty() { return Err(invalid()); } if raw .chars() .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') { Ok(Self(raw)) } else { Err(invalid()) } } /// Returns the slug as a string slice. #[must_use] pub fn as_str(&self) -> &str { &self.0 } } impl std::fmt::Display for MemorySlug { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str(&self.0) } } /// The kind of a memory note, driving how it is surfaced and prioritised. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub enum MemoryType { /// A user-authored preference or instruction. User, /// Feedback captured from a prior interaction. Feedback, /// A project-level fact or decision. Project, /// A reference / external knowledge note. Reference, } /// The structured frontmatter of a memory note. /// /// Serialised with `type` (not `kind`) as the discriminator field name, matching /// the on-disk YAML `metadata.type` (the adapter maps the nesting). Invariant: /// `description` is non-empty (enforced at [`Memory::new`]). #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct MemoryFrontmatter { /// Stable kebab-case identifier (also the file stem). pub name: MemorySlug, /// Human-readable one-line description (the index hook). Non-empty. pub description: String, /// The note's kind. #[serde(rename = "type")] pub r#type: MemoryType, } /// A `[[slug]]` wiki link found in a note's body, pointing at another note. #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct MemoryLink { /// The linked note's slug. pub target: MemorySlug, } /// One row of the aggregated `MEMORY.md` index: the `- [Title](slug.md) — hook` /// line, decomposed into its parts. #[derive(Debug, Clone, PartialEq, Eq)] pub struct MemoryIndexEntry { /// The note's slug. pub slug: MemorySlug, /// The note's display title (currently the slug; titles are derived later). pub title: String, /// The one-line hook (the frontmatter description). pub hook: String, /// The note's kind. pub r#type: MemoryType, } /// A memory note: validated frontmatter plus an opaque Markdown body. /// /// Invariants enforced by [`Memory::new`]: /// - `frontmatter.description` non-empty, /// - `body` non-empty (an empty note carries no knowledge). #[derive(Debug, Clone, PartialEq, Eq)] pub struct Memory { /// Structured metadata. pub frontmatter: MemoryFrontmatter, /// Markdown body of the note. pub body: MarkdownDoc, } impl Memory { /// Builds a validated memory note. /// /// # Errors /// - [`DomainError::EmptyField`] if `frontmatter.description` is empty, /// - [`DomainError::EmptyField`] if `body` is empty. pub fn new(frontmatter: MemoryFrontmatter, body: MarkdownDoc) -> Result { crate::validation::non_empty(&frontmatter.description, "memory.description")?; if body.is_empty() { return Err(DomainError::EmptyField { field: "memory.body", }); } Ok(Self { frontmatter, body }) } /// Returns the note's slug (its identity). #[must_use] pub fn slug(&self) -> &MemorySlug { &self.frontmatter.name } /// Scans the body for `[[slug]]` wiki links, in order of appearance. /// /// Tokens whose inner text is not a valid [`MemorySlug`] are skipped (a /// malformed link is not a hard error here). Duplicates are preserved — the /// caller dedups if it wants a unique link set. No regex: a small linear scan. #[must_use] pub fn outgoing_links(&self) -> Vec { let text = self.body.as_str(); let bytes = text.as_bytes(); let mut links = Vec::new(); let mut i = 0; while i + 1 < bytes.len() { if bytes[i] == b'[' && bytes[i + 1] == b'[' { // Find the closing `]]`. if let Some(close) = text[i + 2..].find("]]") { let inner = &text[i + 2..i + 2 + close]; if let Ok(target) = MemorySlug::new(inner) { links.push(MemoryLink { target }); } i = i + 2 + close + 2; continue; } // No closing token: stop scanning further `[[`. break; } i += 1; } links } /// Produces this note's row in the aggregated `MEMORY.md` index. #[must_use] pub fn index_entry(&self) -> MemoryIndexEntry { MemoryIndexEntry { slug: self.frontmatter.name.clone(), title: self.frontmatter.name.as_str().to_string(), hook: self.frontmatter.description.clone(), r#type: self.frontmatter.r#type, } } }