//! [`FsMemoryStore`] — file implementation of the [`MemoryStore`] port //! (LOT A, étage 1). //! //! Memory notes are the project's persistent, model-agnostic knowledge base. Each //! note is a single Markdown file with a YAML frontmatter header, stored under the //! project's `.ideai/memory/`: //! //! ```text //! /.ideai/memory/ //! ├── MEMORY.md # aggregated index: one `- [Title](slug.md) — hook` line per note //! └── .md # a note: YAML frontmatter + Markdown body //! ``` //! //! A note file looks like: //! //! ```text //! --- //! name: my-note //! description: A one-line hook //! metadata: //! type: project //! --- //! # Body //! ... //! ``` //! //! The `.md` files are the **single source of truth**; `MEMORY.md` is derived and //! kept in sync on every [`save`](MemoryStore::save)/[`delete`](MemoryStore::delete) //! (idempotent upsert / removal of the note's line). All I/O goes through the //! [`FileSystem`] port, so the adapter is location-neutral (SSH/WSL work unchanged) //! and Tauri-agnostic. //! //! Like the sibling stores, [`delete`](MemoryStore::delete) drops the note's line //! from `MEMORY.md` and leaves the orphaned `.md` on disk (the [`FileSystem`] //! port exposes no remove); since listing is index-driven, the note is effectively //! gone. use std::sync::Arc; use async_trait::async_trait; use domain::markdown::MarkdownDoc; use domain::memory::{ Memory, MemoryFrontmatter, MemoryIndexEntry, MemoryLink, MemorySlug, MemoryType, }; use domain::ports::{ FileSystem, FsError, MemoryError, MemoryQuery, MemoryRecall, MemoryStore, RemotePath, }; use domain::project::ProjectPath; /// The `.ideai/` directory name inside a project root. const IDEAI_DIR: &str = ".ideai"; /// Sub-path of the memory store inside `.ideai/`. const MEMORY_DIR: &str = "memory"; /// Aggregated index file name inside the memory dir. const INDEX_FILE: &str = "MEMORY.md"; /// First line of the aggregated index. const INDEX_HEADER: &str = "# Memory Index"; /// File-backed [`MemoryStore`], composing a [`FileSystem`] port. The project root /// is supplied **per call**, so a single instance serves every open project /// (mirroring [`crate::store::FsSkillStore`]). #[derive(Clone)] pub struct FsMemoryStore { fs: Arc, } impl FsMemoryStore { /// Builds the store from an injected [`FileSystem`]. Directories are created /// on first write. #[must_use] pub fn new(fs: Arc) -> Self { Self { fs } } /// `/.ideai/memory`. fn dir(&self, root: &ProjectPath) -> String { let base = root.as_str().trim_end_matches(['/', '\\']); format!("{base}/{IDEAI_DIR}/{MEMORY_DIR}") } /// `/.md`. fn md_path(&self, root: &ProjectPath, slug: &MemorySlug) -> RemotePath { RemotePath::new(format!("{}/{}.md", self.dir(root), slug.as_str())) } /// `/MEMORY.md`. fn index_path(&self, root: &ProjectPath) -> RemotePath { RemotePath::new(format!("{}/{INDEX_FILE}", self.dir(root))) } /// Reads and parses a note by slug. async fn load(&self, root: &ProjectPath, slug: &MemorySlug) -> Result { let bytes = self .fs .read(&self.md_path(root, slug)) .await .map_err(|e| match e { FsError::NotFound(_) => MemoryError::NotFound, other => MemoryError::Io(other.to_string()), })?; let text = String::from_utf8(bytes).map_err(|e| MemoryError::Io(e.to_string()))?; parse_note(&text) } /// Reads the raw `MEMORY.md` text, or `None` if it does not exist yet. async fn read_index_text(&self, root: &ProjectPath) -> Result, MemoryError> { match self.fs.read(&self.index_path(root)).await { Ok(bytes) => String::from_utf8(bytes) .map(Some) .map_err(|e| MemoryError::Io(e.to_string())), Err(FsError::NotFound(_)) => Ok(None), Err(e) => Err(MemoryError::Io(e.to_string())), } } /// Rewrites `MEMORY.md` from the given entries, ensuring the dir exists. async fn write_index( &self, root: &ProjectPath, entries: &[MemoryIndexEntry], ) -> Result<(), MemoryError> { self.fs .create_dir_all(&RemotePath::new(self.dir(root))) .await .map_err(|e| MemoryError::Io(e.to_string()))?; let text = render_index(entries); self.fs .write(&self.index_path(root), text.as_bytes()) .await .map_err(|e| MemoryError::Io(e.to_string())) } /// Lists the slugs known to the index (index-driven listing). async fn index_slugs(&self, root: &ProjectPath) -> Result, MemoryError> { Ok(self .read_index(root) .await? .into_iter() .map(|e| e.slug) .collect()) } } #[async_trait] impl MemoryStore for FsMemoryStore { async fn list(&self, root: &ProjectPath) -> Result, MemoryError> { let slugs = self.index_slugs(root).await?; let mut out = Vec::with_capacity(slugs.len()); for slug in &slugs { out.push(self.load(root, slug).await?); } Ok(out) } async fn get(&self, root: &ProjectPath, slug: &MemorySlug) -> Result { self.load(root, slug).await } async fn save(&self, root: &ProjectPath, memory: &Memory) -> Result<(), MemoryError> { // (1) Write the note file. self.fs .create_dir_all(&RemotePath::new(self.dir(root))) .await .map_err(|e| MemoryError::Io(e.to_string()))?; let text = render_note(memory); self.fs .write(&self.md_path(root, memory.slug()), text.as_bytes()) .await .map_err(|e| MemoryError::Io(e.to_string()))?; // (2) Upsert the index line idempotently (same slug => one line). let mut entries = self.read_index(root).await?; let row = memory.index_entry(); if let Some(slot) = entries.iter_mut().find(|e| e.slug == row.slug) { *slot = row; } else { entries.push(row); } self.write_index(root, &entries).await } async fn delete(&self, root: &ProjectPath, slug: &MemorySlug) -> Result<(), MemoryError> { let mut entries = self.read_index(root).await?; let before = entries.len(); entries.retain(|e| &e.slug != slug); if entries.len() == before { return Err(MemoryError::NotFound); } // The orphaned `.md` is left on disk (no FileSystem delete); the // index no longer references it, so it is effectively gone. self.write_index(root, &entries).await } async fn read_index(&self, root: &ProjectPath) -> Result, MemoryError> { match self.read_index_text(root).await? { Some(text) => Ok(parse_index(&text)), None => Ok(Vec::new()), } } async fn resolve_links( &self, root: &ProjectPath, slug: &MemorySlug, ) -> Result, MemoryError> { let memory = self.load(root, slug).await?; let known = self.index_slugs(root).await?; // Keep only links whose target resolves to a known note (ignore broken). Ok(memory .outgoing_links() .into_iter() .filter(|link| known.contains(&link.target)) .collect()) } } // --------------------------------------------------------------------------- // NaiveMemoryRecall — the default, dependency-free MemoryRecall (LOT B). // --------------------------------------------------------------------------- /// Heuristic divisor turning a character count into an approximate token count /// (~4 characters per token, the usual rule of thumb). Shared by every /// [`MemoryRecall`] adapter so the budget semantics stay identical (DRY). pub(crate) const CHARS_PER_TOKEN: usize = 4; /// Approximate token cost of an index entry's textual payload — the single /// budget-cost function shared by every [`MemoryRecall`] adapter (naïve, vector, /// adaptive), so truncation semantics are identical across them (Liskov / DRY). pub(crate) fn entry_cost(entry: &MemoryIndexEntry) -> usize { let chars = entry.title.len() + entry.hook.len(); chars.div_ceil(CHARS_PER_TOKEN) } /// Greedily takes entries in the given order while their accumulated /// [`entry_cost`] stays within `budget`; stops at the first entry that would /// exceed it (and drops every entry after). A `budget` of `0` yields an empty /// vec. This is the shared truncation used by every recall adapter. pub(crate) fn truncate_to_budget( entries: impl IntoIterator, budget: usize, ) -> Vec { if budget == 0 { return Vec::new(); } let mut spent = 0usize; let mut out = Vec::new(); for entry in entries { let cost = entry_cost(&entry); if spent + cost > budget { break; } spent += cost; out.push(entry); } out } /// Total approximate token cost of an index — a **pure** function of the entries, /// used by [`crate::store::AdaptiveMemoryRecall`] to decide naïve vs. vector /// recall without any I/O. #[must_use] pub fn index_token_size(entries: &[MemoryIndexEntry]) -> usize { entries.iter().map(entry_cost).sum() } /// The default [`MemoryRecall`]: dependency-free, ignores semantic relevance. /// /// It composes an [`Arc`], reads the aggregated index via /// [`MemoryStore::read_index`], and returns the entries **in index order**, /// truncated to fit the query's token budget. It is the baseline against which a /// future `VectorMemoryRecall` (LOT C) is substitutable. /// /// ## Budget semantics /// `token_budget` is an *approximate* budget. Each entry's cost is estimated as /// `ceil((title.len() + hook.len()) / 4)` tokens (≈ 4 chars/token, counting only /// the index line's textual payload). Entries are taken in order, accumulating /// their cost; the first entry whose inclusion would exceed the budget — and every /// entry after it — is dropped. A budget of `0` therefore yields an empty list; /// an empty or missing memory yields an empty list without error. #[derive(Clone)] pub struct NaiveMemoryRecall { store: Arc, } impl NaiveMemoryRecall { /// Builds the recall adapter from a composed [`MemoryStore`]. #[must_use] pub fn new(store: Arc) -> Self { Self { store } } } #[async_trait] impl MemoryRecall for NaiveMemoryRecall { async fn recall( &self, root: &ProjectPath, query: &MemoryQuery, ) -> Result, MemoryError> { // Budget-0 short-circuits before any I/O: a zero budget can hold no entry, // so there is nothing to read (homogeneous with every recall adapter). if query.token_budget == 0 { return Ok(Vec::new()); } let entries = self.store.read_index(root).await?; Ok(truncate_to_budget(entries, query.token_budget)) } } // --------------------------------------------------------------------------- // On-disk format: YAML frontmatter + body, and the MEMORY.md index. // // We hand-roll a tiny, well-scoped YAML reader/writer for exactly the frontmatter // shape we own (`name`, `description`, `metadata.type`). This keeps the crate free // of a YAML dependency for a fixed, simple schema; any deviation surfaces as a // `MemoryError::Frontmatter`. // --------------------------------------------------------------------------- /// Renders a note to its on-disk `---`-fenced frontmatter + body form. fn render_note(memory: &Memory) -> String { let fm = &memory.frontmatter; format!( "---\nname: {}\ndescription: {}\nmetadata:\n type: {}\n---\n{}", fm.name.as_str(), fm.description, type_to_str(fm.r#type), memory.body.as_str(), ) } /// Parses a note from its on-disk form. fn parse_note(text: &str) -> Result { let rest = text .strip_prefix("---\n") .or_else(|| text.strip_prefix("---\r\n")) .ok_or_else(|| MemoryError::Frontmatter("missing opening `---` fence".to_string()))?; // Find the closing `---` fence at the start of a line. let (fm_block, body) = split_frontmatter(rest) .ok_or_else(|| MemoryError::Frontmatter("missing closing `---` fence".to_string()))?; let frontmatter = parse_frontmatter(fm_block)?; Memory::new(frontmatter, MarkdownDoc::new(body)) .map_err(|e| MemoryError::Frontmatter(e.to_string())) } /// Splits the post-opening-fence text into `(frontmatter_block, body)` at the /// closing `---` line. Returns `None` if no closing fence is present. fn split_frontmatter(rest: &str) -> Option<(&str, &str)> { let mut offset = 0; for line in rest.split_inclusive('\n') { let trimmed = line.trim_end_matches(['\n', '\r']); if trimmed == "---" { let fm = &rest[..offset]; let body = &rest[offset + line.len()..]; return Some((fm, body)); } offset += line.len(); } None } /// Parses the frontmatter key/values into a validated [`MemoryFrontmatter`]. fn parse_frontmatter(block: &str) -> Result { let err = |reason: &str| MemoryError::Frontmatter(reason.to_string()); let mut name: Option = None; let mut description: Option = None; let mut type_str: Option = None; let mut in_metadata = false; for raw in block.lines() { if raw.trim().is_empty() { continue; } let indented = raw.starts_with(' ') || raw.starts_with('\t'); let (key, value) = raw .split_once(':') .ok_or_else(|| err("frontmatter line missing `:`"))?; let key = key.trim(); let value = value.trim(); if !indented { in_metadata = false; match key { "name" => name = Some(value.to_string()), "description" => description = Some(value.to_string()), "metadata" => { in_metadata = true; if !value.is_empty() { return Err(err("`metadata` must be a nested block")); } } _ => return Err(err("unknown frontmatter key")), } } else if in_metadata && key == "type" { type_str = Some(value.to_string()); } else { return Err(err("unexpected indented frontmatter line")); } } let name = name.ok_or_else(|| err("missing `name`"))?; let description = description.ok_or_else(|| err("missing `description`"))?; let type_str = type_str.ok_or_else(|| err("missing `metadata.type`"))?; let name = MemorySlug::new(name).map_err(|e| MemoryError::Frontmatter(e.to_string()))?; let r#type = str_to_type(&type_str).ok_or_else(|| err("unknown `metadata.type` value"))?; Ok(MemoryFrontmatter { name, description, r#type, }) } /// Renders the aggregated `MEMORY.md` index. fn render_index(entries: &[MemoryIndexEntry]) -> String { let mut out = String::from(INDEX_HEADER); out.push('\n'); if !entries.is_empty() { out.push('\n'); for e in entries { out.push_str(&format!( "- [{}]({}.md) — {}\n", e.title, e.slug.as_str(), e.hook )); } } out } /// Parses the `MEMORY.md` index lines back into structured entries. Lines that do /// not match the `- [Title](slug.md) — hook` shape are skipped (tolerant read); /// the `type` is not stored in the index line and defaults to /// [`MemoryType::Reference`]. fn parse_index(text: &str) -> Vec { let mut out = Vec::new(); for line in text.lines() { let line = line.trim(); if !line.starts_with("- [") { continue; } if let Some(entry) = parse_index_line(line) { out.push(entry); } } out } /// Parses one `- [Title](slug.md) — hook` line. fn parse_index_line(line: &str) -> Option { let rest = line.strip_prefix("- [")?; let (title, rest) = rest.split_once("](")?; let (target, rest) = rest.split_once(')')?; let slug_str = target.strip_suffix(".md").unwrap_or(target); let slug = MemorySlug::new(slug_str).ok()?; let hook = rest.trim_start().strip_prefix('—').unwrap_or(rest).trim(); Some(MemoryIndexEntry { slug, title: title.to_string(), hook: hook.to_string(), r#type: MemoryType::Reference, }) } /// Maps a [`MemoryType`] to its YAML/string form (matches serde camelCase). fn type_to_str(t: MemoryType) -> &'static str { match t { MemoryType::User => "user", MemoryType::Feedback => "feedback", MemoryType::Project => "project", MemoryType::Reference => "reference", } } /// Parses a [`MemoryType`] from its string form. fn str_to_type(s: &str) -> Option { match s { "user" => Some(MemoryType::User), "feedback" => Some(MemoryType::Feedback), "project" => Some(MemoryType::Project), "reference" => Some(MemoryType::Reference), _ => None, } }