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:
@ -1514,3 +1514,184 @@ pub fn parse_skill_id(raw: &str) -> Result<SkillId, ErrorDto> {
|
||||
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}"),
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user