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

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