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:
414
crates/application/src/memory/usecases.rs
Normal file
414
crates/application/src/memory/usecases.rs
Normal file
@ -0,0 +1,414 @@
|
||||
//! Memory use cases (ARCHITECTURE §14.5.1).
|
||||
//!
|
||||
//! - **CRUD**: [`CreateMemory`], [`UpdateMemory`], [`GetMemory`],
|
||||
//! [`ListMemories`], [`DeleteMemory`].
|
||||
//! - **Navigation** (read-only): [`ReadMemoryIndex`] (structured `MEMORY.md`
|
||||
//! rows) and [`ResolveMemoryLinks`] (a note's outgoing `[[slug]]` links).
|
||||
//!
|
||||
//! Mutating use cases announce a [`DomainEvent`]; the read use cases emit none.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::ports::{EventBus, MemoryQuery, MemoryRecall, MemoryStore};
|
||||
use domain::{
|
||||
DomainEvent, MarkdownDoc, Memory, MemoryFrontmatter, MemoryIndexEntry, MemoryLink, MemorySlug,
|
||||
MemoryType, ProjectPath,
|
||||
};
|
||||
|
||||
use crate::error::AppError;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CreateMemory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Input for [`CreateMemory::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CreateMemoryInput {
|
||||
/// Active project root (the note is stored under its `.ideai/memory/`).
|
||||
pub project_root: ProjectPath,
|
||||
/// Raw slug for the new note (validated into a [`MemorySlug`]).
|
||||
pub name: String,
|
||||
/// Human-readable one-line description (the index hook). Non-empty.
|
||||
pub description: String,
|
||||
/// The note's kind.
|
||||
pub r#type: MemoryType,
|
||||
/// Markdown body of the note.
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
/// Output of [`CreateMemory::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CreateMemoryOutput {
|
||||
/// The created note.
|
||||
pub memory: Memory,
|
||||
}
|
||||
|
||||
/// Creates a memory note in the project's store and upserts the index.
|
||||
///
|
||||
/// Announces [`DomainEvent::MemorySaved`] on success.
|
||||
pub struct CreateMemory {
|
||||
memories: Arc<dyn MemoryStore>,
|
||||
events: Arc<dyn EventBus>,
|
||||
}
|
||||
|
||||
impl CreateMemory {
|
||||
/// Builds the use case from its ports.
|
||||
#[must_use]
|
||||
pub fn new(memories: Arc<dyn MemoryStore>, events: Arc<dyn EventBus>) -> Self {
|
||||
Self { memories, events }
|
||||
}
|
||||
|
||||
/// Executes creation.
|
||||
///
|
||||
/// # Errors
|
||||
/// - [`AppError::Invalid`] if `name` is not a valid kebab-case slug or the
|
||||
/// note's invariants are violated (empty description/body),
|
||||
/// - [`AppError::Store`] on persistence failure.
|
||||
pub async fn execute(&self, input: CreateMemoryInput) -> Result<CreateMemoryOutput, AppError> {
|
||||
let slug = MemorySlug::new(input.name).map_err(|e| AppError::Invalid(e.to_string()))?;
|
||||
let frontmatter = MemoryFrontmatter {
|
||||
name: slug.clone(),
|
||||
description: input.description,
|
||||
r#type: input.r#type,
|
||||
};
|
||||
let memory = Memory::new(frontmatter, MarkdownDoc::new(input.content))
|
||||
.map_err(|e| AppError::Invalid(e.to_string()))?;
|
||||
self.memories.save(&input.project_root, &memory).await?;
|
||||
self.events.publish(DomainEvent::MemorySaved { slug });
|
||||
Ok(CreateMemoryOutput { memory })
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// UpdateMemory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Input for [`UpdateMemory::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct UpdateMemoryInput {
|
||||
/// Active project root.
|
||||
pub project_root: ProjectPath,
|
||||
/// Slug of the note to replace.
|
||||
pub slug: MemorySlug,
|
||||
/// New description (the index hook). Non-empty.
|
||||
pub description: String,
|
||||
/// New kind.
|
||||
pub r#type: MemoryType,
|
||||
/// New Markdown body.
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
/// Output of [`UpdateMemory::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct UpdateMemoryOutput {
|
||||
/// The updated note.
|
||||
pub memory: Memory,
|
||||
}
|
||||
|
||||
/// Replaces a memory note's content (re-validating its invariants).
|
||||
///
|
||||
/// Announces [`DomainEvent::MemorySaved`] on success. The input carries the full
|
||||
/// note, so no prior `get` is needed — this is replace semantics.
|
||||
pub struct UpdateMemory {
|
||||
memories: Arc<dyn MemoryStore>,
|
||||
events: Arc<dyn EventBus>,
|
||||
}
|
||||
|
||||
impl UpdateMemory {
|
||||
/// Builds the use case from its ports.
|
||||
#[must_use]
|
||||
pub fn new(memories: Arc<dyn MemoryStore>, events: Arc<dyn EventBus>) -> Self {
|
||||
Self { memories, events }
|
||||
}
|
||||
|
||||
/// Executes the update.
|
||||
///
|
||||
/// # Errors
|
||||
/// - [`AppError::Invalid`] if the note's invariants are violated (empty
|
||||
/// description/body),
|
||||
/// - [`AppError::Store`] on persistence failure.
|
||||
pub async fn execute(&self, input: UpdateMemoryInput) -> Result<UpdateMemoryOutput, AppError> {
|
||||
let frontmatter = MemoryFrontmatter {
|
||||
name: input.slug.clone(),
|
||||
description: input.description,
|
||||
r#type: input.r#type,
|
||||
};
|
||||
let memory = Memory::new(frontmatter, MarkdownDoc::new(input.content))
|
||||
.map_err(|e| AppError::Invalid(e.to_string()))?;
|
||||
self.memories.save(&input.project_root, &memory).await?;
|
||||
self.events.publish(DomainEvent::MemorySaved { slug: input.slug });
|
||||
Ok(UpdateMemoryOutput { memory })
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ListMemories
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Input for [`ListMemories::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ListMemoriesInput {
|
||||
/// Active project root.
|
||||
pub project_root: ProjectPath,
|
||||
}
|
||||
|
||||
/// Output of [`ListMemories::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ListMemoriesOutput {
|
||||
/// All notes in the project.
|
||||
pub memories: Vec<Memory>,
|
||||
}
|
||||
|
||||
/// Lists the memory notes of a project.
|
||||
pub struct ListMemories {
|
||||
memories: Arc<dyn MemoryStore>,
|
||||
}
|
||||
|
||||
impl ListMemories {
|
||||
/// Builds the use case.
|
||||
#[must_use]
|
||||
pub fn new(memories: Arc<dyn MemoryStore>) -> Self {
|
||||
Self { memories }
|
||||
}
|
||||
|
||||
/// Lists the project's notes.
|
||||
///
|
||||
/// # Errors
|
||||
/// - [`AppError::Invalid`] if a note's frontmatter is malformed,
|
||||
/// - [`AppError::Store`] on persistence failure.
|
||||
pub async fn execute(&self, input: ListMemoriesInput) -> Result<ListMemoriesOutput, AppError> {
|
||||
Ok(ListMemoriesOutput {
|
||||
memories: self.memories.list(&input.project_root).await?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GetMemory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Input for [`GetMemory::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct GetMemoryInput {
|
||||
/// Active project root.
|
||||
pub project_root: ProjectPath,
|
||||
/// Slug of the note to read.
|
||||
pub slug: MemorySlug,
|
||||
}
|
||||
|
||||
/// Output of [`GetMemory::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct GetMemoryOutput {
|
||||
/// The requested note.
|
||||
pub memory: Memory,
|
||||
}
|
||||
|
||||
/// Reads one memory note by slug.
|
||||
pub struct GetMemory {
|
||||
memories: Arc<dyn MemoryStore>,
|
||||
}
|
||||
|
||||
impl GetMemory {
|
||||
/// Builds the use case.
|
||||
#[must_use]
|
||||
pub fn new(memories: Arc<dyn MemoryStore>) -> Self {
|
||||
Self { memories }
|
||||
}
|
||||
|
||||
/// Reads the note.
|
||||
///
|
||||
/// # Errors
|
||||
/// - [`AppError::NotFound`] if no note carries that slug,
|
||||
/// - [`AppError::Invalid`] if the note's frontmatter is malformed,
|
||||
/// - [`AppError::Store`] on persistence failure.
|
||||
pub async fn execute(&self, input: GetMemoryInput) -> Result<GetMemoryOutput, AppError> {
|
||||
Ok(GetMemoryOutput {
|
||||
memory: self.memories.get(&input.project_root, &input.slug).await?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DeleteMemory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Input for [`DeleteMemory::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DeleteMemoryInput {
|
||||
/// Active project root.
|
||||
pub project_root: ProjectPath,
|
||||
/// Slug of the note to delete.
|
||||
pub slug: MemorySlug,
|
||||
}
|
||||
|
||||
/// Deletes a memory note (and removes its index row).
|
||||
///
|
||||
/// Announces [`DomainEvent::MemoryDeleted`] on success.
|
||||
pub struct DeleteMemory {
|
||||
memories: Arc<dyn MemoryStore>,
|
||||
events: Arc<dyn EventBus>,
|
||||
}
|
||||
|
||||
impl DeleteMemory {
|
||||
/// Builds the use case from its ports.
|
||||
#[must_use]
|
||||
pub fn new(memories: Arc<dyn MemoryStore>, events: Arc<dyn EventBus>) -> Self {
|
||||
Self { memories, events }
|
||||
}
|
||||
|
||||
/// Deletes the note.
|
||||
///
|
||||
/// # Errors
|
||||
/// - [`AppError::NotFound`] if no note carries that slug,
|
||||
/// - [`AppError::Store`] on persistence failure.
|
||||
pub async fn execute(&self, input: DeleteMemoryInput) -> Result<(), AppError> {
|
||||
self.memories.delete(&input.project_root, &input.slug).await?;
|
||||
self.events.publish(DomainEvent::MemoryDeleted { slug: input.slug });
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ReadMemoryIndex
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Input for [`ReadMemoryIndex::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ReadMemoryIndexInput {
|
||||
/// Active project root.
|
||||
pub project_root: ProjectPath,
|
||||
}
|
||||
|
||||
/// Output of [`ReadMemoryIndex::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ReadMemoryIndexOutput {
|
||||
/// One row per note in the aggregated `MEMORY.md` index.
|
||||
pub entries: Vec<MemoryIndexEntry>,
|
||||
}
|
||||
|
||||
/// Reads the structured `MEMORY.md` index (drives the graphical memory view).
|
||||
pub struct ReadMemoryIndex {
|
||||
memories: Arc<dyn MemoryStore>,
|
||||
}
|
||||
|
||||
impl ReadMemoryIndex {
|
||||
/// Builds the use case.
|
||||
#[must_use]
|
||||
pub fn new(memories: Arc<dyn MemoryStore>) -> Self {
|
||||
Self { memories }
|
||||
}
|
||||
|
||||
/// Reads the index rows.
|
||||
///
|
||||
/// # Errors
|
||||
/// - [`AppError::Store`] on an I/O failure.
|
||||
pub async fn execute(
|
||||
&self,
|
||||
input: ReadMemoryIndexInput,
|
||||
) -> Result<ReadMemoryIndexOutput, AppError> {
|
||||
Ok(ReadMemoryIndexOutput {
|
||||
entries: self.memories.read_index(&input.project_root).await?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// RecallMemory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Input for [`RecallMemory::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct RecallMemoryInput {
|
||||
/// Active project root.
|
||||
pub project_root: ProjectPath,
|
||||
/// The recall query (often the agent's current working context).
|
||||
pub text: String,
|
||||
/// Approximate token budget bounding the returned entries (`0` ⇒ empty).
|
||||
pub token_budget: usize,
|
||||
}
|
||||
|
||||
/// Output of [`RecallMemory::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct RecallMemoryOutput {
|
||||
/// The recalled index entries, in relevance order, fitting the budget.
|
||||
pub entries: Vec<MemoryIndexEntry>,
|
||||
}
|
||||
|
||||
/// Recalls the most relevant memory entries for a query within a token budget
|
||||
/// (LOT B, étage 1). Read-only — emits no event.
|
||||
pub struct RecallMemory {
|
||||
recall: Arc<dyn MemoryRecall>,
|
||||
}
|
||||
|
||||
impl RecallMemory {
|
||||
/// Builds the use case from the [`MemoryRecall`] port.
|
||||
#[must_use]
|
||||
pub fn new(recall: Arc<dyn MemoryRecall>) -> Self {
|
||||
Self { recall }
|
||||
}
|
||||
|
||||
/// Executes recall. Best-effort: an empty or absent memory yields an empty
|
||||
/// list, and a budget of `0` yields an empty list.
|
||||
///
|
||||
/// # Errors
|
||||
/// - [`AppError::Store`] on an unexpected I/O failure of the underlying store.
|
||||
pub async fn execute(&self, input: RecallMemoryInput) -> Result<RecallMemoryOutput, AppError> {
|
||||
let query = MemoryQuery {
|
||||
text: input.text,
|
||||
token_budget: input.token_budget,
|
||||
};
|
||||
Ok(RecallMemoryOutput {
|
||||
entries: self.recall.recall(&input.project_root, &query).await?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ResolveMemoryLinks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Input for [`ResolveMemoryLinks::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ResolveMemoryLinksInput {
|
||||
/// Active project root.
|
||||
pub project_root: ProjectPath,
|
||||
/// Slug of the source note whose outgoing links are resolved.
|
||||
pub slug: MemorySlug,
|
||||
}
|
||||
|
||||
/// Output of [`ResolveMemoryLinks::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ResolveMemoryLinksOutput {
|
||||
/// The note's resolved outgoing `[[slug]]` links (broken links dropped).
|
||||
pub links: Vec<MemoryLink>,
|
||||
}
|
||||
|
||||
/// Resolves a note's outgoing `[[slug]]` links (drives link navigation).
|
||||
pub struct ResolveMemoryLinks {
|
||||
memories: Arc<dyn MemoryStore>,
|
||||
}
|
||||
|
||||
impl ResolveMemoryLinks {
|
||||
/// Builds the use case.
|
||||
#[must_use]
|
||||
pub fn new(memories: Arc<dyn MemoryStore>) -> Self {
|
||||
Self { memories }
|
||||
}
|
||||
|
||||
/// Resolves the outgoing links.
|
||||
///
|
||||
/// # Errors
|
||||
/// - [`AppError::NotFound`] if the source note does not exist,
|
||||
/// - [`AppError::Store`] on persistence failure.
|
||||
pub async fn execute(
|
||||
&self,
|
||||
input: ResolveMemoryLinksInput,
|
||||
) -> Result<ResolveMemoryLinksOutput, AppError> {
|
||||
Ok(ResolveMemoryLinksOutput {
|
||||
links: self
|
||||
.memories
|
||||
.resolve_links(&input.project_root, &input.slug)
|
||||
.await?,
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user