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:
@ -19,8 +19,8 @@ use application::{
|
||||
DeleteLayout, DeleteMemory, DeleteProfile, DeleteSkill, DeleteTemplate,
|
||||
DescribeEmbedderEngines, DetectAgentDrift, DetectProfiles, DismissEmbedderSuggestion,
|
||||
FirstRunState, GetMemory, GetProjectPermissions, GetProjectWorkState, GitBranches, GitCheckout,
|
||||
GitCommit, GitGraph, GitInit, GitLog, GitStage, GitStatus, GitUnstage, HealthUseCase,
|
||||
InspectConversation, LaunchAgent, LaunchAgentInput, ListAgents, ListAgentsInput,
|
||||
GitCommit, GitGraph, GitInit, GitLog, GitStage, GitStatus, GitUnstage, HarvestMemoryFromTurn,
|
||||
HealthUseCase, InspectConversation, LaunchAgent, LaunchAgentInput, ListAgents, ListAgentsInput,
|
||||
ListEmbedderProfiles, ListLayouts, ListMemories, ListProfiles, ListProjects,
|
||||
ListResumableAgents, ListSkills, ListTemplates, LiveAgentRegistry, LiveSessions, LoadLayout,
|
||||
McpRuntime, MoveTabToNewWindow, MutateLayout, OnnxModelView, OpenProject, OpenTerminal,
|
||||
@ -1181,7 +1181,15 @@ impl AppState {
|
||||
// ReadContext/ProposeContext/ReadMemory/WriteMemory derrière le garde partagé.
|
||||
// Sans ça, `require_context_guard()` reste `None` ⇒ les outils MCP
|
||||
// `idea_context_*`/`idea_memory_*` échouent pour tous les agents.
|
||||
.with_context_guard(context_guard),
|
||||
.with_context_guard(context_guard)
|
||||
// Auto-memory harvest (Lot E1) : après le checkpoint Response d'un `ask`
|
||||
// réussi, parse les blocs ` ```idea-memory ` de la réponse et persiste les
|
||||
// notes valides via le `MemoryStore` (root par appel). Best-effort strict :
|
||||
// un échec n'altère jamais la réponse/le ticket/le handoff.
|
||||
.with_memory_harvest(Arc::new(HarvestMemoryFromTurn::new(
|
||||
Arc::clone(&memory_store_port),
|
||||
Arc::clone(&events_port),
|
||||
))),
|
||||
// NB (régression corrigée) : on ne câble PAS `.with_structured(...)` ici.
|
||||
// Décision produit lot B-2 (« Option 1 Terminal + MCP », cf. construction
|
||||
// de `LaunchAgent` plus haut) : la fabrique structurée est décâblée, donc
|
||||
|
||||
@ -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))
|
||||
}
|
||||
|
||||
|
||||
242
crates/application/tests/memory_harvest.rs
Normal file
242
crates/application/tests/memory_harvest.rs
Normal file
@ -0,0 +1,242 @@
|
||||
//! Tests for the Lot E1 auto-memory harvest use case (`HarvestMemoryFromTurn`).
|
||||
//!
|
||||
//! Best-effort, port-faked: a `Response` turn carrying valid ` ```idea-memory `
|
||||
//! blocks is persisted + announced; non-`Response` turns are no-ops; invalid blocks
|
||||
//! are skipped individually; a store failure is reported, never propagated.
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use domain::conversation_log::TurnRole;
|
||||
use domain::events::DomainEvent;
|
||||
use domain::ports::{EventBus, EventStream, MemoryError, MemoryStore};
|
||||
use domain::{Memory, MemoryIndexEntry, MemoryLink, MemorySlug, ProjectPath};
|
||||
|
||||
use application::HarvestMemoryFromTurn;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fakes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// In-memory store keyed by slug, with an optional forced `save` failure.
|
||||
#[derive(Default)]
|
||||
struct FakeMemories {
|
||||
saved: Mutex<Vec<Memory>>,
|
||||
fail_save: bool,
|
||||
}
|
||||
|
||||
impl FakeMemories {
|
||||
fn failing() -> Self {
|
||||
Self {
|
||||
saved: Mutex::new(Vec::new()),
|
||||
fail_save: true,
|
||||
}
|
||||
}
|
||||
fn slugs(&self) -> Vec<String> {
|
||||
self.saved
|
||||
.lock()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|m| m.slug().as_str().to_owned())
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl MemoryStore for FakeMemories {
|
||||
async fn list(&self, _root: &ProjectPath) -> Result<Vec<Memory>, MemoryError> {
|
||||
Ok(self.saved.lock().unwrap().clone())
|
||||
}
|
||||
async fn get(&self, _root: &ProjectPath, slug: &MemorySlug) -> Result<Memory, MemoryError> {
|
||||
self.saved
|
||||
.lock()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|m| m.slug() == slug)
|
||||
.cloned()
|
||||
.ok_or(MemoryError::NotFound)
|
||||
}
|
||||
async fn save(&self, _root: &ProjectPath, memory: &Memory) -> Result<(), MemoryError> {
|
||||
if self.fail_save {
|
||||
return Err(MemoryError::Frontmatter("forced failure".to_owned()));
|
||||
}
|
||||
self.saved.lock().unwrap().push(memory.clone());
|
||||
Ok(())
|
||||
}
|
||||
async fn delete(&self, _root: &ProjectPath, _slug: &MemorySlug) -> Result<(), MemoryError> {
|
||||
Ok(())
|
||||
}
|
||||
async fn read_index(&self, _root: &ProjectPath) -> Result<Vec<MemoryIndexEntry>, MemoryError> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
async fn resolve_links(
|
||||
&self,
|
||||
_root: &ProjectPath,
|
||||
_slug: &MemorySlug,
|
||||
) -> Result<Vec<MemoryLink>, MemoryError> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default, Clone)]
|
||||
struct SpyBus(Arc<Mutex<Vec<DomainEvent>>>);
|
||||
impl SpyBus {
|
||||
fn saved_slugs(&self) -> Vec<String> {
|
||||
self.0
|
||||
.lock()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter_map(|e| match e {
|
||||
DomainEvent::MemorySaved { slug } => Some(slug.as_str().to_owned()),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
impl EventBus for SpyBus {
|
||||
fn publish(&self, event: DomainEvent) {
|
||||
self.0.lock().unwrap().push(event);
|
||||
}
|
||||
fn subscribe(&self) -> EventStream {
|
||||
Box::new(std::iter::empty())
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn root() -> ProjectPath {
|
||||
ProjectPath::new("/home/me/demo").unwrap()
|
||||
}
|
||||
|
||||
fn block(slug: &str, body: &str) -> String {
|
||||
format!(
|
||||
"```idea-memory\nslug: {slug}\ntitle: Title\ntype: project\ndescription: hook\n---\n{body}\n```"
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn ignores_non_response_turns() {
|
||||
let store = Arc::new(FakeMemories::default());
|
||||
let bus = Arc::new(SpyBus::default());
|
||||
let harvester = HarvestMemoryFromTurn::new(
|
||||
Arc::clone(&store) as Arc<dyn MemoryStore>,
|
||||
Arc::clone(&bus) as Arc<dyn EventBus>,
|
||||
);
|
||||
let text = block("note-a", "Body");
|
||||
|
||||
for role in [TurnRole::Prompt, TurnRole::ToolActivity] {
|
||||
let outcome = harvester.harvest(&root(), role, &text).await;
|
||||
assert_eq!(outcome.found, 0, "non-response turns are not parsed");
|
||||
assert!(outcome.saved.is_empty());
|
||||
}
|
||||
assert!(store.slugs().is_empty(), "nothing persisted");
|
||||
assert!(bus.saved_slugs().is_empty(), "nothing announced");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn response_with_valid_block_saves_and_announces() {
|
||||
let store = Arc::new(FakeMemories::default());
|
||||
let bus = Arc::new(SpyBus::default());
|
||||
let harvester = HarvestMemoryFromTurn::new(
|
||||
Arc::clone(&store) as Arc<dyn MemoryStore>,
|
||||
Arc::clone(&bus) as Arc<dyn EventBus>,
|
||||
);
|
||||
let text = format!(
|
||||
"Sure, noting that.\n\n{}",
|
||||
block("api-url", "Use the staging URL.")
|
||||
);
|
||||
|
||||
let outcome = harvester.harvest(&root(), TurnRole::Response, &text).await;
|
||||
|
||||
assert_eq!(outcome.found, 1);
|
||||
assert_eq!(outcome.saved.len(), 1);
|
||||
assert_eq!(outcome.saved[0].as_str(), "api-url");
|
||||
assert_eq!(outcome.skipped, 0);
|
||||
assert!(outcome.errors.is_empty());
|
||||
assert_eq!(store.slugs(), vec!["api-url"], "persisted via the store");
|
||||
assert_eq!(bus.saved_slugs(), vec!["api-url"], "MemorySaved announced");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn partial_invalid_blocks_are_skipped_individually() {
|
||||
let store = Arc::new(FakeMemories::default());
|
||||
let bus = Arc::new(SpyBus::default());
|
||||
let harvester = HarvestMemoryFromTurn::new(
|
||||
Arc::clone(&store) as Arc<dyn MemoryStore>,
|
||||
Arc::clone(&bus) as Arc<dyn EventBus>,
|
||||
);
|
||||
// One valid, one with an invalid slug.
|
||||
let text = format!(
|
||||
"{}\n{}",
|
||||
block("good-one", "Body"),
|
||||
block("Bad Slug", "Body")
|
||||
);
|
||||
|
||||
let outcome = harvester.harvest(&root(), TurnRole::Response, &text).await;
|
||||
|
||||
assert_eq!(outcome.found, 2);
|
||||
assert_eq!(outcome.saved.len(), 1, "only the valid one persisted");
|
||||
assert_eq!(outcome.saved[0].as_str(), "good-one");
|
||||
assert_eq!(outcome.skipped, 1);
|
||||
assert_eq!(outcome.errors.len(), 1);
|
||||
assert_eq!(store.slugs(), vec!["good-one"]);
|
||||
assert_eq!(bus.saved_slugs(), vec!["good-one"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn store_failure_is_reported_not_propagated() {
|
||||
let store = Arc::new(FakeMemories::failing());
|
||||
let bus = Arc::new(SpyBus::default());
|
||||
let harvester = HarvestMemoryFromTurn::new(
|
||||
Arc::clone(&store) as Arc<dyn MemoryStore>,
|
||||
Arc::clone(&bus) as Arc<dyn EventBus>,
|
||||
);
|
||||
let text = block("doomed", "Body");
|
||||
|
||||
// No panic, no Result — the call simply returns an outcome carrying the error.
|
||||
let outcome = harvester.harvest(&root(), TurnRole::Response, &text).await;
|
||||
|
||||
assert_eq!(outcome.found, 1);
|
||||
assert!(outcome.saved.is_empty(), "save failed ⇒ nothing saved");
|
||||
assert_eq!(outcome.skipped, 1);
|
||||
assert_eq!(outcome.errors.len(), 1);
|
||||
assert!(
|
||||
outcome.errors[0].contains("store failed"),
|
||||
"{:?}",
|
||||
outcome.errors
|
||||
);
|
||||
// A failed save must not announce a MemorySaved event.
|
||||
assert!(bus.saved_slugs().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn parses_only_current_text_no_full_log() {
|
||||
// The use case takes only the turn's `text`; it has no `ConversationLog`
|
||||
// dependency, so it structurally cannot read the full thread. We assert it
|
||||
// acts purely on the text passed in: a response with no directive saves
|
||||
// nothing even though the store already holds (unrelated) notes.
|
||||
let store = Arc::new(FakeMemories::default());
|
||||
let bus = Arc::new(SpyBus::default());
|
||||
let harvester = HarvestMemoryFromTurn::new(
|
||||
Arc::clone(&store) as Arc<dyn MemoryStore>,
|
||||
Arc::clone(&bus) as Arc<dyn EventBus>,
|
||||
);
|
||||
|
||||
let outcome = harvester
|
||||
.harvest(
|
||||
&root(),
|
||||
TurnRole::Response,
|
||||
"A plain reply with no directive.",
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(outcome.found, 0);
|
||||
assert!(outcome.saved.is_empty());
|
||||
assert!(store.slugs().is_empty());
|
||||
}
|
||||
@ -22,9 +22,9 @@ use domain::ids::{AgentId, NodeId, ProfileId, ProjectId};
|
||||
use domain::markdown::MarkdownDoc;
|
||||
use domain::ports::{
|
||||
AgentContextStore, AgentRuntime, ContextInjectionPlan, DirEntry, EventBus, EventStream,
|
||||
ExitStatus, FileSystem, FsError, IdGenerator, OutputStream, PreparedContext, ProfileStore,
|
||||
PtyError, PtyHandle, PtyPort, RemotePath, RuntimeError, SessionPlan, SkillStore, SpawnSpec,
|
||||
StoreError,
|
||||
ExitStatus, FileSystem, FsError, IdGenerator, MemoryError, MemoryStore, OutputStream,
|
||||
PreparedContext, ProfileStore, PtyError, PtyHandle, PtyPort, RemotePath, RuntimeError,
|
||||
SessionPlan, SkillStore, SpawnSpec, StoreError,
|
||||
};
|
||||
use domain::profile::{
|
||||
AgentProfile, ContextInjection, McpCapability, McpConfigStrategy, McpTransport,
|
||||
@ -34,12 +34,13 @@ use domain::project::{Project, ProjectPath};
|
||||
use domain::remote::RemoteRef;
|
||||
use domain::skill::{Skill, SkillScope};
|
||||
use domain::terminal::{SessionKind, TerminalSession};
|
||||
use domain::{Memory, MemoryIndexEntry, MemoryLink, MemorySlug};
|
||||
use domain::{OrchestratorCommand, OrchestratorRequest, PtySize, SessionId};
|
||||
use uuid::Uuid;
|
||||
|
||||
use application::{
|
||||
CloseTerminal, CreateAgentFromScratch, CreateSkill, LaunchAgent, ListAgents,
|
||||
OrchestratorService, TerminalSessions, UpdateAgentContext,
|
||||
CloseTerminal, CreateAgentFromScratch, CreateSkill, HarvestMemoryFromTurn, LaunchAgent,
|
||||
ListAgents, OrchestratorService, TerminalSessions, UpdateAgentContext,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -382,6 +383,54 @@ impl EventBus for SpyBus {
|
||||
}
|
||||
}
|
||||
|
||||
/// In-memory [`MemoryStore`] for the auto-memory harvest hook (Lot E1): records the
|
||||
/// saved slugs, with an optional forced `save` failure to prove harvest errors stay
|
||||
/// best-effort (never break the reply).
|
||||
#[derive(Default)]
|
||||
struct HarvestMemories {
|
||||
saved: Mutex<Vec<MemorySlug>>,
|
||||
fail_save: bool,
|
||||
}
|
||||
impl HarvestMemories {
|
||||
fn slugs(&self) -> Vec<String> {
|
||||
self.saved
|
||||
.lock()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|s| s.as_str().to_owned())
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
#[async_trait]
|
||||
impl MemoryStore for HarvestMemories {
|
||||
async fn list(&self, _root: &ProjectPath) -> Result<Vec<Memory>, MemoryError> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
async fn get(&self, _root: &ProjectPath, _slug: &MemorySlug) -> Result<Memory, MemoryError> {
|
||||
Err(MemoryError::NotFound)
|
||||
}
|
||||
async fn save(&self, _root: &ProjectPath, memory: &Memory) -> Result<(), MemoryError> {
|
||||
if self.fail_save {
|
||||
return Err(MemoryError::Frontmatter("forced failure".to_owned()));
|
||||
}
|
||||
self.saved.lock().unwrap().push(memory.slug().clone());
|
||||
Ok(())
|
||||
}
|
||||
async fn delete(&self, _root: &ProjectPath, _slug: &MemorySlug) -> Result<(), MemoryError> {
|
||||
Ok(())
|
||||
}
|
||||
async fn read_index(&self, _root: &ProjectPath) -> Result<Vec<MemoryIndexEntry>, MemoryError> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
async fn resolve_links(
|
||||
&self,
|
||||
_root: &ProjectPath,
|
||||
_slug: &MemorySlug,
|
||||
) -> Result<Vec<MemoryLink>, MemoryError> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
}
|
||||
|
||||
struct SeqIds(Mutex<u128>);
|
||||
impl SeqIds {
|
||||
fn new() -> Self {
|
||||
@ -1047,17 +1096,30 @@ struct AskFixture {
|
||||
/// The mediated inbox the service holds — lets a test observe `busy_state`
|
||||
/// transitions (e.g. an `idea_reply` flips the emitter back to Idle — lot C5).
|
||||
mediator: Arc<TestMediator>,
|
||||
/// The auto-memory store wired into the harvest hook (Lot E1): lets a test
|
||||
/// assert which notes a successful reply persisted.
|
||||
memories: Arc<HarvestMemories>,
|
||||
}
|
||||
|
||||
/// Builds an orchestrator wired with a real [`InMemoryMailbox`] + PTY (B-3) and a
|
||||
/// plain (non-structured) [`LaunchAgent`] — so a dead target is launched as a PTY,
|
||||
/// exactly like production after B-2.
|
||||
fn ask_fixture(contexts: FakeContexts) -> AskFixture {
|
||||
ask_fixture_with_memory(contexts, false)
|
||||
}
|
||||
|
||||
/// Like [`ask_fixture`] but lets a test force the auto-memory store to fail its
|
||||
/// `save`, proving the harvest stays best-effort (the reply still succeeds).
|
||||
fn ask_fixture_with_memory(contexts: FakeContexts, fail_memory: bool) -> AskFixture {
|
||||
let profiles = Arc::new(FakeProfiles::new(vec![claude_profile()]));
|
||||
let sessions = Arc::new(TerminalSessions::new());
|
||||
let pty = FakePty::new(sid(777));
|
||||
let bus = SpyBus::default();
|
||||
let mailbox = Arc::new(TestMailbox::new());
|
||||
let memories = Arc::new(HarvestMemories {
|
||||
saved: Mutex::new(Vec::new()),
|
||||
fail_save: fail_memory,
|
||||
});
|
||||
|
||||
let create = Arc::new(CreateAgentFromScratch::new(
|
||||
Arc::new(contexts.clone()),
|
||||
@ -1109,7 +1171,11 @@ fn ask_fixture(contexts: FakeContexts) -> AskFixture {
|
||||
Arc::clone(&mailbox) as Arc<dyn AgentMailbox>,
|
||||
)
|
||||
.with_conversations(conversations)
|
||||
.with_events(Arc::new(bus.clone())),
|
||||
.with_events(Arc::new(bus.clone()))
|
||||
.with_memory_harvest(Arc::new(HarvestMemoryFromTurn::new(
|
||||
Arc::clone(&memories) as Arc<dyn MemoryStore>,
|
||||
Arc::new(bus.clone()),
|
||||
))),
|
||||
);
|
||||
|
||||
AskFixture {
|
||||
@ -1119,6 +1185,7 @@ fn ask_fixture(contexts: FakeContexts) -> AskFixture {
|
||||
bus,
|
||||
pty,
|
||||
mediator,
|
||||
memories,
|
||||
}
|
||||
}
|
||||
|
||||
@ -1220,6 +1287,134 @@ async fn ask_live_pty_target_writes_task_and_returns_reply() {
|
||||
assert_eq!(fx.mailbox.pending(&aid(1)), 0, "ticket drained on reply");
|
||||
}
|
||||
|
||||
// --- Lot E1: auto-memory harvest on a successful ask reply -----------------
|
||||
|
||||
/// A reply carrying an `idea-memory` block is harvested **after** the reply
|
||||
/// resolves: the note is persisted via the store and a `MemorySaved` event fires.
|
||||
#[tokio::test]
|
||||
async fn ask_reply_with_directive_harvests_memory_after_reply() {
|
||||
let agent = scratch_agent(aid(1), "architect", "agents/architect.md");
|
||||
let fx = ask_fixture(FakeContexts::with_agent(&agent, "# persona"));
|
||||
seed_live_pty(&fx.sessions, aid(1), sid(800));
|
||||
|
||||
let svc = Arc::clone(&fx.service);
|
||||
let ask = tokio::spawn(async move { svc.dispatch(&project(), cmd(ASK_JSON)).await });
|
||||
await_until(|| fx.mailbox.pending(&aid(1)) == 1).await;
|
||||
|
||||
let reply = "Here is the answer.\n\n```idea-memory\nslug: section-17-rule\ntitle: §17 rule\ntype: project\ndescription: How §17 routing works\n---\nRoute structured asks straight to the session.\n```";
|
||||
fx.service
|
||||
.dispatch(&project(), reply_cmd(aid(1), reply))
|
||||
.await
|
||||
.expect("reply ok");
|
||||
|
||||
let out = timeout(TEST_GUARD, ask)
|
||||
.await
|
||||
.expect("ask completes")
|
||||
.expect("join ok")
|
||||
.expect("ask ok");
|
||||
// The reply is returned unchanged …
|
||||
assert_eq!(out.reply.as_deref(), Some(reply));
|
||||
// … and the embedded note was harvested into the store + announced.
|
||||
assert_eq!(fx.memories.slugs(), vec!["section-17-rule"]);
|
||||
let saved: Vec<_> = fx
|
||||
.bus
|
||||
.events()
|
||||
.into_iter()
|
||||
.filter_map(|e| match e {
|
||||
DomainEvent::MemorySaved { slug } => Some(slug.as_str().to_owned()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert!(
|
||||
saved.contains(&"section-17-rule".to_owned()),
|
||||
"MemorySaved announced, got {saved:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// A store failure during harvest must not break the reply: the ask still returns
|
||||
/// its content, and nothing is persisted.
|
||||
#[tokio::test]
|
||||
async fn ask_reply_harvest_store_failure_does_not_break_reply() {
|
||||
let agent = scratch_agent(aid(1), "architect", "agents/architect.md");
|
||||
let fx = ask_fixture_with_memory(FakeContexts::with_agent(&agent, "# persona"), true);
|
||||
seed_live_pty(&fx.sessions, aid(1), sid(800));
|
||||
|
||||
let svc = Arc::clone(&fx.service);
|
||||
let ask = tokio::spawn(async move { svc.dispatch(&project(), cmd(ASK_JSON)).await });
|
||||
await_until(|| fx.mailbox.pending(&aid(1)) == 1).await;
|
||||
|
||||
let reply = "Reply.\n\n```idea-memory\nslug: doomed-note\ntitle: T\ntype: project\ndescription: hook\n---\nBody.\n```";
|
||||
fx.service
|
||||
.dispatch(&project(), reply_cmd(aid(1), reply))
|
||||
.await
|
||||
.expect("reply ok despite failing memory store");
|
||||
|
||||
let out = timeout(TEST_GUARD, ask)
|
||||
.await
|
||||
.expect("ask completes")
|
||||
.expect("join ok")
|
||||
.expect("ask still ok despite harvest store failure");
|
||||
assert_eq!(
|
||||
out.reply.as_deref(),
|
||||
Some(reply),
|
||||
"reply unaffected by harvest"
|
||||
);
|
||||
assert!(
|
||||
fx.memories.slugs().is_empty(),
|
||||
"failed save persisted nothing"
|
||||
);
|
||||
}
|
||||
|
||||
/// A reply with no directive harvests nothing (and the reply is unchanged) — the
|
||||
/// common case must not touch the memory store.
|
||||
#[tokio::test]
|
||||
async fn ask_reply_without_directive_harvests_nothing() {
|
||||
let agent = scratch_agent(aid(1), "architect", "agents/architect.md");
|
||||
let fx = ask_fixture(FakeContexts::with_agent(&agent, "# persona"));
|
||||
seed_live_pty(&fx.sessions, aid(1), sid(800));
|
||||
|
||||
let svc = Arc::clone(&fx.service);
|
||||
let ask = tokio::spawn(async move { svc.dispatch(&project(), cmd(ASK_JSON)).await });
|
||||
await_until(|| fx.mailbox.pending(&aid(1)) == 1).await;
|
||||
|
||||
fx.service
|
||||
.dispatch(&project(), reply_cmd(aid(1), "Just a plain answer."))
|
||||
.await
|
||||
.expect("reply ok");
|
||||
|
||||
timeout(TEST_GUARD, ask)
|
||||
.await
|
||||
.expect("ask completes")
|
||||
.expect("join ok")
|
||||
.expect("ask ok");
|
||||
assert!(fx.memories.slugs().is_empty());
|
||||
}
|
||||
|
||||
/// A cancelled turn (head ticket dropped before any reply) yields no reply, so the
|
||||
/// harvest never runs — nothing is persisted even with a would-be directive pending.
|
||||
#[tokio::test]
|
||||
async fn ask_cancelled_turn_does_not_harvest() {
|
||||
let agent = scratch_agent(aid(1), "architect", "agents/architect.md");
|
||||
let fx = ask_fixture(FakeContexts::with_agent(&agent, "# persona"));
|
||||
seed_live_pty(&fx.sessions, aid(1), sid(800));
|
||||
|
||||
let svc = Arc::clone(&fx.service);
|
||||
let ask = tokio::spawn(async move { svc.dispatch(&project(), cmd(ASK_JSON)).await });
|
||||
await_until(|| fx.mailbox.pending(&aid(1)) == 1).await;
|
||||
|
||||
// Cancel the head ticket (drops the reply sender) ⇒ the ask resolves as a
|
||||
// channel-closed error: no Response turn, hence no harvest.
|
||||
let ticket = fx.mailbox.ticket_ids(&aid(1))[0];
|
||||
fx.mailbox.cancel_head(aid(1), ticket);
|
||||
|
||||
let out = timeout(TEST_GUARD, ask)
|
||||
.await
|
||||
.expect("ask completes")
|
||||
.expect("join ok");
|
||||
assert!(out.is_err(), "cancelled turn ⇒ ask errors");
|
||||
assert!(fx.memories.slugs().is_empty(), "no harvest on cancel");
|
||||
}
|
||||
|
||||
/// Lot C5 — explicit OR signal: an `idea_reply` from the emitting agent flips it back
|
||||
/// to `Idle` (its FIFO can advance), independently of any prompt-ready pattern. Before
|
||||
/// the reply the agent is `Busy` on the delegated turn; after, it is `Idle`.
|
||||
@ -1639,9 +1834,6 @@ async fn ask_different_targets_run_in_parallel() {
|
||||
#[derive(Clone, Default)]
|
||||
struct CapturingFs(Arc<Mutex<Vec<(String, String)>>>);
|
||||
impl CapturingFs {
|
||||
fn writes(&self) -> Vec<(String, String)> {
|
||||
self.0.lock().unwrap().clone()
|
||||
}
|
||||
/// Contenu écrit dont le chemin se termine par `suffix` (ex. `.mcp.json`).
|
||||
fn content_ending_with(&self, suffix: &str) -> Option<String> {
|
||||
self.0
|
||||
@ -2733,6 +2925,9 @@ fn ask_fixture_with_record(
|
||||
bus,
|
||||
pty,
|
||||
mediator,
|
||||
// Harvest is intentionally not wired here: these fixtures exercise the
|
||||
// RecordTurn checkpoint in isolation (proving it stays unchanged).
|
||||
memories: Arc::new(HarvestMemories::default()),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -43,6 +43,7 @@ pub mod layout;
|
||||
pub mod mailbox;
|
||||
pub mod markdown;
|
||||
pub mod memory;
|
||||
pub mod memory_harvest;
|
||||
pub mod orchestrator;
|
||||
pub mod permission;
|
||||
pub mod ports;
|
||||
@ -112,6 +113,11 @@ pub use markdown::MarkdownDoc;
|
||||
|
||||
pub use memory::{Memory, MemoryFrontmatter, MemoryIndexEntry, MemoryLink, MemorySlug, MemoryType};
|
||||
|
||||
pub use memory_harvest::{
|
||||
parse_memory_directives, MemoryHarvestDirective, MemoryHarvestReport, MAX_BLOCKS,
|
||||
MAX_BLOCK_BYTES, MAX_DESCRIPTION_CHARS,
|
||||
};
|
||||
|
||||
pub use remote::{RemoteKind, RemoteRef, SshAuth};
|
||||
|
||||
pub use terminal::{PtySize, SessionKind, SessionStatus, TerminalSession};
|
||||
|
||||
411
crates/domain/src/memory_harvest.rs
Normal file
411
crates/domain/src/memory_harvest.rs
Normal file
@ -0,0 +1,411 @@
|
||||
//! Auto-memory harvest parser (Lot E1) — pure domain.
|
||||
//!
|
||||
//! When an agent ends a `Response` turn it may embed one or more fenced
|
||||
//! ` ```idea-memory ` blocks asking IdeA to persist a memory note. This module
|
||||
//! owns the **pure** parsing of that directive: it turns the raw turn text into a
|
||||
//! [`MemoryHarvestReport`] of validated [`MemoryHarvestDirective`]s plus per-block
|
||||
//! parse errors. It performs no I/O and never fails as a whole — invalid blocks
|
||||
//! are skipped individually so the valid ones can still be persisted by the
|
||||
//! application layer (best-effort harvest).
|
||||
//!
|
||||
//! ## Directive format
|
||||
//!
|
||||
//! ````markdown
|
||||
//! ```idea-memory
|
||||
//! slug: kebab-case-slug
|
||||
//! title: Human title
|
||||
//! type: project|reference|feedback|user
|
||||
//! description: One-line recall hook
|
||||
//! ---
|
||||
//! Markdown body to persist.
|
||||
//! ```
|
||||
//! ````
|
||||
//!
|
||||
//! `slug`, `title`, `type`, `description` are mandatory frontmatter keys; the
|
||||
//! `---` line separates them from a non-empty Markdown body (preserved verbatim
|
||||
//! save for outer whitespace). Fences with any other info string are ignored.
|
||||
|
||||
use crate::markdown::MarkdownDoc;
|
||||
use crate::memory::{MemorySlug, MemoryType};
|
||||
|
||||
/// The fence info string that marks a harvest directive block.
|
||||
const FENCE_INFO: &str = "idea-memory";
|
||||
|
||||
/// Maximum number of directive blocks honoured per response. Extra blocks are
|
||||
/// reported as errors and skipped (a single response should not dump dozens of
|
||||
/// notes).
|
||||
pub const MAX_BLOCKS: usize = 3;
|
||||
|
||||
/// Maximum raw size (bytes) of a single directive block's inner content.
|
||||
pub const MAX_BLOCK_BYTES: usize = 8 * 1024;
|
||||
|
||||
/// Maximum length (characters) of a directive's `description` (the index hook).
|
||||
pub const MAX_DESCRIPTION_CHARS: usize = 200;
|
||||
|
||||
/// A validated memory-harvest directive parsed from one ` ```idea-memory ` block.
|
||||
///
|
||||
/// Pure value object. Note that `title` is captured (it is a mandatory key) even
|
||||
/// though the persisted [`crate::memory::Memory`] entity has no dedicated title
|
||||
/// field today — the application maps the rest onto a `Memory` and the title is
|
||||
/// kept here for callers/forward use.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct MemoryHarvestDirective {
|
||||
/// Validated kebab-case slug (the note identity / file stem).
|
||||
pub slug: MemorySlug,
|
||||
/// Human-readable title (mandatory in the directive).
|
||||
pub title: String,
|
||||
/// The note kind.
|
||||
pub r#type: MemoryType,
|
||||
/// One-line recall hook (bounded, non-empty).
|
||||
pub description: String,
|
||||
/// The Markdown body to persist (non-empty).
|
||||
pub body: MarkdownDoc,
|
||||
}
|
||||
|
||||
/// Outcome of parsing a turn's text for harvest directives.
|
||||
///
|
||||
/// `found` counts every ` ```idea-memory ` fence detected (valid or not), so the
|
||||
/// application can report how many directives the agent emitted versus how many
|
||||
/// were usable. `directives` are the valid ones (in document order); `errors`
|
||||
/// carries one human-readable message per skipped block.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
pub struct MemoryHarvestReport {
|
||||
/// Number of `idea-memory` fences detected in the text.
|
||||
pub found: usize,
|
||||
/// Successfully parsed, validated directives (document order).
|
||||
pub directives: Vec<MemoryHarvestDirective>,
|
||||
/// One message per skipped (invalid / over-limit) block.
|
||||
pub errors: Vec<String>,
|
||||
}
|
||||
|
||||
/// Parses `text` for ` ```idea-memory ` directive blocks, best-effort.
|
||||
///
|
||||
/// Never fails: malformed blocks land in [`MemoryHarvestReport::errors`] and valid
|
||||
/// ones in [`MemoryHarvestReport::directives`]. Fences with a different info string
|
||||
/// are skipped entirely. At most [`MAX_BLOCKS`] blocks are honoured; further blocks
|
||||
/// are reported as errors.
|
||||
#[must_use]
|
||||
pub fn parse_memory_directives(text: &str) -> MemoryHarvestReport {
|
||||
let mut report = MemoryHarvestReport::default();
|
||||
let lines: Vec<&str> = text.lines().collect();
|
||||
let mut i = 0;
|
||||
while i < lines.len() {
|
||||
let line = lines[i];
|
||||
let Some(info) = fence_info(line) else {
|
||||
i += 1;
|
||||
continue;
|
||||
};
|
||||
// A fenced block opens here. Find its closing fence (a line whose trimmed
|
||||
// content is exactly ```), tracking the inner content lines.
|
||||
let mut j = i + 1;
|
||||
while j < lines.len() && lines[j].trim_end() != "```" {
|
||||
j += 1;
|
||||
}
|
||||
let inner = if j <= lines.len() {
|
||||
lines[i + 1..j.min(lines.len())].join("\n")
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
// Advance past the closing fence (or to EOF for an unterminated block).
|
||||
let next = if j < lines.len() { j + 1 } else { lines.len() };
|
||||
|
||||
if info == FENCE_INFO {
|
||||
report.found += 1;
|
||||
if report.found > MAX_BLOCKS {
|
||||
report.errors.push(format!(
|
||||
"skipped idea-memory block #{}: exceeds the max of {MAX_BLOCKS} blocks per response",
|
||||
report.found
|
||||
));
|
||||
} else {
|
||||
match parse_block(&inner) {
|
||||
Ok(directive) => report.directives.push(directive),
|
||||
Err(err) => report.errors.push(err),
|
||||
}
|
||||
}
|
||||
}
|
||||
i = next;
|
||||
}
|
||||
report
|
||||
}
|
||||
|
||||
/// Returns the (trimmed) info string of a fence opener line, or `None` if `line`
|
||||
/// is not a ```` ``` ```` fence opener.
|
||||
fn fence_info(line: &str) -> Option<&str> {
|
||||
let trimmed = line.trim();
|
||||
let rest = trimmed.strip_prefix("```")?;
|
||||
// A bare ``` (no info string) is a generic fence opener, not ours.
|
||||
Some(rest.trim())
|
||||
}
|
||||
|
||||
/// Parses one block's inner content into a validated directive, or an error
|
||||
/// message describing why it was skipped.
|
||||
fn parse_block(inner: &str) -> Result<MemoryHarvestDirective, String> {
|
||||
if inner.len() > MAX_BLOCK_BYTES {
|
||||
return Err(format!(
|
||||
"block exceeds the max size of {MAX_BLOCK_BYTES} bytes ({} bytes)",
|
||||
inner.len()
|
||||
));
|
||||
}
|
||||
|
||||
// Split frontmatter (until a `---` line) from the body.
|
||||
let mut sep = None;
|
||||
for (idx, line) in inner.lines().enumerate() {
|
||||
if line.trim() == "---" {
|
||||
sep = Some(idx);
|
||||
break;
|
||||
}
|
||||
}
|
||||
let Some(sep) = sep else {
|
||||
return Err("missing '---' separator between frontmatter and body".to_owned());
|
||||
};
|
||||
let block_lines: Vec<&str> = inner.lines().collect();
|
||||
let frontmatter = &block_lines[..sep];
|
||||
let body_raw = block_lines[sep + 1..].join("\n");
|
||||
|
||||
// Collect the known frontmatter keys (unknown keys are ignored leniently).
|
||||
let mut slug_raw = None;
|
||||
let mut title = None;
|
||||
let mut type_raw = None;
|
||||
let mut description = None;
|
||||
for line in frontmatter {
|
||||
let Some((key, value)) = line.split_once(':') else {
|
||||
continue;
|
||||
};
|
||||
let value = value.trim().to_owned();
|
||||
match key.trim() {
|
||||
"slug" => slug_raw = Some(value),
|
||||
"title" => title = Some(value),
|
||||
"type" => type_raw = Some(value),
|
||||
"description" => description = Some(value),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
let slug_raw = required(slug_raw, "slug")?;
|
||||
let title = required(title, "title")?;
|
||||
let type_raw = required(type_raw, "type")?;
|
||||
let description = required(description, "description")?;
|
||||
|
||||
let slug = MemorySlug::new(&slug_raw).map_err(|_| format!("invalid slug: {slug_raw:?}"))?;
|
||||
let r#type = parse_type(&type_raw)?;
|
||||
|
||||
if description.chars().count() > MAX_DESCRIPTION_CHARS {
|
||||
return Err(format!(
|
||||
"description exceeds {MAX_DESCRIPTION_CHARS} characters"
|
||||
));
|
||||
}
|
||||
|
||||
let body = body_raw.trim();
|
||||
if body.is_empty() {
|
||||
return Err("empty body".to_owned());
|
||||
}
|
||||
|
||||
Ok(MemoryHarvestDirective {
|
||||
slug,
|
||||
title,
|
||||
r#type,
|
||||
description,
|
||||
body: MarkdownDoc::new(body.to_owned()),
|
||||
})
|
||||
}
|
||||
|
||||
/// Requires a non-empty frontmatter value for `field`.
|
||||
fn required(value: Option<String>, field: &str) -> Result<String, String> {
|
||||
match value {
|
||||
Some(v) if !v.trim().is_empty() => Ok(v.trim().to_owned()),
|
||||
Some(_) => Err(format!("empty required field: {field}")),
|
||||
None => Err(format!("missing required field: {field}")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Maps the `type` value to a [`MemoryType`].
|
||||
fn parse_type(raw: &str) -> Result<MemoryType, String> {
|
||||
match raw {
|
||||
"project" => Ok(MemoryType::Project),
|
||||
"reference" => Ok(MemoryType::Reference),
|
||||
"feedback" => Ok(MemoryType::Feedback),
|
||||
"user" => Ok(MemoryType::User),
|
||||
other => Err(format!("invalid type: {other:?}")),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn block(slug: &str, title: &str, ty: &str, desc: &str, body: &str) -> String {
|
||||
format!(
|
||||
"```idea-memory\nslug: {slug}\ntitle: {title}\ntype: {ty}\ndescription: {desc}\n---\n{body}\n```"
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_a_single_valid_block() {
|
||||
let text = format!(
|
||||
"Here is what I learned.\n\n{}\n\nDone.",
|
||||
block(
|
||||
"api-base-url",
|
||||
"API base URL",
|
||||
"project",
|
||||
"Where the API lives",
|
||||
"Use `https://api`."
|
||||
)
|
||||
);
|
||||
let report = parse_memory_directives(&text);
|
||||
assert_eq!(report.found, 1);
|
||||
assert!(report.errors.is_empty(), "{:?}", report.errors);
|
||||
assert_eq!(report.directives.len(), 1);
|
||||
let d = &report.directives[0];
|
||||
assert_eq!(d.slug.as_str(), "api-base-url");
|
||||
assert_eq!(d.title, "API base URL");
|
||||
assert_eq!(d.r#type, MemoryType::Project);
|
||||
assert_eq!(d.description, "Where the API lives");
|
||||
assert_eq!(d.body.as_str(), "Use `https://api`.");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_multiple_valid_blocks_in_order() {
|
||||
let text = format!(
|
||||
"{}\n{}",
|
||||
block("first-note", "First", "project", "one", "Body one"),
|
||||
block("second-note", "Second", "reference", "two", "Body two")
|
||||
);
|
||||
let report = parse_memory_directives(&text);
|
||||
assert_eq!(report.found, 2);
|
||||
assert_eq!(report.directives.len(), 2);
|
||||
assert_eq!(report.directives[0].slug.as_str(), "first-note");
|
||||
assert_eq!(report.directives[1].slug.as_str(), "second-note");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_slug_is_skipped_with_error() {
|
||||
let text = block("Not Kebab!", "Bad", "project", "hook", "Body");
|
||||
let report = parse_memory_directives(&text);
|
||||
assert_eq!(report.found, 1);
|
||||
assert!(report.directives.is_empty());
|
||||
assert_eq!(report.errors.len(), 1);
|
||||
assert!(
|
||||
report.errors[0].contains("invalid slug"),
|
||||
"{:?}",
|
||||
report.errors
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_body_is_skipped() {
|
||||
let text = block("ok-slug", "Title", "project", "hook", " ");
|
||||
let report = parse_memory_directives(&text);
|
||||
assert_eq!(report.found, 1);
|
||||
assert!(report.directives.is_empty());
|
||||
assert!(report.errors[0].contains("empty body"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_required_field_is_skipped() {
|
||||
// No `title:` key.
|
||||
let text =
|
||||
"```idea-memory\nslug: ok-slug\ntype: project\ndescription: hook\n---\nBody\n```";
|
||||
let report = parse_memory_directives(text);
|
||||
assert_eq!(report.found, 1);
|
||||
assert!(report.directives.is_empty());
|
||||
assert!(report.errors[0].contains("title"), "{:?}", report.errors);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_type_is_skipped() {
|
||||
let text = block("ok-slug", "Title", "wisdom", "hook", "Body");
|
||||
let report = parse_memory_directives(&text);
|
||||
assert!(report.directives.is_empty());
|
||||
assert!(report.errors[0].contains("invalid type"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_separator_is_skipped() {
|
||||
let text = "```idea-memory\nslug: ok-slug\ntitle: T\ntype: project\ndescription: hook\nBody without separator\n```";
|
||||
let report = parse_memory_directives(text);
|
||||
assert_eq!(report.found, 1);
|
||||
assert!(report.directives.is_empty());
|
||||
assert!(
|
||||
report.errors[0].contains("separator"),
|
||||
"{:?}",
|
||||
report.errors
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn caps_at_max_blocks() {
|
||||
let mut text = String::new();
|
||||
for n in 0..(MAX_BLOCKS + 2) {
|
||||
text.push_str(&block(&format!("note-{n}"), "T", "project", "hook", "Body"));
|
||||
text.push('\n');
|
||||
}
|
||||
let report = parse_memory_directives(&text);
|
||||
assert_eq!(report.found, MAX_BLOCKS + 2);
|
||||
assert_eq!(
|
||||
report.directives.len(),
|
||||
MAX_BLOCKS,
|
||||
"only the first MAX_BLOCKS honoured"
|
||||
);
|
||||
assert_eq!(report.errors.len(), 2, "the surplus blocks are reported");
|
||||
assert!(report.errors[0].contains("exceeds the max"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oversize_block_is_skipped() {
|
||||
let huge_body = "x".repeat(MAX_BLOCK_BYTES + 1);
|
||||
let text = block("big-note", "T", "project", "hook", &huge_body);
|
||||
let report = parse_memory_directives(&text);
|
||||
assert_eq!(report.found, 1);
|
||||
assert!(report.directives.is_empty());
|
||||
assert!(report.errors[0].contains("max size"), "{:?}", report.errors);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn over_long_description_is_skipped() {
|
||||
let long_desc = "d".repeat(MAX_DESCRIPTION_CHARS + 1);
|
||||
let text = block("ok-slug", "T", "project", &long_desc, "Body");
|
||||
let report = parse_memory_directives(&text);
|
||||
assert!(report.directives.is_empty());
|
||||
assert!(report.errors[0].contains("description exceeds"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_idea_memory_fences_are_ignored() {
|
||||
let text = "```rust\nslug: looks-like-one\ntitle: T\ntype: project\ndescription: hook\n---\nfn main() {}\n```\n```\nplain\n```";
|
||||
let report = parse_memory_directives(text);
|
||||
assert_eq!(report.found, 0);
|
||||
assert!(report.directives.is_empty());
|
||||
assert!(report.errors.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn markdown_body_is_preserved() {
|
||||
let body = "# Heading\n\n- bullet **bold**\n- second\n\n1. step\n\n`inline code`";
|
||||
let text = block("md-note", "Markdown", "reference", "hook", body);
|
||||
let report = parse_memory_directives(&text);
|
||||
assert_eq!(report.directives.len(), 1, "{:?}", report.errors);
|
||||
assert_eq!(report.directives[0].body.as_str(), body);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_text_yields_empty_report() {
|
||||
let report = parse_memory_directives("");
|
||||
assert_eq!(report.found, 0);
|
||||
assert!(report.directives.is_empty());
|
||||
assert!(report.errors.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn valid_and_invalid_blocks_coexist() {
|
||||
let text = format!(
|
||||
"{}\n{}",
|
||||
block("good-one", "Good", "project", "hook", "Body"),
|
||||
block("Bad Slug", "Bad", "project", "hook", "Body")
|
||||
);
|
||||
let report = parse_memory_directives(&text);
|
||||
assert_eq!(report.found, 2);
|
||||
assert_eq!(report.directives.len(), 1);
|
||||
assert_eq!(report.directives[0].slug.as_str(), "good-one");
|
||||
assert_eq!(report.errors.len(), 1);
|
||||
}
|
||||
}
|
||||
@ -12,7 +12,7 @@ import { createMockGateways, MockSystemGateway } from "./index";
|
||||
const gateways: Gateways = createMockGateways();
|
||||
|
||||
describe("createMockGateways", () => {
|
||||
it("exposes all fourteen gateways", () => {
|
||||
it("exposes all gateways", () => {
|
||||
expect(Object.keys(gateways).sort()).toEqual([
|
||||
"agent",
|
||||
"embedder",
|
||||
@ -28,6 +28,7 @@ describe("createMockGateways", () => {
|
||||
"system",
|
||||
"template",
|
||||
"terminal",
|
||||
"workState",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@ -91,6 +91,7 @@ export type DomainEvent =
|
||||
| { type: "layoutChanged"; projectId: string }
|
||||
| { type: "remoteConnected"; projectId: string }
|
||||
| { type: "gitStateChanged"; projectId: string }
|
||||
| { type: "memorySaved"; slug: string }
|
||||
| {
|
||||
type: "orchestratorRequestProcessed";
|
||||
requesterId: string;
|
||||
|
||||
@ -18,7 +18,11 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, waitFor, fireEvent } from "@testing-library/react";
|
||||
|
||||
import { MockMemoryGateway, MockEmbedderGateway } from "@/adapters/mock";
|
||||
import {
|
||||
MockMemoryGateway,
|
||||
MockEmbedderGateway,
|
||||
MockSystemGateway,
|
||||
} from "@/adapters/mock";
|
||||
import type { GatewayError } from "@/domain";
|
||||
import type { Gateways } from "@/ports";
|
||||
import { DIProvider } from "@/app/di";
|
||||
@ -26,14 +30,19 @@ import { MemoryPanel } from "./MemoryPanel";
|
||||
|
||||
const PROJECT_ID = "proj-memory-test";
|
||||
|
||||
function renderMemoryPanel(memory?: MockMemoryGateway) {
|
||||
function renderMemoryPanel(
|
||||
memory?: MockMemoryGateway,
|
||||
system: MockSystemGateway = new MockSystemGateway(),
|
||||
) {
|
||||
const mem = memory ?? new MockMemoryGateway();
|
||||
const gateways = {
|
||||
memory: mem,
|
||||
embedder: new MockEmbedderGateway(),
|
||||
system,
|
||||
} as unknown as Gateways;
|
||||
return {
|
||||
memory: mem,
|
||||
system,
|
||||
...render(
|
||||
<DIProvider gateways={gateways}>
|
||||
<MemoryPanel projectId={PROJECT_ID} />
|
||||
@ -240,6 +249,40 @@ describe("MemoryPanel (with MockMemoryGateway)", () => {
|
||||
const alert = await screen.findByRole("alert");
|
||||
expect(alert.textContent).toMatch(/boom from gateway/i);
|
||||
});
|
||||
|
||||
it("refreshes the index when a memorySaved domain event fires", async () => {
|
||||
const memory = new MockMemoryGateway();
|
||||
const system = new MockSystemGateway();
|
||||
const spy = vi.spyOn(memory, "readIndex");
|
||||
renderMemoryPanel(memory, system);
|
||||
await waitForMemoryIdle();
|
||||
spy.mockClear();
|
||||
|
||||
await memory.createMemory({
|
||||
projectId: PROJECT_ID,
|
||||
name: "Harvested Note",
|
||||
description: "created by idea-memory",
|
||||
type: "project",
|
||||
content: "harvested body",
|
||||
});
|
||||
system.emit({ type: "memorySaved", slug: "harvested-note" });
|
||||
|
||||
expect(await screen.findByText("Harvested Note")).toBeTruthy();
|
||||
expect(spy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not refresh the index for unrelated domain events", async () => {
|
||||
const memory = new MockMemoryGateway();
|
||||
const system = new MockSystemGateway();
|
||||
const spy = vi.spyOn(memory, "readIndex");
|
||||
renderMemoryPanel(memory, system);
|
||||
await waitForMemoryIdle();
|
||||
spy.mockClear();
|
||||
|
||||
system.emit({ type: "projectCreated", projectId: "other-project" });
|
||||
|
||||
await waitFor(() => expect(spy).not.toHaveBeenCalled());
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@ -46,7 +46,7 @@ function describe(e: unknown): string {
|
||||
}
|
||||
|
||||
export function useMemory(projectId: string): MemoryViewModel {
|
||||
const { memory } = useGateways();
|
||||
const { memory, system } = useGateways();
|
||||
|
||||
const [entries, setEntries] = useState<MemoryIndexEntry[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@ -69,6 +69,24 @@ export function useMemory(projectId: string): MemoryViewModel {
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!system) return;
|
||||
let unsubscribe: (() => void) | undefined;
|
||||
let cancelled = false;
|
||||
void system.onDomainEvent((event) => {
|
||||
if (event.type === "memorySaved") {
|
||||
void refresh();
|
||||
}
|
||||
}).then((u) => {
|
||||
if (cancelled) u();
|
||||
else unsubscribe = u;
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
unsubscribe?.();
|
||||
};
|
||||
}, [refresh, system]);
|
||||
|
||||
const createMemory = useCallback(
|
||||
async (input: Omit<CreateMemoryInput, "projectId">) => {
|
||||
setBusy(true);
|
||||
|
||||
Reference in New Issue
Block a user