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.
- cjm_markdown_decompose_core/__init__.py +1 -0
- cjm_markdown_decompose_core/extract.py +174 -0
- cjm_markdown_decompose_core/ingest.py +53 -0
- cjm_markdown_decompose_core/parse.py +113 -0
- cjm_markdown_decompose_core/project.py +253 -0
- cjm_markdown_decompose_core/relations.py +215 -0
- cjm_markdown_decompose_core/sections.py +112 -0
- cjm_markdown_decompose_core-0.0.11.dist-info/METADATA +177 -0
- cjm_markdown_decompose_core-0.0.11.dist-info/RECORD +12 -0
- cjm_markdown_decompose_core-0.0.11.dist-info/WHEEL +5 -0
- cjm_markdown_decompose_core-0.0.11.dist-info/licenses/LICENSE +201 -0
- cjm_markdown_decompose_core-0.0.11.dist-info/top_level.txt +1 -0
|
@@ -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)
|