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:
492
crates/infrastructure/src/store/memory.rs
Normal file
492
crates/infrastructure/src/store/memory.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user