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:
160
ARCHITECTURE.md
160
ARCHITECTURE.md
@ -600,6 +600,7 @@ IdeA/
|
||||
| L11 | **Packaging & livraison** | Tauri bundle : NSIS `setup.exe`, **AppImage** multi-distro, CI Linux+Windows. | `app-tauri`, CI |
|
||||
| L12 | **Skills** | Entité `Skill`, `SkillStore`, CRUD skills global+projet, assignation agent↔skills, injection dans convention file à l'activation. UI onglet Skills. | `domain/skill`, `application/skill`, `infrastructure/store`, `frontend/features/skills` |
|
||||
| L13 | **OrchestratorApi** | File-watcher `.ideai/requests/`, port `OrchestratorApi`, adapter `FsOrchestratorAdapter`, actions `spawn_agent`/`stop_agent`/`update_agent_context`. | `infrastructure/orchestrator`, `application/agent`, `app-tauri` |
|
||||
| L14 | **Mémoire** | Base de connaissance projet model-agnostic. Modèle 2 étages : `.md` source de vérité (port `MemoryStore`), rappel adaptatif (port `MemoryRecall`), embeddings déclaratifs (port `Embedder`), bascule auto sur seuil. Découpé en sous-lots **A/B/C** (voir §14.5). | `domain/memory`, `application/memory`, `infrastructure/store`, `frontend/features/memory` |
|
||||
|
||||
---
|
||||
|
||||
@ -673,6 +674,165 @@ Git est un **outil posé par-dessus l'IDE**, pas un socle. Supprimer le repo git
|
||||
|
||||
---
|
||||
|
||||
### 14.5 Système de mémoire — base de connaissance projet model-agnostic (L14)
|
||||
|
||||
**Objectif** : doter chaque projet d'une **mémoire persistante** (préférences, décisions, feedback, références) que les agents lisent et enrichissent, **sans jamais dépendre d'un modèle ou d'une CLI** (principe fondateur). La mémoire est commune au project root, partagée par tous les agents (décision : mémoire au niveau projet, pas par `run/<id>`).
|
||||
|
||||
#### Modèle 2 étages (hybride)
|
||||
|
||||
- **Étage 1 — fichiers `.md` (source de vérité)** : chaque note = un `.md` (frontmatter YAML `name`/`description`/`metadata.type` + corps Markdown) sous `.ideai/memory/<slug>.md`. Un index agrégé `MEMORY.md` (une ligne `- [Titre](slug.md) — hook` par note) est **dérivé**, reconstructible. Les notes se référencent par liens `[[slug]]`. C'est le **seul** étage qui fait foi.
|
||||
- **Étage 2 — index de rappel sémantique vectoriel (dérivé)** : embeddings des notes, **jamais source de vérité**, reconstructible et supprimable à tout moment. Sert uniquement à accélérer/cibler le rappel quand la mémoire devient trop grosse pour être lue intégralement.
|
||||
|
||||
#### Bascule étage 1 ↔ étage 2 (automatique, sur seuil objectif)
|
||||
|
||||
Le **rappel** (port `MemoryRecall`) est adaptatif : tant que la taille de la mémoire reste sous un **budget de tokens** configurable, l'adapter **naïf** lit `MEMORY.md` intégralement (zéro dépendance). Au-dessus du seuil, l'adapter **vectoriel** prend le relais. La bascule est une décision objective (taille vs budget), pas un choix manuel. **Défaut : `none`** (rappel naïf, aucun embedder) — esprit Linux « rien d'imposé, tout fonctionnel ».
|
||||
|
||||
#### Ports (frontières domaine)
|
||||
|
||||
- **`MemoryStore`** *(fait — LOT A étage 1)* : CRUD des `.md` + index `MEMORY.md` dérivé + `resolve_links` (ignore les liens cassés). Adapter `FsMemoryStore` (compose `FileSystem`, location-neutral). Erreurs `MemoryError`.
|
||||
- **`MemoryRecall`** *(LOT B)* : rappel adaptatif d'un sous-ensemble pertinent de notes pour une requête. Deux adapters substituables (Liskov) : `NaiveMemoryRecall` (lecture intégrale via `MemoryStore`) et, plus tard, `VectorMemoryRecall` (étage 2).
|
||||
- **`Embedder`** *(LOT C)* : production de vecteurs d'embedding, décrit par des **profils déclaratifs façon CLI LLM** (§9). Stratégies : `localOnnx` / `localServer` / `api` / `none`. `none` = pas d'embedder ⇒ rappel naïf forcé.
|
||||
|
||||
#### Conformité hexagonale
|
||||
|
||||
`MemoryStore`/`MemoryRecall`/`Embedder` sont des **traits du domaine** ; les adapters vivent en `infrastructure`. L'application ne parle qu'aux ports. Le domaine `memory` reste I/O-free (le format YAML/index est **possédé par l'adapter** `FsMemoryStore`, pas par le domaine). La mémoire est **indépendante de git** (§14.4) et du moteur IA.
|
||||
|
||||
#### Découpage en sous-lots (binôme dev+test par sous-lot)
|
||||
|
||||
- **LOT A — Étage 1 `.md`** : *domaine + adapter faits et verts.* Reste à livrer : **use cases `application/memory`** + **câblage app-tauri** (commandes + DTO ; les events `Memory*` et leurs DTO existent déjà). Voir §14.5.1.
|
||||
- **LOT B — Rappel adaptatif** : port `MemoryRecall`, adapter `NaiveMemoryRecall`, use case `RecallMemory`. Voir §14.5.2.
|
||||
- **LOT C — Étage 2 vectoriel** : port `Embedder`, profils déclaratifs `embedder.json`, adapter `VectorMemoryRecall`, logique de bascule sur seuil. Voir §14.5.3.
|
||||
|
||||
##### 14.5.1 LOT A (fin) — contrats application + app-tauri
|
||||
|
||||
**Use cases `application/memory`** (miroir de `application/skill`, chacun `Arc<dyn MemoryStore>`, `execute(Input) -> Result<Output, AppError>`) :
|
||||
|
||||
```rust
|
||||
// CreateMemory — crée/écrit une note + upsert index. Émet MemorySaved.
|
||||
struct CreateMemoryInput { project_root: ProjectPath, name: String, // slug brut → MemorySlug::new
|
||||
description: String, r#type: MemoryType, content: String }
|
||||
struct CreateMemoryOutput { memory: Memory }
|
||||
|
||||
// UpdateMemory — remplace une note existante (revalide invariants). Émet MemorySaved.
|
||||
struct UpdateMemoryInput { project_root: ProjectPath, slug: MemorySlug,
|
||||
description: String, r#type: MemoryType, content: String }
|
||||
struct UpdateMemoryOutput { memory: Memory }
|
||||
|
||||
// ListMemories — liste les notes (pilotée par l'index).
|
||||
struct ListMemoriesInput { project_root: ProjectPath }
|
||||
struct ListMemoriesOutput { memories: Vec<Memory> }
|
||||
|
||||
// GetMemory — une note par slug.
|
||||
struct GetMemoryInput { project_root: ProjectPath, slug: MemorySlug }
|
||||
struct GetMemoryOutput { memory: Memory }
|
||||
|
||||
// DeleteMemory — supprime une note (retire la ligne d'index). Émet MemoryDeleted.
|
||||
struct DeleteMemoryInput { project_root: ProjectPath, slug: MemorySlug } // -> ()
|
||||
|
||||
// ReadMemoryIndex — lit MEMORY.md structuré (pour l'affichage graphique de la mémoire).
|
||||
struct ReadMemoryIndexInput { project_root: ProjectPath }
|
||||
struct ReadMemoryIndexOutput { entries: Vec<MemoryIndexEntry> }
|
||||
|
||||
// ResolveMemoryLinks — liens [[slug]] sortants résolus (liens cassés ignorés).
|
||||
struct ResolveMemoryLinksInput { project_root: ProjectPath, slug: MemorySlug }
|
||||
struct ResolveMemoryLinksOutput { links: Vec<MemoryLink> }
|
||||
```
|
||||
|
||||
> **Décision** : on expose les **7** use cases (CRUD complet + `ReadMemoryIndex` + `ResolveMemoryLinks`). `ReadMemoryIndex` alimente la vue graphique de la mémoire ; `ResolveMemoryLinks` alimente la navigation par liens. `CreateMemory`/`UpdateMemory` portent en plus `Arc<dyn EventBus>`.
|
||||
|
||||
**Émission d'events** (ports `MemoryStore` + `EventBus` ; events déjà définis dans `domain::events`) :
|
||||
|
||||
| Use case | Event émis |
|
||||
|---|---|
|
||||
| `CreateMemory`, `UpdateMemory` | `DomainEvent::MemorySaved { slug }` |
|
||||
| `DeleteMemory` | `DomainEvent::MemoryDeleted { slug }` |
|
||||
| `ListMemories`, `GetMemory`, `ReadMemoryIndex`, `ResolveMemoryLinks` | *(aucun — lectures)* |
|
||||
|
||||
> `MemoryIndexRebuilt { project_id }` n'est **pas** émis par les use cases CRUD (l'index est réécrit en interne par le store à chaque save/delete, pas un rebuild distinct). Le réserver à un futur use case `RebuildMemoryIndex` (reconstruction explicite depuis les `.md`) — non requis pour clore LOT A.
|
||||
|
||||
**Mapping `From<MemoryError> for AppError`** (à ajouter dans `application/src/error.rs`, calqué sur `From<StoreError>`) :
|
||||
|
||||
```rust
|
||||
impl From<MemoryError> for AppError {
|
||||
fn from(e: MemoryError) -> Self {
|
||||
match e {
|
||||
MemoryError::NotFound => Self::NotFound("memory note".to_owned()),
|
||||
MemoryError::Frontmatter(m) => Self::Invalid(m), // donnée malformée = invariant
|
||||
other /* Io | Serialization */ => Self::Store(other.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Commandes app-tauri ↔ DTO** (miroir des commandes skills ; `resolve_project(project_id)` → `project.root` ; slug parsé via `MemorySlug::new` qui renvoie `INVALID` si invalide) :
|
||||
|
||||
| Commande Tauri | Request DTO (camelCase) | Réponse |
|
||||
|---|---|---|
|
||||
| `create_memory` | `{ projectId, name, description, type, content }` | `MemoryDto` |
|
||||
| `update_memory` | `{ projectId, slug, description, type, content }` | `MemoryDto` |
|
||||
| `list_memories` | `{ projectId }` | `MemoryListDto` |
|
||||
| `get_memory` | `{ projectId, slug }` | `MemoryDto` |
|
||||
| `delete_memory` | `{ projectId, slug }` | `()` |
|
||||
| `read_memory_index` | `{ projectId }` | `MemoryIndexDto` |
|
||||
| `resolve_memory_links` | `{ projectId, slug }` | `MemoryLinksDto` |
|
||||
|
||||
DTO de réponse (`Memory`/`MemoryIndexEntry`/`MemoryLink` dérivent déjà `Serialize` côté domaine pour les types persistés ; sinon mapping explicite façon `SkillDto`) : `MemoryDto(Memory)`, `MemoryListDto(Vec<MemoryDto>)`, `MemoryIndexDto(Vec<MemoryIndexEntry>)`, `MemoryLinksDto(Vec<String>)` (slugs cibles). Enregistrer les 7 commandes dans `tauri::generate_handler!` (`app-tauri/src/lib.rs`). Câbler les 7 use cases dans le composition root (`state.rs`) : `FsMemoryStore` (déjà exporté) injecté en `Arc<dyn MemoryStore>`, partagé par tous les use cases, `EventBus` pour Create/Update/Delete.
|
||||
|
||||
> Note d'implémentation : `FsMemoryStore` prend le `root` **par appel** (comme `SkillStore`) ⇒ **une seule** instance store partagée, comme pour les skills.
|
||||
|
||||
##### 14.5.2 LOT B — port `MemoryRecall` + adapter naïf
|
||||
|
||||
```rust
|
||||
/// Rappel adaptatif d'un sous-ensemble pertinent de notes pour une requête.
|
||||
#[async_trait]
|
||||
pub trait MemoryRecall: Send + Sync {
|
||||
/// Renvoie les notes les plus pertinentes pour `query`, limitées à `budget`.
|
||||
/// Contrat : best-effort, jamais d'erreur bloquante sur mémoire vide
|
||||
/// (renvoie une liste vide) ; l'adapter naïf ignore la pertinence et
|
||||
/// renvoie l'index/les notes dans l'ordre, tronqué au budget.
|
||||
async fn recall(
|
||||
&self,
|
||||
root: &ProjectPath,
|
||||
query: &MemoryQuery,
|
||||
) -> Result<Vec<MemoryIndexEntry>, MemoryError>;
|
||||
}
|
||||
|
||||
pub struct MemoryQuery {
|
||||
pub text: String, // requête (souvent le contexte courant de l'agent)
|
||||
pub token_budget: usize, // budget au-delà duquel l'étage 2 prendrait le relais
|
||||
}
|
||||
```
|
||||
|
||||
- **Adapter `NaiveMemoryRecall`** (`infrastructure`) : compose `Arc<dyn MemoryStore>`, lit `read_index` et tronque au budget. Aucune dépendance externe. C'est le **défaut**.
|
||||
- **Use case `RecallMemory`** (`application/memory`) : `RecallMemoryInput { project_root, text, token_budget } -> RecallMemoryOutput { entries: Vec<MemoryIndexEntry> }`, port `Arc<dyn MemoryRecall>`. Pas d'event.
|
||||
- **Contrat de substituabilité (Liskov)** : `NaiveMemoryRecall` et `VectorMemoryRecall` (LOT C) sont interchangeables ; un budget nul ⇒ liste vide ; mémoire absente ⇒ liste vide, jamais d'erreur.
|
||||
|
||||
##### 14.5.3 LOT C — port `Embedder` + profils + adapter vectoriel + bascule
|
||||
|
||||
```rust
|
||||
/// Produit des vecteurs d'embedding, piloté par un profil déclaratif (façon §9).
|
||||
#[async_trait]
|
||||
pub trait Embedder: Send + Sync {
|
||||
fn id(&self) -> &str; // ex. "local-onnx-minilm"
|
||||
async fn embed(&self, texts: &[String]) -> Result<Vec<Vec<f32>>, EmbedderError>;
|
||||
fn dimension(&self) -> usize; // taille des vecteurs produits
|
||||
}
|
||||
```
|
||||
|
||||
- **Profils déclaratifs** `embedder.json` (store global IDE, comme `profiles.json`) : `{ id, name, strategy: "localOnnx"|"localServer"|"api"|"none", model?, endpoint?, apiKeyEnv?, dimension }`. **Open/Closed** : ajouter un moteur d'embedding = une donnée, pas du code.
|
||||
- **Adapter `VectorMemoryRecall`** (`infrastructure`) : compose `Arc<dyn Embedder>` + `Arc<dyn MemoryStore>` + un store de vecteurs dérivé sous `.ideai/memory/.index/` (reconstructible, ajouté au `.gitignore` au même titre que `run/`). Implémente `MemoryRecall`.
|
||||
- **Logique de bascule** (dans le use case `RecallMemory` ou un `AdaptiveMemoryRecall` qui compose les deux adapters) : si taille mémoire ≤ `token_budget` **ou** stratégie embedder = `none` ⇒ `NaiveMemoryRecall` ; sinon ⇒ `VectorMemoryRecall`. Décision objective, testable sans I/O (la mesure de taille est une fonction pure de l'index).
|
||||
- **`EmbedderError`** : nouveau type d'erreur par port (façon `MemoryError`), mappé en `AppError` (`none`/indisponible ⇒ dégrade vers naïf, jamais d'échec dur du rappel).
|
||||
- **Garde-fou produit** : défaut `none` ⇒ LOT C **n'impose aucune dépendance** ; un utilisateur sans embedder a une mémoire pleinement fonctionnelle (étage 1 + rappel naïf).
|
||||
|
||||
#### Anomalies de conformité relevées sur l'existant (à traiter par les agents dev)
|
||||
|
||||
1. **Doublon `DomainError::MalformedFrontmatter` vs `MemoryError::Frontmatter`** : `error.rs` définit `DomainError::MalformedFrontmatter { reason }`, mais le domaine `memory.rs` ne l'utilise jamais (il lève `EmptyField`/`InvalidSlug`) et l'adapter parse via `MemoryError::Frontmatter`. La variante `DomainError::MalformedFrontmatter` est **morte**. **Action** : la supprimer (le parsing de frontmatter est une responsabilité d'adapter → `MemoryError::Frontmatter`), sauf si un futur parseur de frontmatter *dans le domaine* est prévu (il ne l'est pas — le domaine reste format-neutral, cf. doc-module `memory.rs`). Anomalie mineure, sans impact fonctionnel.
|
||||
2. **`MemoryError` non encore mappé dans `AppError`** : normal (la couche application mémoire n'existe pas encore) ; couvert par LOT A (§14.5.1).
|
||||
3. **`MemoryIndexRebuilt` / `MemorySaved` / `MemoryDeleted` déjà câblés bout-en-bout** (domaine event + DTO `app-tauri` + relais) mais **non encore émis** faute de use cases : attendu, résolu par LOT A. RAS sur le sens des dépendances : domaine I/O-free, adapter compose `FileSystem`, application ne référence que les ports. **Conforme hexagonal/SOLID.**
|
||||
|
||||
---
|
||||
|
||||
## 13. Risques techniques & points ouverts (spikes)
|
||||
|
||||
1. **PTY cross-platform** : portable-pty + xterm.js OK sur les 3 OS, mais signaux/resize/exit codes diffèrent (Windows ConPTY). **Spike** L3.
|
||||
|
||||
Reference in New Issue
Block a user