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

@ -150,6 +150,107 @@ pub struct AgentProfile {
pub session: Option<SessionStrategy>,
}
/// Embedding strategy of an [`EmbedderProfile`] (LOT C, étage 2 vectoriel).
///
/// Declarative, Open/Closed: a new engine family is a new variant *only* if it
/// changes the adapter's dispatch; otherwise it is pure data on the profile
/// (`model`, `endpoint`, …). The default product posture is [`None`](Self::None):
/// no embedding ⇒ no heavy dependency, recall stays the naïve étage 1.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum EmbedderStrategy {
/// A local ONNX model run in-process (real integration is a follow-up).
LocalOnnx,
/// A local embedding server reached over HTTP (real integration is a follow-up).
LocalServer,
/// A remote embedding API (real integration is a follow-up).
Api,
/// No embedding engine: recall stays the dependency-free naïve étage 1.
None,
}
/// Declarative configuration for one embedding engine (LOT C, étage 2).
///
/// Stored in the global IDE store as `embedder.json`, mirroring `profiles.json`
/// for [`AgentProfile`]s: adding an engine is **data, not code** (Open/Closed).
///
/// Invariants:
/// - `id` and `name` non-empty,
/// - `dimension` is non-zero (an embedder always produces fixed-length vectors).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct EmbedderProfile {
/// Stable identifier (e.g. `"local-onnx-minilm"`).
pub id: String,
/// Display name.
pub name: String,
/// Embedding strategy driving which concrete adapter is used.
pub strategy: EmbedderStrategy,
/// Model identifier (e.g. an ONNX model name), when the strategy needs one.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
/// Endpoint URL for a server/API strategy.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub endpoint: Option<String>,
/// Name of the environment variable carrying the API key (never the key
/// itself), for an `api` strategy.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub api_key_env: Option<String>,
/// Length of the vectors this engine produces.
pub dimension: usize,
}
impl EmbedderProfile {
/// Builds a validated embedder profile.
///
/// # Errors
/// - [`DomainError::EmptyField`] if `id` or `name` is empty,
/// - [`DomainError::EmptyField`] (`"embedder.dimension"`) if `dimension` is `0`.
pub fn new(
id: impl Into<String>,
name: impl Into<String>,
strategy: EmbedderStrategy,
model: Option<String>,
endpoint: Option<String>,
api_key_env: Option<String>,
dimension: usize,
) -> Result<Self, DomainError> {
let id = id.into();
let name = name.into();
crate::validation::non_empty(&id, "embedder.id")?;
crate::validation::non_empty(&name, "embedder.name")?;
if dimension == 0 {
return Err(DomainError::EmptyField {
field: "embedder.dimension",
});
}
Ok(Self {
id,
name,
strategy,
model,
endpoint,
api_key_env,
dimension,
})
}
/// A dependency-free default profile: strategy [`EmbedderStrategy::None`].
/// Recall stays the naïve étage 1 — nothing is imposed.
#[must_use]
pub fn none() -> Self {
Self {
id: "none".to_owned(),
name: "None".to_owned(),
strategy: EmbedderStrategy::None,
model: None,
endpoint: None,
api_key_env: None,
dimension: 1,
}
}
}
impl AgentProfile {
/// Builds a validated profile.
///