Skip to main content

memstead_base/engine/
export_llms_txt.rs

1//! Render one mem as a single agent-readable Markdown document — the
2//! `/llms-full.txt` shape, shared by the served endpoint and
3//! `memstead export --format llms-txt`.
4//!
5//! The document is built to be *swallowed whole*: an agent whose goal is
6//! "understand this graph fast" reads one document instead of walking the
7//! graph entity by entity. That is why the shape is what it is — every
8//! non-stub entity exactly once, in stable order, with its type visible and
9//! its references resolved to links that work from inside the flat document.
10//!
11//! **This lives in the engine so the served and exported documents cannot
12//! drift.** It was previously deployment-local, and matching the deployed
13//! shape by copying it would have made "matches" a snapshot rather than a
14//! property: two copies of a document shape is exactly how a divergence
15//! starts. One renderer, two callers.
16//!
17//! Stubs never appear. A stub is the engine's placeholder for an unresolved
18//! reference, not content, and the document's own header promises every
19//! non-stub entity — so rendering one would make the header false.
20//!
21//! Empty sections are kept verbatim. An explicitly empty slot is signal to an
22//! agent: it says the schema asks for this and nobody has answered, which is
23//! different from the section not existing.
24
25use crate::entity::EntityId;
26use crate::render;
27
28/// Deployment-supplied inputs to the document header.
29///
30/// Everything here is context the *renderer* cannot know: who is serving the
31/// document and what else the reader should be pointed at. A CLI export has no
32/// deployment identity, so these are optional rather than defaulted — a header
33/// that invents an authority would be lying about provenance, which is the one
34/// thing this document exists to state plainly.
35#[derive(Debug, Clone, Default)]
36pub struct LlmsTxtContext {
37    /// The serving authority (a host). `None` for a CLI export, whose header
38    /// names the mem instead — no deployment is vouching for the bytes.
39    pub authority: Option<String>,
40    /// Absolute link prefix (origin + any gate prefix). Empty renders entity
41    /// references as the relative `entity/<id>`, which is what a document
42    /// exported to a file wants.
43    pub href_prefix: String,
44    /// Cross-origin links the serving surface wants an agent to see. Empty
45    /// omits the block entirely — a user exporting their own mem is not
46    /// advertising someone else's project.
47    pub wider_project: Vec<(String, String)>,
48}
49
50/// A markdown link for one entity. `[` / `]` in a title would break the link
51/// text, so they are folded to parentheses rather than escaped — the title is
52/// display text here, not data being round-tripped.
53pub fn entity_md_link(href_prefix: &str, id: &str, title: &str) -> String {
54    let text = title.replace('[', "(").replace(']', ")");
55    if href_prefix.is_empty() {
56        format!("[{text}](entity/{id})")
57    } else {
58        format!("[{text}]({href_prefix}/entity/{id})")
59    }
60}
61
62/// Drop the frontmatter block the shared entity render emits. The flat
63/// document surfaces the type as a visible line instead; the rest of the
64/// frontmatter is agent-budget metadata that would be noise repeated once per
65/// entity.
66pub fn strip_frontmatter(md: &str) -> String {
67    let mut lines = md.lines();
68    if lines.next() == Some("---") {
69        let mut closed = false;
70        let mut body: Vec<&str> = Vec::new();
71        for line in lines {
72            if !closed && line == "---" {
73                closed = true;
74                continue;
75            }
76            if closed {
77                body.push(line);
78            }
79        }
80        if closed {
81            return body.join("\n").trim_start_matches('\n').to_string();
82        }
83    }
84    md.to_string()
85}
86
87/// Rewrite `[[…]]` wiki-links to markdown links, resolving each occurrence
88/// under a three-rule precedence:
89///
90/// 1. **Qualified references** — the dash form (`[[mem--slug]]`) and the
91///    canonical colon form (`[[mem:slug]]`, the grammar's cross-mem shape,
92///    which hierarchical mems can only write this way) — unambiguous,
93///    always resolve; a qualified miss never falls through to the slug
94///    passes, because the author named a mem.
95/// 2. **Local bare slugs** — a slug present in the source mem binds there,
96///    however many other mems reuse it. That is the engine's authoring
97///    semantics: a bare wiki-link is a same-mem reference.
98/// 3. **Foreign bare slugs** — a slug owned by exactly one *other* mem
99///    resolves to it; a slug two foreign mems both own **stays raw text**.
100///    Guessing between them would fabricate a reference the author never
101///    made, and a visibly unresolved `[[slug]]` is the honest output.
102///
103/// **Code is never rewritten.** Resolution scans the engine's masked view —
104/// the same one every other reader uses — and slices from the original, so a
105/// fenced or inline code sample documenting wiki-link syntax comes out
106/// byte-identical. A hand-rolled string walk without masking is exactly the
107/// defect the HTML exporter was fixed for; sharing the masked view is what
108/// stops this renderer reintroducing it.
109///
110/// `id_titles` must contain **no stubs**. A stub is an unresolved reference
111/// the engine materialised, not content — and this document excludes stubs,
112/// so resolving a link to one would emit a link to a page the document itself
113/// does not contain. Worse, because an engine-authored bare wiki-link creates
114/// a *local* stub, stubs in the map make rule 2 match every bare slug and
115/// rules 3's foreign passes unreachable.
116pub fn linkify_wikilinks(
117    body: String,
118    id_titles: &[(String, String)],
119    href_prefix: &str,
120    source_mem: &str,
121) -> String {
122    let mut by_id: std::collections::HashMap<&str, &str> = std::collections::HashMap::new();
123    let mut local: std::collections::HashMap<&str, (&str, &str)> = std::collections::HashMap::new();
124    let mut foreign: std::collections::HashMap<&str, Option<(&str, &str)>> =
125        std::collections::HashMap::new();
126    for (id, title) in id_titles {
127        by_id.insert(id.as_str(), title.as_str());
128        if let Some((mem, slug)) = id.split_once("--") {
129            if mem == source_mem {
130                local.insert(slug, (id.as_str(), title.as_str()));
131            } else {
132                foreign
133                    .entry(slug)
134                    .and_modify(|e| *e = None)
135                    .or_insert(Some((id.as_str(), title.as_str())));
136            }
137        }
138    }
139
140    let resolve = |target: &str| -> Option<(String, String)> {
141        if let Some(title) = by_id.get(target) {
142            return Some((target.to_string(), title.to_string()));
143        }
144        // The canonical colon form (`mem:slug`) is the same qualified
145        // reference as the dash form one grammar tier over — the entity
146        // parser and both wiki-link decoders read it, and hierarchical mems
147        // can only be referenced this way. Canonicalise and look up; a miss
148        // stays a miss, because the author named a mem and guessing a slug
149        // pass instead would rebind the reference.
150        if !target.contains("::")
151            && let Some((prefix, slug)) = target.split_once(':')
152            && !prefix.is_empty()
153            && !slug.is_empty()
154        {
155            let id = crate::entity::EntityId::new(prefix, slug);
156            return by_id
157                .get(id.as_ref())
158                .map(|title| (id.to_string(), title.to_string()));
159        }
160        if let Some((id, title)) = local.get(target) {
161            return Some((id.to_string(), title.to_string()));
162        }
163        // `Some(None)` is an ambiguous foreign slug — deliberately unresolved.
164        foreign
165            .get(target)
166            .copied()
167            .flatten()
168            .map(|(id, title)| (id.to_string(), title.to_string()))
169    };
170
171    // Offsets come from the masked view; every byte emitted comes from the
172    // original, so masking can only change WHICH spans are rewritten, never
173    // the bytes of the ones that are not.
174    let masked = crate::markdown::mask_code_blocks_and_spans(&body);
175    let mut out = String::with_capacity(body.len());
176    let mut cursor = 0usize;
177    let bytes = masked.as_bytes();
178    let mut i = 0usize;
179    while i + 1 < bytes.len() {
180        if bytes[i] == b'['
181            && bytes[i + 1] == b'['
182            && let Some(rel) = masked[i + 2..].find("]]")
183        {
184            let inner_start = i + 2;
185            let inner_end = inner_start + rel;
186            // The link text is read from the ORIGINAL: the masked view is
187            // only a map of where code is.
188            let inner = &body[inner_start..inner_end];
189            // Only the TARGET half resolves; an author-supplied label wins
190            // as link text, because that is what the author chose a reader
191            // to see. Target normalisation is the decoders' own
192            // `strip_wiki_link_decorations` — `|label`, `#anchor`, `../`,
193            // `.md` — so this renderer reads exactly the grammar the parser
194            // and validators read, no form less. Two rounds of the same
195            // leak (the aliased form, then the colon form) came from
196            // hand-rolling a subset of it here.
197            let label = inner.split_once('|').map(|(_, l)| l.trim());
198            let target = crate::entity::id::strip_wiki_link_decorations(inner);
199            out.push_str(&body[cursor..i]);
200            match resolve(&target) {
201                Some((id, title)) => {
202                    out.push_str(&entity_md_link(href_prefix, &id, label.unwrap_or(&title)))
203                }
204                // Unresolvable: name it as PLAIN TEXT — never a link, never
205                // surviving `[[…]]` syntax.
206                //
207                // A link would invent a target: for an ambiguous foreign slug
208                // it would pick one of two arbitrarily, and for a stub target
209                // it would point at a page this document deliberately
210                // excludes. Leaving the brackets would put internal wiki-link
211                // syntax in front of a reader the header promised a
212                // self-contained document to. Plain text is the third option,
213                // and the only one that is neither a guess nor a leak.
214                // Print the label when the author gave one, else the target —
215                // never the raw `target|label` span, which would put an
216                // internal id and a pipe in front of the reader.
217                None => out.push_str(label.unwrap_or(&target)),
218            }
219            cursor = inner_end + 2;
220            i = inner_end + 2;
221            continue;
222        }
223        i += 1;
224    }
225    out.push_str(&body[cursor..]);
226    out
227}
228
229impl crate::Engine {
230    /// Render `mem` as one Markdown document in the `/llms-full.txt` shape.
231    ///
232    /// Refuses an unmounted mem with [`EngineError::UnknownMem`] rather than
233    /// emitting an empty document — a document that says "Entities: 0" about a
234    /// mem this workspace never mounted is a confident wrong answer, and the
235    /// caller can tell the two apart only if the engine does.
236    ///
237    /// [`EngineError::UnknownMem`]: crate::engine::EngineError::UnknownMem
238    pub fn render_llms_txt(
239        &self,
240        mem: &str,
241        ctx: &LlmsTxtContext,
242    ) -> Result<String, crate::engine::EngineError> {
243        let mounted = self
244            .mounts
245            .iter()
246            .find(|m| m.mount.mem == mem)
247            .ok_or_else(|| crate::engine::EngineError::UnknownMem(mem.to_string()))?;
248
249        let schema_pin = mounted
250            .mount
251            .schema
252            .as_ref()
253            .map(|s| s.as_display())
254            .unwrap_or_default();
255        let config = self.mem_config_for(mem);
256        let subject = config
257            .and_then(|c| c.description.clone())
258            .unwrap_or_else(|| mem.to_string());
259        // The provenance line says who vouches. With an authority that is the
260        // deployment; without one it is the workspace the export came from.
261        // Printing "this deployment vouches" into a file exported from a
262        // laptop would make the header's one load-bearing sentence false.
263        let provenance = match (self.mem_origin_class(mem), ctx.authority.is_some()) {
264            (crate::render::OriginClass::FirstParty, true) => {
265                "first-party (this deployment vouches for the content as its own)"
266            }
267            (crate::render::OriginClass::ThirdParty, true) => {
268                "third-party (this deployment does not vouch for the content)"
269            }
270            (crate::render::OriginClass::FirstParty, false) => {
271                "first-party (authored in the workspace this export came from)"
272            }
273            (crate::render::OriginClass::ThirdParty, false) => {
274                "third-party (a read-only mount — someone else's published content)"
275            }
276        };
277
278        // Every non-stub entity of THIS mem, once, in stable id order.
279        let id_titles = self.entity_id_titles();
280        let mut ids: Vec<String> = self
281            .store
282            .all_entities()
283            .filter(|e| e.mem == mem && !e.stub)
284            .map(|e| e.id.to_string())
285            .collect();
286        ids.sort();
287        let count = ids.len();
288
289        // The header names an authority only when one is serving. A CLI export
290        // names the mem: no deployment is vouching for these bytes, and saying
291        // otherwise would put a false provenance line at the top of the one
292        // document written to be read whole and believed.
293        let heading = match &ctx.authority {
294            Some(a) => format!("# {a} — {subject}\n\nAuthority: {a}\n"),
295            // A mem with no description falls back to its own name as the
296            // subject, which would render "# srcmem — srcmem". Say it once.
297            None if subject == mem => format!("# {mem}\n\nMem: {mem}\n"),
298            None => format!("# {mem} — {subject}\n\nMem: {mem}\n"),
299        };
300        let wider = if ctx.wider_project.is_empty() {
301            String::new()
302        } else {
303            let lines: String = ctx
304                .wider_project
305                .iter()
306                .map(|(url, what)| format!("- {url} — {what}.\n"))
307                .collect();
308            // Trailing blank line: the served document has always had one
309            // between the list and the closing sentence, and without it the
310            // sentence becomes a lazy continuation of the last list item in
311            // Markdown. A `contains`-based test cannot see the difference,
312            // which is exactly why it went unnoticed.
313            format!("The wider project:\n{lines}\n")
314        };
315        let links_sentence = if ctx.href_prefix.is_empty() {
316            "Entity references are relative links to that entity's own page."
317        } else {
318            "Entity references are absolute links to that entity's own page."
319        };
320
321        let mut out = format!(
322            "{heading}\
323Subject: {subject}\n\
324Schema: {schema_pin}\n\
325Entities: {count}\n\
326Provenance: {provenance}\n\n\
327{wider}\
328Every non-stub entity of this Memstead graph follows, once, with its type and \
329sections. {links_sentence}\n\n\
330---\n\n"
331        );
332
333        for id in &ids {
334            let Some(entity) = self.get_entity(&EntityId::canonical(id)) else {
335                continue;
336            };
337            let md = strip_frontmatter(&render::render_entity_markdown(entity, None));
338            let typed = match md.split_once('\n') {
339                Some((title_line, rest)) => format!(
340                    "{title_line}\n\n_Type: {}_\n\n{}",
341                    entity.entity_type,
342                    rest.trim_start_matches('\n')
343                ),
344                None => format!("{md}\n\n_Type: {}_", entity.entity_type),
345            };
346            out.push_str(&linkify_wikilinks(typed, &id_titles, &ctx.href_prefix, mem));
347            if !out.ends_with('\n') {
348                out.push('\n');
349            }
350            out.push_str("\n---\n\n");
351        }
352
353        Ok(out)
354    }
355
356    /// `(id, title)` for every entity in the store — the lookup the link
357    /// rewriter needs. Workspace-wide on purpose: a wiki-link may reach into
358    /// another mounted mem, and resolving it is what makes the flat document
359    /// navigable.
360    fn entity_id_titles(&self) -> Vec<(String, String)> {
361        // Stubs are excluded. This document omits stub entities, so linking to
362        // one would point at a page the document does not contain — and since
363        // an engine-authored bare wiki-link materialises a LOCAL stub, leaving
364        // stubs in would make every bare slug resolve locally and the
365        // foreign-slug rules unreachable.
366        let mut out: Vec<(String, String)> = self
367            .store
368            .all_entities()
369            .filter(|e| !e.stub)
370            .map(|e| (e.id.to_string(), e.title.clone()))
371            .collect();
372        out.sort();
373        out
374    }
375}
376
377#[cfg(test)]
378mod tests {
379    use super::*;
380
381    fn titles() -> Vec<(String, String)> {
382        vec![
383            ("engine--mem".to_string(), "Mem".to_string()),
384            ("flagship--mem".to_string(), "Mem".to_string()),
385            ("engine--pipeline".to_string(), "Pipeline".to_string()),
386        ]
387    }
388
389    /// The three passes, and their precedence. A full id always resolves; a
390    /// bare slug binds mem-locally first (the engine's authoring semantics);
391    /// and a slug two FOREIGN mems both own stays raw text rather than being
392    /// guessed — a fabricated reference the author never made is worse than a
393    /// visibly unresolved one.
394    #[test]
395    fn wiki_links_resolve_in_three_passes_and_never_guess() {
396        let t = titles();
397
398        // Pass 1 — full id.
399        assert_eq!(
400            linkify_wikilinks("See [[engine--pipeline]].".to_string(), &t, "", "engine"),
401            "See [Pipeline](entity/engine--pipeline)."
402        );
403
404        // Pass 3 wins over pass 2 — the slug exists locally, so it binds
405        // locally however many other mems reuse it.
406        assert_eq!(
407            linkify_wikilinks("A [[mem]].".to_string(), &t, "", "engine"),
408            "A [Mem](entity/engine--mem)."
409        );
410        assert_eq!(
411            linkify_wikilinks("A [[mem]].".to_string(), &t, "", "flagship"),
412            "A [Mem](entity/flagship--mem)."
413        );
414
415        // Pass 2 — unique in exactly one foreign mem, so it resolves.
416        assert_eq!(
417            linkify_wikilinks("A [[pipeline]].".to_string(), &t, "", "flagship"),
418            "A [Pipeline](entity/engine--pipeline)."
419        );
420
421        // Ambiguous across two FOREIGN mems and absent locally: named, not
422        // guessed — and not left as wiki-link syntax either.
423        assert_eq!(
424            linkify_wikilinks("A [[mem]].".to_string(), &t, "", "plugin"),
425            "A mem.",
426            "an ambiguous foreign slug degrades to plain text, never a guess"
427        );
428    }
429
430    /// Exactly two link forms exist, selected by whether a base is given.
431    /// A third — root-relative `/entity/<id>` — must never appear: the
432    /// document is read both from a file and from a served page, and only
433    /// these two work in both places.
434    #[test]
435    fn only_two_link_forms_are_emitted() {
436        let t = titles();
437        let rel = linkify_wikilinks("[[engine--mem]]".to_string(), &t, "", "engine");
438        assert_eq!(rel, "[Mem](entity/engine--mem)");
439        assert!(!rel.contains("(/entity/"), "never root-relative: {rel}");
440
441        let abs = linkify_wikilinks(
442            "[[engine--mem]]".to_string(),
443            &t,
444            "https://example.com",
445            "engine",
446        );
447        assert_eq!(abs, "[Mem](https://example.com/entity/engine--mem)");
448    }
449
450    /// A title carrying brackets would break the markdown link text, so they
451    /// fold to parentheses. The link target is unaffected.
452    #[test]
453    fn bracketed_titles_cannot_break_the_link() {
454        let t = vec![("m--x".to_string(), "A [bracketed] title".to_string())];
455        assert_eq!(
456            linkify_wikilinks("[[m--x]]".to_string(), &t, "", "m"),
457            "[A (bracketed) title](entity/m--x)"
458        );
459    }
460
461    /// Wiki-link syntax inside code is documentation, not a reference. A
462    /// fenced block or inline span showing `[[slug]]` must come out
463    /// byte-identical — the defect the HTML exporter was fixed for, which a
464    /// hand-rolled string walk reintroduces the moment nobody checks.
465    #[test]
466    fn code_spans_and_fences_are_never_rewritten() {
467        let t = titles();
468        let body = "Prose [[engine--mem]] resolves.\n\n\
469             Inline `[[engine--mem]]` does not.\n\n\
470             ```\n[[engine--mem]]\n```\n";
471        let out = linkify_wikilinks(body.to_string(), &t, "", "engine");
472
473        assert!(
474            out.contains("Prose [Mem](entity/engine--mem) resolves."),
475            "prose still resolves: {out}"
476        );
477        assert!(
478            out.contains("Inline `[[engine--mem]]` does not."),
479            "an inline code span is left alone: {out}"
480        );
481        assert!(
482            out.contains("```\n[[engine--mem]]\n```"),
483            "a fenced block is left alone: {out}"
484        );
485    }
486
487    /// The engine's grammar has three wiki-link forms and this renderer must
488    /// read all of them. Only the target half resolves; an author-supplied
489    /// label is what a reader sees, and a `#Section` suffix addresses within
490    /// the target rather than naming a different one.
491    ///
492    /// Passing the whole span to the resolver made every aliased reference
493    /// miss and fall to the plain-text arm — printing the internal id and the
494    /// pipe into prose, precisely where the author had written a label.
495    #[test]
496    fn alias_and_anchor_wiki_link_forms_resolve() {
497        let t = titles();
498
499        // `[[target|label]]` — resolves, and the LABEL is the link text.
500        assert_eq!(
501            linkify_wikilinks("See [[engine--mem|the mem]].".to_string(), &t, "", "engine"),
502            "See [the mem](entity/engine--mem)."
503        );
504        // `[[target#Section]]` — the anchor addresses within the target.
505        assert_eq!(
506            linkify_wikilinks(
507                "See [[engine--mem#Identity]].".to_string(),
508                &t,
509                "",
510                "engine"
511            ),
512            "See [Mem](entity/engine--mem)."
513        );
514        // Both at once, and on a bare slug rather than a full id.
515        assert_eq!(
516            linkify_wikilinks("See [[mem#Identity|here]].".to_string(), &t, "", "engine"),
517            "See [here](entity/engine--mem)."
518        );
519        // Unresolvable WITH a label: the reader gets the label, never the
520        // internal target or the pipe.
521        assert_eq!(
522            linkify_wikilinks("See [[ghost|that thing]].".to_string(), &t, "", "engine"),
523            "See that thing."
524        );
525    }
526
527    /// The canonical colon cross-mem form (`[[mem:slug]]`) is a qualified
528    /// reference like the dash form — the grammar's own shape for it, and
529    /// the only one a hierarchical mem can be written in. It resolves to
530    /// the same target, an author label still wins as link text, and a miss
531    /// degrades to plain text WITHOUT falling through to the slug passes:
532    /// the author named a mem, and rebinding the slug elsewhere would be a
533    /// guess. The body of a live mem carried exactly this form into prose
534    /// as unresolved text when only `|` and `#` were hand-stripped here.
535    #[test]
536    fn colon_cross_mem_form_resolves_as_a_qualified_reference() {
537        let t = titles();
538
539        assert_eq!(
540            linkify_wikilinks("See [[flagship:mem]].".to_string(), &t, "", "engine"),
541            "See [Mem](entity/flagship--mem)."
542        );
543        assert_eq!(
544            linkify_wikilinks(
545                "See [[engine:mem|the mem]].".to_string(),
546                &t,
547                "",
548                "flagship"
549            ),
550            "See [the mem](entity/engine--mem)."
551        );
552        // A colon miss is a miss: `pipeline` is unique in a foreign mem, but
553        // the author qualified it into a mem that does not own it.
554        assert_eq!(
555            linkify_wikilinks("See [[flagship:pipeline]].".to_string(), &t, "", "engine"),
556            "See flagship:pipeline."
557        );
558    }
559
560    /// A reference to nothing is named in plain text — the same treatment an
561    /// ambiguous foreign slug gets, for the same reason: say what could not be
562    /// resolved without inventing a target, and without leaking internal
563    /// wiki-link syntax into a document promised as self-contained.
564    ///
565    /// The one place `[[…]]` legitimately survives is inside code, which is
566    /// never rewritten at all.
567    #[test]
568    fn an_unresolvable_reference_degrades_to_plain_text() {
569        let t = titles();
570        assert_eq!(
571            linkify_wikilinks("See [[ghost]].".to_string(), &t, "", "engine"),
572            "See ghost."
573        );
574        // A full id whose target is a stub is the same case: stubs are absent
575        // from the map by design, so the reference cannot resolve — and this
576        // is the shape the auto-generated `## Relationships` block emits.
577        assert_eq!(
578            linkify_wikilinks(
579                "- **USES**: [[engine--phantom]]".to_string(),
580                &t,
581                "",
582                "engine"
583            ),
584            "- **USES**: engine--phantom"
585        );
586    }
587
588    /// Frontmatter is dropped; a body that has none is returned untouched.
589    #[test]
590    fn frontmatter_is_stripped_only_when_present() {
591        assert_eq!(
592            strip_frontmatter("---\ntype: spec\n---\n\n# Title\n\nBody."),
593            "# Title\n\nBody."
594        );
595        assert_eq!(strip_frontmatter("# Title\n\nBody."), "# Title\n\nBody.");
596        // An unterminated block is not frontmatter — returning the body
597        // half-eaten would silently lose content.
598        assert_eq!(strip_frontmatter("---\nno close"), "---\nno close");
599    }
600}