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:
278
crates/infrastructure/tests/memory_store.rs
Normal file
278
crates/infrastructure/tests/memory_store.rs
Normal file
@ -0,0 +1,278 @@
|
||||
//! Tests for [`FsMemoryStore`] against an **in-memory mock** [`FileSystem`]:
|
||||
//! `save` writes the `<slug>.md` AND the `MEMORY.md` index line; get/list/delete;
|
||||
//! upsert idempotence (same slug => one index line); malformed frontmatter
|
||||
//! surfaces as [`MemoryError::Frontmatter`]; and `resolve_links` ignores broken
|
||||
//! links.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
use domain::markdown::MarkdownDoc;
|
||||
use domain::memory::{Memory, MemoryFrontmatter, MemorySlug, MemoryType};
|
||||
use domain::ports::{DirEntry, FileSystem, FsError, MemoryError, MemoryStore, RemotePath};
|
||||
use domain::project::ProjectPath;
|
||||
use infrastructure::FsMemoryStore;
|
||||
|
||||
/// A minimal in-memory [`FileSystem`] mock: a path -> bytes map behind a mutex.
|
||||
/// `create_dir_all`/`symlink`/`list` are no-ops or derived; only read/write/exists
|
||||
/// matter for the memory store.
|
||||
#[derive(Default)]
|
||||
struct MemFs {
|
||||
files: Mutex<HashMap<String, Vec<u8>>>,
|
||||
}
|
||||
|
||||
impl MemFs {
|
||||
fn arc() -> Arc<Self> {
|
||||
Arc::new(Self::default())
|
||||
}
|
||||
fn raw(&self, path: &str) -> Option<String> {
|
||||
self.files
|
||||
.lock()
|
||||
.unwrap()
|
||||
.get(path)
|
||||
.map(|b| String::from_utf8(b.clone()).unwrap())
|
||||
}
|
||||
/// Directly seeds a file (to inject a malformed note bypassing the store).
|
||||
fn put(&self, path: &str, content: &str) {
|
||||
self.files
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert(path.to_string(), content.as_bytes().to_vec());
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl FileSystem for MemFs {
|
||||
async fn read(&self, path: &RemotePath) -> Result<Vec<u8>, FsError> {
|
||||
self.files
|
||||
.lock()
|
||||
.unwrap()
|
||||
.get(path.as_str())
|
||||
.cloned()
|
||||
.ok_or_else(|| FsError::NotFound(path.as_str().to_string()))
|
||||
}
|
||||
async fn write(&self, path: &RemotePath, data: &[u8]) -> Result<(), FsError> {
|
||||
self.files
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert(path.as_str().to_string(), data.to_vec());
|
||||
Ok(())
|
||||
}
|
||||
async fn exists(&self, path: &RemotePath) -> Result<bool, FsError> {
|
||||
Ok(self.files.lock().unwrap().contains_key(path.as_str()))
|
||||
}
|
||||
async fn create_dir_all(&self, _path: &RemotePath) -> Result<(), FsError> {
|
||||
Ok(())
|
||||
}
|
||||
async fn list(&self, _path: &RemotePath) -> Result<Vec<DirEntry>, FsError> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
async fn symlink(&self, _src: &RemotePath, _dst: &RemotePath) -> Result<(), FsError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn root() -> ProjectPath {
|
||||
ProjectPath::new("/proj").unwrap()
|
||||
}
|
||||
|
||||
fn slug(s: &str) -> MemorySlug {
|
||||
MemorySlug::new(s).unwrap()
|
||||
}
|
||||
|
||||
fn note(slug_str: &str, hook: &str, ty: MemoryType, body: &str) -> Memory {
|
||||
Memory::new(
|
||||
MemoryFrontmatter {
|
||||
name: MemorySlug::new(slug_str).unwrap(),
|
||||
description: hook.to_string(),
|
||||
r#type: ty,
|
||||
},
|
||||
MarkdownDoc::new(body),
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn missing_index_lists_empty() {
|
||||
let store = FsMemoryStore::new(MemFs::arc());
|
||||
assert!(store.list(&root()).await.unwrap().is_empty());
|
||||
assert!(store.read_index(&root()).await.unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn save_writes_md_and_index_line() {
|
||||
let fs = MemFs::arc();
|
||||
let store = FsMemoryStore::new(fs.clone());
|
||||
store
|
||||
.save(&root(), ¬e("alpha", "the hook", MemoryType::Project, "# Body"))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// The note `.md` exists with frontmatter + body.
|
||||
let md = fs.raw("/proj/.ideai/memory/alpha.md").expect("note written");
|
||||
assert!(md.starts_with("---\n"));
|
||||
assert!(md.contains("name: alpha"));
|
||||
assert!(md.contains("description: the hook"));
|
||||
assert!(md.contains("type: project"));
|
||||
assert!(md.contains("# Body"));
|
||||
|
||||
// The index has the header + the line.
|
||||
let index = fs.raw("/proj/.ideai/memory/MEMORY.md").expect("index written");
|
||||
assert!(index.starts_with("# Memory Index"));
|
||||
assert!(index.contains("- [alpha](alpha.md) — the hook"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_roundtrips_the_saved_note() {
|
||||
let store = FsMemoryStore::new(MemFs::arc());
|
||||
let m = note("topic-x", "hook", MemoryType::Feedback, "content [[other]]");
|
||||
store.save(&root(), &m).await.unwrap();
|
||||
|
||||
let got = store.get(&root(), &slug("topic-x")).await.unwrap();
|
||||
assert_eq!(got, m);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_missing_is_not_found() {
|
||||
let store = FsMemoryStore::new(MemFs::arc());
|
||||
assert!(matches!(
|
||||
store.get(&root(), &slug("nope")).await.unwrap_err(),
|
||||
MemoryError::NotFound
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_returns_all_saved_notes() {
|
||||
let store = FsMemoryStore::new(MemFs::arc());
|
||||
store
|
||||
.save(&root(), ¬e("a", "ha", MemoryType::User, "A"))
|
||||
.await
|
||||
.unwrap();
|
||||
store
|
||||
.save(&root(), ¬e("b", "hb", MemoryType::Project, "B"))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let mut slugs: Vec<_> = store
|
||||
.list(&root())
|
||||
.await
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.map(|m| m.slug().as_str().to_string())
|
||||
.collect();
|
||||
slugs.sort();
|
||||
assert_eq!(slugs, vec!["a".to_string(), "b".to_string()]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn save_is_idempotent_upsert_single_index_line() {
|
||||
let fs = MemFs::arc();
|
||||
let store = FsMemoryStore::new(fs.clone());
|
||||
store
|
||||
.save(&root(), ¬e("dup", "hook v1", MemoryType::Project, "v1"))
|
||||
.await
|
||||
.unwrap();
|
||||
store
|
||||
.save(&root(), ¬e("dup", "hook v2", MemoryType::Project, "v2"))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Content was replaced.
|
||||
assert_eq!(
|
||||
store
|
||||
.get(&root(), &slug("dup"))
|
||||
.await
|
||||
.unwrap()
|
||||
.body
|
||||
.as_str(),
|
||||
"v2"
|
||||
);
|
||||
// Exactly one index entry for the slug.
|
||||
let entries = store.read_index(&root()).await.unwrap();
|
||||
assert_eq!(entries.len(), 1);
|
||||
assert_eq!(entries[0].hook, "hook v2");
|
||||
// And the raw index has only one matching line.
|
||||
let index = fs.raw("/proj/.ideai/memory/MEMORY.md").unwrap();
|
||||
assert_eq!(index.matches("- [dup]").count(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_removes_index_line_and_is_not_found_twice() {
|
||||
let store = FsMemoryStore::new(MemFs::arc());
|
||||
store
|
||||
.save(&root(), ¬e("gone", "h", MemoryType::User, "x"))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
store.delete(&root(), &slug("gone")).await.unwrap();
|
||||
assert!(store.list(&root()).await.unwrap().is_empty());
|
||||
assert!(matches!(
|
||||
store.delete(&root(), &slug("gone")).await.unwrap_err(),
|
||||
MemoryError::NotFound
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn malformed_frontmatter_surfaces_as_error() {
|
||||
let fs = MemFs::arc();
|
||||
// Seed the index so the slug is listed, plus a malformed note file.
|
||||
fs.put(
|
||||
"/proj/.ideai/memory/MEMORY.md",
|
||||
"# Memory Index\n\n- [bad](bad.md) — h\n",
|
||||
);
|
||||
fs.put(
|
||||
"/proj/.ideai/memory/bad.md",
|
||||
"no frontmatter fence here\njust body",
|
||||
);
|
||||
let store = FsMemoryStore::new(fs);
|
||||
|
||||
assert!(matches!(
|
||||
store.get(&root(), &slug("bad")).await.unwrap_err(),
|
||||
MemoryError::Frontmatter(_)
|
||||
));
|
||||
// list propagates the same error.
|
||||
assert!(matches!(
|
||||
store.list(&root()).await.unwrap_err(),
|
||||
MemoryError::Frontmatter(_)
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resolve_links_ignores_broken_links() {
|
||||
let store = FsMemoryStore::new(MemFs::arc());
|
||||
// `target` exists; `ghost` does not.
|
||||
store
|
||||
.save(&root(), ¬e("target", "h", MemoryType::Project, "T"))
|
||||
.await
|
||||
.unwrap();
|
||||
store
|
||||
.save(
|
||||
&root(),
|
||||
¬e(
|
||||
"src",
|
||||
"h",
|
||||
MemoryType::Project,
|
||||
"links to [[target]] and [[ghost]]",
|
||||
),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let resolved = store.resolve_links(&root(), &slug("src")).await.unwrap();
|
||||
let targets: Vec<_> = resolved
|
||||
.into_iter()
|
||||
.map(|l| l.target.as_str().to_string())
|
||||
.collect();
|
||||
assert_eq!(targets, vec!["target".to_string()]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resolve_links_missing_source_is_not_found() {
|
||||
let store = FsMemoryStore::new(MemFs::arc());
|
||||
assert!(matches!(
|
||||
store.resolve_links(&root(), &slug("nope")).await.unwrap_err(),
|
||||
MemoryError::NotFound
|
||||
));
|
||||
}
|
||||
Reference in New Issue
Block a user