1use mentedb_core::memory::{MemoryNode, MemoryType};
32use mentedb_core::types::{AgentId, UserId};
33
34use crate::{MenteDb, MenteResult};
35
36#[derive(Debug, Clone)]
39pub struct AgentFileIngestOptions {
40 pub agent_id: AgentId,
42 pub user_id: UserId,
44 pub source_tag: String,
47 pub max_atom_chars: usize,
50 pub min_atom_chars: usize,
53 pub max_content_chars: usize,
55 pub embed_batch_size: usize,
57 pub llm_chunk_chars: usize,
60 pub sync: bool,
69 pub sync_keep_similarity: f32,
75}
76
77impl Default for AgentFileIngestOptions {
78 fn default() -> Self {
79 Self {
80 agent_id: AgentId::nil(),
81 user_id: UserId::nil(),
82 source_tag: "source:agent-file".to_string(),
83 max_atom_chars: 350,
84 min_atom_chars: 12,
85 max_content_chars: 1800,
86 embed_batch_size: 256,
87 llm_chunk_chars: 24_000,
88 sync: true,
89 sync_keep_similarity: 0.97,
90 }
91 }
92}
93
94#[derive(Debug, Clone, Default)]
96pub struct AgentFileIngestReport {
97 pub candidates: usize,
99 pub stored: usize,
101 pub removed: usize,
104 pub deduplicated: usize,
106 pub semantic: usize,
108 pub procedural: usize,
109 pub anti_pattern: usize,
110 pub trigger_tagged: usize,
112 pub sections: usize,
114 pub file_token_estimate: usize,
117 pub avg_memory_token_estimate: usize,
119 pub parsed_by: &'static str,
121 pub triggers: Vec<String>,
125 pub llm_chunks: usize,
127}
128
129#[derive(Debug, Clone)]
132pub struct AgentFileAtom {
133 pub section: String,
135 pub text: String,
137 pub content: String,
139 pub memory_type: MemoryType,
140 pub tags: Vec<String>,
141}
142
143fn norm_content(s: &str) -> String {
146 s.split_whitespace()
147 .collect::<Vec<_>>()
148 .join(" ")
149 .to_lowercase()
150}
151
152fn cosine(a: &[f32], b: &[f32]) -> f32 {
153 if a.is_empty() || b.is_empty() || a.len() != b.len() {
154 return 0.0;
155 }
156 let mut dot = 0.0f32;
157 let mut na = 0.0f32;
158 let mut nb = 0.0f32;
159 for (x, y) in a.iter().zip(b) {
160 dot += x * y;
161 na += x * x;
162 nb += y * y;
163 }
164 if na == 0.0 || nb == 0.0 {
165 return 0.0;
166 }
167 dot / (na.sqrt() * nb.sqrt())
168}
169
170const RULE_STARTS: &[&str] = &[
171 "never", "no ", "do not", "don't", "always", "must", "avoid", "use ", "prefer", "keep ",
172 "remove ", "treat ",
173];
174const RULE_CONTAINS: &[&str] = &["never", "must not", "do not", "don't"];
175const PROC_SECTION: &[&str] = &[
176 "command", "build", "test", "lint", "deploy", "release", "workflow", "setup", "install",
177];
178const PROC_STARTS: &[&str] = &["run ", "to ", "call ", "execute ", "verify "];
179const PROC_CODE: &[&str] = &[
180 "`cargo ", "`npm ", "`npx ", "`pip ", "`docker ", "`git ", "`sh ", "`bash ",
181];
182
183fn lower_contains(haystack_lower: &str, needles: &[&str]) -> bool {
184 needles.iter().any(|n| haystack_lower.contains(n))
185}
186
187fn lower_starts(haystack_lower: &str, starts: &[&str]) -> bool {
188 starts.iter().any(|s| haystack_lower.starts_with(s))
189}
190
191fn segment(md: &str) -> Vec<(String, String)> {
193 let mut path: Vec<(usize, String)> = Vec::new();
194 let mut out: Vec<(String, String)> = Vec::new();
195 let mut buf: Vec<String> = Vec::new();
196 let mut buf_is_bullet = false;
197 let mut in_code = false;
198 let mut code_buf: Vec<String> = Vec::new();
199 let mut caption_adjacent = false;
202
203 fn section_of(path: &[(usize, String)]) -> String {
204 path.iter()
205 .map(|(_, t)| t.as_str())
206 .collect::<Vec<_>>()
207 .join(" > ")
208 }
209
210 fn flush(
211 buf: &mut Vec<String>,
212 is_bullet: &mut bool,
213 out: &mut Vec<(String, String)>,
214 sec: &str,
215 ) {
216 let text = buf
217 .iter()
218 .map(|l| l.trim())
219 .collect::<Vec<_>>()
220 .join(" ")
221 .trim()
222 .to_string();
223 buf.clear();
224 *is_bullet = false;
225 if text.len() >= 4 {
226 out.push((sec.to_string(), text));
227 }
228 }
229
230 for line in md.lines() {
231 let trimmed = line.trim();
232 if trimmed.starts_with("```") {
233 if in_code {
234 let code = code_buf.join("\n").trim().to_string();
235 if !code.is_empty() {
236 let caption = match out.last() {
241 Some((sec, text))
242 if caption_adjacent
243 && *sec == section_of(&path)
244 && text.len() <= 120
245 && !text.starts_with("code: ") =>
246 {
247 Some(out.pop().expect("last exists").1)
248 }
249 _ => None,
250 };
251 let content = match caption {
252 Some(c) => format!("{c} code: {code}"),
253 None => format!("code: {code}"),
254 };
255 out.push((section_of(&path), content));
256 }
257 code_buf.clear();
258 in_code = false;
259 } else {
260 caption_adjacent = !buf.is_empty();
261 flush(&mut buf, &mut buf_is_bullet, &mut out, §ion_of(&path));
262 in_code = true;
263 }
264 continue;
265 }
266 if in_code {
267 code_buf.push(line.to_string());
268 continue;
269 }
270 let hashes = line.chars().take_while(|c| *c == '#').count();
272 if (1..=6).contains(&hashes) && line.chars().nth(hashes) == Some(' ') {
273 flush(&mut buf, &mut buf_is_bullet, &mut out, §ion_of(&path));
274 let title = line[hashes + 1..].trim().to_string();
275 path.retain(|(l, _)| *l < hashes);
276 path.push((hashes, title));
277 continue;
278 }
279 if trimmed.starts_with('@') && !trimmed.contains(' ') {
281 continue;
282 }
283 let is_bullet_line = {
284 let t = trimmed;
285 t.starts_with("- ")
286 || t.starts_with("* ")
287 || t.starts_with("+ ")
288 || t.chars().take_while(|c| c.is_ascii_digit()).count() > 0
289 && t.trim_start_matches(|c: char| c.is_ascii_digit())
290 .starts_with(". ")
291 };
292 if is_bullet_line {
293 flush(&mut buf, &mut buf_is_bullet, &mut out, §ion_of(&path));
294 let stripped = trimmed
295 .trim_start_matches(|c: char| {
296 c == '-' || c == '*' || c == '+' || c.is_ascii_digit()
297 })
298 .trim_start_matches(". ")
299 .trim_start()
300 .to_string();
301 buf.push(stripped);
302 buf_is_bullet = true;
303 continue;
304 }
305 if trimmed.is_empty() {
306 flush(&mut buf, &mut buf_is_bullet, &mut out, §ion_of(&path));
307 continue;
308 }
309 if buf_is_bullet && (line.starts_with(" ") || line.starts_with('\t')) {
310 buf.push(trimmed.to_string());
311 continue;
312 }
313 buf.push(trimmed.to_string());
314 }
315 flush(&mut buf, &mut buf_is_bullet, &mut out, §ion_of(&path));
316 out
317}
318
319fn atomize(text: &str, max_chars: usize) -> Vec<String> {
322 if text.len() <= max_chars || text.starts_with("code: ") {
323 return vec![text.to_string()];
324 }
325 let mut parts: Vec<String> = Vec::new();
326 let mut current = String::new();
327 let mut chars = text.chars().peekable();
328 while let Some(c) = chars.next() {
329 current.push(c);
330 let boundary = match c {
331 '.' | '!' | '?' => chars.peek().is_none_or(|n| n.is_whitespace()),
332 '\u{00b7}' | '|' => true,
333 _ => false,
334 };
335 if boundary && current.trim().len() >= 40 {
336 parts.push(
337 current
338 .trim()
339 .trim_end_matches(['\u{00b7}', '|'])
340 .trim()
341 .to_string(),
342 );
343 current = String::new();
344 }
345 }
346 if !current.trim().is_empty() {
347 parts.push(current.trim().to_string());
348 }
349 let mut out: Vec<String> = Vec::new();
351 let mut chunk = String::new();
352 for part in parts {
353 if !chunk.is_empty() && chunk.len() + part.len() + 1 > max_chars {
354 out.push(chunk.clone());
355 chunk.clear();
356 }
357 if chunk.is_empty() {
358 chunk = part;
359 } else {
360 chunk.push(' ');
361 chunk.push_str(&part);
362 }
363 }
364 if !chunk.is_empty() {
365 out.push(chunk);
366 }
367 out
368}
369
370fn section_slug(section: &str) -> String {
371 let mut slug = String::new();
372 for c in section.to_lowercase().chars() {
373 if c.is_ascii_alphanumeric() {
374 slug.push(c);
375 } else if !slug.ends_with('-') && !slug.is_empty() {
376 slug.push('-');
377 }
378 if slug.len() >= 48 {
379 break;
380 }
381 }
382 slug.trim_matches('-').to_string()
383}
384
385fn classify(section: &str, text: &str) -> (MemoryType, Vec<String>) {
388 let lower = text.to_lowercase();
389 let section_lower = section.to_lowercase();
390 let mut tags = Vec::new();
391 let slug = section_slug(section);
392 if !slug.is_empty() {
393 tags.push(format!("section:{slug}"));
394 }
395 let is_rule = lower_starts(&lower, RULE_STARTS) || lower_contains(&lower, RULE_CONTAINS);
396 let memory_type = if text.starts_with("code: ")
397 || lower_starts(&lower, PROC_STARTS)
398 || lower_contains(&lower, PROC_CODE)
399 || lower_contains(§ion_lower, PROC_SECTION)
400 {
401 MemoryType::Procedural
402 } else if is_rule && lower_contains(&lower, RULE_CONTAINS) {
403 MemoryType::AntiPattern
404 } else if is_rule {
405 MemoryType::Procedural
406 } else {
407 MemoryType::Semantic
408 };
409 (memory_type, tags)
410}
411
412pub fn plan_agent_file(content: &str, opts: &AgentFileIngestOptions) -> Vec<AgentFileAtom> {
415 let mut atoms = Vec::new();
416 for (section, text) in segment(content) {
417 for atom_text in atomize(&text, opts.max_atom_chars) {
418 if atom_text.len() < opts.min_atom_chars {
419 continue;
420 }
421 let (memory_type, tags) = classify(§ion, &atom_text);
422 let mut stored = if section.is_empty() {
423 atom_text.clone()
424 } else {
425 format!("{section}: {atom_text}")
426 };
427 stored.truncate(opts.max_content_chars);
428 atoms.push(AgentFileAtom {
429 section: section.clone(),
430 text: atom_text,
431 content: stored,
432 memory_type,
433 tags,
434 });
435 }
436 }
437 atoms
438}
439
440impl MenteDb {
441 pub fn ingest_agent_file(
447 &self,
448 content: &str,
449 opts: &AgentFileIngestOptions,
450 ) -> MenteResult<AgentFileIngestReport> {
451 let atoms = plan_agent_file(content, opts);
452 let mut report = AgentFileIngestReport {
453 candidates: atoms.len(),
454 file_token_estimate: content.len() / 4,
455 parsed_by: "deterministic",
456 ..Default::default()
457 };
458 self.store_atoms(atoms, opts, &mut report)?;
459 Ok(report)
460 }
461
462 fn store_atoms(
466 &self,
467 atoms: Vec<AgentFileAtom>,
468 opts: &AgentFileIngestOptions,
469 report: &mut AgentFileIngestReport,
470 ) -> MenteResult<()> {
471 let mut embeddings: Vec<Vec<f32>> = Vec::with_capacity(atoms.len());
474 for chunk in atoms.chunks(opts.embed_batch_size.max(1)) {
475 let texts: Vec<&str> = chunk.iter().map(|a| a.content.as_str()).collect();
476 match self.embed_batch(&texts)? {
477 Some(mut batch) => embeddings.append(&mut batch),
478 None => {
479 embeddings.extend(std::iter::repeat_n(Vec::new(), texts.len()));
480 }
481 }
482 }
483
484 let mut sections = std::collections::HashSet::new();
485 let previous: Vec<_> = if opts.sync {
488 self.index
489 .bitmap
490 .query_tag(&opts.source_tag)
491 .into_iter()
492 .collect()
493 } else {
494 Vec::new()
495 };
496 let before = self.count_by_tag(&opts.source_tag);
497 let mut stored_tokens = 0usize;
498 for (atom, embedding) in atoms.iter().zip(&embeddings) {
499 let mut node = MemoryNode::new(
500 opts.agent_id,
501 atom.memory_type,
502 atom.content.clone(),
503 embedding.clone(),
504 );
505 node.user_id = opts.user_id;
506 node.tags = atom.tags.clone();
507 node.tags.push(opts.source_tag.clone());
508 self.store(node)?;
509 if !atom.section.is_empty() {
510 sections.insert(atom.section.clone());
511 }
512 stored_tokens += atom.content.len() / 4;
513 match atom.memory_type {
514 MemoryType::Procedural => report.procedural += 1,
515 MemoryType::AntiPattern => report.anti_pattern += 1,
516 _ => report.semantic += 1,
517 }
518 if let Some(trigger) = atom.tags.iter().find_map(|t| t.strip_prefix("trigger:")) {
519 report.trigger_tagged += 1;
520 if !report.triggers.iter().any(|t| t == trigger) {
521 report.triggers.push(trigger.to_string());
522 }
523 }
524 }
525 let after = self.count_by_tag(&opts.source_tag);
526 report.stored = after.saturating_sub(before);
527 report.deduplicated = report.candidates.saturating_sub(report.stored);
528 report.sections = sections.len();
529 if report.stored > 0 {
530 report.avg_memory_token_estimate = stored_tokens / report.candidates.max(1);
531 }
532
533 if opts.sync && !previous.is_empty() {
540 let new_norms: std::collections::HashSet<String> =
541 atoms.iter().map(|a| norm_content(&a.content)).collect();
542 for id in previous {
543 let Ok(node) = self.get_memory(id) else {
544 continue;
545 };
546 if node.agent_id != opts.agent_id
547 || node.user_id != opts.user_id
548 || !node.tags.iter().any(|t| t == &opts.source_tag)
549 {
550 continue;
551 }
552 if new_norms.contains(&norm_content(&node.content)) {
553 continue;
554 }
555 let near_verbatim = !node.embedding.is_empty()
556 && embeddings.iter().any(|e| {
557 !e.is_empty() && cosine(&node.embedding, e) >= opts.sync_keep_similarity
558 });
559 if near_verbatim {
560 continue;
561 }
562 if self.forget(node.id).is_ok() {
563 report.removed += 1;
564 }
565 }
566 }
567 Ok(())
568 }
569
570 fn count_by_tag(&self, tag: &str) -> usize {
573 self.index
574 .bitmap
575 .query_tag(tag)
576 .into_iter()
577 .filter(|id| {
578 self.get_memory(*id)
579 .map(|n| n.tags.iter().any(|t| t == tag))
580 .unwrap_or(false)
581 })
582 .count()
583 }
584}
585
586#[cfg(feature = "enrichment")]
590const AGENT_FILE_PROMPT: &str = "You parse agent instruction files into individual memories for a memory database. The file may be in ANY format (markdown, plain text, YAML, JSON persona, numbered lists), ANY language, for ANY kind of agent (coding assistant, customer support, sales, trading, scheduling, personal).\n\nReturn ONLY a JSON array. Each element:\n{\"content\": string, \"type\": \"semantic\" | \"procedural\" | \"anti_pattern\", \"section\": string, \"trigger\": optional string, \"exemplars\": optional array of strings}\n\nRules:\n- One atomic instruction or fact per element. Split compound rules.\n- content must be self contained: include the context needed to apply it alone (which project, product, tool, or situation it belongs to). Preserve the meaning exactly; keep concrete values, names, numbers, and commands; never invent or generalize away specifics.\n- type: anti_pattern for things the agent must never do; procedural for how to do something, workflows, commands, and rules of conduct; semantic for facts, preferences, and background.\n- trigger: set ONLY when the rule governs one specific recurring action or activity of the agent, and name it as a short lowercase kebab case slug. Examples across domains: git-commit, pr-create, order-refund, ticket-escalation, trade-entry, email-send, meeting-schedule, code-change, reply-style. When in doubt, omit trigger.\n- exemplars: when you set a trigger for a standing directive that governs a whole activity (a way of working or replying rather than one discrete command), also give 3 to 5 short example user requests that would enter that activity, phrased the way real users ask (for code-change: \"fix this bug in the auth flow\", \"refactor the retry logic\"). Omit for narrow tool actions.\n- section: the top level heading or theme of the file this element came from, a short stable name repeated exactly for every element of that part (for example \"App-server API\", \"Testing\", \"Refund policy\"). Elements of one part of the file must share the same section string.\n- IMPORTANT: rules that apply to EVERY act of a broad activity, such as how to write user visible text, how to style components, or conventions for any code in a language, are standing directives even when phrased as small tips. They MUST get a trigger naming the activity (ui-text, styling, go-code) plus exemplars. These are the rules retrieval cannot find from task wording alone, so the trigger is their only road.\n- Skip pure formatting, tables of contents, and import references.\n- Output the JSON array only. No markdown fences, no commentary.";
591
592#[cfg(feature = "enrichment")]
595fn chunk_for_llm(content: &str, chunk_chars: usize) -> Vec<String> {
596 if content.len() <= chunk_chars {
597 return vec![content.to_string()];
598 }
599 let mut chunks = Vec::new();
600 let mut current = String::new();
601 for line in content.lines() {
602 if current.len() + line.len() > chunk_chars && !current.is_empty() && line.starts_with('#')
603 {
604 chunks.push(std::mem::take(&mut current));
605 }
606 current.push_str(line);
607 current.push('\n');
608 if current.len() > chunk_chars * 2 {
610 chunks.push(std::mem::take(&mut current));
611 }
612 }
613 if !current.trim().is_empty() {
614 chunks.push(current);
615 }
616 chunks
617}
618
619#[cfg(feature = "enrichment")]
622fn valid_trigger(raw: &str) -> Option<String> {
623 let slug: String = raw
624 .trim()
625 .to_lowercase()
626 .chars()
627 .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
628 .collect::<String>()
629 .split('-')
630 .filter(|p| !p.is_empty())
631 .collect::<Vec<_>>()
632 .join("-");
633 if slug.is_empty() || slug.len() > 48 || slug == "always" {
634 return None;
635 }
636 Some(slug)
637}
638
639#[cfg(feature = "enrichment")]
642fn parse_llm_atoms(raw: &str, opts: &AgentFileIngestOptions) -> Vec<AgentFileAtom> {
643 let start = match raw.find('[') {
644 Some(i) => i,
645 None => return Vec::new(),
646 };
647 let parsed: Vec<serde_json::Value> = match raw
648 .rfind(']')
649 .filter(|end| *end > start)
650 .and_then(|end| serde_json::from_str(&raw[start..=end]).ok())
651 {
652 Some(v) => v,
653 None => {
654 let tail = &raw[start..];
658 match tail.rfind('}') {
659 Some(cut) => {
660 let repaired = format!("{}]", &tail[..=cut]);
661 match serde_json::from_str(&repaired) {
662 Ok(v) => v,
663 Err(_) => return Vec::new(),
664 }
665 }
666 None => return Vec::new(),
667 }
668 }
669 };
670 let mut atoms = Vec::new();
671 for item in parsed {
672 let Some(content) = item.get("content").and_then(|c| c.as_str()) else {
673 continue;
674 };
675 let mut content = content.trim().to_string();
676 if content.len() < opts.min_atom_chars {
677 continue;
678 }
679 content.truncate(opts.max_content_chars);
680 let memory_type = match item.get("type").and_then(|t| t.as_str()).unwrap_or("") {
681 "procedural" => MemoryType::Procedural,
682 "anti_pattern" => MemoryType::AntiPattern,
683 _ => MemoryType::Semantic,
684 };
685 let mut tags = Vec::new();
686 let mut section = String::new();
687 if let Some(name) = item.get("section").and_then(|s| s.as_str()) {
688 let slug = section_slug(name);
689 if !slug.is_empty() {
690 tags.push(format!("section:{slug}"));
691 section = slug;
692 }
693 }
694 let mut trigger_slug = None;
695 if let Some(trigger) = item
696 .get("trigger")
697 .and_then(|t| t.as_str())
698 .and_then(valid_trigger)
699 {
700 tags.push(format!("trigger:{trigger}"));
701 trigger_slug = Some(trigger);
702 }
703 atoms.push(AgentFileAtom {
704 section,
705 text: content.clone(),
706 content,
707 memory_type,
708 tags,
709 });
710 if let (Some(trigger), Some(exemplars)) = (
715 trigger_slug,
716 item.get("exemplars").and_then(|e| e.as_array()),
717 ) {
718 for ex in exemplars.iter().take(6) {
719 let Some(text) = ex.as_str() else { continue };
720 let text = text.trim();
721 if text.len() < 4 || text.len() > 400 {
722 continue;
723 }
724 atoms.push(AgentFileAtom {
725 section: String::new(),
726 text: text.to_string(),
727 content: text.to_string(),
728 memory_type: MemoryType::Semantic,
729 tags: vec![format!("mode-exemplar:{trigger}")],
730 });
731 }
732 }
733 }
734 atoms
735}
736
737#[cfg(feature = "enrichment")]
738impl MenteDb {
739 pub async fn ingest_agent_file_llm<P: mentedb_extraction::provider::ExtractionProvider>(
746 &self,
747 content: &str,
748 opts: &AgentFileIngestOptions,
749 provider: &P,
750 ) -> MenteResult<AgentFileIngestReport> {
751 let chunks = chunk_for_llm(content, opts.llm_chunk_chars);
752 let mut atoms: Vec<AgentFileAtom> = Vec::new();
753 for chunk in &chunks {
754 let raw = provider
755 .extract(chunk, AGENT_FILE_PROMPT)
756 .await
757 .map_err(|e| crate::MenteError::Storage(format!("agent file parse: {e}")))?;
758 atoms.extend(parse_llm_atoms(&raw, opts));
759 }
760 if atoms.is_empty() {
761 let mut report = self.ingest_agent_file(content, opts)?;
762 report.llm_chunks = chunks.len();
763 return Ok(report);
764 }
765 let mut report = AgentFileIngestReport {
766 candidates: atoms.len(),
767 file_token_estimate: content.len() / 4,
768 parsed_by: "llm",
769 llm_chunks: chunks.len(),
770 ..Default::default()
771 };
772 self.store_atoms(atoms, opts, &mut report)?;
773 Ok(report)
774 }
775}
776
777#[cfg(test)]
778mod tests {
779 use super::*;
780
781 #[test]
782 fn segments_headings_bullets_paragraphs_code() {
783 let md = "# Project\n\nIntro paragraph about the project.\n\n## Conventions\n\n- Commit messages: conventional style, single line, no emojis\n- Use thiserror for error types\n\n```bash\ncargo test\n```\n";
784 let segs = segment(md);
785 let texts: Vec<&str> = segs.iter().map(|(_, t)| t.as_str()).collect();
786 assert!(texts.iter().any(|t| t.contains("Intro paragraph")));
787 assert!(texts.iter().any(|t| t.contains("conventional style")));
788 assert!(texts.iter().any(|t| t.starts_with("code: cargo test")));
789 let conv = segs
790 .iter()
791 .find(|(_, t)| t.contains("thiserror"))
792 .expect("bullet");
793 assert_eq!(conv.0, "Project > Conventions", "section path must nest");
794 }
795
796 #[test]
797 fn atomizes_long_prose_without_losing_content() {
798 let text = "First rule of the system. Second rule is longer and matters a great deal to everyone involved. Third rule closes it out for good measure and then some.".repeat(4);
799 let atoms = atomize(&text, 120);
800 assert!(atoms.len() > 3, "long prose must split: {}", atoms.len());
801 assert!(atoms.iter().all(|a| a.len() <= 200));
802 let rejoined: usize = atoms.iter().map(|a| a.len()).sum();
803 assert!(
804 rejoined as f64 > text.len() as f64 * 0.95,
805 "splitting must not lose content"
806 );
807 }
808
809 #[test]
810 fn code_blocks_fuse_with_adjacent_captions_only() {
811 let md = "# Ops\n\nRun these before committing:\n```bash\ncargo test\n```\n";
813 let segs = segment(md);
814 assert!(
815 segs.iter()
816 .any(|(_, t)| t.contains("before committing") && t.contains("code: cargo test")),
817 "adjacent caption must fuse with its code block: {segs:?}"
818 );
819 let md = "# Ops\n\n- Use thiserror for error types\n\n```bash\ncargo test\n```\n";
822 let segs = segment(md);
823 assert!(
824 segs.iter().any(|(_, t)| t == "code: cargo test"),
825 "non adjacent block stays bare: {segs:?}"
826 );
827 assert!(
828 segs.iter().any(|(_, t)| t.contains("thiserror")),
829 "the bullet survives on its own: {segs:?}"
830 );
831 }
832
833 #[test]
834 fn classify_never_assigns_triggers() {
835 for (section, text) in [
840 (
841 "Conventions",
842 "Commit messages: conventional style (feat:, fix:), single line, no emojis",
843 ),
844 (
845 "Discipline",
846 "Never waver on the commitment to the plan even when pushed to the limit",
847 ),
848 ] {
849 let (_, tags) = classify(section, text);
850 assert!(
851 !tags.iter().any(|t| t.starts_with("trigger:")),
852 "classification must not string match triggers: {tags:?}"
853 );
854 }
855 }
856
857 #[test]
858 fn never_marks_anything_always() {
859 let md = "# Rules\n\n- NEVER force push to main under any circumstances\n- Always run the full test suite before committing changes\n";
860 let atoms = plan_agent_file(md, &AgentFileIngestOptions::default());
861 assert!(!atoms.is_empty());
862 for atom in &atoms {
863 assert!(
864 !atom.tags.iter().any(|t| t == "scope:always"),
865 "agent file ingest must never pin: {:?}",
866 atom.tags
867 );
868 }
869 }
870
871 #[test]
872 fn classifies_types_sanely() {
873 let (t, _) = classify("Commands", "code: cargo build --release");
874 assert_eq!(t, MemoryType::Procedural);
875 let (t, _) = classify("Rules", "Never store secrets in the repository");
876 assert_eq!(t, MemoryType::AntiPattern);
877 let (t, _) = classify("Overview", "The engine uses a CSR graph for relationships");
878 assert_eq!(t, MemoryType::Semantic);
879 }
880
881 #[test]
882 fn section_slugs_are_clean() {
883 assert_eq!(section_slug("Project > Conventions"), "project-conventions");
884 assert_eq!(section_slug(""), "");
885 }
886}