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:
194
crates/domain/src/memory.rs
Normal file
194
crates/domain/src/memory.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user