feat(memory): système de mémoire projet model-agnostic (L14, LOT A+B+C)

Base de connaissance persistante par projet, indépendante de tout modèle/CLU
et de git. Cadrage archi en §14.5 (ARCHITECTURE.md), cycle Archi→Dev→Test.

LOT A — étage 1 (.md, source de vérité)
  - domaine: entité Memory (+ MemorySlug, MemoryType, MemoryFrontmatter,
    MemoryLink, MemoryIndexEntry), liens [[slug]], index MEMORY.md dérivé
  - port MemoryStore + MemoryError, adapter FsMemoryStore (.ideai/memory/)
  - application: 7 use cases (Create/Update/List/Get/Delete/ReadIndex/
    ResolveLinks), From<MemoryError> for AppError
  - app-tauri: commandes + DTO, events MemorySaved/MemoryDeleted
  - suppression de la variante morte DomainError::MalformedFrontmatter

LOT B — rappel adaptatif (étage 1)
  - port MemoryRecall + MemoryQuery, adapter NaiveMemoryRecall (troncature
    au budget de tokens, court-circuit budget-0), use case RecallMemory

LOT C — étage 2 vectoriel (structure complète, zéro dépendance lourde)
  - port Embedder + EmbedderError, profils déclaratifs EmbedderProfile/
    EmbedderStrategy (embedder.json)
  - VectorMemoryRecall (cosinus, cache .ideai/memory/.index/ gitignoré)
  - AdaptiveMemoryRecall (bascule pure should_use_vector), défaut none
  - HashEmbedder (déterministe, tests), StubEmbedder (onnx/server/api)

Tests: 57 binaires verts, build + clippy --workspace sans warning.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-08 08:47:23 +02:00
parent 3ed0f6b45f
commit 98a8b7292a
30 changed files with 4978 additions and 14 deletions

3
.gitignore vendored
View File

@ -32,6 +32,9 @@ frontend/coverage/
# Ephemeral per-agent run directories (isolated PTY cwd + generated convention # Ephemeral per-agent run directories (isolated PTY cwd + generated convention
# files), created at activation — not versioned (ARCHITECTURE §9.1 / §14.1). # files), created at activation — not versioned (ARCHITECTURE §9.1 / §14.1).
.ideai/run/ .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 ─────────────────────────────────────────────────────────── # ─── Editors / OS ───────────────────────────────────────────────────────────
.idea/ .idea/

View File

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

View File

