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