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:
@ -24,7 +24,7 @@ use serde::{Deserialize, Serialize};
|
||||
|
||||
use domain::ids::ProfileId;
|
||||
use domain::ports::{FileSystem, ProfileStore, RemotePath, StoreError};
|
||||
use domain::profile::AgentProfile;
|
||||
use domain::profile::{AgentProfile, EmbedderProfile};
|
||||
|
||||
/// File name of the profiles store inside the app-data dir.
|
||||
const PROFILES_FILE: &str = "profiles.json";
|
||||
@ -147,3 +147,126 @@ impl ProfileStore for FsProfileStore {
|
||||
self.write_doc(&doc).await
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// FsEmbedderProfileStore — declarative embedder profiles (`embedder.json`, LOT C).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// File name of the embedder-profiles store inside the app-data dir.
|
||||
const EMBEDDER_FILE: &str = "embedder.json";
|
||||
|
||||
/// Current schema version of the embedder-profiles file.
|
||||
const EMBEDDER_VERSION: u32 = 1;
|
||||
|
||||
/// On-disk shape of `embedder.json` (LOT C, §14.5.3), mirroring [`ProfilesDoc`].
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct EmbedderDoc {
|
||||
/// Schema version.
|
||||
version: u32,
|
||||
/// All configured embedder profiles.
|
||||
profiles: Vec<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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user