feat(memory): récolte automatique contrôlée de la mémoire (Lot E1)
Câble la récolte automatique de mémoire de bout en bout, sous contrôle explicite, du domaine jusqu'à l'UI : - domain : modèle `memory_harvest` (candidats de récolte, décision) + exports `lib.rs`. - application : use case `memory/harvest` branché dans `memory/mod`, `lib.rs`, le cycle de vie d'agent (`agent/lifecycle`) et l'orchestrateur (`orchestrator/service`). - app-tauri : wiring runtime dans `state`. - frontend : DTO `domain/index` + hook `useMemory`. Couverture : domain `memory_harvest` (14), application `memory_harvest` (5) et `orchestrator_service` (récolte, 4), plus patch test-only du mock gateway `workState` (`mock.test.ts`) et UI `memory.test`. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
114
crates/application/src/memory/harvest.rs
Normal file
114
crates/application/src/memory/harvest.rs
Normal file
@ -0,0 +1,114 @@
|
||||
//! Auto-memory harvest use case (Lot E1).
|
||||
//!
|
||||
//! [`HarvestMemoryFromTurn`] turns the text of a finished agent turn into persisted
|
||||
//! memory notes when it embeds ` ```idea-memory ` directives (parsed by the pure
|
||||
//! [`domain::memory_harvest`] module). It is **best-effort strict**: it only acts on
|
||||
//! a `Response` turn, parses **only the current turn's text** (never the full log),
|
||||
//! and never fails — parse/store errors are collected into the returned
|
||||
//! [`MemoryHarvestOutcome`] so the orchestrator can drop them without ever turning a
|
||||
//! successful reply/ticket/handoff into an error.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::conversation_log::TurnRole;
|
||||
use domain::ports::{EventBus, MemoryStore};
|
||||
use domain::{
|
||||
parse_memory_directives, DomainEvent, MarkdownDoc, Memory, MemoryFrontmatter,
|
||||
MemoryHarvestDirective, MemorySlug, ProjectPath,
|
||||
};
|
||||
|
||||
/// The outcome of harvesting one turn: a report the caller may log or ignore.
|
||||
///
|
||||
/// `found` is the number of `idea-memory` directives the agent emitted; `saved`
|
||||
/// the slugs successfully persisted; `skipped` how many were dropped (parse or
|
||||
/// store failures); `errors` the human-readable reasons (parse + store).
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
pub struct MemoryHarvestOutcome {
|
||||
/// Number of `idea-memory` directives detected in the turn text.
|
||||
pub found: usize,
|
||||
/// Slugs of the notes that were saved (and announced).
|
||||
pub saved: Vec<MemorySlug>,
|
||||
/// Number of directives dropped (invalid, over-limit, or store failure).
|
||||
pub skipped: usize,
|
||||
/// One message per dropped directive (parse + persistence reasons).
|
||||
pub errors: Vec<String>,
|
||||
}
|
||||
|
||||
/// Validates and persists the memory directives embedded in a finished turn.
|
||||
///
|
||||
/// Talks only to ports ([`MemoryStore`], [`EventBus`]); announces
|
||||
/// [`DomainEvent::MemorySaved`] per saved note like the manual CRUD use cases.
|
||||
pub struct HarvestMemoryFromTurn {
|
||||
memories: Arc<dyn MemoryStore>,
|
||||
events: Arc<dyn EventBus>,
|
||||
}
|
||||
|
||||
impl HarvestMemoryFromTurn {
|
||||
/// Builds the use case from its ports.
|
||||
#[must_use]
|
||||
pub fn new(memories: Arc<dyn MemoryStore>, events: Arc<dyn EventBus>) -> Self {
|
||||
Self { memories, events }
|
||||
}
|
||||
|
||||
/// Harvests `text` (the current turn's content) into `root`'s memory store.
|
||||
///
|
||||
/// Best-effort: returns a [`MemoryHarvestOutcome`] and **never** an error. A
|
||||
/// non-`Response` turn is a no-op (empty outcome) — prompts and tool activity
|
||||
/// never carry harvest directives.
|
||||
pub async fn harvest(
|
||||
&self,
|
||||
root: &ProjectPath,
|
||||
role: TurnRole,
|
||||
text: &str,
|
||||
) -> MemoryHarvestOutcome {
|
||||
if role != TurnRole::Response {
|
||||
return MemoryHarvestOutcome::default();
|
||||
}
|
||||
let report = parse_memory_directives(text);
|
||||
let mut outcome = MemoryHarvestOutcome {
|
||||
found: report.found,
|
||||
// Parse-level skips are already final; store-level skips are added below.
|
||||
skipped: report.errors.len(),
|
||||
errors: report.errors,
|
||||
saved: Vec::new(),
|
||||
};
|
||||
for directive in report.directives {
|
||||
match self.persist(root, directive).await {
|
||||
Ok(slug) => outcome.saved.push(slug),
|
||||
Err(err) => {
|
||||
outcome.skipped += 1;
|
||||
outcome.errors.push(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
outcome
|
||||
}
|
||||
|
||||
/// Persists one validated directive and announces it, mapping the (today
|
||||
/// title-less) [`Memory`] entity from the directive. Returns the saved slug or
|
||||
/// a message on a build/store failure (never panics).
|
||||
async fn persist(
|
||||
&self,
|
||||
root: &ProjectPath,
|
||||
directive: MemoryHarvestDirective,
|
||||
) -> Result<MemorySlug, String> {
|
||||
let slug = directive.slug.clone();
|
||||
// The persisted `Memory` has no title field today; the directive's `title`
|
||||
// is intentionally not stored separately (the index derives its title from
|
||||
// the slug). See the Lot E1 report's écart note.
|
||||
let frontmatter = MemoryFrontmatter {
|
||||
name: slug.clone(),
|
||||
description: directive.description,
|
||||
r#type: directive.r#type,
|
||||
};
|
||||
let memory = Memory::new(frontmatter, MarkdownDoc::new(directive.body.as_str()))
|
||||
.map_err(|e| format!("invalid memory {slug}: {e}"))?;
|
||||
self.memories
|
||||
.save(root, &memory)
|
||||
.await
|
||||
.map_err(|e| format!("store failed for {slug}: {e}"))?;
|
||||
self.events
|
||||
.publish(DomainEvent::MemorySaved { slug: slug.clone() });
|
||||
Ok(slug)
|
||||
}
|
||||
}
|
||||
@ -12,8 +12,11 @@
|
||||
//! [`UpdateMemory`], [`DeleteMemory`]) announce a [`domain::DomainEvent`]; the
|
||||
//! read use cases emit nothing.
|
||||
|
||||
mod harvest;
|
||||
mod usecases;
|
||||
|
||||
pub use harvest::{HarvestMemoryFromTurn, MemoryHarvestOutcome};
|
||||
|
||||
pub use usecases::{
|
||||
CreateMemory, CreateMemoryInput, CreateMemoryOutput, DeleteMemory, DeleteMemoryInput,
|
||||
GetMemory, GetMemoryInput, GetMemoryOutput, ListMemories, ListMemoriesInput,
|
||||
|
||||
Reference in New Issue
Block a user