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:
2026-06-21 02:00:05 +02:00
parent 33e65dec74
commit b12081be18
14 changed files with 1155 additions and 20 deletions

View File

@ -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);

View File

@ -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,

View 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)
}
}

View File

@ -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,

View File

@ -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))
}