Skip to main content

mentedb/
agent_file.rs

1//! Agent file ingestion: turn a standing instruction file (CLAUDE.md,
2//! AGENTS.md, .cursorrules, a system prompt) into individual memories that
3//! are retrieved on demand instead of injected whole into every prompt.
4//!
5//! Two parsers, one contract:
6//! - The primary parser is an LLM (`ingest_agent_file_llm`, behind the
7//!   `enrichment` feature): agent files come in any format, any language,
8//!   for any kind of agent, and only a model parses that reliably. This is
9//!   a one time cost per file, nothing like a model call per write; the
10//!   write path stays deterministic. The model also names action triggers
11//!   with an open vocabulary (git-commit, order-refund, trade-entry), so
12//!   nothing here is specific to developer files.
13//! - The fallback parser (`ingest_agent_file`) is deterministic markdown
14//!   segmentation for installs with no LLM configured: heading paths for
15//!   self contained memories, sentence level atomization, and type
16//!   classification.
17//!
18//! Which memories bear on an action the agent is about to take is answered at
19//! recall time by [`MenteDb::recall_for_action`], semantic recall re ranked by
20//! how strongly a memory governs behavior, not by any stored action tag.
21//! - Nothing is marked `scope:always` by either parser. The point of
22//!   ingesting an agent file is that no rule rides every turn; rules arrive
23//!   by topic or by action.
24//!
25//! Memories go through the normal write pipeline one by one, so write time
26//! deduplication and supersession apply: re ingesting an edited file updates
27//! the store instead of duplicating it. Embeddings are computed in batches
28//! ahead of the stores, so a large file does not pay one provider round trip
29//! per line.
30
31use mentedb_core::memory::{MemoryNode, MemoryType};
32use mentedb_core::types::{AgentId, UserId};
33
34use crate::{MenteDb, MenteResult};
35
36/// Options for [`MenteDb::ingest_agent_file`]. All thresholds live here, not
37/// as magic numbers in the implementation.
38#[derive(Debug, Clone)]
39pub struct AgentFileIngestOptions {
40    /// Owner of the created memories; nil means shared knowledge.
41    pub agent_id: AgentId,
42    /// Orthogonal user owner; nil means shared.
43    pub user_id: UserId,
44    /// A provenance tag added to every memory, so the whole ingest can be
45    /// listed, re done, or removed as a unit. Default `source:agent-file`.
46    pub source_tag: String,
47    /// Long prose above this many characters is split into atomic
48    /// statements.
49    pub max_atom_chars: usize,
50    /// Segments shorter than this are noise (stray words, separators) and
51    /// are skipped.
52    pub min_atom_chars: usize,
53    /// Hard cap on stored content length per memory.
54    pub max_content_chars: usize,
55    /// Batch size for embedding calls.
56    pub embed_batch_size: usize,
57    /// Chunk size in characters for LLM parsing of large files; chunks split
58    /// at section boundaries.
59    pub llm_chunk_chars: usize,
60    /// When true (the default), re ingesting is a full sync with the file:
61    /// memories from a previous ingest of the same source_tag and owner
62    /// scope whose rule no longer appears in the file are forgotten. Edit
63    /// the file and re ingest; new rules are stored, unchanged rules
64    /// deduplicate, edited rules replace their old version, deleted rules
65    /// are removed. Ingesting several different files into the same owner
66    /// scope requires a distinct source_tag per file, otherwise each file's
67    /// sync prunes the others'.
68    pub sync: bool,
69    /// Survival gate for sync: an existing memory is kept when its content
70    /// matches a new atom verbatim after whitespace and case normalization,
71    /// or its embedding reaches this cosine similarity against any new atom.
72    /// Near verbatim by default, so a meaningfully edited rule is replaced
73    /// rather than kept alongside its old version.
74    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/// What an ingest did, in numbers the caller can show a user.
95#[derive(Debug, Clone, Default)]
96pub struct AgentFileIngestReport {
97    /// Atomic candidates produced by segmentation.
98    pub candidates: usize,
99    /// Memories actually stored (candidates minus dedup and failures).
100    pub stored: usize,
101    /// Memories from a previous ingest forgotten by sync because their rule
102    /// no longer appears in the file.
103    pub removed: usize,
104    /// Candidates the write pipeline folded into an existing memory.
105    pub deduplicated: usize,
106    /// Stored memories by type.
107    pub semantic: usize,
108    pub procedural: usize,
109    pub anti_pattern: usize,
110    /// Stored memories carrying a `trigger:` action tag.
111    pub trigger_tagged: usize,
112    /// Distinct section paths seen.
113    pub sections: usize,
114    /// Rough size of the whole file in tokens (length divided by four, an
115    /// estimate for comparison displays, not a tokenizer).
116    pub file_token_estimate: usize,
117    /// Rough average tokens per stored memory (same estimate).
118    pub avg_memory_token_estimate: usize,
119    /// Which parser produced the atoms: "llm" or "deterministic".
120    pub parsed_by: &'static str,
121    /// The distinct action trigger names assigned, so callers can discover
122    /// the open vocabulary the parser chose ("order-refund",
123    /// "ticket-escalation") instead of guessing slugs.
124    pub triggers: Vec<String>,
125    /// Number of chunks sent to the LLM (zero for the deterministic parser).
126    pub llm_chunks: usize,
127}
128
129/// One segmented, classified atom ready to store. Exposed so harnesses and
130/// the demo can inspect classification without storing.
131#[derive(Debug, Clone)]
132pub struct AgentFileAtom {
133    /// Section path, for example "Conventions > Commits".
134    pub section: String,
135    /// The atomic statement, without the section prefix.
136    pub text: String,
137    /// Full stored content: section prefix plus text.
138    pub content: String,
139    pub memory_type: MemoryType,
140    pub tags: Vec<String>,
141}
142
143/// Whitespace and case insensitive content identity, the verbatim leg of
144/// sync survival matching.
145fn 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
191/// Segment a markdown agent file into (section path, text) candidates.
192fn 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    // True when the line directly above the opening fence held text (no
200    // blank line between): only that text is a caption for the block.
201    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                    // Fuse the block with the short caption right above it
237                    // ("Run these before committing:"), so the commands are
238                    // retrievable from natural language questions instead of
239                    // embedding as bare code.
240                    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, &section_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        // Headings adjust the section path.
271        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, &section_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        // Import style lines (@file) are references, not memories.
280        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, &section_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, &section_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, &section_of(&path));
316    out
317}
318
319/// Split long prose into atomic statements at sentence and separator
320/// boundaries, re grouped to at most `max_chars` per atom.
321fn 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    // Re group small parts up to the cap.
350    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
385/// Classify one atom. Deliberately deterministic; ambiguous content lands on
386/// `Semantic`, which only affects type quotas, never whether it is stored.
387fn 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(&section_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
412/// Segment and classify a file without storing anything. The demo and the
413/// coverage harness use this to show classification before ingest.
414pub 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(&section, &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    /// Ingest an agent instruction file as individual memories. See the
442    /// module docs for the design; the short version: atomic memories,
443    /// section provenance, action triggers where they apply, nothing pinned
444    /// to every turn, normal write pipeline so re ingesting an edited file
445    /// deduplicates instead of duplicating.
446    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    /// Shared tail for both parsers: batch embed, assign anchor based
463    /// triggers when asked (the fallback parser; the LLM names its own),
464    /// store through the full write pipeline, and fill the report.
465    fn store_atoms(
466        &self,
467        atoms: Vec<AgentFileAtom>,
468        opts: &AgentFileIngestOptions,
469        report: &mut AgentFileIngestReport,
470    ) -> MenteResult<()> {
471        // Batch embed ahead of the stores so a large file pays one provider
472        // round trip per batch, not per line.
473        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        // Snapshot the previous ingest before storing, so sync can compare
486        // the file's new state against what an earlier version left behind.
487        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        // Sync: a memory from a previous ingest survives only if its rule is
534        // still in the file, verbatim after normalization or near verbatim by
535        // embedding. Everything else was deleted or meaningfully edited by
536        // the user, so the old version is forgotten. Scoped to this ingest's
537        // owner pair; another agent's memories under the same tag are never
538        // touched.
539        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    /// How many live memories carry a tag. Bitmap candidates are verified
571    /// against the node, mirroring count_standing_rules.
572    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/// System prompt for the LLM parser. The contract: any file, any format,
587/// any language, any kind of agent, atomic self contained memories, open
588/// trigger vocabulary, JSON only.
589#[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/// Split a large file into LLM sized chunks at section boundaries so no
593/// rule is cut in half.
594#[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        // Hard stop for files without headings.
609        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/// Validate an LLM proposed trigger into the tag vocabulary: lowercase
620/// kebab case, bounded length, never a pin.
621#[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/// Parse the LLM response into atoms. Tolerant of fences and stray prose
640/// around the array; strict about each element.
641#[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            // A completion cut by the output token cap loses the closing
655            // bracket or ends mid element. Recover every complete element
656            // up to the cut instead of dropping the whole chunk.
657            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        // Standing directives arrive with exemplar turns: short example
711        // requests that enter the mode. They are stored as activation
712        // anchors for the injection mode channel (mode-exemplar tags keep
713        // them out of ordinary context), never as instructions.
714        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    /// Ingest an agent file with an LLM parser: any format, any language,
740    /// any kind of agent, open trigger vocabulary. One model pass per file
741    /// (chunked at section boundaries for large files), a one time cost;
742    /// the write path stays deterministic. Falls back to the deterministic
743    /// parser when the model returns nothing usable, so ingest never comes
744    /// back empty because of a bad completion.
745    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        // Adjacent caption, no blank line: fused into one retrievable atom.
812        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        // Blank line between: the bullet is not a caption, the block stands
820        // alone.
821        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        // Triggers are assigned by embedding similarity to anchors or named
836        // by the LLM parser, never by string matching in classification:
837        // phrase matching false fires on prose about commitment and misses
838        // every rephrase.
839        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}