merge(memory): intègre la récolte automatique contrôlée (Lot E1) dans develop
Intègre le cœur E1 (feat memory récolte auto contrôlée) et le fix indépendant mcp runtime dir socket, tests E1 ciblés verts. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -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/<uid>/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
|
||||
|
||||
@ -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