cjm-markdown-decompose-core 0.0.11__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1 @@
1
+ __version__ = "0.0.11"
@@ -0,0 +1,174 @@
1
+ """Map parsed Markdown onto dev-graph-schema nodes (coarse tier).
2
+
3
+ This is the dev-domain binding: it turns a `ParsedMarkdown` into a `NoteNode`
4
+ (one coarse node per file) carrying the frontmatter-derived identity/metadata,
5
+ with the body's `[[wiki-links]]` available as `REFERENCES` edges. The pure
6
+ parsing it builds on (`parse`) stays schema-free; this module is where the
7
+ markdown corpus meets the dev schema.
8
+ """
9
+
10
+ import datetime
11
+ from pathlib import Path
12
+ from typing import Any, Dict, Optional
13
+
14
+ from cjm_context_graph_primitives.provenance import SourceRef
15
+ from cjm_dev_graph_schema.nodes import NoteNode
16
+
17
+ from .parse import parse_markdown, ParsedMarkdown
18
+ from .relations import harvest_relations
19
+ from .sections import decompose_sections
20
+
21
+
22
+ def _json_safe(
23
+ value: Any, # A parsed-frontmatter value (PyYAML may produce date/datetime, nested)
24
+ ) -> Any: # The same value with date/datetime coerced to ISO strings (recursively)
25
+ """Coerce frontmatter values to JSON-serializable forms for the graph wire dict.
26
+
27
+ PyYAML parses `date: 2023-8-21` into a Python `datetime.date` (and timestamps
28
+ into `datetime`), which the metadata passthrough carries verbatim and the graph
29
+ store then can't JSON-encode. Dates are stable identity-irrelevant metadata, so
30
+ ISO strings are the faithful wire form (the memory corpus never hit this — it has
31
+ no date frontmatter; the blog posts do)."""
32
+ if isinstance(value, datetime.date): # also catches datetime.datetime (a subclass)
33
+ return value.isoformat()
34
+ if isinstance(value, dict):
35
+ return {k: _json_safe(v) for k, v in value.items()}
36
+ if isinstance(value, (list, tuple)):
37
+ return [_json_safe(v) for v in value]
38
+ return value
39
+
40
+
41
+ def slug_from(
42
+ path: str, # The file path
43
+ frontmatter: Dict[str, Any], # Parsed frontmatter
44
+ corpus_root: Optional[str] = None, # Root to make the fallback slug relative to
45
+ ) -> str: # Stable note slug
46
+ """Derive the stable slug: frontmatter `name` if present, else the path.
47
+
48
+ The fallback is the corpus-relative path without extension (or the bare stem
49
+ when no root is given) — stable as long as the file does not move, which is
50
+ why an explicit frontmatter `name` is preferred for content that outlives its
51
+ location.
52
+
53
+ SSG convention (Quarto/Hugo/…): a `<dir>/index.md` file is identified by its
54
+ DIRECTORY — the permalink — not the literal `index` stem. Otherwise every
55
+ post in a `posts/<slug>/index.md` tree collapses onto one `index` slug (the
56
+ corpus-findings identity collision). The post's stable identity is its
57
+ directory path relative to the corpus root."""
58
+ name = frontmatter.get("name")
59
+ if isinstance(name, str) and name.strip():
60
+ return name.strip()
61
+ p = Path(path)
62
+ # `<dir>/index.md` → identify by the directory (permalink); else drop the suffix.
63
+ ident = p.parent if p.stem.lower() == "index" else p.with_suffix("")
64
+ if corpus_root:
65
+ try:
66
+ rel = ident.relative_to(corpus_root)
67
+ except ValueError:
68
+ rel = Path(ident.name)
69
+ # A root-level `index.md` relativizes to "." — fall back to the dir name.
70
+ return rel.as_posix() if rel != Path(".") else ident.name
71
+ return ident.name
72
+
73
+
74
+ def title_from(
75
+ slug: str, # The note slug
76
+ frontmatter: Dict[str, Any], # Parsed frontmatter
77
+ ) -> str: # Display title
78
+ """Derive a display title: explicit frontmatter `title`, else the slug titleized."""
79
+ title = frontmatter.get("title")
80
+ if isinstance(title, str) and title.strip():
81
+ return title.strip()
82
+ return slug.replace("-", " ").replace("_", " ").strip().title()
83
+
84
+
85
+ def note_type_from(
86
+ frontmatter: Dict[str, Any], # Parsed frontmatter
87
+ ) -> Optional[str]: # Memory category (user | feedback | project | reference) or None
88
+ """Read the memory category from `metadata.type` (None when absent)."""
89
+ meta = frontmatter.get("metadata")
90
+ if isinstance(meta, dict):
91
+ t = meta.get("type")
92
+ if isinstance(t, str) and t.strip():
93
+ return t.strip()
94
+ return None
95
+
96
+
97
+ def note_from_parsed(
98
+ path: str, # The file path (provenance locator)
99
+ content_hash: str, # Content hash over the file bytes
100
+ parsed: ParsedMarkdown, # The parsed document
101
+ corpus_root: Optional[str] = None, # Root for the fallback slug
102
+ profile: Optional[str] = None, # Relationship-harvest profile (None = auto-detect)
103
+ with_sections: bool = False, # Also decompose the body into Section nodes (notes corpus; off for the memory/dev corpus)
104
+ lossless: bool = False, # Lossless body-on-graph mode (memory corpus): verbatim per-section `raw` spans + level-0 preamble + verbatim `frontmatter_raw`, so the file round-trips byte-for-byte (M1). Implies sectioning
105
+ ) -> NoteNode: # The coarse Note node
106
+ """Build a coarse `NoteNode` from already-parsed Markdown.
107
+
108
+ Beyond the coarse identity/metadata, the per-source-type relationship
109
+ harvesters (`relations`) add the corpus's real relationship signals —
110
+ categories, series membership, cross-post links, aliases — selected by the
111
+ detected (or given) source profile. A cross-post link to THIS post's own
112
+ section is dropped (a self-reference is not a cross-post edge).
113
+
114
+ Body content comes on-graph in one of two opt-in modes. `with_sections`
115
+ (Scope A, the notes corpus) decomposes the body into navigable `Section` nodes
116
+ at the section grain. `lossless` (M1, the high-stakes memory corpus) is the
117
+ stronger form: it sections too, but each section carries its heading-inclusive
118
+ verbatim `raw` span and the Note carries the verbatim `frontmatter_raw`, so the
119
+ file reconstructs byte-for-byte (`frontmatter_raw + ''.join(s.raw in order)`).
120
+ Both are off for plain coarse ingestion."""
121
+ fm = parsed.frontmatter
122
+ slug = slug_from(path, fm, corpus_root)
123
+ description = fm.get("description")
124
+ rel = harvest_relations(parsed, profile)
125
+ own = {slug, Path(path).parent.name} # this post's own permalink (both namespaces)
126
+ cross = [(p, a) for (p, a) in rel.cross_post_refs if p not in own]
127
+ note = NoteNode(
128
+ slug=slug,
129
+ title=title_from(slug, fm),
130
+ path=path,
131
+ content_hash=content_hash,
132
+ description=description.strip() if isinstance(description, str) else "",
133
+ note_type=note_type_from(fm),
134
+ references=list(parsed.wiki_links),
135
+ metadata={k: _json_safe(v) for k, v in fm.items()
136
+ if k not in ("name", "title", "description", "metadata")},
137
+ categories=rel.categories,
138
+ series_refs=rel.series_refs,
139
+ aliases=rel.aliases,
140
+ cross_post_refs=cross,
141
+ frontmatter_raw=parsed.frontmatter_raw if lossless else "",
142
+ )
143
+ if with_sections or lossless:
144
+ note.sections = decompose_sections(parsed.body, note.id, path, lossless=lossless)
145
+ return note
146
+
147
+
148
+ def note_from_text(
149
+ path: str, # The file path (provenance locator)
150
+ text: str, # The full document text
151
+ corpus_root: Optional[str] = None, # Root for the fallback slug
152
+ profile: Optional[str] = None, # Relationship-harvest profile (None = auto-detect)
153
+ with_sections: bool = False, # Also decompose the body into Section nodes
154
+ lossless: bool = False, # Lossless body-on-graph mode (memory corpus); see note_from_parsed
155
+ ) -> NoteNode: # The coarse Note node
156
+ """Parse + map in one step from in-memory text (hashes the UTF-8 bytes)."""
157
+ return note_from_parsed(path, SourceRef.compute_hash(text.encode("utf-8")),
158
+ parse_markdown(text), corpus_root, profile, with_sections, lossless)
159
+
160
+
161
+ def note_from_file(
162
+ path: str, # Path to the Markdown file
163
+ corpus_root: Optional[str] = None, # Root for the fallback slug
164
+ profile: Optional[str] = None, # Relationship-harvest profile (None = auto-detect)
165
+ with_sections: bool = False, # Also decompose the body into Section nodes
166
+ lossless: bool = False, # Lossless body-on-graph mode (memory corpus); see note_from_parsed
167
+ ) -> NoteNode: # The coarse Note node
168
+ """Read a Markdown file and map it to a coarse `NoteNode`.
169
+
170
+ The content hash is computed over the raw file bytes (faithful to the file as
171
+ stored); the text is decoded as UTF-8 for parsing."""
172
+ raw = Path(path).read_bytes()
173
+ return note_from_parsed(path, SourceRef.compute_hash(raw),
174
+ parse_markdown(raw.decode("utf-8")), corpus_root, profile, with_sections, lossless)
@@ -0,0 +1,53 @@
1
+ """Flatten decomposed notes into graph elements for idempotent extension.
2
+
3
+ The pure (queue-free) half of putting a corpus on the graph: turn `NoteNode`s
4
+ into the `(nodes, edges)` wire-dict lists that `cjm_context_graph_layer.ops.
5
+ extend_graph` commits. The queue/capability wiring itself is the projection-CLI
6
+ driver's concern (it owns the JobQueue + graph-storage capability); keeping the
7
+ flattening here makes it reusable and unit-testable without a running graph.
8
+ """
9
+
10
+ from typing import Any, Dict, Iterable, List, Optional, Tuple
11
+
12
+ from cjm_dev_graph_schema.nodes import NoteNode, SeriesNode, TopicNode
13
+
14
+
15
+ def corpus_graph_elements(
16
+ notes: Iterable[NoteNode], # Decomposed notes
17
+ aliases: Optional[Dict[str, str]] = None, # Confirmed {drifted-slug: canonical-slug} link aliases
18
+ ) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]: # (node wire dicts, edge wire dicts)
19
+ """Collect notes into the node + edge wire-dict lists `extend_graph` expects.
20
+
21
+ One `Note` node per file plus its relationship edges — `REFERENCES`
22
+ (`[[wiki-links]]` + cross-post links), `TAGGED` (categories), `IN_SERIES`
23
+ (series membership) — and, when the note was decomposed `with_sections`, its
24
+ `Section` nodes + `HAS_SECTION`/`PART_OF` edges (the body content + hierarchy).
25
+ The shared facet nodes (`Topic` per category, `Series` per series) are emitted
26
+ ONCE, deduped across the corpus, so independent notes sharing a category/series
27
+ converge on one node. Deterministic ids make the result idempotent under
28
+ `extend_graph` — re-ingesting collides into verified no-ops rather than
29
+ duplicating.
30
+
31
+ A confirmed `aliases` map resolves drifted link slugs to their canonical note
32
+ before the edge is built, so a once-dangling `[[wiki-link]]` (or cross-post
33
+ link) lands on the real note (slug-drift rot healed on-graph, file untouched)."""
34
+ nodes: List[Dict[str, Any]] = []
35
+ edges: List[Dict[str, Any]] = []
36
+ topics: Dict[str, None] = {} # distinct category keys (first-seen order)
37
+ series: Dict[str, None] = {} # distinct series keys (first-seen order)
38
+ for n in notes:
39
+ nodes.append(n.to_graph_node())
40
+ edges.extend(n.reference_edges(aliases))
41
+ edges.extend(n.cross_post_edges(aliases))
42
+ edges.extend(n.tagged_edges())
43
+ edges.extend(n.series_edges())
44
+ for sec in n.sections:
45
+ nodes.append(sec.to_graph_node())
46
+ edges.extend(sec.structural_edges())
47
+ for c in n.categories:
48
+ topics.setdefault(c, None)
49
+ for s in n.series_refs:
50
+ series.setdefault(s, None)
51
+ nodes.extend(TopicNode(key=k).to_graph_node() for k in topics)
52
+ nodes.extend(SeriesNode(key=k).to_graph_node() for k in series)
53
+ return nodes, edges
@@ -0,0 +1,113 @@
1
+ """Schema-free Markdown parsing (stdlib + PyYAML).
2
+
3
+ Splits YAML frontmatter from the body, extracts `[[wiki-links]]`, and reads
4
+ ATX headings. Carries NO graph-schema dependency on purpose — this is the
5
+ genuinely-general layer, reusable for any Markdown corpus (memory files today,
6
+ blog posts tomorrow). The dev-domain binding lives in `extract`.
7
+ """
8
+
9
+ import re
10
+ from dataclasses import dataclass, field
11
+ from typing import Any, Dict, List, Optional, Tuple
12
+
13
+ import yaml
14
+
15
+ # A frontmatter block is a leading `---` line, YAML, and a closing `---` line.
16
+ _FRONTMATTER_RE = re.compile(r"\A---[ \t]*\r?\n(.*?)\r?\n---[ \t]*\r?\n?", re.DOTALL)
17
+ # `[[target]]` wiki-link; the target is the inner text (a slug), trimmed.
18
+ _WIKI_LINK_RE = re.compile(r"\[\[([^\[\]]+?)\]\]")
19
+ # ATX heading: 1-6 leading `#`, then the text.
20
+ _HEADING_RE = re.compile(r"^(#{1,6})[ \t]+(.*?)[ \t]*#*[ \t]*$", re.MULTILINE)
21
+ # Code spans, stripped before wiki-link extraction so QUOTED example syntax
22
+ # (`[[wiki-link]]` in prose ABOUT links) is not mistaken for a real reference:
23
+ # fenced blocks first (multi-line), then inline backtick runs (single-line).
24
+ _FENCED_CODE_RE = re.compile(r"```.*?```", re.DOTALL)
25
+ _INLINE_CODE_RE = re.compile(r"(`+)(?:.+?)\1")
26
+
27
+
28
+ @dataclass
29
+ class ParsedMarkdown:
30
+ """The structural decomposition of one Markdown document."""
31
+ frontmatter: Dict[str, Any] = field(default_factory=dict) # Parsed YAML frontmatter ({} if none)
32
+ body: str = "" # Document body (frontmatter stripped)
33
+ wiki_links: List[str] = field(default_factory=list) # `[[link]]` targets, de-duplicated in first-seen order
34
+ headings: List[Tuple[int, str]] = field(default_factory=list) # (level, text) per ATX heading, in document order
35
+ frontmatter_raw: str = "" # The VERBATIM frontmatter prefix (fences + YAML + trailing newline); "" when none. `frontmatter_raw + body == original text`, so it is the lossless round-trip source for the frontmatter (the parsed `frontmatter` dict is a derived projection)
36
+
37
+
38
+ def split_frontmatter(
39
+ text: str, # Full document text
40
+ ) -> Tuple[Optional[str], str]: # (raw frontmatter YAML or None, body)
41
+ """Split a leading `---`-delimited YAML frontmatter block from the body.
42
+
43
+ Returns `(None, text)` when there is no frontmatter (the body is the whole
44
+ document); the leading delimiter must be the very first thing in the file."""
45
+ m = _FRONTMATTER_RE.match(text)
46
+ if not m:
47
+ return None, text
48
+ return m.group(1), text[m.end():]
49
+
50
+
51
+ def parse_frontmatter(
52
+ raw: Optional[str], # Raw frontmatter YAML (from split_frontmatter), or None
53
+ ) -> Dict[str, Any]: # Parsed mapping ({} when absent or non-mapping)
54
+ """Parse frontmatter YAML into a dict (empty dict when absent).
55
+
56
+ Non-mapping frontmatter (a bare scalar/list) yields `{}` — frontmatter is a
57
+ metadata mapping by convention; anything else is ignored rather than guessed."""
58
+ if not raw or not raw.strip():
59
+ return {}
60
+ loaded = yaml.safe_load(raw)
61
+ return loaded if isinstance(loaded, dict) else {}
62
+
63
+
64
+ def strip_code(
65
+ body: str, # Document body
66
+ ) -> str: # Body with fenced + inline code spans blanked
67
+ """Blank out fenced + inline code spans (replaced with a space, length-agnostic).
68
+
69
+ Wiki-link extraction runs on the result so that `[[link]]` written inside
70
+ backticks — example syntax in notes that DISCUSS links, not a real reference —
71
+ is never picked up as an edge (the corpus-findings extraction false positive)."""
72
+ return _INLINE_CODE_RE.sub(" ", _FENCED_CODE_RE.sub(" ", body))
73
+
74
+
75
+ def extract_wiki_links(
76
+ body: str, # Document body
77
+ ) -> List[str]: # `[[link]]` targets, de-duplicated in first-seen order
78
+ """Extract `[[wiki-link]]` targets from the body, de-duplicated, order-preserved.
79
+
80
+ The target is the trimmed inner text (a note slug); code spans are stripped
81
+ first so quoted example syntax is excluded. Order and de-duplication are stable
82
+ so the resulting REFERENCES edge set is deterministic across re-extraction."""
83
+ seen: Dict[str, None] = {}
84
+ for m in _WIKI_LINK_RE.finditer(strip_code(body)):
85
+ target = m.group(1).strip()
86
+ if target:
87
+ seen.setdefault(target, None)
88
+ return list(seen)
89
+
90
+
91
+ def extract_headings(
92
+ body: str, # Document body
93
+ ) -> List[Tuple[int, str]]: # (level, text) per ATX heading, in document order
94
+ """Extract ATX headings (`#`..`######`) as (level, text) pairs."""
95
+ return [(len(m.group(1)), m.group(2).strip()) for m in _HEADING_RE.finditer(body)]
96
+
97
+
98
+ def parse_markdown(
99
+ text: str, # Full document text
100
+ ) -> ParsedMarkdown: # The structural decomposition
101
+ """Parse a Markdown document into frontmatter + body + wiki-links + headings."""
102
+ raw_fm, body = split_frontmatter(text)
103
+ # The verbatim frontmatter prefix is everything `split_frontmatter` consumed
104
+ # (the leading `---` fence, YAML, closing fence, trailing newline) — i.e. the
105
+ # bytes before `body`. Reconstructed losslessly as `text[:len(text)-len(body)]`
106
+ # so that `frontmatter_raw + body == text` holds by construction ("" when none).
107
+ return ParsedMarkdown(
108
+ frontmatter=parse_frontmatter(raw_fm),
109
+ body=body,
110
+ wiki_links=extract_wiki_links(body),
111
+ headings=extract_headings(body),
112
+ frontmatter_raw=text[:len(text) - len(body)],
113
+ )
@@ -0,0 +1,253 @@
1
+ """Projections from the decomposed content back out to files.
2
+
3
+ The first projection is the `MEMORY.md` index: one line per note, grouped by
4
+ category, with each line derived from the note's own frontmatter (title +
5
+ description). This is the self-hosting payoff in miniature — the index becomes a
6
+ PROJECTION of a single source of truth (each note) rather than a file edited by
7
+ hand. Operates on `NoteNode`s, whether they come straight from `extract` or from
8
+ a graph query.
9
+ """
10
+
11
+ import os
12
+ from typing import Any, Dict, Iterable, List, Optional, Tuple
13
+
14
+ from cjm_dev_graph_schema.nodes import NoteNode, SectionNode
15
+
16
+ # Default category section order + display headings for the memory index.
17
+ DEFAULT_SECTION_ORDER = ("project", "feedback", "reference", "user")
18
+ _SECTION_HEADINGS = {
19
+ "project": "Project",
20
+ "feedback": "Feedback",
21
+ "reference": "Reference",
22
+ "user": "User",
23
+ }
24
+ _UNCATEGORIZED = "Other"
25
+
26
+
27
+ def note_index_line(
28
+ note: NoteNode, # The note to render
29
+ ) -> str: # One Markdown index line: "- [Title](file.md) — description"
30
+ """Render one index line from a note's frontmatter-derived fields."""
31
+ link = os.path.basename(note.path)
32
+ line = f"- [{note.title}]({link})"
33
+ if note.description:
34
+ line += f" — {note.description}"
35
+ return line
36
+
37
+
38
+ def _node_field(
39
+ node: Any, # A queried graph node (typed GraphNode or wire dict)
40
+ key: str, # Property name
41
+ default: Any = None,
42
+ ) -> Any: # The property value
43
+ """Read one property from a queried node, tolerating GraphNode or wire dict."""
44
+ props = node.properties if hasattr(node, "properties") else node.get("properties", {})
45
+ return props.get(key, default)
46
+
47
+
48
+ def note_view_from_graph_node(
49
+ node: Any, # A queried `Note` graph node (typed GraphNode or wire dict)
50
+ ) -> NoteNode: # An index-relevant NoteNode reconstructed from the graph
51
+ """Reconstruct an index-relevant `NoteNode` from a queried graph node.
52
+
53
+ Carries the fields the index needs (title / description / path / note_type);
54
+ `references` are an edge query, not needed for the index, so they are left
55
+ empty. This is what closes the self-hosting loop — the projection reads the
56
+ GRAPH, not the in-memory extraction."""
57
+ return NoteNode(
58
+ slug=_node_field(node, "slug", "") or "",
59
+ title=_node_field(node, "title", "") or "",
60
+ path=_node_field(node, "path", "") or "",
61
+ content_hash="",
62
+ description=_node_field(node, "description", "") or "",
63
+ note_type=_node_field(node, "note_type"),
64
+ )
65
+
66
+
67
+ def render_memory_index_from_graph_nodes(
68
+ nodes: Iterable[Any], # Queried `Note` graph nodes (typed GraphNode or wire dicts)
69
+ **kwargs: Any, # Forwarded to render_memory_index (title / section_order / preamble)
70
+ ) -> str: # The rendered MEMORY.md text
71
+ """Render the memory index FROM queried graph nodes (the self-hosting path)."""
72
+ return render_memory_index((note_view_from_graph_node(n) for n in nodes), **kwargs)
73
+
74
+
75
+ def render_memory_index(
76
+ notes: Iterable[NoteNode], # The notes to index
77
+ title: str = "# Project Memory", # Top-level document title
78
+ section_order: Iterable[str] = DEFAULT_SECTION_ORDER, # Category order for sections
79
+ preamble: Optional[str] = None, # Optional prose inserted under the title
80
+ ) -> str: # The rendered MEMORY.md text
81
+ """Render a `MEMORY.md` index: notes grouped by category, one line each.
82
+
83
+ Deterministic: notes are bucketed by `note_type`, sections emitted in
84
+ `section_order` (any leftover categories appended alphabetically, then an
85
+ `Other` bucket for notes without a category), and lines within a section
86
+ sorted by title. Same notes in -> same text out (so a regenerate is a clean
87
+ idempotent overwrite)."""
88
+ buckets: Dict[str, List[NoteNode]] = {}
89
+ for n in notes:
90
+ buckets.setdefault(n.note_type or _UNCATEGORIZED, []).append(n)
91
+
92
+ ordered = list(section_order)
93
+ leftovers = sorted(k for k in buckets if k not in ordered and k != _UNCATEGORIZED)
94
+ section_keys = [k for k in ordered if k in buckets] + leftovers
95
+ if _UNCATEGORIZED in buckets:
96
+ section_keys.append(_UNCATEGORIZED)
97
+
98
+ parts: List[str] = [title]
99
+ if preamble:
100
+ parts.append(preamble.strip())
101
+ for key in section_keys:
102
+ heading = _SECTION_HEADINGS.get(key, key.title())
103
+ lines = [note_index_line(n) for n in sorted(buckets[key], key=lambda n: n.title.lower())]
104
+ parts.append(f"## {heading}\n" + "\n".join(lines))
105
+
106
+ return "\n\n".join(parts) + "\n"
107
+
108
+
109
+ # --- Onboarding surface (the MEMORY.md-as-projection reframe) -----------------
110
+ #
111
+ # A different projection from `render_memory_index`: instead of ENUMERATING every
112
+ # note (which pays session budget for mostly task-conditional content), emit a
113
+ # radically-minimal resident core + a MAP of how to pull the rest from the graph
114
+ # on demand. Domain-neutral: the graph-specific prose (how to query THIS graph,
115
+ # the guardrails) is injected by the driver; the core only assembles the structure
116
+ # + the coverage map + the terse push-core lines.
117
+
118
+ _DEFAULT_INTRO = (
119
+ "> Project knowledge lives in a **context graph**, not this file. This is your MAP "
120
+ "+ the few things to know *before* querying. Pull the rest on demand — don't expect "
121
+ "it enumerated here."
122
+ )
123
+ _DEFAULT_HOW_TO_QUERY = (
124
+ "## How to query\n"
125
+ "Use your read-only graph query tool: `relevant \"<your task>\"` ranks nearby nodes; "
126
+ "`show <id>` drills into one node + its neighbours; `state` is an overview."
127
+ )
128
+ _DEFAULT_HOW_TO_PULL = (
129
+ "## How to pull\n"
130
+ "1. At task start, run `relevant \"<task>\"` — it ranks by structural nearness "
131
+ "(decisions, notes, code, cross-links), richer than any static list.\n"
132
+ "2. `show <id>` for a node's STRUCTURE — title + neighbours, NOT its body; `read <id>` DELIVERS\n"
133
+ " the verbatim body. Pull BOTH on any node you are about to act on.\n"
134
+ "3. If a landmark above names your area, use its query hint as a starting point.\n"
135
+ "4. Treat what you pull as the live source of truth; this surface is only the map."
136
+ )
137
+
138
+
139
+ def first_sentence(
140
+ text: str, # The full description text
141
+ limit: int = 220, # Hard character cap when no sentence boundary is found
142
+ ) -> str: # A terse one-line hook
143
+ """A deterministic terse hook: the description's first sentence, capped.
144
+
145
+ The frontmatter `description` is a full recall-abstract (it feeds relevance, so we
146
+ keep it whole there); the onboarding surface needs a terse navigational hook, so
147
+ take the first sentence (else hard-cap with an ellipsis). Same text in -> same hook."""
148
+ t = " ".join((text or "").split())
149
+ if not t:
150
+ return ""
151
+ best = -1
152
+ for sep in (". ", "; "):
153
+ i = t.find(sep)
154
+ if 0 < i <= limit and (best < 0 or i < best):
155
+ best = i
156
+ if best > 0:
157
+ return t[:best + 1].rstrip()
158
+ return (t[:limit].rstrip() + "…") if len(t) > limit else t
159
+
160
+
161
+ def render_onboarding_surface(
162
+ notes: Iterable[NoteNode], # All Note nodes (the coverage map + push-core lookup)
163
+ push_slugs: Iterable[str], # Allowlist of slugs rendered inline as the resident PUSH core
164
+ landmarks: Iterable[Tuple[str, str]], # (label, query-hint) coverage pointers (NOT an enumeration)
165
+ arc_lead: str, # The live arc-lead anchor (one line)
166
+ *,
167
+ title: str = "# Project Memory — Onboarding Surface", # Document title
168
+ intro: str = _DEFAULT_INTRO, # Orientation prose under the title
169
+ how_to_query: str = _DEFAULT_HOW_TO_QUERY, # Graph-specific "how to query" section (driver overrides)
170
+ how_to_pull: str = _DEFAULT_HOW_TO_PULL, # The pull-discipline section
171
+ push_hooks: Optional[Dict[str, str]] = None, # slug -> terse COMPLETE hook (else first_sentence fallback)
172
+ coverage: Optional[str] = None, # Pre-rendered AUTO landmark subsection (whole-graph facets), appended under the curated map
173
+ ) -> str: # The rendered onboarding-surface markdown
174
+ """Render the onboarding surface: orientation + how-to-query + resident PUSH core + landmark map + how-to-pull.
175
+
176
+ The push core renders one line per allowlisted slug (title + an explicit terse hook
177
+ — else the description's first sentence — + a `show <id>` pull-hint for the full node,
178
+ the exact-node analogue of a landmark's `relevant` cluster hint); the landmark map
179
+ lists coverage pointers + per-category counts (the territory, not its contents).
180
+ Deterministic for a given (notes, seeds) — a regenerate is a clean idempotent overwrite.
181
+ Graph-specific prose is injected via `how_to_query` so the core stays domain-neutral.
182
+
183
+ A push node's `description` is a full recall-abstract that often BURIES the actionable
184
+ point, so truncating it can mislead (e.g. severing "NEVER delete the journal"); the
185
+ explicit `push_hooks` hook is a terse COMPLETE statement — `show <id>` fetches the rest."""
186
+ notes = list(notes)
187
+ by_slug = {n.slug: n for n in notes}
188
+ counts: Dict[str, int] = {}
189
+ for n in notes:
190
+ counts[n.note_type or "other"] = counts.get(n.note_type or "other", 0) + 1
191
+ total = sum(counts.values())
192
+
193
+ parts: List[str] = [f"{title}\n\n{intro}", how_to_query]
194
+ hooks = push_hooks or {}
195
+ push_lines: List[str] = []
196
+ for s in push_slugs:
197
+ if s not in by_slug:
198
+ push_lines.append(f"- _(push node not found on-graph: {s})_")
199
+ continue
200
+ n = by_slug[s]
201
+ push_lines.append(f"- **{n.title}** — {hooks.get(s) or first_sentence(n.description)} ↳ `show {n.id}`")
202
+ parts.append("## Resident core (read me)\n- " + arc_lead + "\n" + "\n".join(push_lines))
203
+ cov = ", ".join(f"{k}:{v}" for k, v in sorted(counts.items()))
204
+ lm = "\n".join(f"- **{label}** → `relevant \"{hint}\"`" for label, hint in landmarks)
205
+ landmark_section = ("## Landmark map — what's on-graph (coverage, not enumeration)\n"
206
+ f"_~{total} notes ({cov}); query a landmark to pull its cluster:_\n" + lm)
207
+ if coverage: # the auto-derived whole-graph facets (curated map stays; this augments it)
208
+ landmark_section += "\n\n" + coverage.strip()
209
+ parts.append(landmark_section)
210
+ parts.append(how_to_pull)
211
+ return "\n\n".join(parts) + "\n"
212
+
213
+
214
+ # --- Lossless note round-trip (M1: memory bodies on-graph) --------------------
215
+ #
216
+ # The inverse of `lossless` decomposition: reassemble a note's exact file text
217
+ # from its verbatim parts — the Note's `frontmatter_raw` plus every Section's
218
+ # heading-inclusive `raw` span in document order. PURE concatenation, no
219
+ # canonical-seam emission (prose derives nothing, unlike code's imports/ordering),
220
+ # so the result is byte-for-byte the original file when the note was decomposed
221
+ # `lossless=True`. This is M1's content-fidelity gate (file -> graph -> file ==
222
+ # file) and the read leg of authoring memory on-graph (M2).
223
+
224
+ def render_note_text(
225
+ frontmatter_raw: str, # The note's verbatim frontmatter prefix ("" when none)
226
+ sections: Iterable[SectionNode], # The note's Section nodes (any order; sorted by `order` here)
227
+ ) -> str: # The reconstructed file text
228
+ """Reassemble a note's exact file text from its verbatim parts (lossless mode).
229
+
230
+ `frontmatter_raw + ''.join(s.raw for s in sorted-by-order)`. Requires sections
231
+ decomposed `lossless=True` (each carries its heading-inclusive `raw` span, with
232
+ a level-0 preamble at order 0); a Scope-A section set has empty `raw` and will
233
+ NOT round-trip — that is the mode boundary, not a bug."""
234
+ ordered = sorted(sections, key=lambda s: s.order)
235
+ return frontmatter_raw + "".join(s.raw for s in ordered)
236
+
237
+
238
+ def note_text_from_graph_nodes(
239
+ note_node: Any, # The queried `Note` graph node (typed GraphNode or wire dict)
240
+ section_nodes: Iterable[Any], # The note's queried `Section` graph nodes
241
+ ) -> str: # The reconstructed file text (the self-hosting round-trip path)
242
+ """Reconstruct a note's file text FROM the graph (frontmatter_raw + ordered raw spans).
243
+
244
+ Reads `frontmatter_raw` off the Note and each Section's `raw`/`order` off the
245
+ queried nodes — the projection reads the GRAPH, not the in-memory extraction
246
+ (the same loop-closing move as `render_memory_index_from_graph_nodes`)."""
247
+ frontmatter_raw = _node_field(note_node, "frontmatter_raw", "") or ""
248
+ spans = sorted(
249
+ ((int(_node_field(s, "order", 0) or 0), _node_field(s, "raw", "") or "")
250
+ for s in section_nodes),
251
+ key=lambda t: t[0],
252
+ )
253
+ return frontmatter_raw + "".join(raw for _, raw in spans)