@ -17,6 +17,8 @@ use application::{
UpdateAgentContextInput, UpdateAgentContextInput,
AssignSkillToAgentInput, CreateSkillInput, DeleteSkillInput, ListSkillsInput, AssignSkillToAgentInput, CreateSkillInput, DeleteSkillInput, ListSkillsInput,
UnassignSkillFromAgentInput, UpdateSkillInput, UnassignSkillFromAgentInput, UpdateSkillInput,
CreateMemoryInput, DeleteMemoryInput, GetMemoryInput, ListMemoriesInput, ReadMemoryIndexInput,
RecallMemoryInput, ResolveMemoryLinksInput, UpdateMemoryInput,
}; };
use domain::ports::PtyHandle; use domain::ports::PtyHandle;
@ -37,6 +39,8 @@ use crate::dto::{
TemplateListDto, TerminalClosedDto, TerminalSessionDto, UpdateAgentContextRequestDto, TemplateListDto, TerminalClosedDto, TerminalSessionDto, UpdateAgentContextRequestDto,
UpdateTemplateRequestDto, WriteTerminalRequestDto, parse_skill_id, AssignSkillRequestDto, UpdateTemplateRequestDto, WriteTerminalRequestDto, parse_skill_id, AssignSkillRequestDto,
CreateSkillRequestDto, SkillDto, SkillListDto, UnassignSkillRequestDto, UpdateSkillRequestDto, CreateSkillRequestDto, SkillDto, SkillListDto, UnassignSkillRequestDto, UpdateSkillRequestDto,
parse_memory_slug, CreateMemoryRequestDto, MemoryDto, MemoryIndexDto, MemoryLinksDto,
MemoryListDto, RecallMemoryRequestDto, UpdateMemoryRequestDto,
}; };
use domain::{SkillRef, SkillScope}; use domain::{SkillRef, SkillScope};
use crate::pty::{PtyBridge, PtyChunk}; use crate::pty::{PtyBridge, PtyChunk};
@ -1392,3 +1396,197 @@ pub async fn unassign_skill_from_agent(
.await .await
.map_err(ErrorDto::from) .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<MemoryDto, ErrorDto> {
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<MemoryDto, ErrorDto> {
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<MemoryListDto, ErrorDto> {
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<MemoryDto, ErrorDto> {
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<MemoryIndexDto, ErrorDto> {
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<MemoryIndexDto, ErrorDto> {
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<MemoryLinksDto, ErrorDto> {
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)
}

View File

@ -1514,3 +1514,184 @@ pub fn parse_skill_id(raw: &str) -> Result<SkillId, ErrorDto> {
message: format!("invalid skill id: {raw}"), 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<Memory> 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<CreateMemoryOutput> for MemoryDto {
fn from(out: CreateMemoryOutput) -> Self {
Self::from(out.memory)
}
}
impl From<UpdateMemoryOutput> for MemoryDto {
fn from(out: UpdateMemoryOutput) -> Self {
Self::from(out.memory)
}
}
impl From<GetMemoryOutput> 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<MemoryDto>);
impl From<ListMemoriesOutput> 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<MemoryIndexEntry> 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<MemoryIndexEntryDto>);
impl From<ReadMemoryIndexOutput> for MemoryIndexDto {
fn from(out: ReadMemoryIndexOutput) -> Self {
Self(out.entries.into_iter().map(MemoryIndexEntryDto::from).collect())
}
}
impl From<RecallMemoryOutput> 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<String>);
impl From<ResolveMemoryLinksOutput> 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, ErrorDto> {
MemorySlug::new(raw).map_err(|_| ErrorDto {
code: "INVALID".to_owned(),
message: format!("invalid memory slug: {raw}"),
})
}

View File

@ -111,6 +111,24 @@ pub enum DomainEventDto {
/// Whether IdeA handled it successfully. /// Whether IdeA handled it successfully.
ok: bool, 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). /// Raw PTY output (normally routed to a per-session channel, not here).
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
PtyOutput { PtyOutput {
@ -185,6 +203,15 @@ impl From<&DomainEvent> for DomainEventDto {
action: action.clone(), action: action.clone(),
ok: *ok, 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 { DomainEvent::PtyOutput { session_id, bytes } => Self::PtyOutput {
session_id: session_id.to_string(), session_id: session_id.to_string(),
bytes: bytes.clone(), bytes: bytes.clone(),

View File

@ -140,6 +140,14 @@ pub fn run() {
commands::delete_skill, commands::delete_skill,
commands::assign_skill_to_agent, commands::assign_skill_to_agent,
commands::unassign_skill_from_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, commands::move_tab_to_new_window,
]) ])
.run(tauri::generate_context!()) .run(tauri::generate_context!())

View File

@ -19,19 +19,22 @@ use application::{
OpenTerminal, OrchestratorService, ReadAgentContext, ReferenceProfiles, RenameLayout, OpenTerminal, OrchestratorService, ReadAgentContext, ReferenceProfiles, RenameLayout,
ResizeTerminal, SaveProfile, SetActiveLayout, SnapshotRunningAgents, ResizeTerminal, SaveProfile, SetActiveLayout, SnapshotRunningAgents,
AssignSkillToAgent, CreateSkill, DeleteSkill, UnassignSkillFromAgent, UpdateSkill, AssignSkillToAgent, CreateSkill, DeleteSkill, UnassignSkillFromAgent, UpdateSkill,
CreateMemory, DeleteMemory, GetMemory, ListMemories, ReadMemoryIndex, RecallMemory,
ResolveMemoryLinks, UpdateMemory,
SyncAgentWithTemplate, TerminalSessions, UpdateAgentContext, UpdateTemplate, WriteToTerminal, SyncAgentWithTemplate, TerminalSessions, UpdateAgentContext, UpdateTemplate, WriteToTerminal,
}; };
use domain::ports::{ use domain::ports::{
AgentContextStore, AgentRuntime, Clock, EventBus, FileSystem, GitPort, IdGenerator, 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 domain::{DomainEvent, Project, ProjectId};
use infrastructure::{ use infrastructure::{
ClaudeTranscriptInspector, CliAgentRuntime, FsOrchestratorWatcher, FsProfileStore, ClaudeTranscriptInspector, CliAgentRuntime, FsOrchestratorWatcher, FsProfileStore,
FsProjectStore, FsSkillStore, FsTemplateStore, Git2Repository, IdeaiContextStore, FsMemoryStore, FsProjectStore, FsSkillStore, FsTemplateStore, Git2Repository, IdeaiContextStore,
LocalFileSystem, LocalProcessSpawner, OrchestratorWatchHandle, PortablePtyAdapter, SystemClock, LocalFileSystem, LocalProcessSpawner, NaiveMemoryRecall, OrchestratorWatchHandle,
TokioBroadcastEventBus, UuidGenerator, PortablePtyAdapter, SystemClock, TokioBroadcastEventBus, UuidGenerator,
}; };
use crate::pty::PtyBridge; use crate::pty::PtyBridge;
@ -172,6 +175,25 @@ pub struct AppState {
pub assign_skill: Arc<AssignSkillToAgent>, pub assign_skill: Arc<AssignSkillToAgent>,
/// Unassign a skill from an agent. /// Unassign a skill from an agent.
pub unassign_skill: Arc<UnassignSkillFromAgent>, pub unassign_skill: Arc<UnassignSkillFromAgent>,
// --- Memory (LOT A — §14.5.1) ---
/// Create a memory note in the project's store.
pub create_memory: Arc<CreateMemory>,
/// Replace an existing memory note.
pub update_memory: Arc<UpdateMemory>,
/// List the project's memory notes.
pub list_memories: Arc<ListMemories>,
/// Read one memory note by slug.
pub get_memory: Arc<GetMemory>,
/// Delete a memory note.
pub delete_memory: Arc<DeleteMemory>,
/// Read the structured `MEMORY.md` index.
pub read_memory_index: Arc<ReadMemoryIndex>,
/// Resolve a note's outgoing `[[slug]]` links.
pub resolve_memory_links: Arc<ResolveMemoryLinks>,
/// Recall the most relevant memory entries for a query within a budget
/// (LOT B — §14.5.2).
pub recall_memory: Arc<RecallMemory>,
// --- Orchestrator (§14.3) --- // --- Orchestrator (§14.3) ---
/// Dispatches validated orchestrator requests to the agent/skill use cases. /// Dispatches validated orchestrator requests to the agent/skill use cases.
/// Shared by every per-project filesystem watcher. /// Shared by every per-project filesystem watcher.
@ -458,6 +480,36 @@ impl AppState {
Arc::clone(&events_port), 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<dyn MemoryStore>;
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<dyn MemoryRecall>;
let recall_memory = Arc::new(RecallMemory::new(memory_recall_port));
// --- Orchestrator service (§14.3) --- // --- Orchestrator service (§14.3) ---
// Dispatches file-based orchestrator requests through the *same* use cases // Dispatches file-based orchestrator requests through the *same* use cases
// the UI drives (IdeA stays the single source of truth for the agent/skill // the UI drives (IdeA stays the single source of truth for the agent/skill
@ -540,6 +592,14 @@ impl AppState {
delete_skill, delete_skill,
assign_skill, assign_skill,
unassign_skill, unassign_skill,
create_memory,
update_memory,
list_memories,
get_memory,
delete_memory,
read_memory_index,
resolve_memory_links,
recall_memory,
orchestrator_service, orchestrator_service,
orchestrator_watchers: Mutex::new(HashMap::new()), orchestrator_watchers: Mutex::new(HashMap::new()),
move_tab, move_tab,

View File

@ -6,7 +6,8 @@
//! with one error shape when building its `ErrorDTO`. //! with one error shape when building its `ErrorDTO`.
use domain::ports::{ 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. /// Errors surfaced by application use cases.
@ -84,6 +85,26 @@ impl From<StoreError> for AppError {
} }
} }
impl From<MemoryError> for AppError {
fn from(e: MemoryError) -> Self {
match e {
MemoryError::NotFound => Self::NotFound("memory note".to_owned()),
MemoryError::Frontmatter(m) => Self::Invalid(m),
other => Self::Store(other.to_string()),
}
}
}
impl From<EmbedderError> 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<PtyError> for AppError { impl From<PtyError> for AppError {
fn from(e: PtyError) -> Self { fn from(e: PtyError) -> Self {
Self::Process(e.to_string()) Self::Process(e.to_string())

View File

@ -16,6 +16,7 @@ pub mod error;
pub mod git; pub mod git;
pub mod health; pub mod health;
pub mod layout; pub mod layout;
pub mod memory;
pub mod orchestrator; pub mod orchestrator;
pub mod project; pub mod project;
pub mod remote; pub mod remote;
@ -53,6 +54,13 @@ pub use layout::{
RenameLayoutInput, SetActiveLayout, SetActiveLayoutInput, SnapshotRunningAgents, RenameLayoutInput, SetActiveLayout, SetActiveLayoutInput, SnapshotRunningAgents,
SnapshotRunningAgentsInput, SnapshotRunningAgentsOutput, LAYOUTS_FILE, 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::{ pub use project::{
CloseProject, CloseProjectInput, CloseProjectOutput, CloseTab, CloseTabInput, CreateProject, CloseProject, CloseProjectInput, CloseProjectOutput, CloseTab, CloseTabInput, CreateProject,
CreateProjectInput, CreateProjectOutput, ListProjects, ListProjectsOutput, OpenProject, CreateProjectInput, CreateProjectOutput, ListProjects, ListProjectsOutput, OpenProject,

View File

@ -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/<slug>.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,
};

View File

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

View File

@ -2,7 +2,9 @@
//! relies on, and the per-port `From` mappings. //! relies on, and the per-port `From` mappings.
use application::AppError; use application::AppError;
use domain::ports::{FsError, GitError, ProcessError, PtyError, RemoteError, StoreError}; use domain::ports::{
EmbedderError, FsError, GitError, MemoryError, ProcessError, PtyError, RemoteError, StoreError,
};
#[test] #[test]
fn codes_are_stable() { fn codes_are_stable() {
@ -55,3 +57,43 @@ fn git_and_remote_map_through() {
"REMOTE" "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())
);
}

View File

@ -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<Mutex<Vec<Memory>>>);
#[async_trait]
impl MemoryStore for FakeMemories {
async fn list(&self, _root: &ProjectPath) -> Result<Vec<Memory>, MemoryError> {
Ok(self.0.lock().unwrap().clone())
}
async fn get(&self, _root: &ProjectPath, slug: &MemorySlug) -> Result<Memory, MemoryError> {
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<Vec<MemoryIndexEntry>, MemoryError> {
Ok(self
.0
.lock()
.unwrap()
.iter()
.map(Memory::index_entry)
.collect())
}
async fn resolve_links(
&self,
_root: &ProjectPath,
slug: &MemorySlug,
) -> Result<Vec<MemoryLink>, 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<Mutex<Vec<DomainEvent>>>);
impl SpyBus {
fn events(&self) -> Vec<DomainEvent> {
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<MemorySlug> {
events
.iter()
.filter_map(|e| match e {
DomainEvent::MemorySaved { slug } => Some(slug.clone()),
_ => None,
})
.collect()
}
fn deleted_slugs(events: &[DomainEvent]) -> Vec<MemorySlug> {
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<Vec<MemoryIndexEntry>>,
seen: Arc<Mutex<Vec<MemoryQuery>>>,
}
impl FakeRecall {
fn returning(entries: Vec<MemoryIndexEntry>) -> Self {
Self {
entries: Arc::new(entries),
seen: Arc::default(),
}
}
fn queries(&self) -> Vec<MemoryQuery> {
self.seen.lock().unwrap().clone()
}
}
#[async_trait]
impl MemoryRecall for FakeRecall {
async fn recall(
&self,
_root: &ProjectPath,
query: &MemoryQuery,
) -> Result<Vec<MemoryIndexEntry>, 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"
);
}

View File

@ -65,6 +65,13 @@ pub enum DomainError {
reason: String, 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. /// A generic invariant violation with an explanatory message.
#[error("invariant violated: {0}")] #[error("invariant violated: {0}")]
Invariant(String), Invariant(String),

View File

@ -2,6 +2,7 @@
//! presentation layer (ARCHITECTURE §3.2). //! presentation layer (ARCHITECTURE §3.2).
use crate::ids::{AgentId, ProjectId, SessionId, SkillId, TemplateId}; use crate::ids::{AgentId, ProjectId, SessionId, SkillId, TemplateId};
use crate::memory::MemorySlug;
use crate::template::TemplateVersion; use crate::template::TemplateVersion;
/// Events emitted by the domain/application as state changes occur. /// Events emitted by the domain/application as state changes occur.
@ -90,6 +91,21 @@ pub enum DomainEvent {
/// Whether IdeA handled it successfully. /// Whether IdeA handled it successfully.
ok: bool, 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). /// Raw PTY output (usually routed to a dedicated channel, not this bus).
PtyOutput { PtyOutput {
/// The session. /// The session.

View File

@ -37,6 +37,7 @@ pub mod git;
pub mod ids; pub mod ids;
pub mod layout; pub mod layout;
pub mod markdown; pub mod markdown;
pub mod memory;
pub mod orchestrator; pub mod orchestrator;
pub mod ports; pub mod ports;
pub mod profile; pub mod profile;
@ -67,10 +68,16 @@ pub use skill::{Skill, SkillRef, SkillScope};
pub use template::{AgentTemplate, TemplateVersion}; 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 markdown::MarkdownDoc;
pub use memory::{
Memory, MemoryFrontmatter, MemoryIndexEntry, MemoryLink, MemorySlug, MemoryType,
};
pub use remote::{RemoteKind, RemoteRef, SshAuth}; pub use remote::{RemoteKind, RemoteRef, SshAuth};
pub use terminal::{PtySize, SessionKind, SessionStatus, TerminalSession}; pub use terminal::{PtySize, SessionKind, SessionStatus, TerminalSession};
@ -87,9 +94,11 @@ pub use events::DomainEvent;
pub use orchestrator::{OrchestratorCommand, OrchestratorError, OrchestratorRequest}; pub use orchestrator::{OrchestratorCommand, OrchestratorError, OrchestratorRequest};
pub use ports::{ 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, ExitStatus, FileSystem, FsError, GitCommitInfo, GitError, GitFileStatus, GitPort, GraphCommit,
IdGenerator, Output, OutputStream, PreparedContext, ProcessError, ProcessSpawner, ProfileStore, IdGenerator, MemoryError, MemoryQuery, MemoryRecall, MemoryStore, Output, OutputStream,
ProjectStore, PtyError, PtyHandle, PtyPort, RemoteError, RemoteHost, RemotePath, RuntimeError, PreparedContext, ProcessError,
SpawnSpec, StoreError, TemplateStore, ProcessSpawner, ProfileStore, ProjectStore, PtyError, PtyHandle, PtyPort, RemoteError,
RemoteHost, RemotePath, RuntimeError, SpawnSpec, StoreError, TemplateStore,
}; };

194
crates/domain/src/memory.rs Normal file
View File

@ -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/<slug>.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 (`<slug>.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<String>) -> Result<Self, DomainError> {
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<Self, DomainError> {
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<MemoryLink> {
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,
}
}
}

View File

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

View File

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

View File

@ -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<T>(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::<EmbedderStrategy>("\"localOnnx\"").unwrap(),
EmbedderStrategy::LocalOnnx
);
assert_eq!(
serde_json::from_str::<EmbedderStrategy>("\"localServer\"").unwrap(),
EmbedderStrategy::LocalServer
);
assert_eq!(
serde_json::from_str::<EmbedderStrategy>("\"api\"").unwrap(),
EmbedderStrategy::Api
);
assert_eq!(
serde_json::from_str::<EmbedderStrategy>("\"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);
}

View File

@ -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\""
);
}

View File

@ -40,5 +40,8 @@ pub use pty::PortablePtyAdapter;
pub use remote::{remote_host, LocalHost}; pub use remote::{remote_host, LocalHost};
pub use runtime::CliAgentRuntime; pub use runtime::CliAgentRuntime;
pub use store::{ 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,
}; };

View File

@ -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<Box<dyn Embedder>> {
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<String>, 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<Vec<Vec<f32>>, 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<String>, 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<f32> {
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::<f32>().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<Vec<Vec<f32>>, 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
}

View File

@ -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
//! <project_root>/.ideai/memory/
//! ├── MEMORY.md # aggregated index: one `- [Title](slug.md) — hook` line per note
//! └── <slug>.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 `<slug>.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<dyn FileSystem>,
}
impl FsMemoryStore {
/// Builds the store from an injected [`FileSystem`]. Directories are created
/// on first write.
#[must_use]
pub fn new(fs: Arc<dyn FileSystem>) -> Self {
Self { fs }
}
/// `<root>/.ideai/memory`.
fn dir(&self, root: &ProjectPath) -> String {
let base = root.as_str().trim_end_matches(['/', '\\']);
format!("{base}/{IDEAI_DIR}/{MEMORY_DIR}")
}
/// `<memory-dir>/<slug>.md`.
fn md_path(&self, root: &ProjectPath, slug: &MemorySlug) -> RemotePath {
RemotePath::new(format!("{}/{}.md", self.dir(root), slug.as_str()))
}
/// `<memory-dir>/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<Memory, MemoryError> {
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<Option<String>, 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<Vec<MemorySlug>, 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<Vec<Memory>, 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<Memory, MemoryError> {
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 `<slug>.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<Vec<MemoryIndexEntry>, 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<Vec<MemoryLink>, 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<Item = MemoryIndexEntry>,
budget: usize,
) -> Vec<MemoryIndexEntry> {
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<dyn MemoryStore>`], 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<dyn MemoryStore>,
}
impl NaiveMemoryRecall {
/// Builds the recall adapter from a composed [`MemoryStore`].
#[must_use]
pub fn new(store: Arc<dyn MemoryStore>) -> Self {
Self { store }
}
}
#[async_trait]
impl MemoryRecall for NaiveMemoryRecall {
async fn recall(
&self,
root: &ProjectPath,
query: &MemoryQuery,
) -> Result<Vec<MemoryIndexEntry>, 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<Memory, MemoryError> {
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<MemoryFrontmatter, MemoryError> {
let err = |reason: &str| MemoryError::Frontmatter(reason.to_string());
let mut name: Option<String> = None;
let mut description: Option<String> = None;
let mut type_str: Option<String> = 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<MemoryIndexEntry> {
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<MemoryIndexEntry> {
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<MemoryType> {
match s {
"user" => Some(MemoryType::User),
"feedback" => Some(MemoryType::Feedback),
"project" => Some(MemoryType::Project),
"reference" => Some(MemoryType::Reference),
_ => None,
}
}

View File

@ -5,13 +5,19 @@
//! JSON files in the app data directory (machine-local, outside any project). //! JSON files in the app data directory (machine-local, outside any project).
mod context; mod context;
mod embedder;
mod memory;
mod profile; mod profile;
mod project; mod project;
mod skill; mod skill;
mod template; mod template;
mod vector;
pub use context::IdeaiContextStore; 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 project::FsProjectStore;
pub use skill::FsSkillStore; pub use skill::FsSkillStore;
pub use template::FsTemplateStore; pub use template::FsTemplateStore;
pub use vector::{should_use_vector, AdaptiveMemoryRecall, VectorMemoryRecall};

View File

@ -24,7 +24,7 @@ use serde::{Deserialize, Serialize};
use domain::ids::ProfileId; use domain::ids::ProfileId;
use domain::ports::{FileSystem, ProfileStore, RemotePath, StoreError}; 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. /// File name of the profiles store inside the app-data dir.
const PROFILES_FILE: &str = "profiles.json"; const PROFILES_FILE: &str = "profiles.json";
@ -147,3 +147,126 @@ impl ProfileStore for FsProfileStore {
self.write_doc(&doc).await 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<EmbedderProfile>,
}
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<dyn FileSystem>,
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<dyn FileSystem>, app_data_dir: impl Into<String>) -> Self {
Self {
fs,
app_data_dir: app_data_dir.into(),
}
}
/// `<app-data-dir>/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<EmbedderDoc, StoreError> {
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<Vec<EmbedderProfile>, 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
}
}

View File

@ -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<String, Vec<f32>>,
}
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<dyn Embedder>,
store: Arc<dyn MemoryStore>,
fs: Arc<dyn FileSystem>,
}
impl VectorMemoryRecall {
/// Builds the vector recall from its composed ports.
#[must_use]
pub fn new(
embedder: Arc<dyn Embedder>,
store: Arc<dyn MemoryStore>,
fs: Arc<dyn FileSystem>,
) -> Self {
Self {
embedder,
store,
fs,
}
}
/// `<root>/.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}")
}
/// `<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::<VectorDoc>(&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<Vec<MemoryIndexEntry>, 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<String> = 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<Vec<Vec<f32>>, 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::<f32>().sqrt();
let nb: f32 = b.iter().map(|x| x * x).sum::<f32>().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<dyn MemoryRecall>,
vector: Arc<dyn MemoryRecall>,
store: Arc<dyn MemoryStore>,
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<dyn MemoryRecall>,
vector: Arc<dyn MemoryRecall>,
store: Arc<dyn MemoryStore>,
strategy: EmbedderStrategy,
) -> Self {
Self {
naive,
vector,
store,
strategy,
}
}
}
#[async_trait]
impl MemoryRecall for AdaptiveMemoryRecall {
async fn recall(
&self,
root: &ProjectPath,
query: &MemoryQuery,
) -> Result<Vec<MemoryIndexEntry>, 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,
}
}
}

View File

@ -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<HashMap<String, Vec<u8>>>,
}
impl MemFs {
fn arc() -> Arc<Self> {
Arc::new(Self::default())
}
}
#[async_trait]
impl FileSystem for MemFs {
async fn read(&self, path: &RemotePath) -> Result<Vec<u8>, 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<bool, FsError> {
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<Vec<DirEntry>, 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<String> {
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<Vec<Memory>, MemoryError> {
panic!("recall must not call list")
}
async fn get(&self, _root: &ProjectPath, _slug: &MemorySlug) -> Result<Memory, MemoryError> {
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<Vec<MemoryIndexEntry>, MemoryError> {
self.read_index_calls.fetch_add(1, Ordering::SeqCst);
Ok(Vec::new())
}
async fn resolve_links(
&self,
_root: &ProjectPath,
_slug: &MemorySlug,
) -> Result<Vec<MemoryLink>, 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(&notes).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"]
);
}

View File

@ -0,0 +1,278 @@
//! Tests for [`FsMemoryStore`] against an **in-memory mock** [`FileSystem`]:
//! `save` writes the `<slug>.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<HashMap<String, Vec<u8>>>,
}
impl MemFs {
fn arc() -> Arc<Self> {
Arc::new(Self::default())
}
fn raw(&self, path: &str) -> Option<String> {
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<Vec<u8>, 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<bool, FsError> {
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<Vec<DirEntry>, 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(), &note("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(), &note("a", "ha", MemoryType::User, "A"))
.await
.unwrap();
store
.save(&root(), &note("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(), &note("dup", "hook v1", MemoryType::Project, "v1"))
.await
.unwrap();
store
.save(&root(), &note("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(), &note("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(), &note("target", "h", MemoryType::Project, "T"))
.await
.unwrap();
store
.save(
&root(),
&note(
"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
));
}

View File

@ -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<HashMap<String, Vec<u8>>>,
}
impl MemFs {
fn arc() -> Arc<Self> {
Arc::new(Self::default())
}
fn raw(&self, path: &str) -> Option<String> {
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<Vec<u8>, 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<bool, FsError> {
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<Vec<DirEntry>, 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<Vec<Vec<f32>>, 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 `<slug> <hook>` (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<String> {
entries.iter().map(|e| e.slug.as_str().to_string()).collect()
}
async fn store_with(notes: &[Memory], fs: Arc<MemFs>) -> Arc<FsMemoryStore> {
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::<f32>().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<dyn Embedder>,
store: Arc<FsMemoryStore>,
fs: Arc<MemFs>,
) -> 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(&notes, 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(&notes, 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(&notes, 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(&notes, 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(&notes, 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<dyn Embedder>,
) -> (Arc<MemFs>, AdaptiveMemoryRecall) {
let fs = MemFs::arc();
let store = store_with(notes, fs.clone()).await;
let naive: Arc<dyn MemoryRecall> = Arc::new(NaiveMemoryRecall::new(store.clone()));
let vector: Arc<dyn MemoryRecall> =
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(&notes, fs.clone()).await;
let naive = NaiveMemoryRecall::new(store.clone());
// Adaptive with strategy `none` (embedder present but never used).
let naive_dyn: Arc<dyn MemoryRecall> = Arc::new(NaiveMemoryRecall::new(store.clone()));
let vector_dyn: Arc<dyn MemoryRecall> = 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(
&notes,
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(
&notes,
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 &notes {
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(&notes, fs.clone()).await;
let naive_ref = NaiveMemoryRecall::new(store.clone());
let naive_dyn: Arc<dyn MemoryRecall> = Arc::new(NaiveMemoryRecall::new(store.clone()));
let vector_dyn: Arc<dyn MemoryRecall> = 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<dyn MemoryRecall> = Arc::new(NaiveMemoryRecall::new(empty_store.clone()));
let vector: Arc<dyn MemoryRecall> = 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());
}