Files
IdeA/crates/domain/src/memory_harvest.rs
Blomios b12081be18 feat(memory): récolte automatique contrôlée de la mémoire (Lot E1)
Câble la récolte automatique de mémoire de bout en bout, sous contrôle
explicite, du domaine jusqu'à l'UI :

- domain : modèle `memory_harvest` (candidats de récolte, décision) +
  exports `lib.rs`.
- application : use case `memory/harvest` branché dans `memory/mod`,
  `lib.rs`, le cycle de vie d'agent (`agent/lifecycle`) et
  l'orchestrateur (`orchestrator/service`).
- app-tauri : wiring runtime dans `state`.
- frontend : DTO `domain/index` + hook `useMemory`.

Couverture : domain `memory_harvest` (14), application `memory_harvest`
(5) et `orchestrator_service` (récolte, 4), plus patch test-only du mock
gateway `workState` (`mock.test.ts`) et UI `memory.test`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 02:00:05 +02:00

412 lines
15 KiB
Rust

//! 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);
}
}