From b12081be187cb6c88995dca9cfcbcdde62852e46 Mon Sep 17 00:00:00 2001 From: Blomios Date: Sun, 21 Jun 2026 02:00:05 +0200 Subject: [PATCH 1/2] =?UTF-8?q?feat(memory):=20r=C3=A9colte=20automatique?= =?UTF-8?q?=20contr=C3=B4l=C3=A9e=20de=20la=20m=C3=A9moire=20(Lot=20E1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- crates/app-tauri/src/state.rs | 14 +- crates/application/src/agent/lifecycle.rs | 42 ++ crates/application/src/lib.rs | 9 +- crates/application/src/memory/harvest.rs | 114 +++++ crates/application/src/memory/mod.rs | 3 + .../application/src/orchestrator/service.rs | 50 +++ crates/application/tests/memory_harvest.rs | 242 +++++++++++ .../application/tests/orchestrator_service.rs | 213 ++++++++- crates/domain/src/lib.rs | 6 + crates/domain/src/memory_harvest.rs | 411 ++++++++++++++++++ frontend/src/adapters/mock/mock.test.ts | 3 +- frontend/src/domain/index.ts | 1 + frontend/src/features/memory/memory.test.tsx | 47 +- frontend/src/features/memory/useMemory.ts | 20 +- 14 files changed, 1155 insertions(+), 20 deletions(-) create mode 100644 crates/application/src/memory/harvest.rs create mode 100644 crates/application/tests/memory_harvest.rs create mode 100644 crates/domain/src/memory_harvest.rs diff --git a/crates/app-tauri/src/state.rs b/crates/app-tauri/src/state.rs index 1d52a14..781b107 100644 --- a/crates/app-tauri/src/state.rs +++ b/crates/app-tauri/src/state.rs @@ -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 diff --git a/crates/application/src/agent/lifecycle.rs b/crates/application/src/agent/lifecycle.rs index 3e44839..c038434 100644 --- a/crates/application/src/agent/lifecycle.rs +++ b/crates/application/src/agent/lifecycle.rs @@ -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); diff --git a/crates/application/src/lib.rs b/crates/application/src/lib.rs index f72b0d3..9a34bf1 100644 --- a/crates/application/src/lib.rs +++ b/crates/application/src/lib.rs @@ -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, diff --git a/crates/application/src/memory/harvest.rs b/crates/application/src/memory/harvest.rs new file mode 100644 index 0000000..b1782e2 --- /dev/null +++ b/crates/application/src/memory/harvest.rs @@ -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, + /// Number of directives dropped (invalid, over-limit, or store failure). + pub skipped: usize, + /// One message per dropped directive (parse + persistence reasons). + pub errors: Vec, +} + +/// 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, + events: Arc, +} + +impl HarvestMemoryFromTurn { + /// Builds the use case from its ports. + #[must_use] + pub fn new(memories: Arc, events: Arc) -> 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 { + 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) + } +} diff --git a/crates/application/src/memory/mod.rs b/crates/application/src/memory/mod.rs index fa8996f..aa8f221 100644 --- a/crates/application/src/memory/mod.rs +++ b/crates/application/src/memory/mod.rs @@ -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, diff --git a/crates/application/src/orchestrator/service.rs b/crates/application/src/orchestrator/service.rs index e30de66..e267673 100644 --- a/crates/application/src/orchestrator/service.rs +++ b/crates/application/src/orchestrator/service.rs @@ -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>, + /// 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>, } /// 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) -> 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)) } diff --git a/crates/application/tests/memory_harvest.rs b/crates/application/tests/memory_harvest.rs new file mode 100644 index 0000000..c2c2122 --- /dev/null +++ b/crates/application/tests/memory_harvest.rs @@ -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>, + fail_save: bool, +} + +impl FakeMemories { + fn failing() -> Self { + Self { + saved: Mutex::new(Vec::new()), + fail_save: true, + } + } + fn slugs(&self) -> Vec { + 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, MemoryError> { + Ok(self.saved.lock().unwrap().clone()) + } + async fn get(&self, _root: &ProjectPath, slug: &MemorySlug) -> Result { + 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, MemoryError> { + Ok(Vec::new()) + } + async fn resolve_links( + &self, + _root: &ProjectPath, + _slug: &MemorySlug, + ) -> Result, MemoryError> { + Ok(Vec::new()) + } +} + +#[derive(Default, Clone)] +struct SpyBus(Arc>>); +impl SpyBus { + fn saved_slugs(&self) -> Vec { + 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, + Arc::clone(&bus) as Arc, + ); + 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, + Arc::clone(&bus) as Arc, + ); + 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, + Arc::clone(&bus) as Arc, + ); + // 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, + Arc::clone(&bus) as Arc, + ); + 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, + Arc::clone(&bus) as Arc, + ); + + 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()); +} diff --git a/crates/application/tests/orchestrator_service.rs b/crates/application/tests/orchestrator_service.rs index b966d76..a36c3c2 100644 --- a/crates/application/tests/orchestrator_service.rs +++ b/crates/application/tests/orchestrator_service.rs @@ -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>, + fail_save: bool, +} +impl HarvestMemories { + fn slugs(&self) -> Vec { + 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, MemoryError> { + Ok(Vec::new()) + } + async fn get(&self, _root: &ProjectPath, _slug: &MemorySlug) -> Result { + 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, MemoryError> { + Ok(Vec::new()) + } + async fn resolve_links( + &self, + _root: &ProjectPath, + _slug: &MemorySlug, + ) -> Result, MemoryError> { + Ok(Vec::new()) + } +} + struct SeqIds(Mutex); 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, + /// The auto-memory store wired into the harvest hook (Lot E1): lets a test + /// assert which notes a successful reply persisted. + memories: Arc, } /// 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, ) .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, + 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>>); 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 { 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()), } } diff --git a/crates/domain/src/lib.rs b/crates/domain/src/lib.rs index 996a63f..740cc56 100644 --- a/crates/domain/src/lib.rs +++ b/crates/domain/src/lib.rs @@ -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}; diff --git a/crates/domain/src/memory_harvest.rs b/crates/domain/src/memory_harvest.rs new file mode 100644 index 0000000..ac5f961 --- /dev/null +++ b/crates/domain/src/memory_harvest.rs @@ -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, + /// One message per skipped (invalid / over-limit) block. + pub errors: Vec, +} + +/// 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 { + 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, field: &str) -> Result { + 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 { + 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); + } +} diff --git a/frontend/src/adapters/mock/mock.test.ts b/frontend/src/adapters/mock/mock.test.ts index bf071d1..e8c66a7 100644 --- a/frontend/src/adapters/mock/mock.test.ts +++ b/frontend/src/adapters/mock/mock.test.ts @@ -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", ]); }); }); diff --git a/frontend/src/domain/index.ts b/frontend/src/domain/index.ts index 2319b63..085e123 100644 --- a/frontend/src/domain/index.ts +++ b/frontend/src/domain/index.ts @@ -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; diff --git a/frontend/src/features/memory/memory.test.tsx b/frontend/src/features/memory/memory.test.tsx index 1f0fb21..40e90d0 100644 --- a/frontend/src/features/memory/memory.test.tsx +++ b/frontend/src/features/memory/memory.test.tsx @@ -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( @@ -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()); + }); }); // --------------------------------------------------------------------------- diff --git a/frontend/src/features/memory/useMemory.ts b/frontend/src/features/memory/useMemory.ts index 86cace1..aee47d2 100644 --- a/frontend/src/features/memory/useMemory.ts +++ b/frontend/src/features/memory/useMemory.ts @@ -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([]); const [error, setError] = useState(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) => { setBusy(true); From 8c7c47c0e855a1bf1004c2d8047f6b840ad71758 Mon Sep 17 00:00:00 2001 From: Blomios Date: Sun, 21 Jun 2026 02:00:13 +0200 Subject: [PATCH 2/2] =?UTF-8?q?fix(mcp):=20fallback=20d=C3=A9terministe=20?= =?UTF-8?q?du=20runtime=20dir=20socket?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `unix_runtime_dir` essaie désormais les bases candidates dans l'ordre de priorité ($XDG_RUNTIME_DIR → $TMPDIR → /tmp) et retient la première dont le sous-dossier `idea-mcp` existe ou peut être créé. Un $XDG_RUNTIME_DIR positionné mais inutilisable (sandbox/CI pointant un chemin inexistant ou en lecture seule) ne doit plus dead-end le bind loopback : sans ce fallback le socket ne se liait jamais en silence et la délégation inter-agents mourait. Déterministe pour un environnement donné, donc le côté bind et tout lecteur de `socket_path()` s'accordent sur le même répertoire. Indépendant du Lot E1. Co-Authored-By: Claude Opus 4.8 --- crates/app-tauri/src/mcp_endpoint.rs | 26 +++++++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/crates/app-tauri/src/mcp_endpoint.rs b/crates/app-tauri/src/mcp_endpoint.rs index b4dc728..3f9204b 100644 --- a/crates/app-tauri/src/mcp_endpoint.rs +++ b/crates/app-tauri/src/mcp_endpoint.rs @@ -87,13 +87,29 @@ impl McpEndpoint { /// per-user runtime dir (`$XDG_RUNTIME_DIR`, falling back to `$TMPDIR`, then /// `/tmp`). Kept short so the full socket path stays well under the ~108-byte /// `sockaddr_un` limit (`/run/user//idea-mcp/<32 hex>.sock` ≈ 50 bytes). +/// +/// The candidate bases are tried **in priority order**, returning the first whose +/// `idea-mcp` subdir exists or can be created. A set-but-unusable `$XDG_RUNTIME_DIR` +/// (e.g. a sandbox/CI where it points at a non-existent or read-only path) must not +/// dead-end the loopback bind: without this fallback the socket silently never +/// binds and inter-agent delegation dies. Deterministic for a given environment, so +/// the bind side and any `socket_path()` reader always agree on the same directory. #[cfg(unix)] fn unix_runtime_dir() -> PathBuf { - let base = std::env::var_os("XDG_RUNTIME_DIR") - .map(PathBuf::from) - .or_else(|| std::env::var_os("TMPDIR").map(PathBuf::from)) - .unwrap_or_else(|| PathBuf::from("/tmp")); - base.join("idea-mcp") + let candidates = [ + std::env::var_os("XDG_RUNTIME_DIR").map(PathBuf::from), + std::env::var_os("TMPDIR").map(PathBuf::from), + Some(PathBuf::from("/tmp")), + ]; + for base in candidates.into_iter().flatten() { + let dir = base.join("idea-mcp"); + if dir.is_dir() || std::fs::create_dir_all(&dir).is_ok() { + return dir; + } + } + // Last resort: keep the historical `/tmp` target even if creation just failed + // (the bind will surface the real error rather than silently picking nowhere). + PathBuf::from("/tmp").join("idea-mcp") } /// **Single source of truth.** Computes the deterministic loopback endpoint of a