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}