diff --git a/.gitignore b/.gitignore
index 3d00d81..bd232f2 100644
--- a/.gitignore
+++ b/.gitignore
@@ -27,6 +27,10 @@ Thumbs.db
# Go
vendor/
+# Python
+__pycache__/
+*.py[cod]
+
# Mnemon data (local dev)
*.db
*.db-wal
diff --git a/docs/harness/README.md b/docs/harness/README.md
index 57e5244..623de78 100644
--- a/docs/harness/README.md
+++ b/docs/harness/README.md
@@ -28,8 +28,8 @@ projection into host surfaces, and optional daemon scheduling.
| Loop Module Standard | [EN](LOOP_MODULE_STANDARD.md) / [中文](../zh/harness/LOOP_MODULE_STANDARD.md) |
| Host Projection | [EN](HOST_PROJECTION.md) / [中文](../zh/harness/HOST_PROJECTION.md) |
| Harness Roadmap | [EN](ROADMAP.md) / [中文](../zh/harness/ROADMAP.md) |
-| Memory Loop | [EN](memory-loop/DESIGN.md) / [中文](../zh/harness/memory-loop/DESIGN.md) / [site](../site/memory-loop/site.html) |
-| Skill Loop | [EN](skill-loop/DESIGN.md) / [中文](../zh/harness/skill-loop/DESIGN.md) / [site](../site/skill-loop/site.html) |
+| Memory Loop | [EN](memory-loop/DESIGN.md) / [中文](../zh/harness/memory-loop/DESIGN.md) / [site](../site/memory-loop/index.html) |
+| Skill Loop | [EN](skill-loop/DESIGN.md) / [中文](../zh/harness/skill-loop/DESIGN.md) / [site](../site/skill-loop/index.html) |
| Eval Loop | [EN](eval-loop/DESIGN.md) / [中文](../zh/harness/eval-loop/DESIGN.md) |
## Installable Assets
diff --git a/docs/harness/memory-loop/DESIGN.md b/docs/harness/memory-loop/DESIGN.md
index ebc64b3..8354fda 100644
--- a/docs/harness/memory-loop/DESIGN.md
+++ b/docs/harness/memory-loop/DESIGN.md
@@ -1,6 +1,6 @@
# Memory Loop MVP Design
-Related visualization: [site.html](../../site/memory-loop/site.html)
+Related visualization: [memory-loop](../../site/memory-loop/index.html)
Chinese version: [DESIGN.md](../../zh/harness/memory-loop/DESIGN.md)
diff --git a/docs/harness/skill-loop/DESIGN.md b/docs/harness/skill-loop/DESIGN.md
index 923bde9..9bc6acd 100644
--- a/docs/harness/skill-loop/DESIGN.md
+++ b/docs/harness/skill-loop/DESIGN.md
@@ -1,6 +1,6 @@
# Skill Loop MVP Design
-Related visualization: [site.html](../../site/skill-loop/site.html)
+Related visualization: [skill-loop](../../site/skill-loop/index.html)
Installable MVP assets: [harness/modules/skill-loop](../../../harness/modules/skill-loop/README.md)
diff --git a/docs/server/render_docs.py b/docs/server/render_docs.py
new file mode 100644
index 0000000..764e81b
--- /dev/null
+++ b/docs/server/render_docs.py
@@ -0,0 +1,455 @@
+#!/usr/bin/env python3
+"""Serve docs/ as rendered Markdown without generating HTML files."""
+
+from __future__ import annotations
+
+import html
+import mimetypes
+import os
+import re
+from http import HTTPStatus
+from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
+from pathlib import Path
+from urllib.parse import quote, unquote, urlparse
+
+
+ROOT = Path(__file__).resolve().parents[1]
+PREFIX = "/docs"
+HIDDEN_TOP_LEVEL = {"server"}
+PORT = int(os.environ.get("MNEMON_DOCS_RENDERER_PORT", "4180"))
+
+
+CSS = """
+:root {
+ color-scheme: light;
+ --bg: #f6f8fb;
+ --ink: #151922;
+ --muted: #667085;
+ --line: #d9e0ea;
+ --panel: #ffffff;
+ --accent: #2f7d55;
+}
+* { box-sizing: border-box; }
+body {
+ margin: 0;
+ background: var(--bg);
+ color: var(--ink);
+ font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
+ line-height: 1.65;
+}
+a { color: #1d5f91; }
+.topbar {
+ position: sticky;
+ top: 0;
+ z-index: 2;
+ display: flex;
+ justify-content: space-between;
+ gap: 18px;
+ padding: 14px 24px;
+ border-bottom: 1px solid var(--line);
+ background: rgba(246, 248, 251, 0.96);
+}
+.brand {
+ color: var(--ink);
+ font-weight: 800;
+ text-decoration: none;
+}
+nav {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 14px;
+ font-size: 14px;
+}
+.page {
+ width: min(980px, calc(100vw - 32px));
+ margin: 0 auto;
+ padding: 34px 0 72px;
+}
+h1, h2, h3, h4, h5, h6 {
+ line-height: 1.2;
+ letter-spacing: 0;
+}
+h1 {
+ margin: 0 0 20px;
+ font-size: clamp(32px, 5vw, 56px);
+}
+h2 {
+ margin-top: 36px;
+ padding-top: 10px;
+ border-top: 1px solid var(--line);
+}
+p, li { max-width: 82ch; }
+pre {
+ overflow-x: auto;
+ padding: 16px;
+ border: 1px solid var(--line);
+ border-radius: 8px;
+ background: #111827;
+ color: #f8fafc;
+}
+code {
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
+}
+p code, li code, td code {
+ padding: 2px 5px;
+ border: 1px solid var(--line);
+ border-radius: 5px;
+ background: var(--panel);
+}
+blockquote {
+ margin-left: 0;
+ padding: 10px 16px;
+ border-left: 4px solid var(--accent);
+ color: var(--muted);
+ background: var(--panel);
+}
+table {
+ display: block;
+ width: 100%;
+ overflow-x: auto;
+ border-collapse: collapse;
+}
+th, td {
+ padding: 8px 10px;
+ border: 1px solid var(--line);
+ text-align: left;
+ vertical-align: top;
+}
+th { background: var(--panel); }
+img {
+ max-width: 100%;
+ height: auto;
+ border: 1px solid var(--line);
+ border-radius: 8px;
+ background: var(--panel);
+}
+.listing {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
+ gap: 10px;
+ padding: 0;
+ list-style: none;
+}
+.listing a {
+ display: block;
+ padding: 12px 14px;
+ border: 1px solid var(--line);
+ border-radius: 8px;
+ background: var(--panel);
+ text-decoration: none;
+}
+@media (max-width: 720px) {
+ .topbar { align-items: flex-start; flex-direction: column; }
+}
+""".strip()
+
+
+def is_hidden(path: Path) -> bool:
+ try:
+ rel = path.relative_to(ROOT)
+ except ValueError:
+ return True
+ return bool(rel.parts and rel.parts[0] in HIDDEN_TOP_LEVEL)
+
+
+def resolve_request(path: str) -> Path | None:
+ if path == PREFIX:
+ path = PREFIX + "/"
+ if not path.startswith(PREFIX + "/"):
+ return None
+ rel = unquote(path[len(PREFIX) + 1 :])
+ target = (ROOT / rel).resolve()
+ try:
+ target.relative_to(ROOT)
+ except ValueError:
+ return None
+ if not target.exists() and target.suffix == "":
+ markdown_target = target.with_suffix(".md")
+ try:
+ markdown_target.relative_to(ROOT)
+ except ValueError:
+ return None
+ if markdown_target.exists():
+ target = markdown_target
+ if is_hidden(target):
+ return None
+ return target
+
+
+def slugify(text: str) -> str:
+ slug = re.sub(r"[^\w\u4e00-\u9fff -]+", "", text.lower()).strip()
+ slug = re.sub(r"\s+", "-", slug)
+ return slug or "section"
+
+
+def rewrite_link(href: str, source: Path) -> str:
+ if re.match(r"^[a-zA-Z][a-zA-Z0-9+.-]*:", href) or href.startswith("#"):
+ return html.escape(href, quote=True)
+ target, marker, fragment = href.partition("#")
+ resolved = (source.parent / unquote(target)).resolve()
+ try:
+ rel = resolved.relative_to(ROOT)
+ except ValueError:
+ return "#"
+
+ rewritten = PREFIX + "/" + quote(str(rel))
+ return html.escape(rewritten + (marker + fragment if marker else ""), quote=True)
+
+
+def inline(text: str, source: Path) -> str:
+ text = html.escape(text)
+ text = re.sub(r"`([^`]+)`", r"\1", text)
+ text = re.sub(r"\*\*([^*]+)\*\*", r"\1", text)
+ text = re.sub(r"__([^_]+)__", r"\1", text)
+ text = re.sub(r"\*([^*]+)\*", r"\1", text)
+ text = re.sub(r"_([^_]+)_", r"\1", text)
+
+ def image(match: re.Match[str]) -> str:
+ alt = match.group(1)
+ href = rewrite_link(match.group(2), source)
+ return f''
+
+ def link(match: re.Match[str]) -> str:
+ label = match.group(1)
+ href = rewrite_link(match.group(2), source)
+ return f'{label}'
+
+ text = re.sub(r"!\[([^\]]*)\]\(([^)]+)\)", image, text)
+ text = re.sub(r"\[([^\]]+)\]\(([^)]+)\)", link, text)
+ return text
+
+
+def is_table_separator(line: str) -> bool:
+ cells = [cell.strip() for cell in line.strip().strip("|").split("|")]
+ return bool(cells) and all(re.fullmatch(r":?-{3,}:?", cell or "") for cell in cells)
+
+
+def render_table(rows: list[str], source: Path) -> str:
+ parsed = [[cell.strip() for cell in row.strip().strip("|").split("|")] for row in rows]
+ head = parsed[0]
+ body = parsed[2:] if len(parsed) > 2 and is_table_separator(rows[1]) else parsed[1:]
+ out = ["
| {inline(cell, source)} | " for cell in head) + out.append("
|---|
| {inline(cell, source)} | " for cell in row) + out.append("
{inline(' '.join(paragraph), source)}
") + paragraph = [] + + def flush_list() -> None: + nonlocal list_type + if list_type: + html_lines.append(f"{list_type}>") + list_type = None + + def flush_blockquote() -> None: + nonlocal blockquote + if blockquote: + html_lines.append(f"{inline(' '.join(blockquote), source)}") + blockquote = [] + + def flush_table() -> None: + nonlocal table_rows + if table_rows: + html_lines.append(render_table(table_rows, source)) + table_rows = [] + + for raw in lines: + line = raw.rstrip() + fence = re.match(r"^```(\w+)?\s*$", line) + if fence: + flush_paragraph() + flush_list() + flush_blockquote() + flush_table() + if in_code: + html_lines.append("") + in_code = False + else: + lang = fence.group(1) or "" + cls = f' class="language-{html.escape(lang)}"' if lang else "" + html_lines.append(f"
")
+ in_code = True
+ continue
+
+ if in_code:
+ html_lines.append(html.escape(raw))
+ continue
+
+ if not line.strip():
+ flush_paragraph()
+ flush_list()
+ flush_blockquote()
+ flush_table()
+ continue
+
+ if "|" in line and line.strip().startswith("|"):
+ flush_paragraph()
+ flush_list()
+ flush_blockquote()
+ table_rows.append(line)
+ continue
+ flush_table()
+
+ heading = re.match(r"^(#{1,6})\s+(.+)$", line)
+ if heading:
+ flush_paragraph()
+ flush_list()
+ flush_blockquote()
+ level = len(heading.group(1))
+ body = heading.group(2).strip()
+ if title == source.stem and level == 1:
+ title = re.sub(r"`", "", body)
+ anchor = slugify(body)
+ html_lines.append(f'{inline(body, source)} ')
+ continue
+
+ quote = re.match(r"^>\s?(.*)$", line)
+ if quote:
+ flush_paragraph()
+ flush_list()
+ blockquote.append(quote.group(1))
+ continue
+
+ item = re.match(r"^[-*]\s+(.+)$", line)
+ ordered = re.match(r"^\d+\.\s+(.+)$", line)
+ if item or ordered:
+ flush_paragraph()
+ flush_blockquote()
+ wanted = "ol" if ordered else "ul"
+ if list_type != wanted:
+ flush_list()
+ html_lines.append(f"<{wanted}>")
+ list_type = wanted
+ html_lines.append(f"{inline((ordered or item).group(1), source)} ")
+ continue
+
+ flush_list()
+ flush_blockquote()
+ paragraph.append(line.strip())
+
+ flush_paragraph()
+ flush_list()
+ flush_blockquote()
+ flush_table()
+ if in_code:
+ html_lines.append("")
+ return title, "\n".join(html_lines)
+
+
+def page(title: str, body: str) -> bytes:
+ document = f"""
+
+
+
+
+ Mnemon Private Docs
+Rendered repository documentation for Mnemon, including Markdown docs, diagrams, and interactive visual references.
+Architecture, assets, hooks, and runtime data flow for the memory loop.
+ + Open site + + + + +Entities, skill state surfaces, curator flow, and runtime data flow for the skill loop.
+ + Open site + + + + +Read rendered Markdown and static assets from the docs directory, including diagrams, localized docs, and harness notes.
+ + Browse files + +Rendered Markdown is mounted at /docs/ and served dynamically from the repository docs directory.
+