feat(memory): système de mémoire projet model-agnostic (L14, LOT A+B+C)

Base de connaissance persistante par projet, indépendante de tout modèle/CLU
et de git. Cadrage archi en §14.5 (ARCHITECTURE.md), cycle Archi→Dev→Test.

LOT A — étage 1 (.md, source de vérité)
  - domaine: entité Memory (+ MemorySlug, MemoryType, MemoryFrontmatter,
    MemoryLink, MemoryIndexEntry), liens [[slug]], index MEMORY.md dérivé
  - port MemoryStore + MemoryError, adapter FsMemoryStore (.ideai/memory/)
  - application: 7 use cases (Create/Update/List/Get/Delete/ReadIndex/
    ResolveLinks), From<MemoryError> for AppError
  - app-tauri: commandes + DTO, events MemorySaved/MemoryDeleted
  - suppression de la variante morte DomainError::MalformedFrontmatter

LOT B — rappel adaptatif (étage 1)
  - port MemoryRecall + MemoryQuery, adapter NaiveMemoryRecall (troncature
    au budget de tokens, court-circuit budget-0), use case RecallMemory

LOT C — étage 2 vectoriel (structure complète, zéro dépendance lourde)
  - port Embedder + EmbedderError, profils déclaratifs EmbedderProfile/
    EmbedderStrategy (embedder.json)
  - VectorMemoryRecall (cosinus, cache .ideai/memory/.index/ gitignoré)
  - AdaptiveMemoryRecall (bascule pure should_use_vector), défaut none
  - HashEmbedder (déterministe, tests), StubEmbedder (onnx/server/api)

Tests: 57 binaires verts, build + clippy --workspace sans warning.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-08 08:47:23 +02:00
parent 3ed0f6b45f
commit 98a8b7292a
30 changed files with 4978 additions and 14 deletions

View File

@ -0,0 +1,492 @@
//! [`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
//! <project_root>/.ideai/memory/
//! ├── MEMORY.md # aggregated index: one `- [Title](slug.md) — hook` line per note
//! └── <slug>.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 `<slug>.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<dyn FileSystem>,
}
impl FsMemoryStore {
/// Builds the store from an injected [`FileSystem`]. Directories are created
/// on first write.
#[must_use]
pub fn new(fs: Arc<dyn FileSystem>) -> Self {
Self { fs }
}
/// `<root>/.ideai/memory`.
fn dir(&self, root: &ProjectPath) -> String {
let base = root.as_str().trim_end_matches(['/', '\\']);
format!("{base}/{IDEAI_DIR}/{MEMORY_DIR}")
}
/// `<memory-dir>/<slug>.md`.
fn md_path(&self, root: &ProjectPath, slug: &MemorySlug) -> RemotePath {
RemotePath::new(format!("{}/{}.md", self.dir(root), slug.as_str()))
}
/// `<memory-dir>/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<Memory, MemoryError> {
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<Option<String>, 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<Vec<MemorySlug>, 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<Vec<Memory>, 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<Memory, MemoryError> {
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 `<slug>.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<Vec<MemoryIndexEntry>, 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<Vec<MemoryLink>, 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<Item = MemoryIndexEntry>,
budget: usize,
) -> Vec<MemoryIndexEntry> {
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<dyn MemoryStore>`], 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<dyn MemoryStore>,
}
impl NaiveMemoryRecall {
/// Builds the recall adapter from a composed [`MemoryStore`].
#[must_use]
pub fn new(store: Arc<dyn MemoryStore>) -> Self {
Self { store }
}
}
#[async_trait]
impl MemoryRecall for NaiveMemoryRecall {
async fn recall(
&self,
root: &ProjectPath,
query: &MemoryQuery,
) -> Result<Vec<MemoryIndexEntry>, 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<Memory, MemoryError> {
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<MemoryFrontmatter, MemoryError> {
let err = |reason: &str| MemoryError::Frontmatter(reason.to_string());
let mut name: Option<String> = None;
let mut description: Option<String> = None;
let mut type_str: Option<String> = 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<MemoryIndexEntry> {
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<MemoryIndexEntry> {
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<MemoryType> {
match s {
"user" => Some(MemoryType::User),
"feedback" => Some(MemoryType::Feedback),
"project" => Some(MemoryType::Project),
"reference" => Some(MemoryType::Reference),
_ => None,
}
}