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

View File

@ -17,6 +17,8 @@ use application::{
UpdateAgentContextInput,
AssignSkillToAgentInput, CreateSkillInput, DeleteSkillInput, ListSkillsInput,
UnassignSkillFromAgentInput, UpdateSkillInput,
CreateMemoryInput, DeleteMemoryInput, GetMemoryInput, ListMemoriesInput, ReadMemoryIndexInput,
RecallMemoryInput, ResolveMemoryLinksInput, UpdateMemoryInput,
};
use domain::ports::PtyHandle;
@ -37,6 +39,8 @@ use crate::dto::{
TemplateListDto, TerminalClosedDto, TerminalSessionDto, UpdateAgentContextRequestDto,
UpdateTemplateRequestDto, WriteTerminalRequestDto, parse_skill_id, AssignSkillRequestDto,
CreateSkillRequestDto, SkillDto, SkillListDto, UnassignSkillRequestDto, UpdateSkillRequestDto,
parse_memory_slug, CreateMemoryRequestDto, MemoryDto, MemoryIndexDto, MemoryLinksDto,
MemoryListDto, RecallMemoryRequestDto, UpdateMemoryRequestDto,
};
use domain::{SkillRef, SkillScope};
use crate::pty::{PtyBridge, PtyChunk};
@ -1392,3 +1396,197 @@ pub async fn unassign_skill_from_agent(
.await
.map_err(ErrorDto::from)
}
// ---------------------------------------------------------------------------
// Memory (LOT A — §14.5.1)
// ---------------------------------------------------------------------------
/// `create_memory` — create a memory note in the project's store.
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for an invalid slug/empty fields or
/// malformed project id, `NOT_FOUND` if the project is unknown, `STORE` on
/// failure).
#[tauri::command]
pub async fn create_memory(
request: CreateMemoryRequestDto,
state: State<'_, AppState>,
) -> Result<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}"),
})
}
// ---------------------------------------------------------------------------
// 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.
ok: bool,
},
/// A memory note was created or updated.
#[serde(rename_all = "camelCase")]
MemorySaved {
/// The saved note's slug.
slug: String,
},
/// A memory note was deleted.
#[serde(rename_all = "camelCase")]
MemoryDeleted {
/// The deleted note's slug.
slug: String,
},
/// The aggregated `MEMORY.md` index was rebuilt.
#[serde(rename_all = "camelCase")]
MemoryIndexRebuilt {
/// Project id.
project_id: String,
},
/// Raw PTY output (normally routed to a per-session channel, not here).
#[serde(rename_all = "camelCase")]
PtyOutput {
@ -185,6 +203,15 @@ impl From<&DomainEvent> for DomainEventDto {
action: action.clone(),
ok: *ok,
},
DomainEvent::MemorySaved { slug } => Self::MemorySaved {
slug: slug.as_str().to_string(),
},
DomainEvent::MemoryDeleted { slug } => Self::MemoryDeleted {
slug: slug.as_str().to_string(),
},
DomainEvent::MemoryIndexRebuilt { project_id } => Self::MemoryIndexRebuilt {
project_id: project_id.to_string(),
},
DomainEvent::PtyOutput { session_id, bytes } => Self::PtyOutput {
session_id: session_id.to_string(),
bytes: bytes.clone(),

View File

@ -140,6 +140,14 @@ pub fn run() {
commands::delete_skill,
commands::assign_skill_to_agent,
commands::unassign_skill_from_agent,
commands::create_memory,
commands::update_memory,
commands::list_memories,
commands::get_memory,
commands::delete_memory,
commands::read_memory_index,
commands::recall_memory,
commands::resolve_memory_links,
commands::move_tab_to_new_window,
])
.run(tauri::generate_context!())

View File

@ -19,19 +19,22 @@ use application::{
OpenTerminal, OrchestratorService, ReadAgentContext, ReferenceProfiles, RenameLayout,
ResizeTerminal, SaveProfile, SetActiveLayout, SnapshotRunningAgents,
AssignSkillToAgent, CreateSkill, DeleteSkill, UnassignSkillFromAgent, UpdateSkill,
CreateMemory, DeleteMemory, GetMemory, ListMemories, ReadMemoryIndex, RecallMemory,
ResolveMemoryLinks, UpdateMemory,
SyncAgentWithTemplate, TerminalSessions, UpdateAgentContext, UpdateTemplate, WriteToTerminal,
};
use domain::ports::{
AgentContextStore, AgentRuntime, Clock, EventBus, FileSystem, GitPort, IdGenerator,
ProcessSpawner, ProfileStore, ProjectStore, PtyPort, SkillStore, TemplateStore,
MemoryRecall, MemoryStore, ProcessSpawner, ProfileStore, ProjectStore, PtyPort, SkillStore,
TemplateStore,
};
use domain::{DomainEvent, Project, ProjectId};
use infrastructure::{
ClaudeTranscriptInspector, CliAgentRuntime, FsOrchestratorWatcher, FsProfileStore,
FsProjectStore, FsSkillStore, FsTemplateStore, Git2Repository, IdeaiContextStore,
LocalFileSystem, LocalProcessSpawner, OrchestratorWatchHandle, PortablePtyAdapter, SystemClock,
TokioBroadcastEventBus, UuidGenerator,
FsMemoryStore, FsProjectStore, FsSkillStore, FsTemplateStore, Git2Repository, IdeaiContextStore,
LocalFileSystem, LocalProcessSpawner, NaiveMemoryRecall, OrchestratorWatchHandle,
PortablePtyAdapter, SystemClock, TokioBroadcastEventBus, UuidGenerator,
};
use crate::pty::PtyBridge;
@ -172,6 +175,25 @@ pub struct AppState {
pub assign_skill: Arc<AssignSkillToAgent>,
/// Unassign a skill from an agent.
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) ---
/// Dispatches validated orchestrator requests to the agent/skill use cases.
/// Shared by every per-project filesystem watcher.
@ -458,6 +480,36 @@ impl AppState {
Arc::clone(&events_port),
));
// --- Memory store + use cases (LOT A — §14.5.1) ---
// A single FsMemoryStore takes the project root per call (like the skill
// store), so one instance serves every open project. The mutating use
// cases share the event bus to announce Memory{Saved,Deleted}.
let memory_store = Arc::new(FsMemoryStore::new(Arc::clone(&fs_port)));
let memory_store_port = Arc::clone(&memory_store) as Arc<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) ---
// Dispatches file-based orchestrator requests through the *same* use cases
// the UI drives (IdeA stays the single source of truth for the agent/skill
@ -540,6 +592,14 @@ impl AppState {
delete_skill,
assign_skill,
unassign_skill,
create_memory,
update_memory,
list_memories,
get_memory,
delete_memory,
read_memory_index,
resolve_memory_links,
recall_memory,
orchestrator_service,
orchestrator_watchers: Mutex::new(HashMap::new()),
move_tab,