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:
@ -31,6 +31,7 @@ use crate::agent::AgentManifest;
|
||||
use crate::events::DomainEvent;
|
||||
use crate::ids::{AgentId, SessionId};
|
||||
use crate::markdown::MarkdownDoc;
|
||||
use crate::memory::{Memory, MemoryIndexEntry, MemoryLink, MemorySlug};
|
||||
use crate::profile::AgentProfile;
|
||||
use crate::project::{Project, ProjectPath};
|
||||
use crate::remote::RemoteKind;
|
||||
@ -257,6 +258,46 @@ pub enum StoreError {
|
||||
Io(String),
|
||||
}
|
||||
|
||||
/// Errors from the [`MemoryStore`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Error)]
|
||||
pub enum MemoryError {
|
||||
/// The requested note was not found.
|
||||
#[error("memory not found")]
|
||||
NotFound,
|
||||
/// A note's frontmatter could not be parsed or validated.
|
||||
#[error("memory frontmatter error: {0}")]
|
||||
Frontmatter(String),
|
||||
/// Underlying I/O error.
|
||||
#[error("memory io failed: {0}")]
|
||||
Io(String),
|
||||
/// (De)serialisation of the index or another structured part failed.
|
||||
#[error("memory serialization failed: {0}")]
|
||||
Serialization(String),
|
||||
}
|
||||
|
||||
/// Errors from an [`Embedder`] (LOT C, étage 2 vectoriel).
|
||||
///
|
||||
/// Best-effort by contract: a [`MemoryRecall`] that composes an embedder must
|
||||
/// **degrade**, never fail hard, on any of these — an unavailable engine or an
|
||||
/// unimplemented strategy maps to a fallback on the naïve recall (it must never
|
||||
/// surface as a hard recall error). See [`Embedder`] and the `VectorMemoryRecall`
|
||||
/// / `AdaptiveMemoryRecall` adapters.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Error)]
|
||||
pub enum EmbedderError {
|
||||
/// The embedding engine is not installed / not reachable (e.g. a local ONNX
|
||||
/// model file is missing, or a remote embedding server / API is unreachable).
|
||||
#[error("embedder unavailable: {0}")]
|
||||
Unavailable(String),
|
||||
/// The requested strategy is not implemented yet (the concrete `localOnnx` /
|
||||
/// `localServer` / `api` backends ship as documented stubs returning this —
|
||||
/// never a panic). Real ONNX/HTTP integration is an explicit follow-up.
|
||||
#[error("embedder strategy unsupported: {0}")]
|
||||
Unsupported(String),
|
||||
/// An I/O failure while producing embeddings.
|
||||
#[error("embedder io failed: {0}")]
|
||||
Io(String),
|
||||
}
|
||||
|
||||
/// Errors from [`RemoteHost`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Error)]
|
||||
pub enum RemoteError {
|
||||
@ -561,6 +602,130 @@ pub trait SkillStore: Send + Sync {
|
||||
) -> Result<(), StoreError>;
|
||||
}
|
||||
|
||||
/// CRUD for project [`Memory`] notes — the `.md` knowledge base under
|
||||
/// `.ideai/memory/` (LOT A, étage 1). Notes are the single source of truth; the
|
||||
/// aggregated `MEMORY.md` index is derived and kept in sync on every write.
|
||||
///
|
||||
/// `root` identifies the project whose `.ideai/memory/` to use; it is supplied
|
||||
/// **per call** (mirroring [`SkillStore`]) so a single store instance serves
|
||||
/// every open project.
|
||||
#[async_trait]
|
||||
pub trait MemoryStore: Send + Sync {
|
||||
/// Lists all memory notes for `root`'s project.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`MemoryError`] on failure (e.g. a malformed note's frontmatter).
|
||||
async fn list(&self, root: &ProjectPath) -> Result<Vec<Memory>, MemoryError>;
|
||||
|
||||
/// Gets a memory note by slug.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`MemoryError::NotFound`] if absent; [`MemoryError::Frontmatter`] if its
|
||||
/// frontmatter is malformed.
|
||||
async fn get(&self, root: &ProjectPath, slug: &MemorySlug) -> Result<Memory, MemoryError>;
|
||||
|
||||
/// Saves (creates or replaces by slug) a note: writes `<slug>.md` and upserts
|
||||
/// its line in `MEMORY.md` idempotently.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`MemoryError`] on failure.
|
||||
async fn save(&self, root: &ProjectPath, memory: &Memory) -> Result<(), MemoryError>;
|
||||
|
||||
/// Deletes a note by slug, removing its line from `MEMORY.md`.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`MemoryError::NotFound`] if absent.
|
||||
async fn delete(&self, root: &ProjectPath, slug: &MemorySlug) -> Result<(), MemoryError>;
|
||||
|
||||
/// Reads the aggregated `MEMORY.md` index as structured entries (empty if the
|
||||
/// index does not exist yet).
|
||||
///
|
||||
/// # Errors
|
||||
/// [`MemoryError`] on an I/O failure.
|
||||
async fn read_index(&self, root: &ProjectPath) -> Result<Vec<MemoryIndexEntry>, MemoryError>;
|
||||
|
||||
/// Resolves the `[[slug]]` links emanating from `slug`'s note, **ignoring
|
||||
/// broken links** (targets that do not resolve to an existing note).
|
||||
///
|
||||
/// # Errors
|
||||
/// [`MemoryError::NotFound`] if `slug` itself does not exist.
|
||||
async fn resolve_links(
|
||||
&self,
|
||||
root: &ProjectPath,
|
||||
slug: &MemorySlug,
|
||||
) -> Result<Vec<MemoryLink>, MemoryError>;
|
||||
}
|
||||
|
||||
/// A recall request: the query text plus the token budget bounding the result
|
||||
/// (LOT B, étage 1). `text` is typically the agent's current context; the naïve
|
||||
/// adapter ignores it, but a semantic [`MemoryRecall`] (LOT C) ranks against it.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct MemoryQuery {
|
||||
/// The recall query (often the agent's current working context).
|
||||
pub text: String,
|
||||
/// Approximate token budget the returned entries must fit within. A budget of
|
||||
/// `0` yields an empty result.
|
||||
pub token_budget: usize,
|
||||
}
|
||||
|
||||
/// Adaptive recall of the most relevant subset of a project's memory index for a
|
||||
/// query, bounded by a token budget (LOT B, étage 1).
|
||||
///
|
||||
/// Contract (best-effort, never blocking):
|
||||
/// - an empty or absent memory yields an empty list, **never** an error;
|
||||
/// - a `token_budget` of `0` yields an empty list;
|
||||
/// - the naïve adapter ignores semantic relevance and returns the index entries
|
||||
/// in order, truncated to fit the budget.
|
||||
///
|
||||
/// **Liskov**: every implementation (`NaiveMemoryRecall`, the future
|
||||
/// `VectorMemoryRecall`) is substitutable — same emptiness/budget guarantees, only
|
||||
/// the relevance strategy differs.
|
||||
#[async_trait]
|
||||
pub trait MemoryRecall: Send + Sync {
|
||||
/// Returns the entries most relevant to `query` for `root`'s project, capped
|
||||
/// at `query.token_budget`.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`MemoryError`] only on an unexpected I/O failure of the underlying store;
|
||||
/// an empty or missing memory is **not** an error (returns an empty list).
|
||||
async fn recall(
|
||||
&self,
|
||||
root: &ProjectPath,
|
||||
query: &MemoryQuery,
|
||||
) -> Result<Vec<MemoryIndexEntry>, MemoryError>;
|
||||
}
|
||||
|
||||
/// Produces embedding vectors for texts, driven by a declarative
|
||||
/// [`crate::profile::EmbedderProfile`] (façon §9 — adding an engine is *data*,
|
||||
/// not code: Open/Closed). The étage-2 vector recall (LOT C) composes this port
|
||||
/// to rank memory notes semantically.
|
||||
///
|
||||
/// Contract:
|
||||
/// - [`embed`](Self::embed) returns **one vector per input text**, in the same
|
||||
/// order, each of length [`dimension`](Self::dimension);
|
||||
/// - it is **best-effort from the caller's standpoint**: any failure is an
|
||||
/// [`EmbedderError`], on which a composing [`MemoryRecall`] degrades to the
|
||||
/// naïve recall — it must never bubble up as a hard recall error;
|
||||
/// - implementations are **substitutable** (Liskov): a deterministic test
|
||||
/// embedder and a real ONNX/HTTP one differ only in vector quality, not in the
|
||||
/// shape of their guarantees.
|
||||
#[async_trait]
|
||||
pub trait Embedder: Send + Sync {
|
||||
/// Stable identifier of this embedder (e.g. `"local-onnx-minilm"`).
|
||||
fn id(&self) -> &str;
|
||||
|
||||
/// Embeds each text into a `dimension()`-length vector, preserving order.
|
||||
///
|
||||
/// # Errors
|
||||
/// - [`EmbedderError::Unavailable`] if the engine is not installed/reachable,
|
||||
/// - [`EmbedderError::Unsupported`] if the strategy is not implemented yet,
|
||||
/// - [`EmbedderError::Io`] on an I/O failure.
|
||||
async fn embed(&self, texts: &[String]) -> Result<Vec<Vec<f32>>, EmbedderError>;
|
||||
|
||||
/// The length of every vector produced by [`embed`](Self::embed).
|
||||
fn dimension(&self) -> usize;
|
||||
}
|
||||
|
||||
/// Persistence of the known-projects registry and the workspace.
|
||||
#[async_trait]
|
||||
pub trait ProjectStore: Send + Sync {
|
||||
|
||||
Reference in New Issue
Block a user