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:
@ -2624,6 +2624,7 @@ pub(crate) fn compose_convention_file(
|
||||
);
|
||||
}
|
||||
out.push_str(skill_awareness(mcp_enabled));
|
||||
out.push_str(memory_awareness());
|
||||
out.push_str("---\n\n");
|
||||
|
||||
if !project_context.trim().is_empty() {
|
||||
@ -2697,6 +2698,18 @@ fn skill_awareness(mcp_enabled: bool) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
/// The auto-memory harvest directive (Lot E1) injected in the `# Orchestration
|
||||
/// IdeA` block, right after [`skill_awareness`]. Independent of MCP: the agent emits
|
||||
/// the directive in its final reply text either way (IdeA harvests it from the
|
||||
/// recorded `Response`, not via a tool call), so a single wording serves both modes.
|
||||
fn memory_awareness() -> &'static str {
|
||||
"**Mémoire IdeA** : si une décision, convention, préférence utilisateur ou fait projet \
|
||||
durable mérite d'être capitalisé, ajoute à ta réponse finale un bloc fenced `idea-memory` \
|
||||
avec `slug`, `title`, `type`, `description`, puis `---` et le corps Markdown. N'en écris pas \
|
||||
pour les tâches temporaires, logs, tickets, résultats transitoires ou données sensibles ; \
|
||||
réutilise le même `slug` pour remplacer une note existante.\n\n"
|
||||
}
|
||||
|
||||
/// Renders a [`MemoryType`] as its stable lowercase label for the convention-file
|
||||
/// memory section (`user`/`feedback`/`project`/`reference`).
|
||||
#[must_use]
|
||||
@ -2885,6 +2898,35 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compose_convention_file_carries_memory_harvest_directive_both_modes() {
|
||||
// The auto-memory harvest directive (Lot E1) is present in both MCP and
|
||||
// file-protocol modes, positioned right after the Skills awareness and
|
||||
// before the orchestration block's closing separator / project context.
|
||||
for mcp_enabled in [true, false] {
|
||||
let doc =
|
||||
compose_convention_file("/root", "ctx", "# Persona", &[], &[], None, mcp_enabled);
|
||||
assert!(
|
||||
doc.contains("**Mémoire IdeA**"),
|
||||
"memory harvest directive present (mcp_enabled={mcp_enabled})"
|
||||
);
|
||||
assert!(
|
||||
doc.contains("bloc fenced `idea-memory`")
|
||||
&& doc.contains("réutilise le même `slug`"),
|
||||
"directive carries the idea-memory wording (mcp_enabled={mcp_enabled})"
|
||||
);
|
||||
// Position: after Skills awareness, before the project-context section.
|
||||
let skills_at = doc.find("**Skills IdeA**").unwrap();
|
||||
let memory_at = doc.find("**Mémoire IdeA**").unwrap();
|
||||
let context_at = doc.find("# Contexte projet").unwrap();
|
||||
assert!(
|
||||
skills_at < memory_at && memory_at < context_at,
|
||||
"order: skills awareness -> memory directive -> project context \
|
||||
(mcp_enabled={mcp_enabled})"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compose_convention_file_skill_awareness_uses_mcp_or_file_creation_path() {
|
||||
let mcp_doc = compose_convention_file("/root", "", "# Persona", &[], &[], None, true);
|
||||
|
||||
@ -74,10 +74,11 @@ pub use layout::{
|
||||
};
|
||||
pub use memory::{
|
||||
CreateMemory, CreateMemoryInput, CreateMemoryOutput, DeleteMemory, DeleteMemoryInput,
|
||||
GetMemory, GetMemoryInput, GetMemoryOutput, ListMemories, ListMemoriesInput,
|
||||
ListMemoriesOutput, ReadMemoryIndex, ReadMemoryIndexInput, ReadMemoryIndexOutput, RecallMemory,
|
||||
RecallMemoryInput, RecallMemoryOutput, ResolveMemoryLinks, ResolveMemoryLinksInput,
|
||||
ResolveMemoryLinksOutput, UpdateMemory, UpdateMemoryInput, UpdateMemoryOutput,
|
||||
GetMemory, GetMemoryInput, GetMemoryOutput, HarvestMemoryFromTurn, ListMemories,
|
||||
ListMemoriesInput, ListMemoriesOutput, MemoryHarvestOutcome, ReadMemoryIndex,
|
||||
ReadMemoryIndexInput, ReadMemoryIndexOutput, RecallMemory, RecallMemoryInput,
|
||||
RecallMemoryOutput, ResolveMemoryLinks, ResolveMemoryLinksInput, ResolveMemoryLinksOutput,
|
||||
UpdateMemory, UpdateMemoryInput, UpdateMemoryOutput,
|
||||
};
|
||||
pub use orchestrator::{
|
||||
ContextGuardUseCases, McpRuntimeProvider, OrchestratorOutcome, OrchestratorService,
|
||||
|
||||
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,
|
||||
|
||||
@ -31,6 +31,7 @@ use domain::{
|
||||
};
|
||||
|
||||
use crate::conversation::RecordTurn;
|
||||
use crate::memory::HarvestMemoryFromTurn;
|
||||
|
||||
use crate::agent::{
|
||||
drain_with_readiness, CreateAgentFromScratch, CreateAgentInput, LaunchAgent, LaunchAgentInput,
|
||||
@ -296,6 +297,12 @@ pub struct OrchestratorService {
|
||||
/// PTY/mailbox legacy (zéro régression pour les call sites/tests qui ne le branchent
|
||||
/// pas).
|
||||
structured: Option<Arc<StructuredSessions>>,
|
||||
/// Harvester d'auto-mémoire (Lot E1), branché **après** le checkpoint Response
|
||||
/// best-effort d'un `ask` réussi : parse les blocs ` ```idea-memory ` de la
|
||||
/// réponse et persiste les notes valides via le `MemoryStore`. Injecté via
|
||||
/// [`Self::with_memory_harvest`] ; `None` ⇒ aucun harvest (zéro régression). Un
|
||||
/// échec de harvest ne transforme **jamais** un succès de délégation en erreur.
|
||||
memory_harvest: Option<Arc<HarvestMemoryFromTurn>>,
|
||||
}
|
||||
|
||||
/// Bundle des quatre use cases C7 sous [`domain::fileguard::FileGuard`], injectés
|
||||
@ -359,6 +366,7 @@ impl OrchestratorService {
|
||||
record_turn: None,
|
||||
clock: None,
|
||||
structured: None,
|
||||
memory_harvest: None,
|
||||
}
|
||||
}
|
||||
|
||||
@ -456,6 +464,16 @@ impl OrchestratorService {
|
||||
self
|
||||
}
|
||||
|
||||
/// Branche le harvester d'auto-mémoire (Lot E1), appelé **après** le checkpoint
|
||||
/// `RecordTurn` de la réponse d'un `ask` réussi. `None` (défaut) ⇒ aucun harvest.
|
||||
/// Le `MemoryStore` prend le `root` par appel, donc un seul use case sert tous
|
||||
/// les projets (pas de provider par root nécessaire, contrairement au handoff).
|
||||
#[must_use]
|
||||
pub fn with_memory_harvest(mut self, harvester: Arc<HarvestMemoryFromTurn>) -> Self {
|
||||
self.memory_harvest = Some(harvester);
|
||||
self
|
||||
}
|
||||
|
||||
/// Persiste **best-effort** un tour (`Prompt`/`Response`) dans `conversation`.
|
||||
///
|
||||
/// No-op silencieux quand le provider/horloge ne sont pas câblés, quand le provider
|
||||
@ -492,6 +510,29 @@ impl OrchestratorService {
|
||||
let _ = record.record(conversation, turn).await;
|
||||
}
|
||||
|
||||
/// Récolte **best-effort** les notes d'auto-mémoire (Lot E1) d'une réponse.
|
||||
///
|
||||
/// No-op silencieux quand le harvester n'est pas câblé. Ne parse **que** le texte
|
||||
/// du tour courant (jamais le log complet) et n'agit que sur un `Response`. Tout
|
||||
/// échec parse/store reste dans l'`outcome` (jamais propagé) : un succès de
|
||||
/// délégation n'est **jamais** transformé en erreur. Branché **après** le
|
||||
/// checkpoint `RecordTurn` de la réponse (append/handoff déjà effectués).
|
||||
async fn harvest_memory_best_effort(&self, root: &ProjectPath, text: &str) {
|
||||
let Some(harvester) = &self.memory_harvest else {
|
||||
return;
|
||||
};
|
||||
let outcome = harvester.harvest(root, TurnRole::Response, text).await;
|
||||
if !outcome.saved.is_empty() || !outcome.errors.is_empty() {
|
||||
crate::diag!(
|
||||
"[harvest] auto-memory: found={} saved={} skipped={} errors={}",
|
||||
outcome.found,
|
||||
outcome.saved.len(),
|
||||
outcome.skipped,
|
||||
outcome.errors.len(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Dispatches a validated command against `project`.
|
||||
///
|
||||
/// # Errors
|
||||
@ -1000,6 +1041,11 @@ impl OrchestratorService {
|
||||
result.clone(),
|
||||
)
|
||||
.await;
|
||||
// Auto-memory harvest (Lot E1), APRÈS l'append/handoff de la réponse :
|
||||
// parse les blocs ` ```idea-memory ` et persiste les notes valides.
|
||||
// Best-effort strict — n'altère jamais ce succès de délégation.
|
||||
self.harvest_memory_best_effort(&project.root, &result)
|
||||
.await;
|
||||
// Succès : désarmer le garde AVANT de retourner. Le `mark_idle` propre
|
||||
// sur cette branche est porté par le médiateur (prompt-ready / idea_reply
|
||||
// qui a résolu le `pending`) ; on ne veut ni re-`cancel_head` un ticket
|
||||
@ -1153,6 +1199,10 @@ impl OrchestratorService {
|
||||
result.clone(),
|
||||
)
|
||||
.await;
|
||||
// Auto-memory harvest (Lot E1), après l'append/handoff de la réponse.
|
||||
// Best-effort strict — un échec n'altère jamais ce succès de délégation.
|
||||
self.harvest_memory_best_effort(&project.root, &result)
|
||||
.await;
|
||||
Ok(self.reply_outcome(agent_id, target, result))
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user