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>
115 lines
4.5 KiB
Rust
115 lines
4.5 KiB
Rust
//! 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)
|
|
}
|
|
}
|