From 98a8b7292a9268056517cd1f06a9f7a015358069 Mon Sep 17 00:00:00 2001 From: Blomios Date: Mon, 8 Jun 2026 08:47:23 +0200 Subject: [PATCH] =?UTF-8?q?feat(memory):=20syst=C3=A8me=20de=20m=C3=A9moir?= =?UTF-8?q?e=20projet=20model-agnostic=20(L14,=20LOT=20A+B+C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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 --- .gitignore | 3 + ARCHITECTURE.md | 160 +++++ crates/app-tauri/src/commands.rs | 198 ++++++ crates/app-tauri/src/dto.rs | 181 ++++++ crates/app-tauri/src/events.rs | 27 + crates/app-tauri/src/lib.rs | 8 + crates/app-tauri/src/state.rs | 68 +- crates/application/src/error.rs | 23 +- crates/application/src/lib.rs | 8 + crates/application/src/memory/mod.rs | 23 + crates/application/src/memory/usecases.rs | 414 ++++++++++++ crates/application/tests/error_codes.rs | 44 +- crates/application/tests/memory_usecases.rs | 632 +++++++++++++++++++ crates/domain/src/error.rs | 7 + crates/domain/src/events.rs | 16 + crates/domain/src/lib.rs | 19 +- crates/domain/src/memory.rs | 194 ++++++ crates/domain/src/ports.rs | 165 +++++ crates/domain/src/profile.rs | 101 +++ crates/domain/tests/embedder_profile.rs | 183 ++++++ crates/domain/tests/memory.rs | 187 ++++++ crates/infrastructure/src/lib.rs | 5 +- crates/infrastructure/src/store/embedder.rs | 173 +++++ crates/infrastructure/src/store/memory.rs | 492 +++++++++++++++ crates/infrastructure/src/store/mod.rs | 8 +- crates/infrastructure/src/store/profile.rs | 125 +++- crates/infrastructure/src/store/vector.rs | 347 ++++++++++ crates/infrastructure/tests/memory_recall.rs | 277 ++++++++ crates/infrastructure/tests/memory_store.rs | 278 ++++++++ crates/infrastructure/tests/vector_recall.rs | 626 ++++++++++++++++++ 30 files changed, 4978 insertions(+), 14 deletions(-) create mode 100644 crates/application/src/memory/mod.rs create mode 100644 crates/application/src/memory/usecases.rs create mode 100644 crates/application/tests/memory_usecases.rs create mode 100644 crates/domain/src/memory.rs create mode 100644 crates/domain/tests/embedder_profile.rs create mode 100644 crates/domain/tests/memory.rs create mode 100644 crates/infrastructure/src/store/embedder.rs create mode 100644 crates/infrastructure/src/store/memory.rs create mode 100644 crates/infrastructure/src/store/vector.rs create mode 100644 crates/infrastructure/tests/memory_recall.rs create mode 100644 crates/infrastructure/tests/memory_store.rs create mode 100644 crates/infrastructure/tests/vector_recall.rs diff --git a/.gitignore b/.gitignore index f2cd6d1..4def58e 100644 --- a/.gitignore +++ b/.gitignore @@ -32,6 +32,9 @@ frontend/coverage/ # Ephemeral per-agent run directories (isolated PTY cwd + generated convention # files), created at activation — not versioned (ARCHITECTURE §9.1 / §14.1). .ideai/run/ +# Derived vector store for semantic recall (LOT C / §14.5.3): embeddings of the +# memory notes, rebuildable from the `.md` source of truth — not versioned. +.ideai/memory/.index/ # ─── Editors / OS ─────────────────────────────────────────────────────────── .idea/ diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 950a727..8e34b66 100644 --- a/ARCHITECTURE.md +++ b/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/`). + +#### 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/.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`, `execute(Input) -> Result`) : + +```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 } + +// 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 } + +// ResolveMemoryLinks — liens [[slug]] sortants résolus (liens cassés ignorés). +struct ResolveMemoryLinksInput { project_root: ProjectPath, slug: MemorySlug } +struct ResolveMemoryLinksOutput { links: Vec } +``` + +> **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`. + +**É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 for AppError`** (à ajouter dans `application/src/error.rs`, calqué sur `From`) : + +```rust +impl From 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)`, `MemoryIndexDto(Vec)`, `MemoryLinksDto(Vec)` (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`, 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, 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`, 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 }`, port `Arc`. 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>, 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` + `Arc` + 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. diff --git a/crates/app-tauri/src/commands.rs b/crates/app-tauri/src/commands.rs index 395cbda..97240c6 100644 --- a/crates/app-tauri/src/commands.rs +++ b/crates/app-tauri/src/commands.rs @@ -17,6 +17,8 @@ use application::{ UpdateAgentContextInput, AssignSkillToAgentInput, CreateSkillInput, DeleteSkillInput, ListSkillsInput, UnassignSkillFromAgentInput, UpdateSkillInput, + CreateMemoryInput, DeleteMemoryInput, GetMemoryInput, ListMemoriesInput, ReadMemoryIndexInput, + RecallMemoryInput, ResolveMemoryLinksInput, UpdateMemoryInput, }; use domain::ports::PtyHandle; @@ -37,6 +39,8 @@ use crate::dto::{ TemplateListDto, TerminalClosedDto, TerminalSessionDto, UpdateAgentContextRequestDto, UpdateTemplateRequestDto, WriteTerminalRequestDto, parse_skill_id, AssignSkillRequestDto, CreateSkillRequestDto, SkillDto, SkillListDto, UnassignSkillRequestDto, UpdateSkillRequestDto, + parse_memory_slug, CreateMemoryRequestDto, MemoryDto, MemoryIndexDto, MemoryLinksDto, + MemoryListDto, RecallMemoryRequestDto, UpdateMemoryRequestDto, }; use domain::{SkillRef, SkillScope}; use crate::pty::{PtyBridge, PtyChunk}; @@ -1392,3 +1396,197 @@ pub async fn unassign_skill_from_agent( .await .map_err(ErrorDto::from) } + +// --------------------------------------------------------------------------- +// Memory (LOT A — §14.5.1) +// --------------------------------------------------------------------------- + +/// `create_memory` — create a memory note in the project's store. +/// +/// # Errors +/// Returns an [`ErrorDto`] (`INVALID` for an invalid slug/empty fields or +/// malformed project id, `NOT_FOUND` if the project is unknown, `STORE` on +/// failure). +#[tauri::command] +pub async fn create_memory( + request: CreateMemoryRequestDto, + state: State<'_, AppState>, +) -> Result { + let project = resolve_project(&request.project_id, &state).await?; + state + .create_memory + .execute(CreateMemoryInput { + project_root: project.root, + name: request.name, + description: request.description, + r#type: request.r#type, + content: request.content, + }) + .await + .map(MemoryDto::from) + .map_err(ErrorDto::from) +} + +/// `update_memory` — replace an existing memory note (re-validates invariants). +/// +/// # Errors +/// Returns an [`ErrorDto`] (`INVALID` for an invalid slug/empty fields, +/// `NOT_FOUND` if the project is unknown, `STORE` on failure). +#[tauri::command] +pub async fn update_memory( + request: UpdateMemoryRequestDto, + state: State<'_, AppState>, +) -> Result { + let project = resolve_project(&request.project_id, &state).await?; + let slug = parse_memory_slug(&request.slug)?; + state + .update_memory + .execute(UpdateMemoryInput { + project_root: project.root, + slug, + description: request.description, + r#type: request.r#type, + content: request.content, + }) + .await + .map(MemoryDto::from) + .map_err(ErrorDto::from) +} + +/// `list_memories` — list the project's memory notes. +/// +/// # Errors +/// Returns an [`ErrorDto`] (`INVALID` for a malformed id, `NOT_FOUND` if the +/// project is unknown, `STORE` on failure). +#[tauri::command] +pub async fn list_memories( + project_id: String, + state: State<'_, AppState>, +) -> Result { + let project = resolve_project(&project_id, &state).await?; + state + .list_memories + .execute(ListMemoriesInput { + project_root: project.root, + }) + .await + .map(MemoryListDto::from) + .map_err(ErrorDto::from) +} + +/// `get_memory` — read one memory note by slug. +/// +/// # Errors +/// Returns an [`ErrorDto`] (`INVALID` for an invalid slug/id, `NOT_FOUND` if the +/// project or note is unknown, `STORE` on failure). +#[tauri::command] +pub async fn get_memory( + project_id: String, + slug: String, + state: State<'_, AppState>, +) -> Result { + let project = resolve_project(&project_id, &state).await?; + let slug = parse_memory_slug(&slug)?; + state + .get_memory + .execute(GetMemoryInput { + project_root: project.root, + slug, + }) + .await + .map(MemoryDto::from) + .map_err(ErrorDto::from) +} + +/// `delete_memory` — remove a memory note (and its index row). +/// +/// # Errors +/// Returns an [`ErrorDto`] (`INVALID` for an invalid slug/id, `NOT_FOUND` if the +/// project or note is unknown, `STORE` on failure). +#[tauri::command] +pub async fn delete_memory( + project_id: String, + slug: String, + state: State<'_, AppState>, +) -> Result<(), ErrorDto> { + let project = resolve_project(&project_id, &state).await?; + let slug = parse_memory_slug(&slug)?; + state + .delete_memory + .execute(DeleteMemoryInput { + project_root: project.root, + slug, + }) + .await + .map_err(ErrorDto::from) +} + +/// `read_memory_index` — read the structured `MEMORY.md` index. +/// +/// # Errors +/// Returns an [`ErrorDto`] (`INVALID` for a malformed id, `NOT_FOUND` if the +/// project is unknown, `STORE` on failure). +#[tauri::command] +pub async fn read_memory_index( + project_id: String, + state: State<'_, AppState>, +) -> Result { + let project = resolve_project(&project_id, &state).await?; + state + .read_memory_index + .execute(ReadMemoryIndexInput { + project_root: project.root, + }) + .await + .map(MemoryIndexDto::from) + .map_err(ErrorDto::from) +} + +/// `recall_memory` — recall the most relevant memory entries for a query within +/// a token budget (LOT B — §14.5.2). +/// +/// # Errors +/// Returns an [`ErrorDto`] (`INVALID` for a malformed id, `NOT_FOUND` if the +/// project is unknown, `STORE` on failure). An empty or absent memory and a zero +/// budget both yield an empty list, not an error. +#[tauri::command] +pub async fn recall_memory( + request: RecallMemoryRequestDto, + state: State<'_, AppState>, +) -> Result { + let project = resolve_project(&request.project_id, &state).await?; + state + .recall_memory + .execute(RecallMemoryInput { + project_root: project.root, + text: request.text, + token_budget: request.token_budget, + }) + .await + .map(MemoryIndexDto::from) + .map_err(ErrorDto::from) +} + +/// `resolve_memory_links` — resolve a note's outgoing `[[slug]]` links. +/// +/// # Errors +/// Returns an [`ErrorDto`] (`INVALID` for an invalid slug/id, `NOT_FOUND` if the +/// project or source note is unknown, `STORE` on failure). +#[tauri::command] +pub async fn resolve_memory_links( + project_id: String, + slug: String, + state: State<'_, AppState>, +) -> Result { + let project = resolve_project(&project_id, &state).await?; + let slug = parse_memory_slug(&slug)?; + state + .resolve_memory_links + .execute(ResolveMemoryLinksInput { + project_root: project.root, + slug, + }) + .await + .map(MemoryLinksDto::from) + .map_err(ErrorDto::from) +} diff --git a/crates/app-tauri/src/dto.rs b/crates/app-tauri/src/dto.rs index 7bffde8..023bc81 100644 --- a/crates/app-tauri/src/dto.rs +++ b/crates/app-tauri/src/dto.rs @@ -1514,3 +1514,184 @@ pub fn parse_skill_id(raw: &str) -> Result { message: format!("invalid skill id: {raw}"), }) } + +// --------------------------------------------------------------------------- +// Memory (LOT A — §14.5.1) +// --------------------------------------------------------------------------- + +use application::{ + CreateMemoryOutput, GetMemoryOutput, ListMemoriesOutput, ReadMemoryIndexOutput, + RecallMemoryOutput, ResolveMemoryLinksOutput, UpdateMemoryOutput, +}; +use domain::{Memory, MemoryIndexEntry, MemorySlug, MemoryType}; + +/// A memory note crossing the wire. +/// +/// Built explicitly from [`Memory`] (its `body` is [`domain::MarkdownDoc`], not +/// directly a JSON string) so the TS mirror gets a flat camelCase shape. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct MemoryDto { + /// The note's slug (its identity, also the file stem). + pub name: String, + /// One-line description (the index hook). + pub description: String, + /// The note's kind. + pub r#type: MemoryType, + /// Markdown body of the note. + pub content: String, +} + +impl From for MemoryDto { + fn from(memory: Memory) -> Self { + Self { + name: memory.frontmatter.name.as_str().to_owned(), + description: memory.frontmatter.description, + r#type: memory.frontmatter.r#type, + content: memory.body.into_string(), + } + } +} + +impl From for MemoryDto { + fn from(out: CreateMemoryOutput) -> Self { + Self::from(out.memory) + } +} + +impl From for MemoryDto { + fn from(out: UpdateMemoryOutput) -> Self { + Self::from(out.memory) + } +} + +impl From for MemoryDto { + fn from(out: GetMemoryOutput) -> Self { + Self::from(out.memory) + } +} + +/// A list of memory notes (transparent array on the wire). +#[derive(Debug, Clone, Serialize)] +#[serde(transparent)] +pub struct MemoryListDto(pub Vec); + +impl From for MemoryListDto { + fn from(out: ListMemoriesOutput) -> Self { + Self(out.memories.into_iter().map(MemoryDto::from).collect()) + } +} + +/// One row of the structured `MEMORY.md` index crossing the wire. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct MemoryIndexEntryDto { + /// The note's slug. + pub slug: String, + /// The note's display title. + pub title: String, + /// The one-line hook (the frontmatter description). + pub hook: String, + /// The note's kind. + pub r#type: MemoryType, +} + +impl From for MemoryIndexEntryDto { + fn from(entry: MemoryIndexEntry) -> Self { + Self { + slug: entry.slug.as_str().to_owned(), + title: entry.title, + hook: entry.hook, + r#type: entry.r#type, + } + } +} + +/// The structured memory index (transparent array on the wire). +#[derive(Debug, Clone, Serialize)] +#[serde(transparent)] +pub struct MemoryIndexDto(pub Vec); + +impl From for MemoryIndexDto { + fn from(out: ReadMemoryIndexOutput) -> Self { + Self(out.entries.into_iter().map(MemoryIndexEntryDto::from).collect()) + } +} + +impl From for MemoryIndexDto { + fn from(out: RecallMemoryOutput) -> Self { + Self(out.entries.into_iter().map(MemoryIndexEntryDto::from).collect()) + } +} + +/// A note's resolved outgoing links — the target slugs (transparent array). +#[derive(Debug, Clone, Serialize)] +#[serde(transparent)] +pub struct MemoryLinksDto(pub Vec); + +impl From for MemoryLinksDto { + fn from(out: ResolveMemoryLinksOutput) -> Self { + Self( + out.links + .into_iter() + .map(|link| link.target.as_str().to_owned()) + .collect(), + ) + } +} + +/// Request DTO for `create_memory`. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CreateMemoryRequestDto { + /// Owning project (resolved to a root). + pub project_id: String, + /// Raw slug for the new note. + pub name: String, + /// One-line description (the index hook). + pub description: String, + /// The note's kind. + pub r#type: MemoryType, + /// Markdown body of the note. + pub content: String, +} + +/// Request DTO for `update_memory`. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UpdateMemoryRequestDto { + /// Owning project (resolved to a root). + pub project_id: String, + /// Slug of the note to replace. + pub slug: String, + /// New description (the index hook). + pub description: String, + /// New kind. + pub r#type: MemoryType, + /// New Markdown body. + pub content: String, +} + +/// Request DTO for `recall_memory` (LOT B — §14.5.2). +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RecallMemoryRequestDto { + /// Owning project (resolved to a root). + pub project_id: String, + /// The recall query (often the agent's current working context). + pub text: String, + /// Approximate token budget bounding the recalled entries (`0` ⇒ empty). + pub token_budget: usize, +} + +/// Parses a memory slug string coming from the frontend. +/// +/// # Errors +/// Returns an [`ErrorDto`] with code `INVALID` if the string is not a valid +/// kebab-case slug. +pub fn parse_memory_slug(raw: &str) -> Result { + MemorySlug::new(raw).map_err(|_| ErrorDto { + code: "INVALID".to_owned(), + message: format!("invalid memory slug: {raw}"), + }) +} diff --git a/crates/app-tauri/src/events.rs b/crates/app-tauri/src/events.rs index 5d8628f..7ac92fb 100644 --- a/crates/app-tauri/src/events.rs +++ b/crates/app-tauri/src/events.rs @@ -111,6 +111,24 @@ pub enum DomainEventDto { /// Whether IdeA handled it successfully. ok: bool, }, + /// A memory note was created or updated. + #[serde(rename_all = "camelCase")] + MemorySaved { + /// The saved note's slug. + slug: String, + }, + /// A memory note was deleted. + #[serde(rename_all = "camelCase")] + MemoryDeleted { + /// The deleted note's slug. + slug: String, + }, + /// The aggregated `MEMORY.md` index was rebuilt. + #[serde(rename_all = "camelCase")] + MemoryIndexRebuilt { + /// Project id. + project_id: String, + }, /// Raw PTY output (normally routed to a per-session channel, not here). #[serde(rename_all = "camelCase")] PtyOutput { @@ -185,6 +203,15 @@ impl From<&DomainEvent> for DomainEventDto { action: action.clone(), ok: *ok, }, + DomainEvent::MemorySaved { slug } => Self::MemorySaved { + slug: slug.as_str().to_string(), + }, + DomainEvent::MemoryDeleted { slug } => Self::MemoryDeleted { + slug: slug.as_str().to_string(), + }, + DomainEvent::MemoryIndexRebuilt { project_id } => Self::MemoryIndexRebuilt { + project_id: project_id.to_string(), + }, DomainEvent::PtyOutput { session_id, bytes } => Self::PtyOutput { session_id: session_id.to_string(), bytes: bytes.clone(), diff --git a/crates/app-tauri/src/lib.rs b/crates/app-tauri/src/lib.rs index 46f206d..b49c5fb 100644 --- a/crates/app-tauri/src/lib.rs +++ b/crates/app-tauri/src/lib.rs @@ -140,6 +140,14 @@ pub fn run() { commands::delete_skill, commands::assign_skill_to_agent, commands::unassign_skill_from_agent, + commands::create_memory, + commands::update_memory, + commands::list_memories, + commands::get_memory, + commands::delete_memory, + commands::read_memory_index, + commands::recall_memory, + commands::resolve_memory_links, commands::move_tab_to_new_window, ]) .run(tauri::generate_context!()) diff --git a/crates/app-tauri/src/state.rs b/crates/app-tauri/src/state.rs index ce84f2f..413bca3 100644 --- a/crates/app-tauri/src/state.rs +++ b/crates/app-tauri/src/state.rs @@ -19,19 +19,22 @@ use application::{ OpenTerminal, OrchestratorService, ReadAgentContext, ReferenceProfiles, RenameLayout, ResizeTerminal, SaveProfile, SetActiveLayout, SnapshotRunningAgents, AssignSkillToAgent, CreateSkill, DeleteSkill, UnassignSkillFromAgent, UpdateSkill, + CreateMemory, DeleteMemory, GetMemory, ListMemories, ReadMemoryIndex, RecallMemory, + ResolveMemoryLinks, UpdateMemory, SyncAgentWithTemplate, TerminalSessions, UpdateAgentContext, UpdateTemplate, WriteToTerminal, }; use domain::ports::{ AgentContextStore, AgentRuntime, Clock, EventBus, FileSystem, GitPort, IdGenerator, - ProcessSpawner, ProfileStore, ProjectStore, PtyPort, SkillStore, TemplateStore, + MemoryRecall, MemoryStore, ProcessSpawner, ProfileStore, ProjectStore, PtyPort, SkillStore, + TemplateStore, }; use domain::{DomainEvent, Project, ProjectId}; use infrastructure::{ ClaudeTranscriptInspector, CliAgentRuntime, FsOrchestratorWatcher, FsProfileStore, - FsProjectStore, FsSkillStore, FsTemplateStore, Git2Repository, IdeaiContextStore, - LocalFileSystem, LocalProcessSpawner, OrchestratorWatchHandle, PortablePtyAdapter, SystemClock, - TokioBroadcastEventBus, UuidGenerator, + FsMemoryStore, FsProjectStore, FsSkillStore, FsTemplateStore, Git2Repository, IdeaiContextStore, + LocalFileSystem, LocalProcessSpawner, NaiveMemoryRecall, OrchestratorWatchHandle, + PortablePtyAdapter, SystemClock, TokioBroadcastEventBus, UuidGenerator, }; use crate::pty::PtyBridge; @@ -172,6 +175,25 @@ pub struct AppState { pub assign_skill: Arc, /// Unassign a skill from an agent. pub unassign_skill: Arc, + + // --- Memory (LOT A — §14.5.1) --- + /// Create a memory note in the project's store. + pub create_memory: Arc, + /// Replace an existing memory note. + pub update_memory: Arc, + /// List the project's memory notes. + pub list_memories: Arc, + /// Read one memory note by slug. + pub get_memory: Arc, + /// Delete a memory note. + pub delete_memory: Arc, + /// Read the structured `MEMORY.md` index. + pub read_memory_index: Arc, + /// Resolve a note's outgoing `[[slug]]` links. + pub resolve_memory_links: Arc, + /// Recall the most relevant memory entries for a query within a budget + /// (LOT B — §14.5.2). + pub recall_memory: Arc, // --- Orchestrator (§14.3) --- /// Dispatches validated orchestrator requests to the agent/skill use cases. /// Shared by every per-project filesystem watcher. @@ -458,6 +480,36 @@ impl AppState { Arc::clone(&events_port), )); + // --- Memory store + use cases (LOT A — §14.5.1) --- + // A single FsMemoryStore takes the project root per call (like the skill + // store), so one instance serves every open project. The mutating use + // cases share the event bus to announce Memory{Saved,Deleted}. + let memory_store = Arc::new(FsMemoryStore::new(Arc::clone(&fs_port))); + let memory_store_port = Arc::clone(&memory_store) as Arc; + let create_memory = Arc::new(CreateMemory::new( + Arc::clone(&memory_store_port), + Arc::clone(&events_port), + )); + let update_memory = Arc::new(UpdateMemory::new( + Arc::clone(&memory_store_port), + Arc::clone(&events_port), + )); + let list_memories = Arc::new(ListMemories::new(Arc::clone(&memory_store_port))); + let get_memory = Arc::new(GetMemory::new(Arc::clone(&memory_store_port))); + let delete_memory = Arc::new(DeleteMemory::new( + Arc::clone(&memory_store_port), + Arc::clone(&events_port), + )); + let read_memory_index = Arc::new(ReadMemoryIndex::new(Arc::clone(&memory_store_port))); + let resolve_memory_links = + Arc::new(ResolveMemoryLinks::new(Arc::clone(&memory_store_port))); + // Naïve recall (LOT B): composes the same store, returns index entries in + // order, truncated to the token budget. The default, dependency-free + // MemoryRecall; substitutable by a VectorMemoryRecall (LOT C). + let memory_recall_port = + Arc::new(NaiveMemoryRecall::new(Arc::clone(&memory_store_port))) as Arc; + let recall_memory = Arc::new(RecallMemory::new(memory_recall_port)); + // --- Orchestrator service (§14.3) --- // Dispatches file-based orchestrator requests through the *same* use cases // the UI drives (IdeA stays the single source of truth for the agent/skill @@ -540,6 +592,14 @@ impl AppState { delete_skill, assign_skill, unassign_skill, + create_memory, + update_memory, + list_memories, + get_memory, + delete_memory, + read_memory_index, + resolve_memory_links, + recall_memory, orchestrator_service, orchestrator_watchers: Mutex::new(HashMap::new()), move_tab, diff --git a/crates/application/src/error.rs b/crates/application/src/error.rs index d553636..9c9522d 100644 --- a/crates/application/src/error.rs +++ b/crates/application/src/error.rs @@ -6,7 +6,8 @@ //! with one error shape when building its `ErrorDTO`. use domain::ports::{ - FsError, GitError, ProcessError, PtyError, RemoteError, RuntimeError, StoreError, + EmbedderError, FsError, GitError, MemoryError, ProcessError, PtyError, RemoteError, + RuntimeError, StoreError, }; /// Errors surfaced by application use cases. @@ -84,6 +85,26 @@ impl From for AppError { } } +impl From for AppError { + fn from(e: MemoryError) -> Self { + match e { + MemoryError::NotFound => Self::NotFound("memory note".to_owned()), + MemoryError::Frontmatter(m) => Self::Invalid(m), + other => Self::Store(other.to_string()), + } + } +} + +impl From for AppError { + /// Maps to [`AppError::Store`] — an embedder is a *derived* recall detail; its + /// failure must degrade the recall (fallback to naïve), never fail hard. This + /// mapping exists for completeness; recall adapters degrade *before* an + /// embedder error ever reaches a use case. + fn from(e: EmbedderError) -> Self { + Self::Store(e.to_string()) + } +} + impl From for AppError { fn from(e: PtyError) -> Self { Self::Process(e.to_string()) diff --git a/crates/application/src/lib.rs b/crates/application/src/lib.rs index f917ab2..5f4e9c3 100644 --- a/crates/application/src/lib.rs +++ b/crates/application/src/lib.rs @@ -16,6 +16,7 @@ pub mod error; pub mod git; pub mod health; pub mod layout; +pub mod memory; pub mod orchestrator; pub mod project; pub mod remote; @@ -53,6 +54,13 @@ pub use layout::{ RenameLayoutInput, SetActiveLayout, SetActiveLayoutInput, SnapshotRunningAgents, SnapshotRunningAgentsInput, SnapshotRunningAgentsOutput, LAYOUTS_FILE, }; +pub use memory::{ + CreateMemory, CreateMemoryInput, CreateMemoryOutput, DeleteMemory, DeleteMemoryInput, + GetMemory, GetMemoryInput, GetMemoryOutput, ListMemories, ListMemoriesInput, ListMemoriesOutput, + ReadMemoryIndex, ReadMemoryIndexInput, ReadMemoryIndexOutput, RecallMemory, RecallMemoryInput, + RecallMemoryOutput, ResolveMemoryLinks, ResolveMemoryLinksInput, ResolveMemoryLinksOutput, + UpdateMemory, UpdateMemoryInput, UpdateMemoryOutput, +}; pub use project::{ CloseProject, CloseProjectInput, CloseProjectOutput, CloseTab, CloseTabInput, CreateProject, CreateProjectInput, CreateProjectOutput, ListProjects, ListProjectsOutput, OpenProject, diff --git a/crates/application/src/memory/mod.rs b/crates/application/src/memory/mod.rs new file mode 100644 index 0000000..5252cfa --- /dev/null +++ b/crates/application/src/memory/mod.rs @@ -0,0 +1,23 @@ +//! Memory use cases (ARCHITECTURE §14.5.1; LOT A, étage 1: `.md`). +//! +//! The memory module owns the CRUD of a project's persistent knowledge base — +//! one Markdown note per [`domain::memory::Memory`], stored under +//! `.ideai/memory/.md` with a derived `MEMORY.md` index. On top of the +//! CRUD it exposes two read-only navigation helpers driving the graphical memory +//! view: [`ReadMemoryIndex`] (the structured index) and [`ResolveMemoryLinks`] +//! (a note's resolved `[[slug]]` outgoing links, broken links dropped). +//! +//! Every use case talks only to ports ([`domain::ports::MemoryStore`], +//! [`domain::ports::EventBus`]). Mutating use cases ([`CreateMemory`], +//! [`UpdateMemory`], [`DeleteMemory`]) announce a [`domain::DomainEvent`]; the +//! read use cases emit nothing. + +mod usecases; + +pub use usecases::{ + CreateMemory, CreateMemoryInput, CreateMemoryOutput, DeleteMemory, DeleteMemoryInput, + GetMemory, GetMemoryInput, GetMemoryOutput, ListMemories, ListMemoriesInput, ListMemoriesOutput, + ReadMemoryIndex, ReadMemoryIndexInput, ReadMemoryIndexOutput, RecallMemory, RecallMemoryInput, + RecallMemoryOutput, ResolveMemoryLinks, ResolveMemoryLinksInput, ResolveMemoryLinksOutput, + UpdateMemory, UpdateMemoryInput, UpdateMemoryOutput, +}; diff --git a/crates/application/src/memory/usecases.rs b/crates/application/src/memory/usecases.rs new file mode 100644 index 0000000..3fba5cc --- /dev/null +++ b/crates/application/src/memory/usecases.rs @@ -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, + events: Arc, +} + +impl CreateMemory { + /// Builds the use case from its ports. + #[must_use] + pub fn new(memories: Arc, events: Arc) -> 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 { + 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, + events: Arc, +} + +impl UpdateMemory { + /// Builds the use case from its ports. + #[must_use] + pub fn new(memories: Arc, events: Arc) -> 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 { + 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, +} + +/// Lists the memory notes of a project. +pub struct ListMemories { + memories: Arc, +} + +impl ListMemories { + /// Builds the use case. + #[must_use] + pub fn new(memories: Arc) -> 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 { + 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, +} + +impl GetMemory { + /// Builds the use case. + #[must_use] + pub fn new(memories: Arc) -> 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 { + 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, + events: Arc, +} + +impl DeleteMemory { + /// Builds the use case from its ports. + #[must_use] + pub fn new(memories: Arc, events: Arc) -> 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, +} + +/// Reads the structured `MEMORY.md` index (drives the graphical memory view). +pub struct ReadMemoryIndex { + memories: Arc, +} + +impl ReadMemoryIndex { + /// Builds the use case. + #[must_use] + pub fn new(memories: Arc) -> Self { + Self { memories } + } + + /// Reads the index rows. + /// + /// # Errors + /// - [`AppError::Store`] on an I/O failure. + pub async fn execute( + &self, + input: ReadMemoryIndexInput, + ) -> Result { + 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, +} + +/// 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, +} + +impl RecallMemory { + /// Builds the use case from the [`MemoryRecall`] port. + #[must_use] + pub fn new(recall: Arc) -> 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 { + 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, +} + +/// Resolves a note's outgoing `[[slug]]` links (drives link navigation). +pub struct ResolveMemoryLinks { + memories: Arc, +} + +impl ResolveMemoryLinks { + /// Builds the use case. + #[must_use] + pub fn new(memories: Arc) -> 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 { + Ok(ResolveMemoryLinksOutput { + links: self + .memories + .resolve_links(&input.project_root, &input.slug) + .await?, + }) + } +} diff --git a/crates/application/tests/error_codes.rs b/crates/application/tests/error_codes.rs index 99f8724..321fc7f 100644 --- a/crates/application/tests/error_codes.rs +++ b/crates/application/tests/error_codes.rs @@ -2,7 +2,9 @@ //! relies on, and the per-port `From` mappings. use application::AppError; -use domain::ports::{FsError, GitError, ProcessError, PtyError, RemoteError, StoreError}; +use domain::ports::{ + EmbedderError, FsError, GitError, MemoryError, ProcessError, PtyError, RemoteError, StoreError, +}; #[test] fn codes_are_stable() { @@ -55,3 +57,43 @@ fn git_and_remote_map_through() { "REMOTE" ); } + +#[test] +fn memory_errors_map_per_variant() { + // NotFound → NotFound. + assert_eq!(AppError::from(MemoryError::NotFound).code(), "NOT_FOUND"); + // Frontmatter → Invalid (a malformed note is an invariant violation, not I/O). + let invalid = AppError::from(MemoryError::Frontmatter("bad yaml".into())); + assert_eq!(invalid.code(), "INVALID"); + assert_eq!(invalid, AppError::Invalid("bad yaml".to_owned())); + // Io → Store. + assert_eq!(AppError::from(MemoryError::Io("disk full".into())).code(), "STORE"); + // Serialization → Store (the catch-all arm). + assert_eq!( + AppError::from(MemoryError::Serialization("bad index".into())).code(), + "STORE" + ); +} + +#[test] +fn embedder_errors_all_map_to_store() { + // An embedder is a *derived* recall detail: every variant maps to STORE (its + // failure must degrade the recall, never fail hard — see the From impl doc). + assert_eq!( + AppError::from(EmbedderError::Unavailable("no model".into())).code(), + "STORE" + ); + assert_eq!( + AppError::from(EmbedderError::Unsupported("localOnnx".into())).code(), + "STORE" + ); + assert_eq!( + AppError::from(EmbedderError::Io("read failed".into())).code(), + "STORE" + ); + // The message is carried through. + assert_eq!( + AppError::from(EmbedderError::Unsupported("api".into())), + AppError::Store("embedder strategy unsupported: api".to_owned()) + ); +} diff --git a/crates/application/tests/memory_usecases.rs b/crates/application/tests/memory_usecases.rs new file mode 100644 index 0000000..c1a3e6f --- /dev/null +++ b/crates/application/tests/memory_usecases.rs @@ -0,0 +1,632 @@ +//! L12 tests for the memory use cases (ARCHITECTURE §14.5.1) with in-memory +//! port fakes (no real store/FS): CRUD (`CreateMemory`, `UpdateMemory`, +//! `GetMemory`, `ListMemories`, `DeleteMemory`) asserting the `MemorySaved` / +//! `MemoryDeleted` events, and the read-only navigation helpers +//! (`ReadMemoryIndex`, `ResolveMemoryLinks`) asserting they emit nothing. + +use std::sync::{Arc, Mutex}; + +use async_trait::async_trait; +use domain::events::DomainEvent; +use domain::ports::{EventBus, EventStream, MemoryError, MemoryQuery, MemoryRecall, MemoryStore}; +use domain::{ + MarkdownDoc, Memory, MemoryFrontmatter, MemoryIndexEntry, MemoryLink, MemorySlug, MemoryType, + ProjectPath, +}; + +use application::{ + CreateMemory, CreateMemoryInput, DeleteMemory, DeleteMemoryInput, GetMemory, GetMemoryInput, + ListMemories, ListMemoriesInput, ReadMemoryIndex, ReadMemoryIndexInput, RecallMemory, + RecallMemoryInput, ResolveMemoryLinks, ResolveMemoryLinksInput, UpdateMemory, UpdateMemoryInput, +}; + +// --------------------------------------------------------------------------- +// Fakes +// --------------------------------------------------------------------------- + +/// In-memory memory store keyed by slug. Index and links are derived from the +/// stored notes (mirroring the real adapter), so navigation reads stay honest. +#[derive(Clone, Default)] +struct FakeMemories(Arc>>); + +#[async_trait] +impl MemoryStore for FakeMemories { + async fn list(&self, _root: &ProjectPath) -> Result, MemoryError> { + Ok(self.0.lock().unwrap().clone()) + } + + async fn get(&self, _root: &ProjectPath, slug: &MemorySlug) -> Result { + self.0 + .lock() + .unwrap() + .iter() + .find(|m| m.slug() == slug) + .cloned() + .ok_or(MemoryError::NotFound) + } + + async fn save(&self, _root: &ProjectPath, memory: &Memory) -> Result<(), MemoryError> { + let mut v = self.0.lock().unwrap(); + if let Some(slot) = v.iter_mut().find(|m| m.slug() == memory.slug()) { + *slot = memory.clone(); + } else { + v.push(memory.clone()); + } + Ok(()) + } + + async fn delete(&self, _root: &ProjectPath, slug: &MemorySlug) -> Result<(), MemoryError> { + let mut v = self.0.lock().unwrap(); + let before = v.len(); + v.retain(|m| m.slug() != slug); + if v.len() == before { + return Err(MemoryError::NotFound); + } + Ok(()) + } + + async fn read_index( + &self, + _root: &ProjectPath, + ) -> Result, MemoryError> { + Ok(self + .0 + .lock() + .unwrap() + .iter() + .map(Memory::index_entry) + .collect()) + } + + async fn resolve_links( + &self, + _root: &ProjectPath, + slug: &MemorySlug, + ) -> Result, MemoryError> { + let v = self.0.lock().unwrap(); + let source = v + .iter() + .find(|m| m.slug() == slug) + .ok_or(MemoryError::NotFound)?; + // Mirror the adapter: drop links whose target note does not exist. + Ok(source + .outgoing_links() + .into_iter() + .filter(|l| v.iter().any(|m| m.slug() == &l.target)) + .collect()) + } +} + +#[derive(Default, Clone)] +struct SpyBus(Arc>>); +impl SpyBus { + fn events(&self) -> Vec { + self.0.lock().unwrap().clone() + } +} +impl EventBus for SpyBus { + fn publish(&self, event: DomainEvent) { + self.0.lock().unwrap().push(event); + } + fn subscribe(&self) -> EventStream { + Box::new(std::iter::empty()) + } +} + +// --------------------------------------------------------------------------- +// Builders +// --------------------------------------------------------------------------- + +fn root() -> ProjectPath { + ProjectPath::new("/home/me/demo").unwrap() +} + +fn slug(s: &str) -> MemorySlug { + MemorySlug::new(s).unwrap() +} + +/// Builds and persists a note directly through the store (bypassing use cases). +async fn seed(store: &FakeMemories, name: &str, body: &str) { + let memory = Memory::new( + MemoryFrontmatter { + name: slug(name), + description: format!("{name} hook"), + r#type: MemoryType::Project, + }, + MarkdownDoc::new(body), + ) + .unwrap(); + store.save(&root(), &memory).await.unwrap(); +} + +fn saved_slugs(events: &[DomainEvent]) -> Vec { + events + .iter() + .filter_map(|e| match e { + DomainEvent::MemorySaved { slug } => Some(slug.clone()), + _ => None, + }) + .collect() +} + +fn deleted_slugs(events: &[DomainEvent]) -> Vec { + events + .iter() + .filter_map(|e| match e { + DomainEvent::MemoryDeleted { slug } => Some(slug.clone()), + _ => None, + }) + .collect() +} + +// --------------------------------------------------------------------------- +// CreateMemory +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn create_persists_note_and_emits_memory_saved_with_slug() { + let store = FakeMemories::default(); + let bus = SpyBus::default(); + let out = CreateMemory::new(Arc::new(store.clone()), Arc::new(bus.clone())) + .execute(CreateMemoryInput { + project_root: root(), + name: "design-decision".to_owned(), + description: "why we chose hexagonal".to_owned(), + r#type: MemoryType::Project, + content: "# body".to_owned(), + }) + .await + .unwrap(); + + assert_eq!(out.memory.slug(), &slug("design-decision")); + // Persisted. + let listed = store.list(&root()).await.unwrap(); + assert_eq!(listed.len(), 1); + assert_eq!(listed[0].slug(), &slug("design-decision")); + // Emits exactly one MemorySaved with the right slug. + assert_eq!(saved_slugs(&bus.events()), vec![slug("design-decision")]); +} + +#[tokio::test] +async fn create_rejects_non_kebab_name_as_invalid() { + let store = FakeMemories::default(); + let bus = SpyBus::default(); + let err = CreateMemory::new(Arc::new(store.clone()), Arc::new(bus.clone())) + .execute(CreateMemoryInput { + project_root: root(), + name: "Not Kebab".to_owned(), + description: "x".to_owned(), + r#type: MemoryType::User, + content: "# body".to_owned(), + }) + .await + .unwrap_err(); + + assert_eq!(err.code(), "INVALID"); + // Nothing persisted, nothing announced. + assert!(store.list(&root()).await.unwrap().is_empty()); + assert!(bus.events().is_empty()); +} + +#[tokio::test] +async fn create_rejects_empty_description() { + let store = FakeMemories::default(); + let bus = SpyBus::default(); + let err = CreateMemory::new(Arc::new(store), Arc::new(bus)) + .execute(CreateMemoryInput { + project_root: root(), + name: "ok-slug".to_owned(), + description: String::new(), + r#type: MemoryType::User, + content: "# body".to_owned(), + }) + .await + .unwrap_err(); + assert_eq!(err.code(), "INVALID"); +} + +#[tokio::test] +async fn create_rejects_empty_content() { + let store = FakeMemories::default(); + let bus = SpyBus::default(); + let err = CreateMemory::new(Arc::new(store), Arc::new(bus)) + .execute(CreateMemoryInput { + project_root: root(), + name: "ok-slug".to_owned(), + description: "a hook".to_owned(), + r#type: MemoryType::User, + content: String::new(), + }) + .await + .unwrap_err(); + assert_eq!(err.code(), "INVALID"); +} + +// --------------------------------------------------------------------------- +// UpdateMemory +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn update_replaces_content_and_emits_memory_saved() { + let store = FakeMemories::default(); + seed(&store, "note-a", "v1").await; + let bus = SpyBus::default(); + + let out = UpdateMemory::new(Arc::new(store.clone()), Arc::new(bus.clone())) + .execute(UpdateMemoryInput { + project_root: root(), + slug: slug("note-a"), + description: "updated hook".to_owned(), + r#type: MemoryType::Reference, + content: "v2".to_owned(), + }) + .await + .unwrap(); + + assert_eq!(out.memory.body.as_str(), "v2"); + assert_eq!(out.memory.frontmatter.description, "updated hook"); + assert_eq!(out.memory.frontmatter.r#type, MemoryType::Reference); + // Replace semantics: still a single note. + let listed = store.list(&root()).await.unwrap(); + assert_eq!(listed.len(), 1); + assert_eq!(listed[0].body.as_str(), "v2"); + assert_eq!(saved_slugs(&bus.events()), vec![slug("note-a")]); +} + +#[tokio::test] +async fn update_revalidates_invariants_and_rejects_empty_content() { + let store = FakeMemories::default(); + seed(&store, "note-a", "v1").await; + let bus = SpyBus::default(); + + let err = UpdateMemory::new(Arc::new(store.clone()), Arc::new(bus.clone())) + .execute(UpdateMemoryInput { + project_root: root(), + slug: slug("note-a"), + description: "still here".to_owned(), + r#type: MemoryType::Project, + content: String::new(), + }) + .await + .unwrap_err(); + + assert_eq!(err.code(), "INVALID"); + // Original note untouched, no event. + assert_eq!(store.get(&root(), &slug("note-a")).await.unwrap().body.as_str(), "v1"); + assert!(bus.events().is_empty()); +} + +// --------------------------------------------------------------------------- +// DeleteMemory +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn delete_removes_note_and_emits_memory_deleted() { + let store = FakeMemories::default(); + seed(&store, "note-a", "body").await; + let bus = SpyBus::default(); + + DeleteMemory::new(Arc::new(store.clone()), Arc::new(bus.clone())) + .execute(DeleteMemoryInput { + project_root: root(), + slug: slug("note-a"), + }) + .await + .unwrap(); + + assert!(store.list(&root()).await.unwrap().is_empty()); + assert_eq!(deleted_slugs(&bus.events()), vec![slug("note-a")]); +} + +#[tokio::test] +async fn delete_unknown_slug_is_not_found_and_emits_nothing() { + let store = FakeMemories::default(); + let bus = SpyBus::default(); + + let err = DeleteMemory::new(Arc::new(store), Arc::new(bus.clone())) + .execute(DeleteMemoryInput { + project_root: root(), + slug: slug("ghost"), + }) + .await + .unwrap_err(); + + assert_eq!(err.code(), "NOT_FOUND"); + assert!(bus.events().is_empty()); +} + +// --------------------------------------------------------------------------- +// GetMemory +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn get_returns_the_note() { + let store = FakeMemories::default(); + seed(&store, "note-a", "hello").await; + + let out = GetMemory::new(Arc::new(store)) + .execute(GetMemoryInput { + project_root: root(), + slug: slug("note-a"), + }) + .await + .unwrap(); + + assert_eq!(out.memory.slug(), &slug("note-a")); + assert_eq!(out.memory.body.as_str(), "hello"); +} + +#[tokio::test] +async fn get_unknown_slug_is_not_found() { + let store = FakeMemories::default(); + let err = GetMemory::new(Arc::new(store)) + .execute(GetMemoryInput { + project_root: root(), + slug: slug("ghost"), + }) + .await + .unwrap_err(); + assert_eq!(err.code(), "NOT_FOUND"); +} + +// --------------------------------------------------------------------------- +// ListMemories +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn list_returns_all_notes() { + let store = FakeMemories::default(); + seed(&store, "note-a", "a").await; + seed(&store, "note-b", "b").await; + + let out = ListMemories::new(Arc::new(store)) + .execute(ListMemoriesInput { + project_root: root(), + }) + .await + .unwrap(); + + let mut slugs: Vec<_> = out.memories.iter().map(|m| m.slug().clone()).collect(); + slugs.sort(); + assert_eq!(slugs, vec![slug("note-a"), slug("note-b")]); +} + +#[tokio::test] +async fn list_is_empty_when_no_notes() { + let store = FakeMemories::default(); + let out = ListMemories::new(Arc::new(store)) + .execute(ListMemoriesInput { + project_root: root(), + }) + .await + .unwrap(); + assert!(out.memories.is_empty()); +} + +// --------------------------------------------------------------------------- +// ReadMemoryIndex +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn read_index_returns_one_row_per_note() { + let store = FakeMemories::default(); + seed(&store, "note-a", "a").await; + seed(&store, "note-b", "b").await; + + let out = ReadMemoryIndex::new(Arc::new(store)) + .execute(ReadMemoryIndexInput { + project_root: root(), + }) + .await + .unwrap(); + + assert_eq!(out.entries.len(), 2); + let mut rows: Vec<_> = out + .entries + .iter() + .map(|e| (e.slug.clone(), e.hook.clone())) + .collect(); + rows.sort(); + assert_eq!( + rows, + vec![ + (slug("note-a"), "note-a hook".to_owned()), + (slug("note-b"), "note-b hook".to_owned()), + ] + ); +} + +// --------------------------------------------------------------------------- +// ResolveMemoryLinks +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn resolve_links_returns_outgoing_links_dropping_broken_ones() { + let store = FakeMemories::default(); + // note-a links to note-b (exists) and to ghost (does not). + seed(&store, "note-a", "see [[note-b]] and [[ghost]]").await; + seed(&store, "note-b", "target body").await; + + let out = ResolveMemoryLinks::new(Arc::new(store)) + .execute(ResolveMemoryLinksInput { + project_root: root(), + slug: slug("note-a"), + }) + .await + .unwrap(); + + // Only the resolvable link survives; the broken one is dropped. + assert_eq!( + out.links, + vec![MemoryLink { + target: slug("note-b") + }] + ); +} + +#[tokio::test] +async fn resolve_links_for_unknown_source_is_not_found() { + let store = FakeMemories::default(); + let err = ResolveMemoryLinks::new(Arc::new(store)) + .execute(ResolveMemoryLinksInput { + project_root: root(), + slug: slug("ghost"), + }) + .await + .unwrap_err(); + assert_eq!(err.code(), "NOT_FOUND"); +} + +// --------------------------------------------------------------------------- +// RecallMemory +// --------------------------------------------------------------------------- + +/// In-memory [`MemoryRecall`] fake: records the queries it received and returns a +/// canned set of entries. Lets us assert the use case delegates faithfully (same +/// `root`, the input `text`/`token_budget` forwarded) and surfaces the result. +#[derive(Clone, Default)] +struct FakeRecall { + entries: Arc>, + seen: Arc>>, +} + +impl FakeRecall { + fn returning(entries: Vec) -> Self { + Self { + entries: Arc::new(entries), + seen: Arc::default(), + } + } + fn queries(&self) -> Vec { + self.seen.lock().unwrap().clone() + } +} + +#[async_trait] +impl MemoryRecall for FakeRecall { + async fn recall( + &self, + _root: &ProjectPath, + query: &MemoryQuery, + ) -> Result, MemoryError> { + self.seen.lock().unwrap().push(query.clone()); + Ok((*self.entries).clone()) + } +} + +fn entry(slug_str: &str, hook: &str) -> MemoryIndexEntry { + MemoryIndexEntry { + slug: slug(slug_str), + title: slug_str.to_owned(), + hook: hook.to_owned(), + r#type: MemoryType::Reference, + } +} + +#[tokio::test] +async fn recall_delegates_to_port_and_returns_entries() { + let recalled = vec![entry("alpha", "first"), entry("beta", "second")]; + let recall = FakeRecall::returning(recalled.clone()); + + let out = RecallMemory::new(Arc::new(recall.clone())) + .execute(RecallMemoryInput { + project_root: root(), + text: "current context".to_owned(), + token_budget: 42, + }) + .await + .unwrap(); + + // Returns exactly what the port produced, in order. + assert_eq!(out.entries, recalled); + // The query was forwarded verbatim (text + budget). + assert_eq!( + recall.queries(), + vec![MemoryQuery { + text: "current context".to_owned(), + token_budget: 42, + }] + ); +} + +#[tokio::test] +async fn recall_empty_result_is_empty_not_error() { + let recall = FakeRecall::returning(Vec::new()); + let out = RecallMemory::new(Arc::new(recall)) + .execute(RecallMemoryInput { + project_root: root(), + text: String::new(), + token_budget: 0, + }) + .await + .unwrap(); + assert!(out.entries.is_empty()); +} + +#[tokio::test] +async fn recall_publishes_no_events() { + // RecallMemory takes no EventBus; assert a shared spy stays empty when other + // (mutating) use cases on the same bus are NOT exercised — i.e. recall is + // read-only by construction. + let recall = FakeRecall::returning(vec![entry("alpha", "first")]); + let bus = SpyBus::default(); + + RecallMemory::new(Arc::new(recall)) + .execute(RecallMemoryInput { + project_root: root(), + text: "ctx".to_owned(), + token_budget: 10, + }) + .await + .unwrap(); + + assert!( + bus.events().is_empty(), + "recall must not publish any DomainEvent" + ); +} + +// --------------------------------------------------------------------------- +// Read use cases emit no events +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn read_use_cases_publish_no_events() { + let store = FakeMemories::default(); + seed(&store, "note-a", "see [[note-a]]").await; + let bus = SpyBus::default(); + + // None of the read use cases take an EventBus — assert the shared spy stays + // empty after exercising every read path on the same store. + GetMemory::new(Arc::new(store.clone())) + .execute(GetMemoryInput { + project_root: root(), + slug: slug("note-a"), + }) + .await + .unwrap(); + ListMemories::new(Arc::new(store.clone())) + .execute(ListMemoriesInput { + project_root: root(), + }) + .await + .unwrap(); + ReadMemoryIndex::new(Arc::new(store.clone())) + .execute(ReadMemoryIndexInput { + project_root: root(), + }) + .await + .unwrap(); + ResolveMemoryLinks::new(Arc::new(store)) + .execute(ResolveMemoryLinksInput { + project_root: root(), + slug: slug("note-a"), + }) + .await + .unwrap(); + + assert!( + bus.events().is_empty(), + "read use cases must not publish any DomainEvent" + ); +} diff --git a/crates/domain/src/error.rs b/crates/domain/src/error.rs index 65b146c..f5837ec 100644 --- a/crates/domain/src/error.rs +++ b/crates/domain/src/error.rs @@ -65,6 +65,13 @@ pub enum DomainError { reason: String, }, + /// A memory slug was not valid kebab-case (`[a-z0-9-]`, non-empty). + #[error("`{value}` is not a valid kebab-case slug")] + InvalidSlug { + /// The offending value. + value: String, + }, + /// A generic invariant violation with an explanatory message. #[error("invariant violated: {0}")] Invariant(String), diff --git a/crates/domain/src/events.rs b/crates/domain/src/events.rs index 916e414..e943a8c 100644 --- a/crates/domain/src/events.rs +++ b/crates/domain/src/events.rs @@ -2,6 +2,7 @@ //! presentation layer (ARCHITECTURE §3.2). use crate::ids::{AgentId, ProjectId, SessionId, SkillId, TemplateId}; +use crate::memory::MemorySlug; use crate::template::TemplateVersion; /// Events emitted by the domain/application as state changes occur. @@ -90,6 +91,21 @@ pub enum DomainEvent { /// Whether IdeA handled it successfully. ok: bool, }, + /// A memory note was created or updated (`.md` written, index upserted). + MemorySaved { + /// The saved note's slug. + slug: MemorySlug, + }, + /// A memory note was deleted. + MemoryDeleted { + /// The deleted note's slug. + slug: MemorySlug, + }, + /// The aggregated `MEMORY.md` index was rebuilt for a project. + MemoryIndexRebuilt { + /// The project whose index was rebuilt. + project_id: ProjectId, + }, /// Raw PTY output (usually routed to a dedicated channel, not this bus). PtyOutput { /// The session. diff --git a/crates/domain/src/lib.rs b/crates/domain/src/lib.rs index 0e5027d..4518943 100644 --- a/crates/domain/src/lib.rs +++ b/crates/domain/src/lib.rs @@ -37,6 +37,7 @@ pub mod git; pub mod ids; pub mod layout; pub mod markdown; +pub mod memory; pub mod orchestrator; pub mod ports; pub mod profile; @@ -67,10 +68,16 @@ pub use skill::{Skill, SkillRef, SkillScope}; pub use template::{AgentTemplate, TemplateVersion}; -pub use profile::{AgentProfile, ContextInjection, SessionStrategy}; +pub use profile::{ + AgentProfile, ContextInjection, EmbedderProfile, EmbedderStrategy, SessionStrategy, +}; pub use markdown::MarkdownDoc; +pub use memory::{ + Memory, MemoryFrontmatter, MemoryIndexEntry, MemoryLink, MemorySlug, MemoryType, +}; + pub use remote::{RemoteKind, RemoteRef, SshAuth}; pub use terminal::{PtySize, SessionKind, SessionStatus, TerminalSession}; @@ -87,9 +94,11 @@ pub use events::DomainEvent; pub use orchestrator::{OrchestratorCommand, OrchestratorError, OrchestratorRequest}; pub use ports::{ - AgentContextStore, AgentRuntime, Clock, ContextInjectionPlan, DirEntry, EventBus, EventStream, + AgentContextStore, AgentRuntime, Clock, ContextInjectionPlan, DirEntry, Embedder, + EmbedderError, EventBus, EventStream, ExitStatus, FileSystem, FsError, GitCommitInfo, GitError, GitFileStatus, GitPort, GraphCommit, - IdGenerator, Output, OutputStream, PreparedContext, ProcessError, ProcessSpawner, ProfileStore, - ProjectStore, PtyError, PtyHandle, PtyPort, RemoteError, RemoteHost, RemotePath, RuntimeError, - SpawnSpec, StoreError, TemplateStore, + IdGenerator, MemoryError, MemoryQuery, MemoryRecall, MemoryStore, Output, OutputStream, + PreparedContext, ProcessError, + ProcessSpawner, ProfileStore, ProjectStore, PtyError, PtyHandle, PtyPort, RemoteError, + RemoteHost, RemotePath, RuntimeError, SpawnSpec, StoreError, TemplateStore, }; diff --git a/crates/domain/src/memory.rs b/crates/domain/src/memory.rs new file mode 100644 index 0000000..6a2d366 --- /dev/null +++ b/crates/domain/src/memory.rs @@ -0,0 +1,194 @@ +//! Memory entity — the persistent, model-agnostic knowledge base of a project +//! (LOT A, étage 1: `.md` files as the single source of truth). +//! +//! A [`Memory`] is one Markdown note stored under `.ideai/memory/.md`. Its +//! frontmatter carries the structured metadata (a kebab-case [`MemorySlug`], a +//! human-readable description, and a [`MemoryType`]); its body is opaque +//! [`MarkdownDoc`] text. Notes cross-reference one another via `[[slug]]` wiki +//! links, scanned by [`Memory::outgoing_links`]. +//! +//! The aggregated `.ideai/memory/MEMORY.md` index (one [`MemoryIndexEntry`] line +//! per note) is derived data: [`Memory::index_entry`] produces a note's row. The +//! adapter (`FsMemoryStore`) owns the on-disk YAML frontmatter and index file +//! formats; the domain stays I/O-free and format-neutral. + +use serde::{Deserialize, Serialize}; + +use crate::error::DomainError; +use crate::markdown::MarkdownDoc; + +/// A kebab-case identifier for a memory note (`[a-z0-9]` plus `-`), used both as +/// the on-disk file stem (`.md`) and as the `[[slug]]` link target. +/// +/// Invariants enforced by [`MemorySlug::new`]: +/// - non-empty, +/// - only lowercase ASCII letters, ASCII digits, and `-`, +/// - therefore no uppercase, no whitespace, and no `.` (so no `..` traversal). +#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(transparent)] +pub struct MemorySlug(String); + +impl MemorySlug { + /// Builds a validated kebab-case slug. + /// + /// # Errors + /// [`DomainError::InvalidSlug`] if `raw` is empty or contains any character + /// outside `[a-z0-9-]`. + pub fn new(raw: impl Into) -> Result { + let raw = raw.into(); + let invalid = || DomainError::InvalidSlug { + value: raw.clone(), + }; + if raw.is_empty() { + return Err(invalid()); + } + if raw + .chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') + { + Ok(Self(raw)) + } else { + Err(invalid()) + } + } + + /// Returns the slug as a string slice. + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl std::fmt::Display for MemorySlug { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.0) + } +} + +/// The kind of a memory note, driving how it is surfaced and prioritised. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum MemoryType { + /// A user-authored preference or instruction. + User, + /// Feedback captured from a prior interaction. + Feedback, + /// A project-level fact or decision. + Project, + /// A reference / external knowledge note. + Reference, +} + +/// The structured frontmatter of a memory note. +/// +/// Serialised with `type` (not `kind`) as the discriminator field name, matching +/// the on-disk YAML `metadata.type` (the adapter maps the nesting). Invariant: +/// `description` is non-empty (enforced at [`Memory::new`]). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct MemoryFrontmatter { + /// Stable kebab-case identifier (also the file stem). + pub name: MemorySlug, + /// Human-readable one-line description (the index hook). Non-empty. + pub description: String, + /// The note's kind. + #[serde(rename = "type")] + pub r#type: MemoryType, +} + +/// A `[[slug]]` wiki link found in a note's body, pointing at another note. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct MemoryLink { + /// The linked note's slug. + pub target: MemorySlug, +} + +/// One row of the aggregated `MEMORY.md` index: the `- [Title](slug.md) — hook` +/// line, decomposed into its parts. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MemoryIndexEntry { + /// The note's slug. + pub slug: MemorySlug, + /// The note's display title (currently the slug; titles are derived later). + pub title: String, + /// The one-line hook (the frontmatter description). + pub hook: String, + /// The note's kind. + pub r#type: MemoryType, +} + +/// A memory note: validated frontmatter plus an opaque Markdown body. +/// +/// Invariants enforced by [`Memory::new`]: +/// - `frontmatter.description` non-empty, +/// - `body` non-empty (an empty note carries no knowledge). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Memory { + /// Structured metadata. + pub frontmatter: MemoryFrontmatter, + /// Markdown body of the note. + pub body: MarkdownDoc, +} + +impl Memory { + /// Builds a validated memory note. + /// + /// # Errors + /// - [`DomainError::EmptyField`] if `frontmatter.description` is empty, + /// - [`DomainError::EmptyField`] if `body` is empty. + pub fn new(frontmatter: MemoryFrontmatter, body: MarkdownDoc) -> Result { + crate::validation::non_empty(&frontmatter.description, "memory.description")?; + if body.is_empty() { + return Err(DomainError::EmptyField { + field: "memory.body", + }); + } + Ok(Self { frontmatter, body }) + } + + /// Returns the note's slug (its identity). + #[must_use] + pub fn slug(&self) -> &MemorySlug { + &self.frontmatter.name + } + + /// Scans the body for `[[slug]]` wiki links, in order of appearance. + /// + /// Tokens whose inner text is not a valid [`MemorySlug`] are skipped (a + /// malformed link is not a hard error here). Duplicates are preserved — the + /// caller dedups if it wants a unique link set. No regex: a small linear scan. + #[must_use] + pub fn outgoing_links(&self) -> Vec { + let text = self.body.as_str(); + let bytes = text.as_bytes(); + let mut links = Vec::new(); + let mut i = 0; + while i + 1 < bytes.len() { + if bytes[i] == b'[' && bytes[i + 1] == b'[' { + // Find the closing `]]`. + if let Some(close) = text[i + 2..].find("]]") { + let inner = &text[i + 2..i + 2 + close]; + if let Ok(target) = MemorySlug::new(inner) { + links.push(MemoryLink { target }); + } + i = i + 2 + close + 2; + continue; + } + // No closing token: stop scanning further `[[`. + break; + } + i += 1; + } + links + } + + /// Produces this note's row in the aggregated `MEMORY.md` index. + #[must_use] + pub fn index_entry(&self) -> MemoryIndexEntry { + MemoryIndexEntry { + slug: self.frontmatter.name.clone(), + title: self.frontmatter.name.as_str().to_string(), + hook: self.frontmatter.description.clone(), + r#type: self.frontmatter.r#type, + } + } +} diff --git a/crates/domain/src/ports.rs b/crates/domain/src/ports.rs index 18d015d..04fc555 100644 --- a/crates/domain/src/ports.rs +++ b/crates/domain/src/ports.rs @@ -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, 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; + + /// Saves (creates or replaces by slug) a note: writes `.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, 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, 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, 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>, 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 { diff --git a/crates/domain/src/profile.rs b/crates/domain/src/profile.rs index dab42eb..7811236 100644 --- a/crates/domain/src/profile.rs +++ b/crates/domain/src/profile.rs @@ -150,6 +150,107 @@ pub struct AgentProfile { pub session: Option, } +/// 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, + /// Endpoint URL for a server/API strategy. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub endpoint: Option, + /// 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, + /// 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, + name: impl Into, + strategy: EmbedderStrategy, + model: Option, + endpoint: Option, + api_key_env: Option, + dimension: usize, + ) -> Result { + 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. /// diff --git a/crates/domain/tests/embedder_profile.rs b/crates/domain/tests/embedder_profile.rs new file mode 100644 index 0000000..5d3b863 --- /dev/null +++ b/crates/domain/tests/embedder_profile.rs @@ -0,0 +1,183 @@ +//! Tests for the declarative [`EmbedderProfile`] / [`EmbedderStrategy`] (LOT C, +//! étage 2 vectoriel): serde camelCase round-trip, the validated `new` +//! constructor, and the dependency-free `none()` default. + +use domain::{DomainError, EmbedderProfile, EmbedderStrategy}; + +fn roundtrip(value: &T) -> T +where + T: serde::Serialize + serde::de::DeserializeOwned + PartialEq + std::fmt::Debug, +{ + let json = serde_json::to_string(value).expect("serialize"); + let back: T = serde_json::from_str(&json).expect("deserialize"); + assert_eq!(&back, value, "round-trip mismatch via {json}"); + back +} + +// --------------------------------------------------------------------------- +// EmbedderStrategy: camelCase wire form (`localOnnx` / `localServer` / `api` / +// `none`). +// --------------------------------------------------------------------------- + +#[test] +fn strategy_serializes_camel_case() { + assert_eq!( + serde_json::to_string(&EmbedderStrategy::LocalOnnx).unwrap(), + "\"localOnnx\"" + ); + assert_eq!( + serde_json::to_string(&EmbedderStrategy::LocalServer).unwrap(), + "\"localServer\"" + ); + assert_eq!( + serde_json::to_string(&EmbedderStrategy::Api).unwrap(), + "\"api\"" + ); + assert_eq!( + serde_json::to_string(&EmbedderStrategy::None).unwrap(), + "\"none\"" + ); +} + +#[test] +fn strategy_deserializes_camel_case() { + assert_eq!( + serde_json::from_str::("\"localOnnx\"").unwrap(), + EmbedderStrategy::LocalOnnx + ); + assert_eq!( + serde_json::from_str::("\"localServer\"").unwrap(), + EmbedderStrategy::LocalServer + ); + assert_eq!( + serde_json::from_str::("\"api\"").unwrap(), + EmbedderStrategy::Api + ); + assert_eq!( + serde_json::from_str::("\"none\"").unwrap(), + EmbedderStrategy::None + ); +} + +#[test] +fn strategy_roundtrips() { + for s in [ + EmbedderStrategy::LocalOnnx, + EmbedderStrategy::LocalServer, + EmbedderStrategy::Api, + EmbedderStrategy::None, + ] { + roundtrip(&s); + } +} + +// --------------------------------------------------------------------------- +// EmbedderProfile: camelCase fields and optional-field skipping. +// --------------------------------------------------------------------------- + +#[test] +fn profile_roundtrips_with_all_fields() { + let p = EmbedderProfile::new( + "local-onnx-minilm", + "Local MiniLM", + EmbedderStrategy::LocalOnnx, + Some("all-MiniLM-L6-v2".to_owned()), + Some("http://localhost:1234".to_owned()), + Some("OPENAI_API_KEY".to_owned()), + 384, + ) + .unwrap(); + let back = roundtrip(&p); + assert_eq!(back.dimension, 384); + assert_eq!(back.strategy, EmbedderStrategy::LocalOnnx); +} + +#[test] +fn profile_uses_camel_case_keys_and_skips_none_options() { + let p = EmbedderProfile::new( + "id-1", + "Name", + EmbedderStrategy::Api, + None, + Some("https://api.example/embed".to_owned()), + Some("MY_KEY".to_owned()), + 16, + ) + .unwrap(); + let json = serde_json::to_string(&p).unwrap(); + assert!(json.contains("\"apiKeyEnv\":\"MY_KEY\""), "camelCase apiKeyEnv: {json}"); + assert!(json.contains("\"strategy\":\"api\""), "camelCase strategy: {json}"); + // `model` is None ⇒ skipped from the wire form. + assert!(!json.contains("\"model\""), "None model must be skipped: {json}"); +} + +#[test] +fn profile_deserializes_from_camel_case_document() { + let json = r#"{ + "id": "srv", + "name": "Server", + "strategy": "localServer", + "endpoint": "http://127.0.0.1:9000", + "dimension": 512 + }"#; + let p: EmbedderProfile = serde_json::from_str(json).unwrap(); + assert_eq!(p.id, "srv"); + assert_eq!(p.strategy, EmbedderStrategy::LocalServer); + assert_eq!(p.endpoint.as_deref(), Some("http://127.0.0.1:9000")); + assert_eq!(p.dimension, 512); + assert!(p.model.is_none()); + assert!(p.api_key_env.is_none()); +} + +// --------------------------------------------------------------------------- +// none(): the dependency-free default. +// --------------------------------------------------------------------------- + +#[test] +fn none_profile_has_none_strategy() { + let p = EmbedderProfile::none(); + assert_eq!(p.strategy, EmbedderStrategy::None); + assert_eq!(p.id, "none"); + assert!(p.dimension > 0, "even the none profile keeps a non-zero dimension"); + // And it round-trips like any other profile. + roundtrip(&p); +} + +// --------------------------------------------------------------------------- +// new(): validation. +// --------------------------------------------------------------------------- + +#[test] +fn new_rejects_empty_id() { + assert!(matches!( + EmbedderProfile::new("", "Name", EmbedderStrategy::None, None, None, None, 8), + Err(DomainError::EmptyField { field: "embedder.id" }) + )); +} + +#[test] +fn new_rejects_empty_name() { + assert!(matches!( + EmbedderProfile::new("id", "", EmbedderStrategy::None, None, None, None, 8), + Err(DomainError::EmptyField { + field: "embedder.name" + }) + )); +} + +#[test] +fn new_rejects_zero_dimension() { + assert!(matches!( + EmbedderProfile::new("id", "Name", EmbedderStrategy::None, None, None, None, 0), + Err(DomainError::EmptyField { + field: "embedder.dimension" + }) + )); +} + +#[test] +fn new_accepts_valid_minimal_profile() { + let p = EmbedderProfile::new("id", "Name", EmbedderStrategy::None, None, None, None, 1).unwrap(); + assert_eq!(p.dimension, 1); + assert_eq!(p.strategy, EmbedderStrategy::None); +} diff --git a/crates/domain/tests/memory.rs b/crates/domain/tests/memory.rs new file mode 100644 index 0000000..acd0ebd --- /dev/null +++ b/crates/domain/tests/memory.rs @@ -0,0 +1,187 @@ +//! Domain tests for the [`Memory`] entity and its value objects (LOT A, étage 1): +//! [`MemorySlug`] validation, `[[slug]]` link scanning, frontmatter serde +//! round-trip, and the derived index entry. + +use domain::{ + DomainError, MarkdownDoc, Memory, MemoryFrontmatter, MemorySlug, MemoryType, +}; + +fn fm(slug: &str, description: &str, ty: MemoryType) -> MemoryFrontmatter { + MemoryFrontmatter { + name: MemorySlug::new(slug).unwrap(), + description: description.to_string(), + r#type: ty, + } +} + +fn note(slug: &str, body: &str) -> Memory { + Memory::new( + fm(slug, "a hook", MemoryType::Project), + MarkdownDoc::new(body), + ) + .unwrap() +} + +// --------------------------------------------------------------------------- +// MemorySlug +// --------------------------------------------------------------------------- + +#[test] +fn slug_accepts_kebab_case() { + assert_eq!( + MemorySlug::new("my-note-42").unwrap().as_str(), + "my-note-42" + ); + assert!(MemorySlug::new("abc").is_ok()); + assert!(MemorySlug::new("a-b-c-1-2-3").is_ok()); +} + +#[test] +fn slug_rejects_uppercase() { + assert!(matches!( + MemorySlug::new("MyNote").unwrap_err(), + DomainError::InvalidSlug { .. } + )); +} + +#[test] +fn slug_rejects_spaces() { + assert!(matches!( + MemorySlug::new("my note").unwrap_err(), + DomainError::InvalidSlug { .. } + )); +} + +#[test] +fn slug_rejects_dot_and_traversal() { + assert!(matches!( + MemorySlug::new("..").unwrap_err(), + DomainError::InvalidSlug { .. } + )); + assert!(matches!( + MemorySlug::new("a.b").unwrap_err(), + DomainError::InvalidSlug { .. } + )); +} + +#[test] +fn slug_rejects_empty() { + assert!(matches!( + MemorySlug::new("").unwrap_err(), + DomainError::InvalidSlug { .. } + )); +} + +#[test] +fn slug_rejects_underscore_and_slash() { + assert!(MemorySlug::new("a_b").is_err()); + assert!(MemorySlug::new("a/b").is_err()); +} + +// --------------------------------------------------------------------------- +// Memory invariants +// --------------------------------------------------------------------------- + +#[test] +fn memory_rejects_empty_description() { + let r = Memory::new( + fm("ok-slug", "", MemoryType::User), + MarkdownDoc::new("body"), + ); + assert!(matches!(r.unwrap_err(), DomainError::EmptyField { .. })); +} + +#[test] +fn memory_rejects_empty_body() { + let r = Memory::new(fm("ok-slug", "hook", MemoryType::User), MarkdownDoc::new("")); + assert!(matches!(r.unwrap_err(), DomainError::EmptyField { .. })); +} + +// --------------------------------------------------------------------------- +// outgoing_links +// --------------------------------------------------------------------------- + +#[test] +fn outgoing_links_none() { + assert!(note("n", "plain body, no links").outgoing_links().is_empty()); +} + +#[test] +fn outgoing_links_multiple() { + let m = note("n", "see [[alpha]] and also [[beta-2]] here"); + let targets: Vec<_> = m + .outgoing_links() + .into_iter() + .map(|l| l.target.as_str().to_string()) + .collect(); + assert_eq!(targets, vec!["alpha".to_string(), "beta-2".to_string()]); +} + +#[test] +fn outgoing_links_preserves_duplicates() { + let m = note("n", "[[alpha]] then again [[alpha]]"); + assert_eq!(m.outgoing_links().len(), 2); +} + +#[test] +fn outgoing_links_skips_invalid_targets() { + // `[[Not Valid]]` is not a kebab-case slug -> skipped. + let m = note("n", "[[Not Valid]] but [[good-one]]"); + let targets: Vec<_> = m + .outgoing_links() + .into_iter() + .map(|l| l.target.as_str().to_string()) + .collect(); + assert_eq!(targets, vec!["good-one".to_string()]); +} + +#[test] +fn outgoing_links_ignores_unterminated() { + let m = note("n", "dangling [[oops without close"); + assert!(m.outgoing_links().is_empty()); +} + +// --------------------------------------------------------------------------- +// index_entry +// --------------------------------------------------------------------------- + +#[test] +fn index_entry_carries_slug_hook_and_type() { + let m = Memory::new( + fm("topic-x", "a useful hook", MemoryType::Feedback), + MarkdownDoc::new("body"), + ) + .unwrap(); + let e = m.index_entry(); + assert_eq!(e.slug.as_str(), "topic-x"); + assert_eq!(e.hook, "a useful hook"); + assert_eq!(e.title, "topic-x"); + assert_eq!(e.r#type, MemoryType::Feedback); +} + +// --------------------------------------------------------------------------- +// Frontmatter serde round-trip (the `type` field name + camelCase variants). +// --------------------------------------------------------------------------- + +#[test] +fn frontmatter_serde_roundtrip() { + let original = fm("round-trip", "desc", MemoryType::Reference); + let json = serde_json::to_string(&original).unwrap(); + // `type` field name (not `kind`), camelCase enum value. + assert!(json.contains("\"type\":\"reference\"")); + assert!(json.contains("\"name\":\"round-trip\"")); + let back: MemoryFrontmatter = serde_json::from_str(&json).unwrap(); + assert_eq!(back, original); +} + +#[test] +fn memory_type_serde_is_camel_case() { + assert_eq!( + serde_json::to_string(&MemoryType::User).unwrap(), + "\"user\"" + ); + assert_eq!( + serde_json::to_string(&MemoryType::Project).unwrap(), + "\"project\"" + ); +} diff --git a/crates/infrastructure/src/lib.rs b/crates/infrastructure/src/lib.rs index 9750aeb..1b9e70d 100644 --- a/crates/infrastructure/src/lib.rs +++ b/crates/infrastructure/src/lib.rs @@ -40,5 +40,8 @@ pub use pty::PortablePtyAdapter; pub use remote::{remote_host, LocalHost}; pub use runtime::CliAgentRuntime; pub use store::{ - FsProfileStore, FsProjectStore, FsSkillStore, FsTemplateStore, IdeaiContextStore, + embedder_from_profile, index_token_size, should_use_vector, AdaptiveMemoryRecall, + FsEmbedderProfileStore, FsMemoryStore, FsProfileStore, FsProjectStore, FsSkillStore, + FsTemplateStore, HashEmbedder, IdeaiContextStore, NaiveMemoryRecall, StubEmbedder, + VectorMemoryRecall, }; diff --git a/crates/infrastructure/src/store/embedder.rs b/crates/infrastructure/src/store/embedder.rs new file mode 100644 index 0000000..8161da6 --- /dev/null +++ b/crates/infrastructure/src/store/embedder.rs @@ -0,0 +1,173 @@ +//! [`Embedder`] adapters (LOT C, étage 2 vectoriel — §14.5.3). +//! +//! The étage-2 semantic recall is driven by a declarative +//! [`EmbedderProfile`]; this module turns such a profile into a concrete +//! [`Embedder`] via [`embedder_from_profile`]. +//! +//! ## Zero heavy dependency by design +//! +//! IdeA's founding posture is **default `none`, nothing imposed, zero +//! dependency** (ARCHITECTURE §14.5.3). The concrete engines therefore ship as +//! **documented stubs**: +//! +//! - [`EmbedderStrategy::LocalOnnx`] / [`EmbedderStrategy::LocalServer`] / +//! [`EmbedderStrategy::Api`] map to [`StubEmbedder`], which returns a clean +//! [`EmbedderError::Unsupported`] (never a panic) — the real ONNX/HTTP +//! integration is an explicit follow-up that would add the heavy crate behind a +//! feature, here and only here; +//! - [`EmbedderStrategy::None`] yields no embedder (`None`): recall stays the +//! dependency-free naïve étage 1. +//! +//! [`HashEmbedder`] is a small **deterministic, dependency-free** embedder used to +//! exercise the whole vector pipeline (it is not a stub: it produces stable, +//! repeatable vectors from a hashing bag-of-words, good enough for tests and for a +//! trivial offline fallback). + +use async_trait::async_trait; + +use domain::ports::{Embedder, EmbedderError}; +use domain::profile::{EmbedderProfile, EmbedderStrategy}; + +/// Builds the concrete [`Embedder`] for a profile, or `None` when the strategy is +/// [`EmbedderStrategy::None`] (recall stays naïve). +/// +/// The concrete engines are stubs ([`StubEmbedder`]) until their real ONNX/HTTP +/// integration lands; they fail cleanly with [`EmbedderError::Unsupported`] rather +/// than panic, so a composing recall degrades to naïve. +#[must_use] +pub fn embedder_from_profile(profile: &EmbedderProfile) -> Option> { + match profile.strategy { + EmbedderStrategy::None => None, + EmbedderStrategy::LocalOnnx => Some(Box::new(StubEmbedder::new( + profile.id.clone(), + profile.dimension, + "localOnnx", + ))), + EmbedderStrategy::LocalServer => Some(Box::new(StubEmbedder::new( + profile.id.clone(), + profile.dimension, + "localServer", + ))), + EmbedderStrategy::Api => Some(Box::new(StubEmbedder::new( + profile.id.clone(), + profile.dimension, + "api", + ))), + } +} + +/// A placeholder [`Embedder`] for a not-yet-implemented concrete strategy. +/// +/// Every [`embed`](Embedder::embed) call returns [`EmbedderError::Unsupported`] +/// (never a panic), naming the strategy. It exists so the wiring, profiles, and +/// adapters are all in place and testable today; swapping in the real engine is a +/// localised follow-up. +pub struct StubEmbedder { + id: String, + dimension: usize, + strategy: &'static str, +} + +impl StubEmbedder { + /// Builds a stub for `strategy` advertising `dimension`. + #[must_use] + pub fn new(id: impl Into, dimension: usize, strategy: &'static str) -> Self { + Self { + id: id.into(), + dimension, + strategy, + } + } +} + +#[async_trait] +impl Embedder for StubEmbedder { + fn id(&self) -> &str { + &self.id + } + + async fn embed(&self, _texts: &[String]) -> Result>, EmbedderError> { + Err(EmbedderError::Unsupported(format!( + "embedder strategy `{}` is not implemented yet (follow-up: real ONNX/HTTP backend)", + self.strategy + ))) + } + + fn dimension(&self) -> usize { + self.dimension + } +} + +/// A deterministic, dependency-free [`Embedder`] backed by a hashing +/// bag-of-words. Pure std (FNV-1a hashing of whitespace tokens into `dimension` +/// buckets, then L2-normalised). Stable across runs and platforms. +/// +/// It is **not** a stub: it lets the étage-2 pipeline run end-to-end with no heavy +/// dependency. Vector quality is crude (lexical overlap only), but the +/// [`Embedder`] contract is fully honoured, so it is a valid offline embedder and +/// the substrate for deterministic tests of [`crate::store::VectorMemoryRecall`]. +#[derive(Clone)] +pub struct HashEmbedder { + id: String, + dimension: usize, +} + +impl HashEmbedder { + /// Builds a hashing embedder producing `dimension`-length vectors. + /// + /// # Panics + /// Panics if `dimension` is `0` (an embedder must produce fixed-length + /// non-empty vectors). + #[must_use] + pub fn new(id: impl Into, dimension: usize) -> Self { + assert!(dimension > 0, "HashEmbedder dimension must be non-zero"); + Self { + id: id.into(), + dimension, + } + } + + /// Embeds one text into an L2-normalised `dimension`-length vector. + fn embed_one(&self, text: &str) -> Vec { + let mut v = vec![0f32; self.dimension]; + for token in text.split_whitespace() { + let lower = token.to_ascii_lowercase(); + let bucket = (fnv1a(lower.as_bytes()) as usize) % self.dimension; + v[bucket] += 1.0; + } + let norm = v.iter().map(|x| x * x).sum::().sqrt(); + if norm > 0.0 { + for x in &mut v { + *x /= norm; + } + } + v + } +} + +#[async_trait] +impl Embedder for HashEmbedder { + fn id(&self) -> &str { + &self.id + } + + async fn embed(&self, texts: &[String]) -> Result>, EmbedderError> { + Ok(texts.iter().map(|t| self.embed_one(t)).collect()) + } + + fn dimension(&self) -> usize { + self.dimension + } +} + +/// FNV-1a 64-bit hash of a byte slice (deterministic, dependency-free). +fn fnv1a(bytes: &[u8]) -> u64 { + const OFFSET: u64 = 0xcbf2_9ce4_8422_2325; + const PRIME: u64 = 0x0000_0100_0000_01b3; + let mut hash = OFFSET; + for &b in bytes { + hash ^= u64::from(b); + hash = hash.wrapping_mul(PRIME); + } + hash +} diff --git a/crates/infrastructure/src/store/memory.rs b/crates/infrastructure/src/store/memory.rs new file mode 100644 index 0000000..a65eb4c --- /dev/null +++ b/crates/infrastructure/src/store/memory.rs @@ -0,0 +1,492 @@ +//! [`FsMemoryStore`] — file implementation of the [`MemoryStore`] port +//! (LOT A, étage 1). +//! +//! Memory notes are the project's persistent, model-agnostic knowledge base. Each +//! note is a single Markdown file with a YAML frontmatter header, stored under the +//! project's `.ideai/memory/`: +//! +//! ```text +//! /.ideai/memory/ +//! ├── MEMORY.md # aggregated index: one `- [Title](slug.md) — hook` line per note +//! └── .md # a note: YAML frontmatter + Markdown body +//! ``` +//! +//! A note file looks like: +//! +//! ```text +//! --- +//! name: my-note +//! description: A one-line hook +//! metadata: +//! type: project +//! --- +//! # Body +//! ... +//! ``` +//! +//! The `.md` files are the **single source of truth**; `MEMORY.md` is derived and +//! kept in sync on every [`save`](MemoryStore::save)/[`delete`](MemoryStore::delete) +//! (idempotent upsert / removal of the note's line). All I/O goes through the +//! [`FileSystem`] port, so the adapter is location-neutral (SSH/WSL work unchanged) +//! and Tauri-agnostic. +//! +//! Like the sibling stores, [`delete`](MemoryStore::delete) drops the note's line +//! from `MEMORY.md` and leaves the orphaned `.md` on disk (the [`FileSystem`] +//! port exposes no remove); since listing is index-driven, the note is effectively +//! gone. + +use std::sync::Arc; + +use async_trait::async_trait; + +use domain::markdown::MarkdownDoc; +use domain::memory::{ + Memory, MemoryFrontmatter, MemoryIndexEntry, MemoryLink, MemorySlug, MemoryType, +}; +use domain::ports::{ + FileSystem, FsError, MemoryError, MemoryQuery, MemoryRecall, MemoryStore, RemotePath, +}; +use domain::project::ProjectPath; + +/// The `.ideai/` directory name inside a project root. +const IDEAI_DIR: &str = ".ideai"; + +/// Sub-path of the memory store inside `.ideai/`. +const MEMORY_DIR: &str = "memory"; + +/// Aggregated index file name inside the memory dir. +const INDEX_FILE: &str = "MEMORY.md"; + +/// First line of the aggregated index. +const INDEX_HEADER: &str = "# Memory Index"; + +/// File-backed [`MemoryStore`], composing a [`FileSystem`] port. The project root +/// is supplied **per call**, so a single instance serves every open project +/// (mirroring [`crate::store::FsSkillStore`]). +#[derive(Clone)] +pub struct FsMemoryStore { + fs: Arc, +} + +impl FsMemoryStore { + /// Builds the store from an injected [`FileSystem`]. Directories are created + /// on first write. + #[must_use] + pub fn new(fs: Arc) -> Self { + Self { fs } + } + + /// `/.ideai/memory`. + fn dir(&self, root: &ProjectPath) -> String { + let base = root.as_str().trim_end_matches(['/', '\\']); + format!("{base}/{IDEAI_DIR}/{MEMORY_DIR}") + } + + /// `/.md`. + fn md_path(&self, root: &ProjectPath, slug: &MemorySlug) -> RemotePath { + RemotePath::new(format!("{}/{}.md", self.dir(root), slug.as_str())) + } + + /// `/MEMORY.md`. + fn index_path(&self, root: &ProjectPath) -> RemotePath { + RemotePath::new(format!("{}/{INDEX_FILE}", self.dir(root))) + } + + /// Reads and parses a note by slug. + async fn load(&self, root: &ProjectPath, slug: &MemorySlug) -> Result { + let bytes = self + .fs + .read(&self.md_path(root, slug)) + .await + .map_err(|e| match e { + FsError::NotFound(_) => MemoryError::NotFound, + other => MemoryError::Io(other.to_string()), + })?; + let text = String::from_utf8(bytes).map_err(|e| MemoryError::Io(e.to_string()))?; + parse_note(&text) + } + + /// Reads the raw `MEMORY.md` text, or `None` if it does not exist yet. + async fn read_index_text(&self, root: &ProjectPath) -> Result, MemoryError> { + match self.fs.read(&self.index_path(root)).await { + Ok(bytes) => String::from_utf8(bytes) + .map(Some) + .map_err(|e| MemoryError::Io(e.to_string())), + Err(FsError::NotFound(_)) => Ok(None), + Err(e) => Err(MemoryError::Io(e.to_string())), + } + } + + /// Rewrites `MEMORY.md` from the given entries, ensuring the dir exists. + async fn write_index( + &self, + root: &ProjectPath, + entries: &[MemoryIndexEntry], + ) -> Result<(), MemoryError> { + self.fs + .create_dir_all(&RemotePath::new(self.dir(root))) + .await + .map_err(|e| MemoryError::Io(e.to_string()))?; + let text = render_index(entries); + self.fs + .write(&self.index_path(root), text.as_bytes()) + .await + .map_err(|e| MemoryError::Io(e.to_string())) + } + + /// Lists the slugs known to the index (index-driven listing). + async fn index_slugs(&self, root: &ProjectPath) -> Result, MemoryError> { + Ok(self + .read_index(root) + .await? + .into_iter() + .map(|e| e.slug) + .collect()) + } +} + +#[async_trait] +impl MemoryStore for FsMemoryStore { + async fn list(&self, root: &ProjectPath) -> Result, MemoryError> { + let slugs = self.index_slugs(root).await?; + let mut out = Vec::with_capacity(slugs.len()); + for slug in &slugs { + out.push(self.load(root, slug).await?); + } + Ok(out) + } + + async fn get(&self, root: &ProjectPath, slug: &MemorySlug) -> Result { + self.load(root, slug).await + } + + async fn save(&self, root: &ProjectPath, memory: &Memory) -> Result<(), MemoryError> { + // (1) Write the note file. + self.fs + .create_dir_all(&RemotePath::new(self.dir(root))) + .await + .map_err(|e| MemoryError::Io(e.to_string()))?; + let text = render_note(memory); + self.fs + .write(&self.md_path(root, memory.slug()), text.as_bytes()) + .await + .map_err(|e| MemoryError::Io(e.to_string()))?; + + // (2) Upsert the index line idempotently (same slug => one line). + let mut entries = self.read_index(root).await?; + let row = memory.index_entry(); + if let Some(slot) = entries.iter_mut().find(|e| e.slug == row.slug) { + *slot = row; + } else { + entries.push(row); + } + self.write_index(root, &entries).await + } + + async fn delete(&self, root: &ProjectPath, slug: &MemorySlug) -> Result<(), MemoryError> { + let mut entries = self.read_index(root).await?; + let before = entries.len(); + entries.retain(|e| &e.slug != slug); + if entries.len() == before { + return Err(MemoryError::NotFound); + } + // The orphaned `.md` is left on disk (no FileSystem delete); the + // index no longer references it, so it is effectively gone. + self.write_index(root, &entries).await + } + + async fn read_index(&self, root: &ProjectPath) -> Result, MemoryError> { + match self.read_index_text(root).await? { + Some(text) => Ok(parse_index(&text)), + None => Ok(Vec::new()), + } + } + + async fn resolve_links( + &self, + root: &ProjectPath, + slug: &MemorySlug, + ) -> Result, MemoryError> { + let memory = self.load(root, slug).await?; + let known = self.index_slugs(root).await?; + // Keep only links whose target resolves to a known note (ignore broken). + Ok(memory + .outgoing_links() + .into_iter() + .filter(|link| known.contains(&link.target)) + .collect()) + } +} + +// --------------------------------------------------------------------------- +// NaiveMemoryRecall — the default, dependency-free MemoryRecall (LOT B). +// --------------------------------------------------------------------------- + +/// Heuristic divisor turning a character count into an approximate token count +/// (~4 characters per token, the usual rule of thumb). Shared by every +/// [`MemoryRecall`] adapter so the budget semantics stay identical (DRY). +pub(crate) const CHARS_PER_TOKEN: usize = 4; + +/// Approximate token cost of an index entry's textual payload — the single +/// budget-cost function shared by every [`MemoryRecall`] adapter (naïve, vector, +/// adaptive), so truncation semantics are identical across them (Liskov / DRY). +pub(crate) fn entry_cost(entry: &MemoryIndexEntry) -> usize { + let chars = entry.title.len() + entry.hook.len(); + chars.div_ceil(CHARS_PER_TOKEN) +} + +/// Greedily takes entries in the given order while their accumulated +/// [`entry_cost`] stays within `budget`; stops at the first entry that would +/// exceed it (and drops every entry after). A `budget` of `0` yields an empty +/// vec. This is the shared truncation used by every recall adapter. +pub(crate) fn truncate_to_budget( + entries: impl IntoIterator, + budget: usize, +) -> Vec { + if budget == 0 { + return Vec::new(); + } + let mut spent = 0usize; + let mut out = Vec::new(); + for entry in entries { + let cost = entry_cost(&entry); + if spent + cost > budget { + break; + } + spent += cost; + out.push(entry); + } + out +} + +/// Total approximate token cost of an index — a **pure** function of the entries, +/// used by [`crate::store::AdaptiveMemoryRecall`] to decide naïve vs. vector +/// recall without any I/O. +#[must_use] +pub fn index_token_size(entries: &[MemoryIndexEntry]) -> usize { + entries.iter().map(entry_cost).sum() +} + +/// The default [`MemoryRecall`]: dependency-free, ignores semantic relevance. +/// +/// It composes an [`Arc`], reads the aggregated index via +/// [`MemoryStore::read_index`], and returns the entries **in index order**, +/// truncated to fit the query's token budget. It is the baseline against which a +/// future `VectorMemoryRecall` (LOT C) is substitutable. +/// +/// ## Budget semantics +/// `token_budget` is an *approximate* budget. Each entry's cost is estimated as +/// `ceil((title.len() + hook.len()) / 4)` tokens (≈ 4 chars/token, counting only +/// the index line's textual payload). Entries are taken in order, accumulating +/// their cost; the first entry whose inclusion would exceed the budget — and every +/// entry after it — is dropped. A budget of `0` therefore yields an empty list; +/// an empty or missing memory yields an empty list without error. +#[derive(Clone)] +pub struct NaiveMemoryRecall { + store: Arc, +} + +impl NaiveMemoryRecall { + /// Builds the recall adapter from a composed [`MemoryStore`]. + #[must_use] + pub fn new(store: Arc) -> Self { + Self { store } + } +} + +#[async_trait] +impl MemoryRecall for NaiveMemoryRecall { + async fn recall( + &self, + root: &ProjectPath, + query: &MemoryQuery, + ) -> Result, MemoryError> { + // Budget-0 short-circuits before any I/O: a zero budget can hold no entry, + // so there is nothing to read (homogeneous with every recall adapter). + if query.token_budget == 0 { + return Ok(Vec::new()); + } + let entries = self.store.read_index(root).await?; + Ok(truncate_to_budget(entries, query.token_budget)) + } +} + +// --------------------------------------------------------------------------- +// On-disk format: YAML frontmatter + body, and the MEMORY.md index. +// +// We hand-roll a tiny, well-scoped YAML reader/writer for exactly the frontmatter +// shape we own (`name`, `description`, `metadata.type`). This keeps the crate free +// of a YAML dependency for a fixed, simple schema; any deviation surfaces as a +// `MemoryError::Frontmatter`. +// --------------------------------------------------------------------------- + +/// Renders a note to its on-disk `---`-fenced frontmatter + body form. +fn render_note(memory: &Memory) -> String { + let fm = &memory.frontmatter; + format!( + "---\nname: {}\ndescription: {}\nmetadata:\n type: {}\n---\n{}", + fm.name.as_str(), + fm.description, + type_to_str(fm.r#type), + memory.body.as_str(), + ) +} + +/// Parses a note from its on-disk form. +fn parse_note(text: &str) -> Result { + let rest = text + .strip_prefix("---\n") + .or_else(|| text.strip_prefix("---\r\n")) + .ok_or_else(|| MemoryError::Frontmatter("missing opening `---` fence".to_string()))?; + + // Find the closing `---` fence at the start of a line. + let (fm_block, body) = split_frontmatter(rest) + .ok_or_else(|| MemoryError::Frontmatter("missing closing `---` fence".to_string()))?; + + let frontmatter = parse_frontmatter(fm_block)?; + Memory::new(frontmatter, MarkdownDoc::new(body)) + .map_err(|e| MemoryError::Frontmatter(e.to_string())) +} + +/// Splits the post-opening-fence text into `(frontmatter_block, body)` at the +/// closing `---` line. Returns `None` if no closing fence is present. +fn split_frontmatter(rest: &str) -> Option<(&str, &str)> { + let mut offset = 0; + for line in rest.split_inclusive('\n') { + let trimmed = line.trim_end_matches(['\n', '\r']); + if trimmed == "---" { + let fm = &rest[..offset]; + let body = &rest[offset + line.len()..]; + return Some((fm, body)); + } + offset += line.len(); + } + None +} + +/// Parses the frontmatter key/values into a validated [`MemoryFrontmatter`]. +fn parse_frontmatter(block: &str) -> Result { + let err = |reason: &str| MemoryError::Frontmatter(reason.to_string()); + + let mut name: Option = None; + let mut description: Option = None; + let mut type_str: Option = None; + let mut in_metadata = false; + + for raw in block.lines() { + if raw.trim().is_empty() { + continue; + } + let indented = raw.starts_with(' ') || raw.starts_with('\t'); + let (key, value) = raw + .split_once(':') + .ok_or_else(|| err("frontmatter line missing `:`"))?; + let key = key.trim(); + let value = value.trim(); + + if !indented { + in_metadata = false; + match key { + "name" => name = Some(value.to_string()), + "description" => description = Some(value.to_string()), + "metadata" => { + in_metadata = true; + if !value.is_empty() { + return Err(err("`metadata` must be a nested block")); + } + } + _ => return Err(err("unknown frontmatter key")), + } + } else if in_metadata && key == "type" { + type_str = Some(value.to_string()); + } else { + return Err(err("unexpected indented frontmatter line")); + } + } + + let name = name.ok_or_else(|| err("missing `name`"))?; + let description = description.ok_or_else(|| err("missing `description`"))?; + let type_str = type_str.ok_or_else(|| err("missing `metadata.type`"))?; + + let name = MemorySlug::new(name).map_err(|e| MemoryError::Frontmatter(e.to_string()))?; + let r#type = str_to_type(&type_str).ok_or_else(|| err("unknown `metadata.type` value"))?; + + Ok(MemoryFrontmatter { + name, + description, + r#type, + }) +} + +/// Renders the aggregated `MEMORY.md` index. +fn render_index(entries: &[MemoryIndexEntry]) -> String { + let mut out = String::from(INDEX_HEADER); + out.push('\n'); + if !entries.is_empty() { + out.push('\n'); + for e in entries { + out.push_str(&format!( + "- [{}]({}.md) — {}\n", + e.title, + e.slug.as_str(), + e.hook + )); + } + } + out +} + +/// Parses the `MEMORY.md` index lines back into structured entries. Lines that do +/// not match the `- [Title](slug.md) — hook` shape are skipped (tolerant read); +/// the `type` is not stored in the index line and defaults to +/// [`MemoryType::Reference`]. +fn parse_index(text: &str) -> Vec { + let mut out = Vec::new(); + for line in text.lines() { + let line = line.trim(); + if !line.starts_with("- [") { + continue; + } + if let Some(entry) = parse_index_line(line) { + out.push(entry); + } + } + out +} + +/// Parses one `- [Title](slug.md) — hook` line. +fn parse_index_line(line: &str) -> Option { + let rest = line.strip_prefix("- [")?; + let (title, rest) = rest.split_once("](")?; + let (target, rest) = rest.split_once(')')?; + let slug_str = target.strip_suffix(".md").unwrap_or(target); + let slug = MemorySlug::new(slug_str).ok()?; + let hook = rest.trim_start().strip_prefix('—').unwrap_or(rest).trim(); + Some(MemoryIndexEntry { + slug, + title: title.to_string(), + hook: hook.to_string(), + r#type: MemoryType::Reference, + }) +} + +/// Maps a [`MemoryType`] to its YAML/string form (matches serde camelCase). +fn type_to_str(t: MemoryType) -> &'static str { + match t { + MemoryType::User => "user", + MemoryType::Feedback => "feedback", + MemoryType::Project => "project", + MemoryType::Reference => "reference", + } +} + +/// Parses a [`MemoryType`] from its string form. +fn str_to_type(s: &str) -> Option { + match s { + "user" => Some(MemoryType::User), + "feedback" => Some(MemoryType::Feedback), + "project" => Some(MemoryType::Project), + "reference" => Some(MemoryType::Reference), + _ => None, + } +} diff --git a/crates/infrastructure/src/store/mod.rs b/crates/infrastructure/src/store/mod.rs index a6c54c8..81ed768 100644 --- a/crates/infrastructure/src/store/mod.rs +++ b/crates/infrastructure/src/store/mod.rs @@ -5,13 +5,19 @@ //! JSON files in the app data directory (machine-local, outside any project). mod context; +mod embedder; +mod memory; mod profile; mod project; mod skill; mod template; +mod vector; pub use context::IdeaiContextStore; -pub use profile::FsProfileStore; +pub use embedder::{embedder_from_profile, HashEmbedder, StubEmbedder}; +pub use memory::{index_token_size, FsMemoryStore, NaiveMemoryRecall}; +pub use profile::{FsEmbedderProfileStore, FsProfileStore}; pub use project::FsProjectStore; pub use skill::FsSkillStore; pub use template::FsTemplateStore; +pub use vector::{should_use_vector, AdaptiveMemoryRecall, VectorMemoryRecall}; diff --git a/crates/infrastructure/src/store/profile.rs b/crates/infrastructure/src/store/profile.rs index c9bec61..685e6e4 100644 --- a/crates/infrastructure/src/store/profile.rs +++ b/crates/infrastructure/src/store/profile.rs @@ -24,7 +24,7 @@ use serde::{Deserialize, Serialize}; use domain::ids::ProfileId; use domain::ports::{FileSystem, ProfileStore, RemotePath, StoreError}; -use domain::profile::AgentProfile; +use domain::profile::{AgentProfile, EmbedderProfile}; /// File name of the profiles store inside the app-data dir. const PROFILES_FILE: &str = "profiles.json"; @@ -147,3 +147,126 @@ impl ProfileStore for FsProfileStore { self.write_doc(&doc).await } } + +// --------------------------------------------------------------------------- +// FsEmbedderProfileStore — declarative embedder profiles (`embedder.json`, LOT C). +// --------------------------------------------------------------------------- + +/// File name of the embedder-profiles store inside the app-data dir. +const EMBEDDER_FILE: &str = "embedder.json"; + +/// Current schema version of the embedder-profiles file. +const EMBEDDER_VERSION: u32 = 1; + +/// On-disk shape of `embedder.json` (LOT C, §14.5.3), mirroring [`ProfilesDoc`]. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct EmbedderDoc { + /// Schema version. + version: u32, + /// All configured embedder profiles. + profiles: Vec, +} + +impl Default for EmbedderDoc { + fn default() -> Self { + Self { + version: EMBEDDER_VERSION, + profiles: Vec::new(), + } + } +} + +/// JSON-file store for declarative [`EmbedderProfile`]s (LOT C, étage 2 vectoriel), +/// calqué on [`FsProfileStore`]: `embedder.json` in the global IDE app-data dir, +/// mirroring `profiles.json`. +/// +/// No domain `EmbedderProfileStore` port exists yet — embedder profiles are pure +/// configuration loaded at the composition root, so this is a concrete loader. A +/// dedicated port (parallel to [`ProfileStore`]) is an easy follow-up the day the +/// UI needs CRUD over embedder profiles. +/// +/// Cheap to clone (everything behind `Arc`). +#[derive(Clone)] +pub struct FsEmbedderProfileStore { + fs: Arc, + app_data_dir: String, +} + +impl FsEmbedderProfileStore { + /// Builds the store from an injected [`FileSystem`] and the app-data dir. + #[must_use] + pub fn new(fs: Arc, app_data_dir: impl Into) -> Self { + Self { + fs, + app_data_dir: app_data_dir.into(), + } + } + + /// `/embedder.json`. + fn path(&self) -> RemotePath { + let base = self.app_data_dir.trim_end_matches(['/', '\\']); + RemotePath::new(format!("{base}/{EMBEDDER_FILE}")) + } + + async fn read_doc(&self) -> Result { + match self.fs.read(&self.path()).await { + Ok(bytes) => { + serde_json::from_slice(&bytes).map_err(|e| StoreError::Serialization(e.to_string())) + } + Err(domain::ports::FsError::NotFound(_)) => Ok(EmbedderDoc::default()), + Err(e) => Err(StoreError::Io(e.to_string())), + } + } + + async fn write_doc(&self, doc: &EmbedderDoc) -> Result<(), StoreError> { + let dir = RemotePath::new(self.app_data_dir.trim_end_matches(['/', '\\']).to_owned()); + self.fs + .create_dir_all(&dir) + .await + .map_err(|e| StoreError::Io(e.to_string()))?; + let bytes = + serde_json::to_vec_pretty(doc).map_err(|e| StoreError::Serialization(e.to_string()))?; + self.fs + .write(&self.path(), &bytes) + .await + .map_err(|e| StoreError::Io(e.to_string())) + } + + /// Lists all configured embedder profiles (empty when `embedder.json` is + /// absent — the default `none` posture). + /// + /// # Errors + /// [`StoreError`] on an I/O or deserialisation failure. + pub async fn list(&self) -> Result, StoreError> { + Ok(self.read_doc().await?.profiles) + } + + /// Saves (creates or replaces by id) an embedder profile. + /// + /// # Errors + /// [`StoreError`] on failure. + pub async fn save(&self, profile: &EmbedderProfile) -> Result<(), StoreError> { + let mut doc = self.read_doc().await?; + if let Some(slot) = doc.profiles.iter_mut().find(|p| p.id == profile.id) { + *slot = profile.clone(); + } else { + doc.profiles.push(profile.clone()); + } + self.write_doc(&doc).await + } + + /// Deletes an embedder profile by id. + /// + /// # Errors + /// [`StoreError::NotFound`] if no profile carries that id. + pub async fn delete(&self, id: &str) -> Result<(), StoreError> { + let mut doc = self.read_doc().await?; + let before = doc.profiles.len(); + doc.profiles.retain(|p| p.id != id); + if doc.profiles.len() == before { + return Err(StoreError::NotFound); + } + self.write_doc(&doc).await + } +} diff --git a/crates/infrastructure/src/store/vector.rs b/crates/infrastructure/src/store/vector.rs new file mode 100644 index 0000000..30efe1e --- /dev/null +++ b/crates/infrastructure/src/store/vector.rs @@ -0,0 +1,347 @@ +//! Étage-2 semantic recall (LOT C, §14.5.3): [`VectorMemoryRecall`] and the +//! [`AdaptiveMemoryRecall`] switch. +//! +//! [`VectorMemoryRecall`] composes an [`Embedder`] + a [`MemoryStore`] + a small +//! **derived vector store** under `.ideai/memory/.index/`, ranks the memory index +//! entries by cosine similarity to the query, and truncates to the token budget — +//! the **same** budget/emptiness semantics as [`super::NaiveMemoryRecall`] +//! (Liskov), only the relevance strategy differs. +//! +//! [`AdaptiveMemoryRecall`] composes both and routes between them by an +//! **objective, pure, I/O-free** decision ([`should_use_vector`]): tiny memories +//! or a `none` strategy go to the naïve recall; otherwise to the vector recall, +//! with an automatic **fallback to naïve** whenever the embedder is unavailable. +//! No path of this module ever fails hard on a missing/unavailable embedder. +//! +//! ## Derived vector store format +//! +//! `.ideai/memory/.index/vectors.json` holds, per slug, the embedding of that +//! note's index line (title + hook), tagged with the producing embedder id and +//! its dimension: +//! +//! ```json +//! { +//! "version": 1, +//! "embedderId": "hash-embedder", +//! "dimension": 64, +//! "vectors": { "my-note": [0.1, 0.0, ...] } +//! } +//! ``` +//! +//! It is **derived data, fully reconstructible** from the `.md` source of truth, +//! and is **gitignored** (`.ideai/memory/.index/`). A stale or absent file is not +//! an error: missing vectors are recomputed on demand and the file is refreshed +//! best-effort. A change of embedder id/dimension invalidates the whole file. + +use std::collections::HashMap; +use std::sync::Arc; + +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; + +use domain::memory::MemoryIndexEntry; +use domain::ports::{ + Embedder, EmbedderError, FileSystem, FsError, MemoryError, MemoryQuery, MemoryRecall, + MemoryStore, RemotePath, +}; +use domain::profile::EmbedderStrategy; +use domain::project::ProjectPath; + +use super::memory::{index_token_size, truncate_to_budget}; + +/// `.ideai/` directory name (mirrors [`super::memory`]). +const IDEAI_DIR: &str = ".ideai"; +/// Memory sub-dir. +const MEMORY_DIR: &str = "memory"; +/// Derived vector-store sub-dir (gitignored). +const INDEX_DIR: &str = ".index"; +/// Derived vector-store file. +const VECTORS_FILE: &str = "vectors.json"; +/// Schema version of the derived vector store. +const VECTORS_VERSION: u32 = 1; + +/// On-disk shape of `.ideai/memory/.index/vectors.json`. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct VectorDoc { + /// Schema version. + version: u32, + /// Id of the embedder that produced these vectors (a mismatch invalidates). + embedder_id: String, + /// Vector length (a mismatch invalidates). + dimension: usize, + /// Per-slug embedding of the note's index line. + vectors: HashMap>, +} + +impl VectorDoc { + fn new(embedder_id: String, dimension: usize) -> Self { + Self { + version: VECTORS_VERSION, + embedder_id, + dimension, + vectors: HashMap::new(), + } + } + + /// Whether this doc was produced by the given embedder (id + dimension). + fn matches(&self, embedder_id: &str, dimension: usize) -> bool { + self.version == VECTORS_VERSION + && self.embedder_id == embedder_id + && self.dimension == dimension + } +} + +/// Semantic [`MemoryRecall`] (étage 2): ranks the memory index by cosine +/// similarity of each note's index line to the query text, truncated to the token +/// budget (identical budget semantics to [`super::NaiveMemoryRecall`]). +/// +/// Composes an [`Embedder`], a [`MemoryStore`] (the index source of truth), and a +/// [`FileSystem`] for the derived vector cache. **Best-effort**: any +/// [`EmbedderError`] surfaces as [`EmbedderError`] to the caller +/// ([`AdaptiveMemoryRecall`] turns it into a naïve fallback); a missing/empty +/// memory yields an empty list, never an error. +#[derive(Clone)] +pub struct VectorMemoryRecall { + embedder: Arc, + store: Arc, + fs: Arc, +} + +impl VectorMemoryRecall { + /// Builds the vector recall from its composed ports. + #[must_use] + pub fn new( + embedder: Arc, + store: Arc, + fs: Arc, + ) -> Self { + Self { + embedder, + store, + fs, + } + } + + /// `/.ideai/memory/.index`. + fn index_dir(root: &ProjectPath) -> String { + let base = root.as_str().trim_end_matches(['/', '\\']); + format!("{base}/{IDEAI_DIR}/{MEMORY_DIR}/{INDEX_DIR}") + } + + /// `/vectors.json`. + fn vectors_path(root: &ProjectPath) -> RemotePath { + RemotePath::new(format!("{}/{VECTORS_FILE}", Self::index_dir(root))) + } + + /// The text embedded for an index entry (its index-line payload). + fn entry_text(entry: &MemoryIndexEntry) -> String { + format!("{} {}", entry.title, entry.hook) + } + + /// Loads the derived vector doc, or a fresh empty one when absent, malformed, + /// or produced by a different embedder/dimension (best-effort: never errors on + /// a stale cache — it is simply rebuilt). + async fn load_doc(&self, root: &ProjectPath) -> VectorDoc { + let empty = || VectorDoc::new(self.embedder.id().to_owned(), self.embedder.dimension()); + match self.fs.read(&Self::vectors_path(root)).await { + Ok(bytes) => match serde_json::from_slice::(&bytes) { + Ok(doc) if doc.matches(self.embedder.id(), self.embedder.dimension()) => doc, + _ => empty(), + }, + Err(FsError::NotFound(_)) | Err(_) => empty(), + } + } + + /// Persists the derived vector doc best-effort (a write failure is swallowed: + /// the cache is reconstructible, recall already has its result). + async fn save_doc(&self, root: &ProjectPath, doc: &VectorDoc) { + let dir = RemotePath::new(Self::index_dir(root)); + if self.fs.create_dir_all(&dir).await.is_err() { + return; + } + if let Ok(bytes) = serde_json::to_vec_pretty(doc) { + let _ = self.fs.write(&Self::vectors_path(root), &bytes).await; + } + } +} + +#[async_trait] +impl MemoryRecall for VectorMemoryRecall { + async fn recall( + &self, + root: &ProjectPath, + query: &MemoryQuery, + ) -> Result, MemoryError> { + if query.token_budget == 0 { + return Ok(Vec::new()); + } + let entries = self.store.read_index(root).await?; + if entries.is_empty() { + return Ok(Vec::new()); + } + + // Load the derived cache and compute any missing note vectors. + let mut doc = self.load_doc(root).await; + let missing: Vec<&MemoryIndexEntry> = entries + .iter() + .filter(|e| !doc.vectors.contains_key(e.slug.as_str())) + .collect(); + if !missing.is_empty() { + let texts: Vec = missing.iter().map(|e| Self::entry_text(e)).collect(); + let vectors = self.recall_embed(&texts).await?; + for (entry, vector) in missing.iter().zip(vectors) { + doc.vectors.insert(entry.slug.as_str().to_owned(), vector); + } + // Drop vectors of notes that no longer exist, then persist best-effort. + let live: std::collections::HashSet<&str> = + entries.iter().map(|e| e.slug.as_str()).collect(); + doc.vectors.retain(|slug, _| live.contains(slug.as_str())); + self.save_doc(root, &doc).await; + } + + // Embed the query and rank by cosine similarity (descending). + let query_vec = self + .recall_embed(std::slice::from_ref(&query.text)) + .await? + .into_iter() + .next() + .unwrap_or_default(); + + let mut ranked: Vec<(f32, MemoryIndexEntry)> = entries + .into_iter() + .map(|entry| { + let score = doc + .vectors + .get(entry.slug.as_str()) + .map_or(0.0, |v| cosine_similarity(&query_vec, v)); + (score, entry) + }) + .collect(); + // Stable, deterministic order: score desc, then slug asc for ties. + ranked.sort_by(|a, b| { + b.0.partial_cmp(&a.0) + .unwrap_or(std::cmp::Ordering::Equal) + .then_with(|| a.1.slug.cmp(&b.1.slug)) + }); + + let ordered = ranked.into_iter().map(|(_, e)| e); + Ok(truncate_to_budget(ordered, query.token_budget)) + } +} + +impl VectorMemoryRecall { + /// Embeds `texts`, mapping an [`EmbedderError`] into a [`MemoryError`] only so + /// the `?` plumbing compiles; in practice [`AdaptiveMemoryRecall`] guards this + /// path and falls back to naïve before any such error reaches a use case. + async fn recall_embed(&self, texts: &[String]) -> Result>, MemoryError> { + self.embedder + .embed(texts) + .await + .map_err(map_embedder_error) + } +} + +/// Maps an [`EmbedderError`] to a [`MemoryError`] for the (guarded) `?` path. +fn map_embedder_error(e: EmbedderError) -> MemoryError { + match e { + EmbedderError::Io(m) => MemoryError::Io(m), + other => MemoryError::Serialization(other.to_string()), + } +} + +/// Cosine similarity of two equal-length vectors; `0.0` when a length mismatches +/// or either vector is zero (defensive — never panics). +fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 { + if a.len() != b.len() { + return 0.0; + } + let dot: f32 = a.iter().zip(b).map(|(x, y)| x * y).sum(); + let na: f32 = a.iter().map(|x| x * x).sum::().sqrt(); + let nb: f32 = b.iter().map(|x| x * x).sum::().sqrt(); + if na == 0.0 || nb == 0.0 { + 0.0 + } else { + dot / (na * nb) + } +} + +// --------------------------------------------------------------------------- +// AdaptiveMemoryRecall — the étage-1/étage-2 switch. +// --------------------------------------------------------------------------- + +/// **Pure, I/O-free** routing decision for [`AdaptiveMemoryRecall`]. +/// +/// Returns `true` (use the vector recall) **iff** the strategy is not +/// [`EmbedderStrategy::None`] **and** the memory is larger than the budget +/// (i.e. there is something to rank/prune semantically). Otherwise the naïve +/// recall is sufficient and cheaper. This is the single objective rule, unit +/// testable without any store. +#[must_use] +pub fn should_use_vector(memory_size: usize, budget: usize, strategy: EmbedderStrategy) -> bool { + if strategy == EmbedderStrategy::None { + return false; + } + memory_size > budget +} + +/// Adaptive [`MemoryRecall`]: routes between a naïve étage-1 recall and a vector +/// étage-2 recall by [`should_use_vector`], with an automatic **fallback to +/// naïve** whenever the vector path fails (embedder unavailable/unsupported). +/// +/// Substitutable for either composed recall (Liskov): same emptiness/budget +/// guarantees, and — by construction — it **never fails hard** on an embedder +/// problem. With strategy [`EmbedderStrategy::None`] it is behaviourally identical +/// to the naïve recall (the default product posture, zero dependency). +#[derive(Clone)] +pub struct AdaptiveMemoryRecall { + naive: Arc, + vector: Arc, + store: Arc, + strategy: EmbedderStrategy, +} + +impl AdaptiveMemoryRecall { + /// Builds the switch from the two recalls, the index store (for the pure size + /// measure), and the active embedder strategy. + #[must_use] + pub fn new( + naive: Arc, + vector: Arc, + store: Arc, + strategy: EmbedderStrategy, + ) -> Self { + Self { + naive, + vector, + store, + strategy, + } + } +} + +#[async_trait] +impl MemoryRecall for AdaptiveMemoryRecall { + async fn recall( + &self, + root: &ProjectPath, + query: &MemoryQuery, + ) -> Result, MemoryError> { + // Budget-0 short-circuits before any I/O, homogeneously with the naïve and + // vector recalls (a zero budget can hold no entry: nothing to read/measure). + if query.token_budget == 0 { + return Ok(Vec::new()); + } + // Pure measure from the index (the only I/O is reading the index once; + // the decision itself is pure via `should_use_vector`). + let memory_size = index_token_size(&self.store.read_index(root).await?); + if !should_use_vector(memory_size, query.token_budget, self.strategy) { + return self.naive.recall(root, query).await; + } + // Vector path with naïve fallback — never fail hard on the embedder. + match self.vector.recall(root, query).await { + Ok(entries) => Ok(entries), + Err(_) => self.naive.recall(root, query).await, + } + } +} diff --git a/crates/infrastructure/tests/memory_recall.rs b/crates/infrastructure/tests/memory_recall.rs new file mode 100644 index 0000000..12e9580 --- /dev/null +++ b/crates/infrastructure/tests/memory_recall.rs @@ -0,0 +1,277 @@ +//! Tests for [`NaiveMemoryRecall`] (LOT B, étage 1): the default, dependency-free +//! [`MemoryRecall`] that reads the aggregated index via a composed [`MemoryStore`] +//! and truncates the entries to fit the query's token budget. +//! +//! Budget semantics under test (mirroring the adapter doc): each entry costs +//! `ceil((title.len() + hook.len()) / 4)` tokens; entries are taken in index order, +//! accumulating cost; the first entry that would exceed the budget — and every one +//! after it — is dropped. `token_budget == 0` ⇒ empty; an empty/absent memory ⇒ +//! empty, never an error. +//! +//! Two backings are exercised: +//! - the **real** [`FsMemoryStore`] over an in-memory [`FileSystem`] mock (same +//! `MemFs` shape as `memory_store.rs`), giving end-to-end coverage of the +//! index round-trip feeding recall; +//! - a tiny **counting fake** `MemoryStore` to assert the `budget == 0` +//! short-circuit performs *no* `read_index` call. + +use std::collections::HashMap; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex}; + +use async_trait::async_trait; + +use domain::markdown::MarkdownDoc; +use domain::memory::{Memory, MemoryFrontmatter, MemoryIndexEntry, MemoryLink, MemorySlug, MemoryType}; +use domain::ports::{ + DirEntry, FileSystem, FsError, MemoryError, MemoryQuery, MemoryRecall, MemoryStore, RemotePath, +}; +use domain::project::ProjectPath; +use infrastructure::{FsMemoryStore, NaiveMemoryRecall}; + +// --------------------------------------------------------------------------- +// In-memory FileSystem mock (same minimal shape as `memory_store.rs`). +// --------------------------------------------------------------------------- + +#[derive(Default)] +struct MemFs { + files: Mutex>>, +} + +impl MemFs { + fn arc() -> Arc { + Arc::new(Self::default()) + } +} + +#[async_trait] +impl FileSystem for MemFs { + async fn read(&self, path: &RemotePath) -> Result, 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 { + 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, FsError> { + Ok(Vec::new()) + } + async fn symlink(&self, _src: &RemotePath, _dst: &RemotePath) -> Result<(), FsError> { + Ok(()) + } +} + +// --------------------------------------------------------------------------- +// Builders +// --------------------------------------------------------------------------- + +fn root() -> ProjectPath { + ProjectPath::new("/proj").unwrap() +} + +/// A note whose index `title` is its slug (length `slug.len()`) and whose `hook` +/// is `desc` — so the recall cost is `ceil((slug.len() + desc.len()) / 4)`. +fn note(slug: &str, desc: &str) -> Memory { + Memory::new( + MemoryFrontmatter { + name: MemorySlug::new(slug).unwrap(), + description: desc.to_string(), + r#type: MemoryType::Project, + }, + MarkdownDoc::new("# body"), + ) + .unwrap() +} + +/// Saves notes (in order) through the real store and returns a recall over it. +async fn recall_over(notes: &[Memory]) -> NaiveMemoryRecall { + let store = Arc::new(FsMemoryStore::new(MemFs::arc())); + for n in notes { + store.save(&root(), n).await.unwrap(); + } + NaiveMemoryRecall::new(store) +} + +fn query(budget: usize) -> MemoryQuery { + MemoryQuery { + text: "anything".to_string(), + token_budget: budget, + } +} + +fn slugs(entries: &[MemoryIndexEntry]) -> Vec { + entries.iter().map(|e| e.slug.as_str().to_string()).collect() +} + +// --------------------------------------------------------------------------- +// Empty / absent memory ⇒ empty list, never an error. +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn absent_index_recalls_empty_without_error() { + let recall = NaiveMemoryRecall::new(Arc::new(FsMemoryStore::new(MemFs::arc()))); + let out = recall.recall(&root(), &query(1000)).await.unwrap(); + assert!(out.is_empty(), "absent memory must recall empty: {out:?}"); +} + +// --------------------------------------------------------------------------- +// Budget 0 ⇒ empty list (and short-circuits before touching the store). +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn zero_budget_recalls_empty() { + let recall = recall_over(&[note("aaaa", "bbbb"), note("cccc", "dddd")]).await; + let out = recall.recall(&root(), &query(0)).await.unwrap(); + assert!(out.is_empty(), "budget 0 must recall empty: {out:?}"); +} + +/// A `MemoryStore` that counts `read_index` calls and panics on every other +/// method — proving the recall path only reads the index, and that a `0` budget +/// short-circuits before any read. +#[derive(Default)] +struct CountingStore { + read_index_calls: AtomicUsize, +} + +#[async_trait] +impl MemoryStore for CountingStore { + async fn list(&self, _root: &ProjectPath) -> Result, MemoryError> { + panic!("recall must not call list") + } + async fn get(&self, _root: &ProjectPath, _slug: &MemorySlug) -> Result { + panic!("recall must not call get") + } + async fn save(&self, _root: &ProjectPath, _memory: &Memory) -> Result<(), MemoryError> { + panic!("recall must not call save") + } + async fn delete(&self, _root: &ProjectPath, _slug: &MemorySlug) -> Result<(), MemoryError> { + panic!("recall must not call delete") + } + async fn read_index( + &self, + _root: &ProjectPath, + ) -> Result, MemoryError> { + self.read_index_calls.fetch_add(1, Ordering::SeqCst); + Ok(Vec::new()) + } + async fn resolve_links( + &self, + _root: &ProjectPath, + _slug: &MemorySlug, + ) -> Result, MemoryError> { + panic!("recall must not call resolve_links") + } +} + +#[tokio::test] +async fn zero_budget_short_circuits_no_read_index() { + let store = Arc::new(CountingStore::default()); + let recall = NaiveMemoryRecall::new(store.clone()); + let out = recall.recall(&root(), &query(0)).await.unwrap(); + assert!(out.is_empty()); + assert_eq!( + store.read_index_calls.load(Ordering::SeqCst), + 0, + "budget 0 must short-circuit before reading the index" + ); +} + +#[tokio::test] +async fn nonzero_budget_reads_index_once() { + let store = Arc::new(CountingStore::default()); + let recall = NaiveMemoryRecall::new(store.clone()); + let _ = recall.recall(&root(), &query(10)).await.unwrap(); + assert_eq!(store.read_index_calls.load(Ordering::SeqCst), 1); +} + +// --------------------------------------------------------------------------- +// Sufficient budget ⇒ all entries, in index order. +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn ample_budget_returns_all_entries_in_index_order() { + // Saved order: alpha, beta, gamma → index order is the same. + let recall = recall_over(&[ + note("alpha", "first hook"), + note("beta", "second hook"), + note("gamma", "third hook"), + ]) + .await; + let out = recall.recall(&root(), &query(100_000)).await.unwrap(); + assert_eq!(slugs(&out), vec!["alpha", "beta", "gamma"]); +} + +// --------------------------------------------------------------------------- +// Intermediate budget ⇒ exact truncation at the cumulative-cost boundary. +// +// Three notes, each title.len()==4 and hook.len()==4 ⇒ 8 chars ⇒ ceil(8/4)==2 +// tokens apiece. Cumulative costs: 2, 4, 6. +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn intermediate_budget_truncates_at_exact_boundary() { + let notes = [note("aaaa", "bbbb"), note("cccc", "dddd"), note("eeee", "ffff")]; + let recall = recall_over(¬es).await; + + // Budget 1 < 2 ⇒ first entry already exceeds ⇒ none. + assert!(recall.recall(&root(), &query(1)).await.unwrap().is_empty()); + + // Budget 2 == cost(1) ⇒ exactly the first entry; second (would reach 4) dropped. + assert_eq!(slugs(&recall.recall(&root(), &query(2)).await.unwrap()), vec!["aaaa"]); + + // Budget 3 < 4 ⇒ still only the first. + assert_eq!(slugs(&recall.recall(&root(), &query(3)).await.unwrap()), vec!["aaaa"]); + + // Budget 4 == cost(1)+cost(2) ⇒ first two; third (would reach 6) dropped. + assert_eq!( + slugs(&recall.recall(&root(), &query(4)).await.unwrap()), + vec!["aaaa", "cccc"] + ); + + // Budget 5 < 6 ⇒ still the first two. + assert_eq!( + slugs(&recall.recall(&root(), &query(5)).await.unwrap()), + vec!["aaaa", "cccc"] + ); + + // Budget 6 == total ⇒ all three. + assert_eq!( + slugs(&recall.recall(&root(), &query(6)).await.unwrap()), + vec!["aaaa", "cccc", "eeee"] + ); +} + +// --------------------------------------------------------------------------- +// ceil rounding: an odd character count rounds *up* (5 chars ⇒ 2 tokens). +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn entry_cost_rounds_up_via_ceil() { + // slug "ab" (2) + hook "c" (1) = 3 chars ⇒ ceil(3/4) == 1 token. + // slug "de" (2) + hook "fgh" (3) = 5 chars ⇒ ceil(5/4) == 2 tokens. + let recall = recall_over(&[note("ab", "c"), note("de", "fgh")]).await; + + // Budget 1 covers only the 1-token first entry; the 2-token second is dropped. + assert_eq!(slugs(&recall.recall(&root(), &query(1)).await.unwrap()), vec!["ab"]); + // Budget 2 still cannot fit the second on top of the first (1 + 2 = 3 > 2). + assert_eq!(slugs(&recall.recall(&root(), &query(2)).await.unwrap()), vec!["ab"]); + // Budget 3 fits both (1 + 2 == 3). + assert_eq!( + slugs(&recall.recall(&root(), &query(3)).await.unwrap()), + vec!["ab", "de"] + ); +} diff --git a/crates/infrastructure/tests/memory_store.rs b/crates/infrastructure/tests/memory_store.rs new file mode 100644 index 0000000..bbbbfbe --- /dev/null +++ b/crates/infrastructure/tests/memory_store.rs @@ -0,0 +1,278 @@ +//! Tests for [`FsMemoryStore`] against an **in-memory mock** [`FileSystem`]: +//! `save` writes the `.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>>, +} + +impl MemFs { + fn arc() -> Arc { + Arc::new(Self::default()) + } + fn raw(&self, path: &str) -> Option { + 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, 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 { + 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, 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 + )); +} diff --git a/crates/infrastructure/tests/vector_recall.rs b/crates/infrastructure/tests/vector_recall.rs new file mode 100644 index 0000000..5398df8 --- /dev/null +++ b/crates/infrastructure/tests/vector_recall.rs @@ -0,0 +1,626 @@ +//! Tests for the étage-2 vector recall (LOT C, §14.5.3): +//! - [`should_use_vector`] — the pure routing decision (full truth table); +//! - [`AdaptiveMemoryRecall`] — the étage-1/étage-2 switch, with naïve equivalence +//! on strategy `none`, routing by size/budget, and the never-fail-hard fallback; +//! - [`VectorMemoryRecall`] — semantic ranking with the deterministic +//! [`HashEmbedder`], budget truncation, the derived `vectors.json` cache and its +//! embedder-id/dimension invalidation, and `StubEmbedder` best-effort behaviour; +//! - [`HashEmbedder`] / [`StubEmbedder`] embedder contracts; +//! - [`embedder_from_profile`] strategy → embedder mapping. +//! +//! Everything is in-memory and deterministic (`MemFs` + `HashEmbedder`); no real +//! I/O, no network. + +use std::collections::HashMap; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex}; + +use async_trait::async_trait; + +use domain::markdown::MarkdownDoc; +use domain::memory::{Memory, MemoryFrontmatter, MemoryIndexEntry, MemorySlug, MemoryType}; +use domain::ports::{ + DirEntry, Embedder, EmbedderError, FileSystem, FsError, MemoryQuery, MemoryRecall, MemoryStore, + RemotePath, +}; +use domain::profile::{EmbedderProfile, EmbedderStrategy}; +use domain::project::ProjectPath; +use infrastructure::{ + embedder_from_profile, should_use_vector, AdaptiveMemoryRecall, FsMemoryStore, HashEmbedder, + NaiveMemoryRecall, StubEmbedder, VectorMemoryRecall, +}; + +// --------------------------------------------------------------------------- +// In-memory FileSystem mock (same shape as `memory_recall.rs` / `memory_store.rs`), +// with raw read access to assert on the derived `vectors.json`. +// --------------------------------------------------------------------------- + +#[derive(Default)] +struct MemFs { + files: Mutex>>, +} + +impl MemFs { + fn arc() -> Arc { + Arc::new(Self::default()) + } + fn raw(&self, path: &str) -> Option { + self.files + .lock() + .unwrap() + .get(path) + .map(|b| String::from_utf8(b.clone()).unwrap()) + } +} + +#[async_trait] +impl FileSystem for MemFs { + async fn read(&self, path: &RemotePath) -> Result, 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 { + 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, FsError> { + Ok(Vec::new()) + } + async fn symlink(&self, _src: &RemotePath, _dst: &RemotePath) -> Result<(), FsError> { + Ok(()) + } +} + +const VECTORS_PATH: &str = "/proj/.ideai/memory/.index/vectors.json"; + +// --------------------------------------------------------------------------- +// A counting wrapper embedder: delegates to an inner embedder and counts the +// number of *texts* it was asked to embed — to assert the derived cache avoids +// recomputing note vectors on a second recall. +// --------------------------------------------------------------------------- + +struct SpyEmbedder { + inner: HashEmbedder, + embedded_texts: AtomicUsize, +} + +impl SpyEmbedder { + fn new(id: &str, dim: usize) -> Self { + Self { + inner: HashEmbedder::new(id, dim), + embedded_texts: AtomicUsize::new(0), + } + } +} + +#[async_trait] +impl Embedder for SpyEmbedder { + fn id(&self) -> &str { + self.inner.id() + } + async fn embed(&self, texts: &[String]) -> Result>, EmbedderError> { + self.embedded_texts.fetch_add(texts.len(), Ordering::SeqCst); + self.inner.embed(texts).await + } + fn dimension(&self) -> usize { + self.inner.dimension() + } +} + +// --------------------------------------------------------------------------- +// Builders. +// --------------------------------------------------------------------------- + +fn root() -> ProjectPath { + ProjectPath::new("/proj").unwrap() +} + +/// A note whose index line is ` ` (title == slug). The vector recall +/// embeds exactly `"{slug} {hook}"`, so picking distinct vocabulary words in +/// `slug`/`hook` makes cosine similarity to a chosen query deterministic. +fn note(slug: &str, hook: &str) -> Memory { + Memory::new( + MemoryFrontmatter { + name: MemorySlug::new(slug).unwrap(), + description: hook.to_string(), + r#type: MemoryType::Project, + }, + MarkdownDoc::new("# body"), + ) + .unwrap() +} + +fn query(text: &str, budget: usize) -> MemoryQuery { + MemoryQuery { + text: text.to_string(), + token_budget: budget, + } +} + +fn slugs(entries: &[MemoryIndexEntry]) -> Vec { + entries.iter().map(|e| e.slug.as_str().to_string()).collect() +} + +async fn store_with(notes: &[Memory], fs: Arc) -> Arc { + let store = Arc::new(FsMemoryStore::new(fs)); + for n in notes { + store.save(&root(), n).await.unwrap(); + } + store +} + +// =========================================================================== +// 1. should_use_vector — pure truth table. +// =========================================================================== + +#[test] +fn should_use_vector_none_strategy_is_always_false() { + // Even when size > budget, a `none` strategy never routes to the vector path. + assert!(!should_use_vector(0, 0, EmbedderStrategy::None)); + assert!(!should_use_vector(100, 10, EmbedderStrategy::None)); + assert!(!should_use_vector(10, 100, EmbedderStrategy::None)); + assert!(!should_use_vector(usize::MAX, 0, EmbedderStrategy::None)); +} + +#[test] +fn should_use_vector_non_none_iff_size_exceeds_budget() { + for strategy in [ + EmbedderStrategy::LocalOnnx, + EmbedderStrategy::LocalServer, + EmbedderStrategy::Api, + ] { + // size > budget ⇒ true. + assert!(should_use_vector(11, 10, strategy), "{strategy:?}: 11>10"); + assert!(should_use_vector(1, 0, strategy), "{strategy:?}: 1>0"); + // size == budget ⇒ false (exact boundary). + assert!(!should_use_vector(10, 10, strategy), "{strategy:?}: 10==10"); + assert!(!should_use_vector(0, 0, strategy), "{strategy:?}: 0==0"); + // size < budget ⇒ false. + assert!(!should_use_vector(9, 10, strategy), "{strategy:?}: 9<10"); + } +} + +// =========================================================================== +// 2. HashEmbedder — determinism, dimension, L2 normalisation. +// =========================================================================== + +#[tokio::test] +async fn hash_embedder_is_deterministic() { + let e = HashEmbedder::new("h", 64); + let a = e.embed(&["hello world".to_string()]).await.unwrap(); + let b = e.embed(&["hello world".to_string()]).await.unwrap(); + assert_eq!(a, b, "same text must give the same vector"); +} + +#[tokio::test] +async fn hash_embedder_dimension_is_consistent() { + let e = HashEmbedder::new("h", 48); + assert_eq!(e.dimension(), 48); + let out = e + .embed(&["alpha beta".to_string(), "gamma".to_string()]) + .await + .unwrap(); + assert_eq!(out.len(), 2, "one vector per input, order preserved"); + assert!(out.iter().all(|v| v.len() == 48), "every vector is dimension-long"); +} + +#[tokio::test] +async fn hash_embedder_vectors_are_l2_normalised() { + let e = HashEmbedder::new("h", 64); + let out = e + .embed(&["one two three four".to_string()]) + .await + .unwrap(); + let norm = out[0].iter().map(|x| x * x).sum::().sqrt(); + assert!((norm - 1.0).abs() < 1e-5, "norm ≈ 1, got {norm}"); +} + +#[tokio::test] +async fn hash_embedder_empty_text_yields_zero_vector_no_panic() { + // No tokens ⇒ zero vector (norm 0, left unnormalised). Must not panic / NaN. + let e = HashEmbedder::new("h", 16); + let out = e.embed(&["".to_string()]).await.unwrap(); + assert_eq!(out[0].len(), 16); + assert!(out[0].iter().all(|x| *x == 0.0)); +} + +// =========================================================================== +// 3. StubEmbedder — always Unsupported, never panics. +// =========================================================================== + +#[tokio::test] +async fn stub_embedder_returns_unsupported() { + let e = StubEmbedder::new("stub", 32, "localOnnx"); + assert_eq!(e.dimension(), 32); + let err = e.embed(&["x".to_string()]).await.unwrap_err(); + assert!(matches!(err, EmbedderError::Unsupported(_)), "got {err:?}"); +} + +// =========================================================================== +// 4. embedder_from_profile — strategy → embedder mapping. +// =========================================================================== + +#[tokio::test] +async fn embedder_from_profile_maps_each_strategy() { + // none ⇒ no embedder (recall stays naïve). + assert!(embedder_from_profile(&EmbedderProfile::none()).is_none()); + + // localOnnx / localServer / api ⇒ a StubEmbedder (Unsupported), dimension kept. + for strategy in [ + EmbedderStrategy::LocalOnnx, + EmbedderStrategy::LocalServer, + EmbedderStrategy::Api, + ] { + let profile = + EmbedderProfile::new("e", "E", strategy, None, None, None, 24).unwrap(); + let embedder = embedder_from_profile(&profile) + .unwrap_or_else(|| panic!("{strategy:?} must yield an embedder")); + assert_eq!(embedder.dimension(), 24); + let err = embedder.embed(&["t".to_string()]).await.unwrap_err(); + assert!( + matches!(err, EmbedderError::Unsupported(_)), + "{strategy:?} stub must be Unsupported, got {err:?}" + ); + } +} + +// =========================================================================== +// 5. VectorMemoryRecall — semantic ranking, budget, cache. +// =========================================================================== + +/// Builds a vector recall over `notes` with the given embedder, sharing one fs. +fn vector_recall( + embedder: Arc, + store: Arc, + fs: Arc, +) -> VectorMemoryRecall { + VectorMemoryRecall::new(embedder, store, fs) +} + +#[tokio::test] +async fn vector_recall_ranks_closest_note_first() { + // Notes have disjoint vocabularies; the query shares all tokens with `beta`, + // so `beta` must rank first (cosine == 1 for it, ~0 for the others). + let fs = MemFs::arc(); + let notes = [ + note("alpha", "apple orange grape"), + note("beta", "kiwi mango papaya"), + note("gamma", "carrot potato onion"), + ]; + let store = store_with(¬es, fs.clone()).await; + let embedder = Arc::new(HashEmbedder::new("hash", 256)); + let recall = vector_recall(embedder, store, fs); + + // Query identical to beta's index line ("beta kiwi mango papaya"). + let out = recall + .recall(&root(), &query("beta kiwi mango papaya", 100_000)) + .await + .unwrap(); + assert_eq!(out.first().map(|e| e.slug.as_str()), Some("beta"), "ranked: {:?}", slugs(&out)); + // All three notes fit the ample budget. + assert_eq!(out.len(), 3); +} + +#[tokio::test] +async fn vector_recall_truncates_to_budget() { + // The closest note ("beta apricot apricot") must survive a tight budget while + // the rest are pruned. Cost of an entry = ceil((title+hook chars)/4). + let fs = MemFs::arc(); + let notes = [ + note("beta", "kiwi"), // "beta kiwi" → 8 chars → 2 tokens + note("alpha", "apple orange grape"), // larger + note("gamma", "carrot potato onion"), // larger + ]; + let store = store_with(¬es, fs.clone()).await; + let embedder = Arc::new(HashEmbedder::new("hash", 256)); + let recall = vector_recall(embedder, store, fs); + + // Budget 2 fits only the top-ranked `beta` (cost 2); the next would exceed. + let out = recall.recall(&root(), &query("beta kiwi", 2)).await.unwrap(); + assert_eq!(slugs(&out), vec!["beta"], "budget must keep only the closest"); +} + +#[tokio::test] +async fn vector_recall_zero_budget_and_empty_memory_are_empty() { + let fs = MemFs::arc(); + let embedder = Arc::new(HashEmbedder::new("hash", 64)); + + // Empty memory ⇒ empty, no error. + let empty_store = Arc::new(FsMemoryStore::new(fs.clone())); + let recall = vector_recall(embedder.clone(), empty_store, fs.clone()); + assert!(recall + .recall(&root(), &query("anything", 1000)) + .await + .unwrap() + .is_empty()); + + // Zero budget ⇒ empty, even with notes present. + let store = store_with(&[note("a", "x"), note("b", "y")], fs.clone()).await; + let recall = vector_recall(embedder, store, fs); + assert!(recall + .recall(&root(), &query("a x", 0)) + .await + .unwrap() + .is_empty()); +} + +#[tokio::test] +async fn vector_recall_writes_and_reuses_the_derived_cache() { + let fs = MemFs::arc(); + let notes = [note("alpha", "apple"), note("beta", "banana")]; + let store = store_with(¬es, fs.clone()).await; + let spy = Arc::new(SpyEmbedder::new("spy", 128)); + let recall = VectorMemoryRecall::new(spy.clone(), store, fs.clone()); + + // First recall: embeds 2 note texts + 1 query text = 3. + let _ = recall.recall(&root(), &query("alpha apple", 100_000)).await.unwrap(); + let after_first = spy.embedded_texts.load(Ordering::SeqCst); + assert_eq!(after_first, 3, "2 notes + 1 query embedded on a cold cache"); + + // The derived cache file now exists with both slugs and the embedder id. + let doc = fs.raw(VECTORS_PATH).expect("vectors.json written"); + assert!(doc.contains("\"embedderId\": \"spy\""), "tagged with embedder id: {doc}"); + assert!(doc.contains("\"alpha\""), "alpha vector cached: {doc}"); + assert!(doc.contains("\"beta\""), "beta vector cached: {doc}"); + + // Second recall: note vectors come from the cache; only the query is embedded. + let _ = recall.recall(&root(), &query("beta banana", 100_000)).await.unwrap(); + let delta = spy.embedded_texts.load(Ordering::SeqCst) - after_first; + assert_eq!(delta, 1, "cache reuse ⇒ only the query is re-embedded, got {delta}"); +} + +#[tokio::test] +async fn vector_recall_invalidates_cache_on_embedder_id_change() { + let fs = MemFs::arc(); + let notes = [note("alpha", "apple"), note("beta", "banana")]; + let store = store_with(¬es, fs.clone()).await; + + // Warm the cache with embedder "first". + let first = Arc::new(SpyEmbedder::new("first", 128)); + VectorMemoryRecall::new(first.clone(), store.clone(), fs.clone()) + .recall(&root(), &query("alpha apple", 100_000)) + .await + .unwrap(); + assert!(fs.raw(VECTORS_PATH).unwrap().contains("\"embedderId\": \"first\"")); + + // A different embedder id must invalidate ⇒ recompute all note vectors. + let second = Arc::new(SpyEmbedder::new("second", 128)); + VectorMemoryRecall::new(second.clone(), store, fs.clone()) + .recall(&root(), &query("beta banana", 100_000)) + .await + .unwrap(); + // 2 notes recomputed + 1 query embedded against the new embedder. + assert_eq!( + second.embedded_texts.load(Ordering::SeqCst), + 3, + "id change must invalidate and recompute note vectors" + ); + // The file is now tagged with the new embedder id. + assert!(fs.raw(VECTORS_PATH).unwrap().contains("\"embedderId\": \"second\"")); +} + +#[tokio::test] +async fn vector_recall_invalidates_cache_on_dimension_change() { + let fs = MemFs::arc(); + let notes = [note("alpha", "apple"), note("beta", "banana")]; + let store = store_with(¬es, fs.clone()).await; + + // Warm the cache at dimension 64. + let dim64 = Arc::new(SpyEmbedder::new("same-id", 64)); + VectorMemoryRecall::new(dim64, store.clone(), fs.clone()) + .recall(&root(), &query("alpha apple", 100_000)) + .await + .unwrap(); + + // Same id, different dimension ⇒ invalidation, all note vectors recomputed. + let dim128 = Arc::new(SpyEmbedder::new("same-id", 128)); + VectorMemoryRecall::new(dim128.clone(), store, fs.clone()) + .recall(&root(), &query("beta banana", 100_000)) + .await + .unwrap(); + assert_eq!( + dim128.embedded_texts.load(Ordering::SeqCst), + 3, + "dimension change must invalidate and recompute" + ); + assert!(fs.raw(VECTORS_PATH).unwrap().contains("\"dimension\": 128")); +} + +#[tokio::test] +async fn vector_recall_with_stub_embedder_errors_without_panic() { + // The vector recall surfaces the embedder error as a MemoryError (the Adaptive + // switch turns that into a naïve fallback — covered below). Here we just pin + // that StubEmbedder is best-effort: an Err, never a panic. + let fs = MemFs::arc(); + let store = store_with(&[note("alpha", "apple")], fs.clone()).await; + let stub = Arc::new(StubEmbedder::new("stub", 64, "localOnnx")); + let recall = VectorMemoryRecall::new(stub, store, fs); + let res = recall.recall(&root(), &query("alpha apple", 1000)).await; + assert!(res.is_err(), "stub embedder ⇒ Err on the vector path, got {res:?}"); +} + +// =========================================================================== +// 6. AdaptiveMemoryRecall — the étage-1/étage-2 switch. +// =========================================================================== + +/// Assembles an [`AdaptiveMemoryRecall`] over `notes` for the given strategy and +/// embedder, sharing one in-memory fs / store. +async fn adaptive_over( + notes: &[Memory], + strategy: EmbedderStrategy, + embedder: Arc, +) -> (Arc, AdaptiveMemoryRecall) { + let fs = MemFs::arc(); + let store = store_with(notes, fs.clone()).await; + let naive: Arc = Arc::new(NaiveMemoryRecall::new(store.clone())); + let vector: Arc = + Arc::new(VectorMemoryRecall::new(embedder, store.clone(), fs.clone())); + let adaptive = AdaptiveMemoryRecall::new(naive, vector, store, strategy); + (fs, adaptive) +} + +#[tokio::test] +async fn adaptive_none_strategy_matches_naive_exactly() { + let notes = [ + note("alpha", "apple orange grape"), + note("beta", "kiwi mango papaya"), + note("gamma", "carrot potato onion"), + ]; + let fs = MemFs::arc(); + let store = store_with(¬es, fs.clone()).await; + let naive = NaiveMemoryRecall::new(store.clone()); + + // Adaptive with strategy `none` (embedder present but never used). + let naive_dyn: Arc = Arc::new(NaiveMemoryRecall::new(store.clone())); + let vector_dyn: Arc = Arc::new(VectorMemoryRecall::new( + Arc::new(HashEmbedder::new("hash", 64)), + store.clone(), + fs.clone(), + )); + let adaptive = AdaptiveMemoryRecall::new(naive_dyn, vector_dyn, store, EmbedderStrategy::None); + + // For several budgets, adaptive(none) == naive, byte-for-byte. + for budget in [0usize, 3, 6, 100, 100_000] { + let q = query("kiwi mango papaya", budget); + let expected = naive.recall(&root(), &q).await.unwrap(); + let got = adaptive.recall(&root(), &q).await.unwrap(); + assert_eq!(got, expected, "strategy none must equal naive at budget {budget}"); + } +} + +#[tokio::test] +async fn adaptive_small_memory_routes_to_naive_order() { + // Memory smaller than the budget ⇒ naïve path ⇒ index order, even with a + // non-none strategy. The query would, on the vector path, reorder by + // similarity; index order proves naïve was used. + let notes = [ + note("alpha", "apple"), + note("beta", "banana"), + note("gamma", "grape"), + ]; + let (_fs, adaptive) = adaptive_over( + ¬es, + EmbedderStrategy::LocalServer, + Arc::new(HashEmbedder::new("hash", 256)), + ) + .await; + + // Huge budget ⇒ memory_size <= budget ⇒ naïve ⇒ index order alpha,beta,gamma. + let out = adaptive + .recall(&root(), &query("gamma grape", 100_000)) + .await + .unwrap(); + assert_eq!(slugs(&out), vec!["alpha", "beta", "gamma"], "naïve keeps index order"); +} + +#[tokio::test] +async fn adaptive_large_memory_routes_to_vector_ranking() { + // Memory larger than the budget + non-none strategy ⇒ vector path ⇒ the note + // closest to the query ranks first (not index order). + let notes = [ + note("alpha", "apple orange grape"), + note("beta", "kiwi mango papaya"), + note("gamma", "carrot potato onion"), + ]; + let (_fs, adaptive) = adaptive_over( + ¬es, + EmbedderStrategy::LocalOnnx, + Arc::new(HashEmbedder::new("hash", 256)), + ) + .await; + + // Budget smaller than the total memory size forces the vector route; the query + // matches `gamma`, so `gamma` must be first (≠ index order, where it is last). + let total = { + let store = FsMemoryStore::new(MemFs::arc()); + for n in ¬es { + store.save(&root(), n).await.unwrap(); + } + infrastructure::index_token_size(&store.read_index(&root()).await.unwrap()) + }; + assert!(total > 4, "sanity: memory must exceed the chosen budget"); + + let out = adaptive + .recall(&root(), &query("carrot potato onion", total - 1)) + .await + .unwrap(); + assert_eq!( + out.first().map(|e| e.slug.as_str()), + Some("gamma"), + "vector path ranks the closest note first: {:?}", + slugs(&out) + ); +} + +#[tokio::test] +async fn adaptive_falls_back_to_naive_when_embedder_fails() { + // Large memory + non-none strategy routes to the vector path; with a + // StubEmbedder that path errors, and Adaptive must degrade to naïve — never a + // hard error. Result therefore equals the naïve recall (index order, truncated). + let notes = [ + note("alpha", "apple orange grape"), + note("beta", "kiwi mango papaya"), + note("gamma", "carrot potato onion"), + ]; + let fs = MemFs::arc(); + let store = store_with(¬es, fs.clone()).await; + let naive_ref = NaiveMemoryRecall::new(store.clone()); + + let naive_dyn: Arc = Arc::new(NaiveMemoryRecall::new(store.clone())); + let vector_dyn: Arc = Arc::new(VectorMemoryRecall::new( + Arc::new(StubEmbedder::new("stub", 64, "localOnnx")), + store.clone(), + fs.clone(), + )); + let adaptive = + AdaptiveMemoryRecall::new(naive_dyn, vector_dyn, store.clone(), EmbedderStrategy::Api); + + let total = infrastructure::index_token_size(&store.read_index(&root()).await.unwrap()); + let q = query("carrot potato onion", total - 1); + + let got = adaptive.recall(&root(), &q).await.expect("fallback must not error"); + let expected = naive_ref.recall(&root(), &q).await.unwrap(); + assert_eq!(got, expected, "embedder failure must degrade to the naïve result"); + assert!(!got.is_empty(), "the degraded result still returns entries"); +} + +#[tokio::test] +async fn adaptive_empty_memory_and_zero_budget_are_empty() { + // Liskov common contract: empty list, never an error, both paths. + let fs = MemFs::arc(); + let empty_store = Arc::new(FsMemoryStore::new(fs.clone())); + let naive: Arc = Arc::new(NaiveMemoryRecall::new(empty_store.clone())); + let vector: Arc = Arc::new(VectorMemoryRecall::new( + Arc::new(HashEmbedder::new("hash", 64)), + empty_store.clone(), + fs.clone(), + )); + let adaptive = + AdaptiveMemoryRecall::new(naive, vector, empty_store, EmbedderStrategy::LocalOnnx); + + // Empty memory. + assert!(adaptive + .recall(&root(), &query("anything", 1000)) + .await + .unwrap() + .is_empty()); + // Zero budget. + assert!(adaptive + .recall(&root(), &query("anything", 0)) + .await + .unwrap() + .is_empty()); +}