sessionwiki 2.0.0__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.
Files changed (118) hide show
  1. sessionwiki/__init__.py +74 -0
  2. sessionwiki/__main__.py +6 -0
  3. sessionwiki/_frontmatter.py +118 -0
  4. sessionwiki/_system_pages.py +44 -0
  5. sessionwiki/adapter_config.py +124 -0
  6. sessionwiki/adapters/__init__.py +134 -0
  7. sessionwiki/adapters/base.py +191 -0
  8. sessionwiki/adapters/claude_code.py +77 -0
  9. sessionwiki/adapters/codex_cli.py +226 -0
  10. sessionwiki/adapters/contrib/__init__.py +1 -0
  11. sessionwiki/adapters/contrib/chatgpt.py +252 -0
  12. sessionwiki/adapters/contrib/copilot_chat.py +121 -0
  13. sessionwiki/adapters/contrib/copilot_cli.py +115 -0
  14. sessionwiki/adapters/contrib/cursor.py +100 -0
  15. sessionwiki/adapters/contrib/gemini_cli.py +74 -0
  16. sessionwiki/adapters/contrib/obsidian.py +121 -0
  17. sessionwiki/adapters/opencode.py +169 -0
  18. sessionwiki/adapters/status.py +58 -0
  19. sessionwiki/adapters/zcode.py +45 -0
  20. sessionwiki/audit.py +174 -0
  21. sessionwiki/backlinks.py +224 -0
  22. sessionwiki/build.py +2786 -0
  23. sessionwiki/cache.py +401 -0
  24. sessionwiki/candidates.py +309 -0
  25. sessionwiki/categories.py +158 -0
  26. sessionwiki/changelog_timeline.py +422 -0
  27. sessionwiki/cli.py +1687 -0
  28. sessionwiki/compare.py +404 -0
  29. sessionwiki/completion.py +191 -0
  30. sessionwiki/confidence.py +179 -0
  31. sessionwiki/config.py +74 -0
  32. sessionwiki/config_schedule.py +77 -0
  33. sessionwiki/context_md.py +182 -0
  34. sessionwiki/convert.py +1746 -0
  35. sessionwiki/docs_pages.py +725 -0
  36. sessionwiki/doctor.py +113 -0
  37. sessionwiki/exporters.py +680 -0
  38. sessionwiki/freshness.py +153 -0
  39. sessionwiki/graph.py +1058 -0
  40. sessionwiki/graphify_bridge.py +509 -0
  41. sessionwiki/ingest_queue.py +101 -0
  42. sessionwiki/lifecycle.py +167 -0
  43. sessionwiki/link_checker.py +148 -0
  44. sessionwiki/lint/__init__.py +170 -0
  45. sessionwiki/lint/rules/__init__.py +69 -0
  46. sessionwiki/lint/rules/_helpers.py +119 -0
  47. sessionwiki/lint/rules/claim_verification.py +44 -0
  48. sessionwiki/lint/rules/content_freshness.py +41 -0
  49. sessionwiki/lint/rules/contradiction_detection.py +45 -0
  50. sessionwiki/lint/rules/duplicate_detection.py +164 -0
  51. sessionwiki/lint/rules/entity_consistency.py +29 -0
  52. sessionwiki/lint/rules/frontmatter_completeness.py +46 -0
  53. sessionwiki/lint/rules/frontmatter_count_consistency.py +61 -0
  54. sessionwiki/lint/rules/frontmatter_validity.py +71 -0
  55. sessionwiki/lint/rules/index_sync.py +68 -0
  56. sessionwiki/lint/rules/link_integrity.py +34 -0
  57. sessionwiki/lint/rules/orphan_detection.py +43 -0
  58. sessionwiki/lint/rules/stale_candidates.py +59 -0
  59. sessionwiki/lint/rules/stale_reference_detection.py +42 -0
  60. sessionwiki/lint/rules/summary_accuracy.py +36 -0
  61. sessionwiki/lint/rules/tags_topics_convention.py +58 -0
  62. sessionwiki/lint/rules/tools_consistency.py +65 -0
  63. sessionwiki/lock.py +126 -0
  64. sessionwiki/log_reader.py +129 -0
  65. sessionwiki/manifest.py +132 -0
  66. sessionwiki/mcp/__init__.py +29 -0
  67. sessionwiki/mcp/__main__.py +5 -0
  68. sessionwiki/mcp/server.py +1061 -0
  69. sessionwiki/models_page.py +291 -0
  70. sessionwiki/obsidian_output.py +208 -0
  71. sessionwiki/opencode/__init__.py +1 -0
  72. sessionwiki/opencode/autosync.py +102 -0
  73. sessionwiki/opencode/export.py +419 -0
  74. sessionwiki/opencode/install.py +152 -0
  75. sessionwiki/pipeline.py +150 -0
  76. sessionwiki/project_topics.py +213 -0
  77. sessionwiki/py.typed +0 -0
  78. sessionwiki/quarantine.py +273 -0
  79. sessionwiki/queue.py +29 -0
  80. sessionwiki/references.py +224 -0
  81. sessionwiki/render/__init__.py +20 -0
  82. sessionwiki/render/css.py +988 -0
  83. sessionwiki/render/docs_css.py +463 -0
  84. sessionwiki/render/js.py +1337 -0
  85. sessionwiki/schema.py +270 -0
  86. sessionwiki/search_facets.py +150 -0
  87. sessionwiki/search_tree.py +184 -0
  88. sessionwiki/serve.py +91 -0
  89. sessionwiki/session_export.py +333 -0
  90. sessionwiki/skill_installer.py +112 -0
  91. sessionwiki/sync/__init__.py +13 -0
  92. sessionwiki/sync/status.py +120 -0
  93. sessionwiki/synth/__init__.py +14 -0
  94. sessionwiki/synth/agent_delegate.py +327 -0
  95. sessionwiki/synth/base.py +125 -0
  96. sessionwiki/synth/cli_helpers.py +98 -0
  97. sessionwiki/synth/estimate.py +146 -0
  98. sessionwiki/synth/ollama.py +379 -0
  99. sessionwiki/synth/openai_compat.py +325 -0
  100. sessionwiki/synth/pipeline.py +1489 -0
  101. sessionwiki/tag_utils.py +62 -0
  102. sessionwiki/tags.py +359 -0
  103. sessionwiki/vault.py +347 -0
  104. sessionwiki/viz_heatmap.py +305 -0
  105. sessionwiki/viz_tokens.py +429 -0
  106. sessionwiki/viz_tools.py +241 -0
  107. sessionwiki/watch.py +154 -0
  108. sessionwiki/zcode/__init__.py +43 -0
  109. sessionwiki/zcode/autosync.py +150 -0
  110. sessionwiki/zcode/export.py +568 -0
  111. sessionwiki/zcode/install.py +386 -0
  112. sessionwiki-2.0.0.dist-info/METADATA +303 -0
  113. sessionwiki-2.0.0.dist-info/RECORD +118 -0
  114. sessionwiki-2.0.0.dist-info/WHEEL +5 -0
  115. sessionwiki-2.0.0.dist-info/entry_points.txt +2 -0
  116. sessionwiki-2.0.0.dist-info/licenses/LICENSE +22 -0
  117. sessionwiki-2.0.0.dist-info/licenses/NOTICE +14 -0
  118. sessionwiki-2.0.0.dist-info/top_level.txt +1 -0
sessionwiki/build.py ADDED
@@ -0,0 +1,2786 @@
1
+ """sessionwiki — static HTML site builder.
2
+
3
+ Reads converted markdown sources under `raw/` and produces a complete static
4
+ site under `site/` with:
5
+
6
+ - Home + projects index + sessions index + per-project + per-session pages
7
+ - Inter + JetBrains Mono typography, purple accent (#7C3AED)
8
+ - Light / dark theme toggle (data-theme + system preference + localStorage)
9
+ - Global search index (site/search-index.json) — client-side fuzzy matcher
10
+ - Cmd+K command palette (vanilla JS, no framework)
11
+ - Keyboard shortcuts: /, g h, g p, g s, j/k, ?
12
+ - highlight.js client-side syntax highlighting (CDN, light + dark themes)
13
+ - Collapsible tool-result sections (<details>) for long outputs
14
+ - Copy-as-markdown + copy-code buttons (Clipboard API + execCommand fallback)
15
+ - Breadcrumbs + reading progress bar
16
+ - Filter bar on the sessions table
17
+ - Mobile-responsive, print-friendly
18
+ - ARIA focus rings and prefers-reduced-motion support
19
+
20
+ Stdlib + `markdown` (required). No optional deps — highlight.js loads from CDN.
21
+ Usage:
22
+ python3 -m sessionwiki build [--synthesize] [--out <dir>]
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import argparse
28
+ import html
29
+ import json
30
+ import os
31
+ import re
32
+ import shutil
33
+ import subprocess
34
+ import sys
35
+ from collections import defaultdict
36
+ from datetime import datetime, timezone
37
+ from pathlib import Path
38
+ from typing import Any, Optional
39
+
40
+ import markdown
41
+ from markdown.preprocessors import Preprocessor
42
+
43
+ from sessionwiki import REPO_ROOT
44
+ from sessionwiki.changelog_timeline import (
45
+ extract_price_points,
46
+ find_recently_updated,
47
+ parse_changelog,
48
+ render_changelog_timeline,
49
+ render_price_sparkline,
50
+ render_recent_activity,
51
+ render_recently_updated,
52
+ )
53
+ from sessionwiki.log_reader import recent_events as _recent_log_events
54
+ from sessionwiki.context_md import is_context_file
55
+ from sessionwiki.freshness import freshness_badge, load_freshness_config
56
+ from sessionwiki.project_topics import (
57
+ extract_session_topics,
58
+ get_project_topics,
59
+ load_project_profile,
60
+ render_topic_chips,
61
+ )
62
+ from sessionwiki.viz_heatmap import collect_session_counts, render_heatmap
63
+ from sessionwiki.viz_tokens import (
64
+ render_project_token_card,
65
+ render_session_token_card,
66
+ render_site_token_stats,
67
+ )
68
+ from sessionwiki.viz_tools import (
69
+ render_project_tool_chart,
70
+ render_session_tool_chart,
71
+ )
72
+
73
+ # ─── paths ─────────────────────────────────────────────────────────────────
74
+
75
+ RAW_DIR = REPO_ROOT / "raw"
76
+ RAW_SESSIONS = RAW_DIR / "sessions"
77
+ DEFAULT_OUT_DIR = REPO_ROOT / "site"
78
+ # v0.7+: optional per-project metadata (topics, description, homepage).
79
+ # Users drop a `wiki/projects/<slug>.md` file with frontmatter.
80
+ PROJECTS_META_DIR = REPO_ROOT / "wiki" / "projects"
81
+
82
+
83
+ # ─── frontmatter ───────────────────────────────────────────────────────────
84
+
85
+ # #409 / #423: build.py used to ship a divergent regex (LF-only, no BOM
86
+ # handling, simpler list parser) which silently dropped frontmatter on
87
+ # Windows-authored files. Unified to the canonical parser in
88
+ # `_frontmatter.py`. Re-exported under the historical name so external
89
+ # consumers (and `tests/test_render_split.py`) keep working.
90
+ from sessionwiki._frontmatter import ( # noqa: E402
91
+ _FRONTMATTER_RE,
92
+ parse_frontmatter,
93
+ )
94
+
95
+
96
+ # ─── discovery ─────────────────────────────────────────────────────────────
97
+
98
+
99
+ # #405 path-traversal guard. Site paths are composed by joining `out_dir`
100
+ # with `project_slug` and other slug values from frontmatter. A poisoned
101
+ # `project: ../../etc/passwd` would otherwise write outside `out_dir`.
102
+ _SAFE_SLUG_RE = re.compile(r"^[A-Za-z0-9._-]+$")
103
+
104
+
105
+ def _safe_slug(value: str | None, *, fallback: str = "_unknown") -> str:
106
+ """Return a path-safe single-segment slug.
107
+
108
+ Rejects empty values, traversal segments (``..``), absolute paths
109
+ (leading ``/`` or backslash), null bytes, and anything containing
110
+ characters outside ``[A-Za-z0-9._-]``. Falls back to ``fallback`` so
111
+ the build keeps going on poisoned frontmatter — the offending
112
+ session lands under a clearly abnormal slug rather than escaping
113
+ ``out_dir``.
114
+ """
115
+ if not value:
116
+ return fallback
117
+ s = str(value).strip()
118
+ # Strip surrounding quotes leaked from naive YAML parsers.
119
+ if len(s) >= 2 and s[0] == s[-1] and s[0] in ("'", '"'):
120
+ s = s[1:-1]
121
+ if not s or s in (".", ".."):
122
+ return fallback
123
+ if "/" in s or "\\" in s or "\x00" in s:
124
+ return fallback
125
+ if not _SAFE_SLUG_RE.match(s):
126
+ return fallback
127
+ return s
128
+
129
+
130
+ def _is_subagent(meta: dict[str, Any], path: Path) -> bool:
131
+ """Return True iff a session is a sub-agent run (#492 / #406).
132
+
133
+ Prefers the adapter-written ``is_subagent`` frontmatter field
134
+ (canonical contract since #406). Falls back to the legacy
135
+ ``"subagent" in path.name`` substring check ONLY if the field is
136
+ missing — needed to keep pre-#406 raw files classified correctly
137
+ until they're re-synced.
138
+
139
+ Accepts the field as either a real bool or one of the
140
+ case-insensitive strings ``"true"`` / ``"false"`` since
141
+ frontmatter parsers historically coerced inconsistently.
142
+ """
143
+ raw = meta.get("is_subagent")
144
+ if isinstance(raw, bool):
145
+ return raw
146
+ if isinstance(raw, str):
147
+ s = raw.strip().lower()
148
+ if s in ("true", "yes", "1"):
149
+ return True
150
+ if s in ("false", "no", "0"):
151
+ return False
152
+ # Field absent or unrecognised — fall back to the legacy heuristic
153
+ # so pre-#406 raw files (no is_subagent field) still get the right
154
+ # answer. The renderer renames sub-agent slugs to
155
+ # `<slug>-subagent-<id>`, so the substring match is correct on
156
+ # canonically-renamed files even when meta is missing.
157
+ return "subagent" in path.name
158
+
159
+
160
+ def discover_sources(root: Path) -> list[tuple[Path, dict[str, Any], str]]:
161
+ out: list[tuple[Path, dict[str, Any], str]] = []
162
+ if not root.exists():
163
+ return out
164
+ for p in sorted(root.rglob("*.md")):
165
+ # v0.5 (#60): `_context.md` files are folder metadata for LLM
166
+ # navigation, not pages. Skip them so they never appear in the
167
+ # session index, search index, or AI-consumable exports.
168
+ if is_context_file(p):
169
+ continue
170
+ try:
171
+ text = p.read_text(encoding="utf-8")
172
+ except OSError:
173
+ continue
174
+ meta, body = parse_frontmatter(text)
175
+ # #405: sanitize the frontmatter values that compose output paths.
176
+ # Original values stay available via meta.get(); the *_safe_*
177
+ # versions are what every path-composition site downstream uses.
178
+ meta["project"] = _safe_slug(
179
+ meta.get("project") or p.parent.name,
180
+ fallback=_safe_slug(p.parent.name, fallback="_unknown"),
181
+ )
182
+ if "slug" in meta:
183
+ meta["slug"] = _safe_slug(meta["slug"], fallback=p.stem)
184
+ out.append((p, meta, body))
185
+ return out
186
+
187
+
188
+ def group_by_project(
189
+ sources: list[tuple[Path, dict[str, Any], str]],
190
+ ) -> dict[str, list[tuple[Path, dict[str, Any], str]]]:
191
+ g: dict[str, list[tuple[Path, dict[str, Any], str]]] = defaultdict(list)
192
+ for p, meta, body in sources:
193
+ project = str(meta.get("project") or p.parent.name)
194
+ g[project].append((p, meta, body))
195
+ for k in g:
196
+ g[k].sort(key=lambda t: str(t[1].get("started", t[0].name)))
197
+ return g
198
+
199
+
200
+ _SLUG_SPLIT_RE = re.compile(r"[-_]+")
201
+
202
+
203
+ def _humanize_slug(slug: str) -> str:
204
+ """Turn a kebab/snake-case slug into a human-readable title.
205
+
206
+ `my-cool-project` → `My Cool Project`. Single-letter parts are
207
+ upper-cased the same way as multi-letter parts. Empty / whitespace
208
+ input returns the original (callers handle the empty case).
209
+ """
210
+ parts = [p for p in _SLUG_SPLIT_RE.split(slug.strip()) if p]
211
+ if not parts:
212
+ return slug.strip()
213
+ return " ".join(p[:1].upper() + p[1:] for p in parts)
214
+
215
+
216
+ def _derive_stub_description(
217
+ sessions: list[tuple[Path, dict[str, Any], str]],
218
+ ) -> str:
219
+ """Pick a sensible description from the most-recent session.
220
+
221
+ Prefers an explicit `summary` (truncated to ~140 chars), then the
222
+ humanised session slug, then empty. `sessions` arrives sorted oldest
223
+ → newest by `discover_sources`/grouping, so we walk the tail.
224
+ """
225
+ for _path, meta, _body in reversed(sessions):
226
+ summary = meta.get("summary")
227
+ if isinstance(summary, str) and summary.strip():
228
+ text = summary.strip().splitlines()[0].strip()
229
+ if len(text) > 140:
230
+ text = text[:137].rstrip() + "..."
231
+ return text
232
+ raw_slug = meta.get("slug")
233
+ if isinstance(raw_slug, str) and raw_slug.strip():
234
+ humanised = _humanize_slug(raw_slug)
235
+ if humanised:
236
+ return humanised
237
+ return ""
238
+
239
+
240
+ def _derive_stub_topics(
241
+ sessions: list[tuple[Path, dict[str, Any], str]],
242
+ max_topics: int = 6,
243
+ ) -> list[str]:
244
+ """Aggregate topics from session frontmatter, then `tools_used` as a
245
+ secondary source. Uses `extract_session_topics` for the tags path so
246
+ the noise filter stays in sync with `project_topics.py`. Falls back
247
+ to `min_count=1` because most projects have only a few sessions and
248
+ the `min_count=2` default in `extract_session_topics` would suppress
249
+ nearly everything at seed time. Returns at most `max_topics` topics.
250
+ """
251
+ metas = [meta for _path, meta, _body in sessions]
252
+ topics = extract_session_topics(metas, max_topics=max_topics, min_count=1)
253
+ if topics:
254
+ return topics
255
+ # Fallback: tools_used aggregation (filtered by the same noise set).
256
+ from sessionwiki.project_topics import _NOISE_TAGS
257
+ counts: dict[str, int] = {}
258
+ for meta in metas:
259
+ raw = meta.get("tools_used")
260
+ if isinstance(raw, list):
261
+ items = raw
262
+ elif isinstance(raw, dict):
263
+ items = list(raw.keys())
264
+ else:
265
+ items = []
266
+ for item in items:
267
+ tag = str(item).strip().lower()
268
+ if tag and tag not in _NOISE_TAGS:
269
+ counts[tag] = counts.get(tag, 0) + 1
270
+ ordered = sorted(counts.items(), key=lambda kv: (-kv[1], kv[0]))
271
+ return [tag for tag, _ in ordered[:max_topics]]
272
+
273
+
274
+ def _format_topics_yaml(topics: list[str]) -> str:
275
+ """Inline-list YAML serialisation that matches the parser in
276
+ `project_topics._parse_topics_frontmatter`."""
277
+ if not topics:
278
+ return "[]"
279
+ return "[" + ", ".join(topics) + "]"
280
+
281
+
282
+ def ensure_project_stubs(
283
+ groups: dict[str, list[tuple[Path, dict[str, Any], str]]],
284
+ meta_dir: Path,
285
+ ) -> list[Path]:
286
+ """Auto-seed ``wiki/projects/<slug>.md`` for any discovered project
287
+ that doesn't already have one (`issues-commands.md` I-12).
288
+
289
+ Without this, real projects render a bare hero — no description, no
290
+ topic chips, no homepage — because those fields come from a hand-
291
+ authored file that never gets created on sync. Seeding pre-populates
292
+ `topics:` from session tags/tools and `description:` from the most-
293
+ recent session's summary or slug, so every real project lights up the
294
+ moment its first session lands (closes #425). Existing hand-authored
295
+ files are never overwritten — only the absence of a file triggers a
296
+ write, so the user's edits always win.
297
+
298
+ Returns the list of stub paths actually written (empty if all project
299
+ metadata files already existed).
300
+ """
301
+ meta_dir.mkdir(parents=True, exist_ok=True)
302
+ written: list[Path] = []
303
+ for slug in sorted(groups):
304
+ target = meta_dir / f"{slug}.md"
305
+ if target.exists():
306
+ continue
307
+ sessions = groups[slug]
308
+ topics = _derive_stub_topics(sessions)
309
+ description = _derive_stub_description(sessions)
310
+ # Escape embedded double-quotes in description so the YAML stays
311
+ # valid — slugs/summaries from real sessions occasionally contain
312
+ # quotes (`"why didn't this work"`).
313
+ description_safe = description.replace("\\", "\\\\").replace('"', '\\"')
314
+ # #py-l8 (#606): drop f-prefix on lines that have no
315
+ # placeholders so ruff's F541 doesn't flag the file. Mixed
316
+ # f-/plain in a concatenation chain is fine.
317
+ stub = (
318
+ "---\n"
319
+ f'title: "{slug}"\n'
320
+ "type: entity\n"
321
+ "entity_type: project\n"
322
+ f"project: {slug}\n"
323
+ f"topics: {_format_topics_yaml(topics)}\n"
324
+ f'description: "{description_safe}"\n'
325
+ 'homepage: ""\n'
326
+ "---\n\n"
327
+ f"# {slug}\n\n"
328
+ "*Auto-generated project stub. `topics` and `description` are "
329
+ "pre-filled from session metadata — edit any field above and "
330
+ "the build will pick it up. Fill in `homepage` to add a link "
331
+ "chip to the project hero.*\n"
332
+ )
333
+ target.write_text(stub, encoding="utf-8")
334
+ written.append(target)
335
+ return written
336
+
337
+
338
+ def ensure_projects_index(meta_dir: Path) -> Path | None:
339
+ """Regenerate ``wiki/projects/index.md`` from the project stubs.
340
+
341
+ The index is a graph hub: every project page gets an inbound
342
+ ``[[wikilink]]`` so the wiki graph no longer shows the auto-seeded
343
+ stubs as orphans. Project pages stay hand-editable; only the index
344
+ is regenerated.
345
+ """
346
+ meta_dir = Path(meta_dir)
347
+ if not meta_dir.exists():
348
+ return None
349
+ stubs = sorted(p for p in meta_dir.glob("*.md") if p.name != "index.md")
350
+ if not stubs:
351
+ return None
352
+
353
+ lines = [
354
+ "---",
355
+ 'title: "Projects"',
356
+ "type: navigation",
357
+ "topics: [projects]",
358
+ f"last_updated: {datetime.now(timezone.utc).date().isoformat()}",
359
+ "---",
360
+ "",
361
+ "# Projects",
362
+ "",
363
+ "Auto-generated project index. Links connect the wiki graph; "
364
+ "edit the individual project pages, not this file.",
365
+ "",
366
+ ]
367
+ for stub in stubs:
368
+ try:
369
+ meta, _ = parse_frontmatter(stub.read_text(encoding="utf-8"))
370
+ except OSError:
371
+ meta = {}
372
+ description = str(meta.get("description") or "").strip()
373
+ if description:
374
+ lines.append(f"- [[{stub.stem}]] — {description}")
375
+ else:
376
+ lines.append(f"- [[{stub.stem}]]")
377
+
378
+ target = meta_dir / "index.md"
379
+ target.write_text("\n".join(lines).rstrip() + "\n", encoding="utf-8")
380
+ return target
381
+
382
+
383
+ # ─── markdown normaliser + renderer ───────────────────────────────────────
384
+
385
+ _H1_LINE_RE = re.compile(r"^#\s+.*\n", re.MULTILINE)
386
+
387
+
388
+ def strip_leading_h1(body: str) -> str:
389
+ m = _H1_LINE_RE.search(body)
390
+ if m and m.start() < 200:
391
+ body = body[: m.start()] + body[m.end() :]
392
+ body = body.lstrip("\n")
393
+ return body
394
+
395
+
396
+ def normalize_markdown(body: str) -> str:
397
+ """Fix common markdown glitches from the converter:
398
+ 1. Insert blank line before lists that follow a bold header.
399
+ 2. Outdent fenced code blocks that are 2-space-indented under a list item.
400
+ """
401
+ body = re.sub(
402
+ r"(\*\*(?:Tools used|Tool results):\*\*)\n(?!\n)", r"\1\n\n", body,
403
+ )
404
+ lines = body.split("\n")
405
+ out: list[str] = []
406
+ i = 0
407
+ while i < len(lines):
408
+ line = lines[i]
409
+ if line.strip() == "```" and line.startswith(" "):
410
+ if out and out[-1].strip():
411
+ out.append("")
412
+ out.append("```")
413
+ i += 1
414
+ while i < len(lines) and lines[i].strip() != "```":
415
+ bl = lines[i]
416
+ if bl.startswith(" "):
417
+ bl = bl[2:]
418
+ out.append(bl)
419
+ i += 1
420
+ if i < len(lines):
421
+ out.append("```")
422
+ i += 1
423
+ if i < len(lines) and lines[i].strip():
424
+ out.append("")
425
+ else:
426
+ out.append(line)
427
+ i += 1
428
+ return "\n".join(out)
429
+
430
+
431
+ # v0.5 (#74): Session content frequently mentions HTML-ish strings in prose —
432
+ # e.g. an assistant describing how a hidden `<textarea class="md-source">` works.
433
+ # The default Python markdown library passes raw HTML tags through unchanged,
434
+ # which means a session that mentions `<textarea>` outside of backticks leaks
435
+ # an unclosed textarea into the DOM, swallowing every following element
436
+ # (including the <script> tag that boots highlight.js). The v0.5 hljs swap
437
+ # made this pre-existing bug catastrophic — before, Pygments rendered code
438
+ # server-side so the broken tail didn't visibly matter; now, a single
439
+ # unescaped tag breaks the whole page's syntax highlighting.
440
+ #
441
+ # The preprocessor below escapes anything that *looks* like an HTML tag start
442
+ # (`<tagname` or `</tagname`) outside of inline backticks. Fenced code blocks
443
+ # are already extracted into placeholders by `fenced_code` (priority 25) before
444
+ # this runs (priority 22). Priority 22 also ensures we run *before*
445
+ # `html_block` (priority 20) so raw HTML blocks never get a chance to be
446
+ # preserved as-is. Bare `<` / `<space>` (e.g. `x < 10`) are left alone —
447
+ # markdown's own escaper handles those. HTML comments (`<!-- ... -->`) are
448
+ # preserved because the regex only matches `<[letter]`, not `<!`, and
449
+ # build.py emits an `<!-- sessionwiki:metadata -->` comment that AI agents parse.
450
+ _TAG_START_RE = re.compile(r"<(/?[A-Za-z][A-Za-z0-9:_-]*)")
451
+ # #sec-13 (#557): also neutralise raw `<![CDATA[` blocks in prose. CDATA
452
+ # isn't allowed in HTML but some browsers / parsers treat it as a
453
+ # start-of-foreign-content marker; surfaces in MathML / SVG islands or
454
+ # in legacy XHTML rendering paths. Escape the leading `<` so the
455
+ # surrounding markdown processor doesn't pass it through as-is.
456
+ _CDATA_START_RE = re.compile(r"<!\[CDATA\[")
457
+ _INLINE_CODE_RE = re.compile(r"`[^`]*`")
458
+
459
+
460
+ class _EscapeRawHtmlPreprocessor(Preprocessor):
461
+ """Escape HTML tag-start patterns outside code spans so raw `<textarea>`
462
+ etc. in session prose can never leak into the DOM as live elements.
463
+ See the comment above `md_to_html` for the full rationale."""
464
+
465
+ def run(self, lines: list[str]) -> list[str]:
466
+ out: list[str] = []
467
+ for line in lines:
468
+ parts: list[tuple[str, str]] = []
469
+ last = 0
470
+ for m in _INLINE_CODE_RE.finditer(line):
471
+ parts.append(("text", line[last : m.start()]))
472
+ parts.append(("code", m.group(0)))
473
+ last = m.end()
474
+ parts.append(("text", line[last:]))
475
+ rebuilt: list[str] = []
476
+ for kind, part in parts:
477
+ if kind == "text":
478
+ # #sec-13: neutralise CDATA markers BEFORE the tag-
479
+ # start sub so we don't accidentally double-escape.
480
+ part = _CDATA_START_RE.sub(r"&lt;![CDATA[", part)
481
+ rebuilt.append(_TAG_START_RE.sub(r"&lt;\1", part))
482
+ else:
483
+ rebuilt.append(part)
484
+ out.append("".join(rebuilt))
485
+ return out
486
+
487
+
488
+ # #283: in-memory content-hash cache for md_to_html. Same markdown body
489
+ # always produces the same HTML, and build steps call md_to_html on the
490
+ # same boilerplate (e.g. `## Connections`) across hundreds of pages.
491
+ # blake2b(digest_size=8) keyed + bounded by a size cap so repeated
492
+ # builds in the same Python process (tests, watch mode, bulk exports)
493
+ # don't re-parse.
494
+ #
495
+ # #417: switched from SHA-256 hex (allocates 64-byte string per call)
496
+ # to blake2b(digest_size=8) returning bytes. ~3× faster + 8× less
497
+ # allocation per cache key on a 5000-page corpus. The 8-byte (64-bit)
498
+ # digest gives a birthday-collision threshold around 4×10^9 entries —
499
+ # the 4096-entry cap stays many orders of magnitude below that.
500
+ _MD_CACHE: dict[bytes, str] = {}
501
+ _PLAIN_CACHE: dict[bytes, str] = {}
502
+ _MD_CACHE_MAX = 4096 # entries; ~20 MB ceiling at ~5 KB avg
503
+ _md_cache_hits = 0
504
+ _md_cache_misses = 0
505
+ _plain_cache_hits = 0
506
+ _plain_cache_misses = 0
507
+
508
+
509
+ def _content_key(body: str) -> bytes:
510
+ """Compute the cache key for a markdown body (#417).
511
+
512
+ blake2b is significantly faster than SHA-256 for short strings,
513
+ and the 8-byte digest is enough headroom for the 4096-entry cap.
514
+ Bytes (not hex) avoids the encode-back-to-string allocation.
515
+ """
516
+ import hashlib as _hl
517
+ return _hl.blake2b(body.encode("utf-8"), digest_size=8).digest()
518
+
519
+
520
+ def md_to_html_cache_stats() -> dict[str, int]:
521
+ """Return ``{hits, misses, size}`` for observability (#283)."""
522
+ return {
523
+ "hits": _md_cache_hits,
524
+ "misses": _md_cache_misses,
525
+ "size": len(_MD_CACHE),
526
+ "plain_hits": _plain_cache_hits,
527
+ "plain_misses": _plain_cache_misses,
528
+ "plain_size": len(_PLAIN_CACHE),
529
+ }
530
+
531
+
532
+ def md_to_html_cache_clear() -> None:
533
+ """Clear the md_to_html + md_to_plain caches (used in tests)."""
534
+ global _md_cache_hits, _md_cache_misses
535
+ global _plain_cache_hits, _plain_cache_misses
536
+ _MD_CACHE.clear()
537
+ _PLAIN_CACHE.clear()
538
+ _md_cache_hits = 0
539
+ _md_cache_misses = 0
540
+ _plain_cache_hits = 0
541
+ _plain_cache_misses = 0
542
+
543
+
544
+ def _evict_first(cache: dict) -> None:
545
+ """FIFO-evict the oldest cache entry."""
546
+ try:
547
+ first_key = next(iter(cache))
548
+ del cache[first_key]
549
+ except StopIteration:
550
+ pass
551
+
552
+
553
+ def md_to_html(body: str) -> str:
554
+ global _md_cache_hits, _md_cache_misses
555
+ key = _content_key(body)
556
+ cached = _MD_CACHE.get(key)
557
+ if cached is not None:
558
+ _md_cache_hits += 1
559
+ return cached
560
+ _md_cache_misses += 1
561
+ result = _md_to_html_uncached(body)
562
+ if len(_MD_CACHE) >= _MD_CACHE_MAX:
563
+ _evict_first(_MD_CACHE)
564
+ _MD_CACHE[key] = result
565
+ return result
566
+
567
+
568
+ def _md_to_html_uncached(body: str) -> str:
569
+ body = normalize_markdown(body)
570
+ # v0.5: highlight.js replaces server-side Pygments/codehilite. The
571
+ # fenced_code extension emits `<pre><code class="language-xxx">` and
572
+ # highlight.js (loaded via CDN in page_head) picks it up client-side.
573
+ # Benefits: lighter builds, no optional dep, consistent look across pages,
574
+ # and auto-detection for untagged blocks.
575
+ extensions = ["fenced_code", "tables", "toc", "sane_lists"]
576
+ ext_configs: dict[str, dict[str, Any]] = {
577
+ # #646: drop `permalink: True`. The Python-Markdown TOC
578
+ # extension's permalink emits a `<a class="headerlink">¶</a>`
579
+ # next to every heading; the site CSS doesn't style
580
+ # `.headerlink` (only `.deep-link` is styled), so axe-core
581
+ # flags every one as a `link-in-text-block` violation
582
+ # (links that aren't visually distinguishable). The JS-
583
+ # driven `.deep-link` icon next to each heading (rendered by
584
+ # render/js.py) is the canonical deep-link affordance — it
585
+ # has CSS + hover state + aria-hidden treatment. Two emitters
586
+ # for the same job; the markdown one is the older one. Anchor
587
+ # targets (`<h2 id="...">`) still ship via toc — links to
588
+ # `#section-name` keep working.
589
+ "toc": {"toc_depth": "2-3"},
590
+ }
591
+ md = markdown.Markdown(extensions=extensions, extension_configs=ext_configs)
592
+ # v0.5 (#74): escape raw HTML tags in prose so session content mentioning
593
+ # `<textarea>` etc. can't break the page. Runs after fenced_code (25) and
594
+ # before html_block (20), so fenced code is preserved verbatim (through
595
+ # placeholders), inline code via backticks is preserved by this
596
+ # preprocessor's own backtick-skipping, and everything else is safe.
597
+ md.preprocessors.register(
598
+ _EscapeRawHtmlPreprocessor(md), "escape_raw_html_tags", 22
599
+ )
600
+ return md.convert(body)
601
+
602
+
603
+ def md_to_plain_text(body: str) -> str:
604
+ """Strip markdown to plain text for the search index.
605
+
606
+ #417: memoized on the same content key as md_to_html. The build
607
+ pipeline calls md_to_html and md_to_plain_text on the same body
608
+ repeatedly (per-page render + search-index extract + RSS summary
609
+ + .txt sibling). Sharing the key makes the second + third + …
610
+ calls free.
611
+ """
612
+ global _plain_cache_hits, _plain_cache_misses
613
+ key = _content_key(body)
614
+ cached = _PLAIN_CACHE.get(key)
615
+ if cached is not None:
616
+ _plain_cache_hits += 1
617
+ return cached
618
+ _plain_cache_misses += 1
619
+ result = _md_to_plain_text_uncached(body)
620
+ if len(_PLAIN_CACHE) >= _MD_CACHE_MAX:
621
+ _evict_first(_PLAIN_CACHE)
622
+ _PLAIN_CACHE[key] = result
623
+ return result
624
+
625
+
626
+ def _md_to_plain_text_uncached(body: str) -> str:
627
+ body = normalize_markdown(strip_leading_h1(body))
628
+ # Remove code blocks (they're noisy in search)
629
+ body = re.sub(r"```.*?```", " ", body, flags=re.DOTALL)
630
+ # Inline code
631
+ body = re.sub(r"`([^`]*)`", r"\1", body)
632
+ # Links: [text](url) → text
633
+ body = re.sub(r"\[([^\]]*)\]\([^\)]*\)", r"\1", body)
634
+ # Wikilinks: [[name]] → name
635
+ body = re.sub(r"\[\[([^\]]*)\]\]", r"\1", body)
636
+ # Headings: strip leading #
637
+ body = re.sub(r"^#+\s*", "", body, flags=re.MULTILINE)
638
+ # Bold/italic marks
639
+ body = re.sub(r"\*\*([^*]*)\*\*", r"\1", body)
640
+ body = re.sub(r"\*([^*]*)\*", r"\1", body)
641
+ # HTML comments
642
+ body = re.sub(r"<!--.*?-->", "", body, flags=re.DOTALL)
643
+ # Collapse whitespace
644
+ body = re.sub(r"\s+", " ", body).strip()
645
+ return body
646
+
647
+
648
+ # ─── helpers for frontmatter consumers ─────────────────────────────────────
649
+
650
+ def get_tools_list(meta: dict[str, Any]) -> list[str]:
651
+ tools = meta.get("tools_used", [])
652
+ if isinstance(tools, str):
653
+ return [t.strip() for t in tools.strip("[]").split(",") if t.strip()]
654
+ return list(tools) if tools else []
655
+
656
+
657
+ def short_started(meta: dict[str, Any]) -> str:
658
+ s = str(meta.get("started", ""))
659
+ return s[:16].replace("T", " ")
660
+
661
+
662
+ # ─── freshness (content staleness) ────────────────────────────────────────
663
+ # Cached once per build so every page sees the same "now" and the same
664
+ # thresholds. Populated lazily by render_freshness().
665
+ _FRESHNESS_CONFIG: Optional[tuple[int, int]] = None
666
+ _BUILD_NOW: Optional[datetime] = None
667
+
668
+
669
+ def render_freshness(meta: dict[str, Any]) -> str:
670
+ """Render a freshness badge for a page's frontmatter using cached config.
671
+
672
+ Thresholds come from ``config.json`` (freshness.green_days /
673
+ yellow_days) or the module defaults. Build-time "now" is cached the
674
+ first call so the whole site renders with one consistent clock.
675
+ """
676
+ global _FRESHNESS_CONFIG, _BUILD_NOW
677
+ if _FRESHNESS_CONFIG is None:
678
+ _FRESHNESS_CONFIG = load_freshness_config()
679
+ if _BUILD_NOW is None:
680
+ _BUILD_NOW = datetime.now(timezone.utc).replace(tzinfo=None)
681
+ green, yellow = _FRESHNESS_CONFIG
682
+ return freshness_badge(meta, now=_BUILD_NOW, green_days=green, yellow_days=yellow)
683
+
684
+
685
+ # ─── html template helpers ─────────────────────────────────────────────────
686
+
687
+ # v0.5: highlight.js for client-side syntax highlighting. Two themes so the
688
+ # switcher can swap between light/dark without a network round-trip. Pinned
689
+ # to a major version for stability, served from jsdelivr.
690
+ HLJS_VERSION = "11.9.0"
691
+ HLJS_LIGHT_CSS = (
692
+ f"https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@{HLJS_VERSION}"
693
+ "/build/styles/github.min.css"
694
+ )
695
+ HLJS_DARK_CSS = (
696
+ f"https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@{HLJS_VERSION}"
697
+ "/build/styles/github-dark.min.css"
698
+ )
699
+ HLJS_SCRIPT = (
700
+ f"https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@{HLJS_VERSION}"
701
+ "/build/highlight.min.js"
702
+ )
703
+
704
+
705
+ def _hljs_head_tags() -> str:
706
+ """Return the `<link>` tags for highlight.js themes. The dark theme is
707
+ loaded with ``disabled`` and the light theme is the default — the runtime
708
+ swaps the ``disabled`` flag when the theme toggles, so code blocks stay in
709
+ sync with the rest of the page."""
710
+ return (
711
+ f' <link id="hljs-light" rel="stylesheet" href="{HLJS_LIGHT_CSS}">\n'
712
+ f' <link id="hljs-dark" rel="stylesheet" href="{HLJS_DARK_CSS}" disabled>\n'
713
+ )
714
+
715
+
716
+ _PRE_PAINT_THEME_SCRIPT = """ <script>
717
+ /* #458: read localStorage.sessionwiki-theme BEFORE first paint so users
718
+ never see a flash of the wrong theme when navigating between pages.
719
+ Falls back to prefers-color-scheme, then dark. Mirrors the same
720
+ pre-paint pattern graph.html already uses (#477). */
721
+ (function () {
722
+ try {
723
+ var t = localStorage.getItem('sessionwiki-theme');
724
+ if (t !== 'dark' && t !== 'light') {
725
+ t = (window.matchMedia && window.matchMedia('(prefers-color-scheme: light)').matches) ? 'light' : 'dark';
726
+ }
727
+ document.documentElement.setAttribute('data-theme', t);
728
+ } catch (e) {
729
+ document.documentElement.setAttribute('data-theme', 'dark');
730
+ }
731
+ })();
732
+ </script>
733
+ """
734
+
735
+
736
+ def page_head(title: str, description: str, css_prefix: str = "", lang: str = "en") -> str:
737
+ # #ui-m13 (#576): lang argument lets callers override the default
738
+ # `<html lang="en">` for translated docs (`docs/i18n/<locale>/`).
739
+ # See also page_head_article() which has the same parameter.
740
+ return f"""<!DOCTYPE html>
741
+ <html lang="{html.escape(lang)}" dir="auto">
742
+ <head>
743
+ <meta charset="UTF-8">
744
+ <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
745
+ <title>{html.escape(title)}</title>
746
+ <meta name="description" content="{html.escape(description)}">
747
+ {_PRE_PAINT_THEME_SCRIPT} <link rel="preconnect" href="https://fonts.googleapis.com">
748
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
749
+ <!-- #ui-m14 (#577): async-load Google Fonts via media="print" + onload swap so it doesn't render-block first paint. <noscript> fallback for JS-disabled users. -->
750
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" media="print" onload="this.media='all'">
751
+ <noscript><link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet"></noscript>
752
+ {_hljs_head_tags()} <link rel="stylesheet" href="{css_prefix}style.css">
753
+ </head>
754
+ <body>
755
+ <a href="#main-content" class="skip-link">Skip to content</a>
756
+ <div class="progress-bar" id="progress-bar"></div>
757
+ """
758
+
759
+
760
+ def page_head_article(
761
+ title: str,
762
+ description: str,
763
+ css_prefix: str = "",
764
+ canonical: str = "",
765
+ date: str = "",
766
+ metadata_comment: str = "",
767
+ lang: str = "en",
768
+ ) -> str:
769
+ """v0.4: Extended page head for session (Article) pages with schema.org
770
+ microdata, canonical link, and an AI-readable metadata HTML comment.
771
+
772
+ #ui-m13 (#576): `lang` arg lets translated docs override the
773
+ default `en`. `dir="auto"` lets the browser infer RTL/LTR per
774
+ paragraph for sessions whose body contains Arabic / Hebrew
775
+ transliterations or quotes."""
776
+ canonical_tag = ""
777
+ if canonical:
778
+ canonical_tag = f' <link rel="canonical" href="{html.escape(canonical)}">\n'
779
+ og_tags = f""" <meta property="og:type" content="article">
780
+ <meta property="og:title" content="{html.escape(title)}">
781
+ <meta property="og:description" content="{html.escape(description)}">
782
+ """
783
+ if date:
784
+ og_tags += f' <meta property="article:published_time" content="{html.escape(date)}">\n'
785
+ return f"""<!DOCTYPE html>
786
+ <html lang="{html.escape(lang)}" dir="auto">
787
+ <head>
788
+ <meta charset="UTF-8">
789
+ <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
790
+ <title>{html.escape(title)}</title>
791
+ <meta name="description" content="{html.escape(description)}">
792
+ {_PRE_PAINT_THEME_SCRIPT}{canonical_tag}{og_tags} <link rel="preconnect" href="https://fonts.googleapis.com">
793
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
794
+ <!-- #ui-m14 (#577): async-load Google Fonts via media="print" + onload swap so it doesn't render-block first paint. <noscript> fallback for JS-disabled users. -->
795
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" media="print" onload="this.media='all'">
796
+ <noscript><link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet"></noscript>
797
+ {_hljs_head_tags()} <link rel="stylesheet" href="{css_prefix}style.css">
798
+ </head>
799
+ <body>
800
+ {metadata_comment}<a href="#main-content" class="skip-link">Skip to content</a>
801
+ <div class="progress-bar" id="progress-bar"></div>
802
+ """
803
+
804
+
805
+ def _build_metadata_comment(
806
+ meta: dict[str, Any],
807
+ slug: str,
808
+ project_slug: str,
809
+ reading_min: int,
810
+ html_stem: str = "",
811
+ ) -> str:
812
+ """An HTML comment at the top of every session page that AI agents can
813
+ parse without needing to fetch the separate .json sibling."""
814
+ fields = [
815
+ f"slug: {slug}",
816
+ f"project: {project_slug}",
817
+ ]
818
+ for key in ("date", "started", "ended", "model", "gitBranch", "permissionMode", "user_messages", "tool_calls"):
819
+ val = meta.get(key)
820
+ if val is not None:
821
+ fields.append(f"{key}: {val}")
822
+ tools = get_tools_list(meta)
823
+ if tools:
824
+ fields.append(f"tools_used: [{', '.join(tools)}]")
825
+ fields.append(f"reading_min: {reading_min}")
826
+ sibling_stem = html_stem or slug
827
+ fields.append(f"txt_sibling: {sibling_stem}.txt")
828
+ fields.append(f"json_sibling: {sibling_stem}.json")
829
+ body = "\n".join(fields)
830
+ return f"<!-- sessionwiki:metadata\n{body}\n-->\n"
831
+
832
+
833
+ def nav_bar(active: str, link_prefix: str = "") -> str:
834
+ def link(href: str, label: str, key: str) -> str:
835
+ cls = ' class="active"' if key == active else ""
836
+ return f'<a href="{link_prefix}{href}"{cls}>{label}</a>'
837
+
838
+ # #460: hamburger pattern for tablet/mobile (≤1023px). The desktop
839
+ # nav-links row is hidden below 1024 (CSS rule), so without this
840
+ # button the Graph / Docs / Changelog entries would be unreachable
841
+ # on mobile (the bottom nav only carries Home / Projects / Sessions).
842
+ # The drawer below mirrors the same 6 links vertically. JS in
843
+ # render/js.py wires aria-expanded, ESC-to-close, and focus return.
844
+ drawer_link = lambda href, label, key: (
845
+ f' <a href="{link_prefix}{href}" class="nav-drawer-link'
846
+ + (' active' if key == active else '') + '">'
847
+ + label + '</a>'
848
+ )
849
+ # Post-review: dropped `role="menu"` + `aria-labelledby` — children
850
+ # are plain <a>, not role="menuitem", so screen readers were being
851
+ # told "press arrow keys" which did nothing. The drawer is a
852
+ # disclosure nav, not an ARIA menu. The hamburger's aria-controls
853
+ # already provides the trigger→drawer association; no role needed
854
+ # on the container.
855
+ nav_drawer_html = f"""<div id="nav-drawer" class="nav-drawer" hidden aria-label="Main navigation">
856
+ {drawer_link("index.html", "Home", "home")}
857
+ {drawer_link("projects/index.html", "Projects", "projects")}
858
+ {drawer_link("sessions/index.html", "Sessions", "sessions")}
859
+ {drawer_link("graph.html", "Graph", "graph")}
860
+ {drawer_link("docs/index.html", "Docs", "docs")}
861
+ {drawer_link("changelog.html", "Changelog", "changelog")}
862
+ </div>"""
863
+ return f"""<header class="nav">
864
+ <div class="nav-inner">
865
+ <a href="{link_prefix}index.html" class="nav-brand">
866
+ <svg aria-hidden="true" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg>
867
+ sessionwiki
868
+ </a>
869
+ <button type="button" class="nav-hamburger" id="nav-hamburger"
870
+ aria-expanded="false" aria-controls="nav-drawer"
871
+ aria-label="Open navigation menu">
872
+ <svg aria-hidden="true" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
873
+ </button>
874
+ <nav class="nav-links">
875
+ {link("index.html", "Home", "home")}
876
+ {link("projects/index.html", "Projects", "projects")}
877
+ {link("sessions/index.html", "Sessions", "sessions")}
878
+ {link("graph.html", "Graph", "graph")}
879
+ {link("docs/index.html", "Docs", "docs")}
880
+ {link("changelog.html", "Changelog", "changelog")}
881
+ <button class="nav-search-btn" id="open-palette"
882
+ aria-label="Open command palette"
883
+ aria-haspopup="dialog" aria-expanded="false" aria-controls="palette">
884
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
885
+ <span>Search</span>
886
+ <kbd>⌘K</kbd>
887
+ </button>
888
+ <button class="theme-toggle" id="theme-toggle"
889
+ aria-label="Toggle dark mode" aria-pressed="false">
890
+ <svg aria-hidden="true" class="icon-moon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
891
+ <svg aria-hidden="true" class="icon-sun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>
892
+ </button>
893
+ </nav>
894
+ </div>
895
+ {nav_drawer_html}
896
+ </header>
897
+ """
898
+
899
+
900
+ def breadcrumbs_bar(crumbs: list[tuple[str, str]], link_prefix: str = "") -> str:
901
+ if not crumbs:
902
+ return ""
903
+ parts = []
904
+ for i, (label, href) in enumerate(crumbs):
905
+ if href:
906
+ parts.append(f'<a href="{link_prefix}{html.escape(href)}">{html.escape(label)}</a>')
907
+ else:
908
+ parts.append(f'<span aria-current="page">{html.escape(label)}</span>')
909
+ sep = ' <span class="crumb-sep">›</span> '
910
+ return f'<nav class="breadcrumbs" aria-label="Breadcrumb">{sep.join(parts)}</nav>'
911
+
912
+
913
+ def hero(title: str, subtitle: str, size: str = "", subtitle_is_html: bool = False) -> str:
914
+ cls = f"hero {size}".strip()
915
+ sub = subtitle if subtitle_is_html else html.escape(subtitle)
916
+ return f"""<main id="main-content">
917
+ <section class="{cls}">
918
+ <div class="container">
919
+ <h1>{html.escape(title)}</h1>
920
+ <p class="hero-sub">{sub}</p>
921
+ </div>
922
+ </section>
923
+ """
924
+
925
+
926
+ def page_foot(js_prefix: str = "") -> str:
927
+ return f"""<footer class="footer">
928
+ <div class="container">
929
+ <p class="muted">sessionwiki · <a href="{js_prefix}index.html">home</a> · press <kbd>?</kbd> for shortcuts</p>
930
+ </div>
931
+ </footer>
932
+ <nav class="mobile-bottom-nav" aria-label="Mobile navigation">
933
+ <a href="{js_prefix}index.html" class="mbn-link" data-page="home">
934
+ <svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg>
935
+ <span>Home</span>
936
+ </a>
937
+ <a href="{js_prefix}projects/index.html" class="mbn-link" data-page="projects">
938
+ <svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>
939
+ <span>Projects</span>
940
+ </a>
941
+ <a href="{js_prefix}sessions/index.html" class="mbn-link" data-page="sessions">
942
+ <svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/></svg>
943
+ <span>Sessions</span>
944
+ </a>
945
+ <button type="button" class="mbn-link" id="mbn-search" aria-label="Search">
946
+ <svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
947
+ <span>Search</span>
948
+ </button>
949
+ <button type="button" class="mbn-link" id="mbn-theme" aria-label="Toggle theme" aria-pressed="false">
950
+ <svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
951
+ <span>Theme</span>
952
+ </button>
953
+ </nav>
954
+ <div id="palette" class="palette">
955
+ <div class="palette-backdrop" id="palette-backdrop"></div>
956
+ <div class="palette-modal" role="dialog" aria-modal="true" aria-label="Command palette">
957
+ <div class="palette-header">
958
+ <svg aria-hidden="true" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
959
+ <input type="text" id="palette-input" aria-label="Search pages" placeholder="Search… or type:session project:llm-wiki date:>2026-03 sort:date" autocomplete="off" spellcheck="false">
960
+ <kbd>ESC</kbd>
961
+ </div>
962
+ <ul class="palette-results" id="palette-results"></ul>
963
+ <div class="palette-footer muted">
964
+ <span><kbd>↑↓</kbd> navigate</span>
965
+ <span><kbd>↵</kbd> open</span>
966
+ <span><kbd>ESC</kbd> close</span>
967
+ </div>
968
+ </div>
969
+ </div>
970
+ <div id="help-dialog" class="help-dialog">
971
+ <div class="palette-backdrop" id="help-backdrop"></div>
972
+ <div class="help-modal">
973
+ <h2>Keyboard shortcuts</h2>
974
+ <table>
975
+ <tr><td><kbd>⌘K</kbd> / <kbd>Ctrl+K</kbd></td><td>Open command palette</td></tr>
976
+ <tr><td><kbd>/</kbd></td><td>Focus search</td></tr>
977
+ <tr><td><kbd>g h</kbd></td><td>Go to home</td></tr>
978
+ <tr><td><kbd>g p</kbd></td><td>Go to projects</td></tr>
979
+ <tr><td><kbd>g s</kbd></td><td>Go to sessions</td></tr>
980
+ <tr><td><kbd>j</kbd> / <kbd>k</kbd></td><td>Next / prev row (tables)</td></tr>
981
+ <tr><td><kbd>?</kbd></td><td>Show this help</td></tr>
982
+ <tr><td><kbd>Esc</kbd></td><td>Close dialogs</td></tr>
983
+ </table>
984
+ <h3>Structured queries</h3>
985
+ <p class="muted help-dialog-hint">Mix key:value filters with free text in the palette:</p>
986
+ <table>
987
+ <tr><td><code>type:session</code></td><td>Only session pages</td></tr>
988
+ <tr><td><code>project:llm-wiki</code></td><td>Filter by project name (substring)</td></tr>
989
+ <tr><td><code>model:claude</code></td><td>Filter by model name (substring)</td></tr>
990
+ <tr><td><code>date:&gt;2026-03-01</code></td><td>Sessions after a date</td></tr>
991
+ <tr><td><code>date:&lt;2026-04-01</code></td><td>Sessions before a date</td></tr>
992
+ <tr><td><code>tags:rust</code></td><td>Pages mentioning a tag/topic</td></tr>
993
+ <tr><td><code>sort:date</code></td><td>Sort results by date (newest first)</td></tr>
994
+ </table>
995
+ <p class="muted help-dialog-example">Example: <code>type:session project:llm-wiki date:&gt;2026-04 sort:date</code></p>
996
+ <button class="btn" id="help-close">Close</button>
997
+ </div>
998
+ </div>
999
+ <script src="{js_prefix}search-index.json" type="application/json" id="search-index-hint"></script>
1000
+ <script>window.LLMWIKI_INDEX_URL = "{js_prefix}search-index.json";</script>
1001
+ <script src="{HLJS_SCRIPT}" defer></script>
1002
+ <script>
1003
+ // v0.5: Run highlight.js once the CDN script lands. Defer keeps it out of
1004
+ // the critical path; the DOMContentLoaded fallback covers the case where
1005
+ // hljs arrives before/after DOM ready depending on cache state.
1006
+ function __sessionwikiHljsInit() {{
1007
+ if (window.hljs) {{ window.hljs.highlightAll(); }}
1008
+ else {{ window.addEventListener('load', function() {{ if (window.hljs) window.hljs.highlightAll(); }}); }}
1009
+ }}
1010
+ if (document.readyState === 'loading') {{
1011
+ document.addEventListener('DOMContentLoaded', __sessionwikiHljsInit);
1012
+ }} else {{
1013
+ __sessionwikiHljsInit();
1014
+ }}
1015
+ </script>
1016
+ <script src="{js_prefix}script.js"></script>
1017
+ </body>
1018
+ </html>
1019
+ """
1020
+
1021
+
1022
+ # ─── page renderers ────────────────────────────────────────────────────────
1023
+
1024
+ def _pluralize(n: int, singular: str, plural: str | None = None) -> str:
1025
+ """Return ``"1 session"`` for n=1, ``"3 sessions"`` for n=3.
1026
+
1027
+ Closes #387 U7. The hero subtitle and any other count-bearing
1028
+ user-facing string should never read as ``"1 sessions"``."""
1029
+ if plural is None:
1030
+ plural = singular + "s"
1031
+ return f"{n} {singular if n == 1 else plural}"
1032
+
1033
+
1034
+ def calc_reading_time(body: str, wpm: int = 225) -> int:
1035
+ """Estimate reading time in minutes from a markdown body."""
1036
+ words = len(re.findall(r"\w+", body))
1037
+ return max(1, round(words / wpm))
1038
+
1039
+
1040
+ def render_session(
1041
+ path: Path,
1042
+ meta: dict[str, Any],
1043
+ body: str,
1044
+ out_dir: Path,
1045
+ project_slug: str,
1046
+ ) -> Path:
1047
+ slug = meta.get("slug", path.stem)
1048
+ date = meta.get("date", "")
1049
+ title_raw = meta.get("title", f"Session: {slug}")
1050
+
1051
+ body = strip_leading_h1(body)
1052
+ body_html = md_to_html(body)
1053
+ # #270: session transcripts often reference files the user had open
1054
+ # during the session (tasks.md, CLAUDE.md, convert.py, etc). Route
1055
+ # the ones that look like repo source code or root files to GitHub
1056
+ # so the links don't dead-end after ingest.
1057
+ from sessionwiki.docs_pages import (
1058
+ rewrite_md_links_to_html,
1059
+ rewrite_source_code_links_to_github,
1060
+ strip_dead_session_refs,
1061
+ )
1062
+ body_html = rewrite_source_code_links_to_github(body_html)
1063
+ # #284: now that README.md and CONTRIBUTING.md compile to
1064
+ # site/README.html / site/CONTRIBUTING.html, session bodies that
1065
+ # reference those files should route to the compiled pages.
1066
+ # Generic .md → .html pass runs AFTER the GitHub rewrite so
1067
+ # source-code and repo-root-only refs (CLAUDE.md, AGENTS.md) still
1068
+ # go to GitHub.
1069
+ body_html = rewrite_md_links_to_html(body_html)
1070
+ # #336: for remaining session-local refs (tasks.md, user_profile.md,
1071
+ # wiki/sources/<proj>/... wikilinks), drop the anchor but keep the
1072
+ # text — they point at files unique to the user's project and don't
1073
+ # compile to anywhere on the site.
1074
+ body_html = strip_dead_session_refs(body_html)
1075
+ raw_md_for_copy = html.escape(body)
1076
+ reading_min = calc_reading_time(body)
1077
+
1078
+ bits: list[str] = []
1079
+ if meta.get("project"):
1080
+ bits.append(
1081
+ f'<a href="../../projects/{html.escape(str(meta["project"]))}.html">{html.escape(str(meta["project"]))}</a>'
1082
+ )
1083
+ # Agent badge — shows Claude / Codex / Copilot / Cursor / Gemini
1084
+ bits.append(render_agent_badge(meta))
1085
+ if meta.get("gitBranch"):
1086
+ bits.append(f'branch <code>{html.escape(str(meta["gitBranch"]))}</code>')
1087
+ if meta.get("model"):
1088
+ bits.append(f'<code>{html.escape(str(meta["model"]))}</code>')
1089
+ if meta.get("started"):
1090
+ bits.append(f'<span class="muted">{html.escape(short_started(meta))}</span>')
1091
+ if meta.get("user_messages"):
1092
+ bits.append(f'{html.escape(str(meta["user_messages"]))} msgs')
1093
+ if meta.get("tool_calls"):
1094
+ bits.append(f'{html.escape(str(meta["tool_calls"]))} tools')
1095
+ bits.append(f'<span class="muted">{reading_min} min read</span>')
1096
+ bits.append(render_freshness(meta))
1097
+ meta_strip = " · ".join(bits) if bits else ""
1098
+
1099
+ tools_list = get_tools_list(meta)
1100
+ tools_preview = ""
1101
+ if tools_list:
1102
+ preview = ", ".join(tools_list[:6])
1103
+ if len(tools_list) > 6:
1104
+ preview += f", +{len(tools_list) - 6} more"
1105
+ tools_preview = f'<div class="meta-tools muted">tools: {html.escape(preview)}</div>'
1106
+
1107
+ # v0.8 (#65): horizontal bar chart of tool calls in this session.
1108
+ # Uses the `tool_counts` JSON dict from frontmatter (#63). Empty
1109
+ # sessions (no recorded calls) render nothing.
1110
+ tool_chart_svg = render_session_tool_chart(meta)
1111
+ tool_chart_block = ""
1112
+ if tool_chart_svg:
1113
+ tool_chart_block = (
1114
+ '<div class="tool-chart-card">'
1115
+ '<div class="tool-chart-label muted">Tool calls</div>'
1116
+ f'{tool_chart_svg}'
1117
+ '</div>'
1118
+ )
1119
+
1120
+ # v0.8 (#66): session token-usage card. Input / cache_creation /
1121
+ # cache_read / output plus a cache-hit-ratio tier badge. Sessions
1122
+ # missing token_totals (older converter output) render nothing.
1123
+ token_card_block = render_session_token_card(meta)
1124
+
1125
+ # IMPORTANT: The HTML file is named `<path.stem>.html` (e.g. date-slug),
1126
+ # NOT `<slug>.html`. The siblings + canonical must use path.stem.
1127
+ html_stem = path.stem
1128
+ raw_md_path = f"../../sources/{project_slug}/{path.name}"
1129
+ actions_html = f"""<div class="session-actions">
1130
+ <button class="btn btn-primary" type="button" aria-label="Copy session content as markdown" title="Copy as markdown" onclick="copyMarkdown(this)">Copy as markdown</button>
1131
+ <a class="btn" href="../../projects/{html.escape(project_slug)}.html">← {html.escape(project_slug)}</a>
1132
+ <a class="btn" href="{html.escape(raw_md_path)}" download>Download .md</a>
1133
+ <a class="btn" href="{html.escape(html_stem + '.txt')}" title="plain-text sibling for AI agents">.txt</a>
1134
+ <a class="btn" href="{html.escape(html_stem + '.json')}" title="structured JSON sibling for AI agents">.json</a>
1135
+ <textarea class="md-source" hidden>{raw_md_for_copy}</textarea>
1136
+ </div>"""
1137
+
1138
+ crumbs = [
1139
+ ("Home", "index.html"),
1140
+ ("Projects", "projects/index.html"),
1141
+ (project_slug, f"projects/{project_slug}.html"),
1142
+ (str(slug), ""),
1143
+ ]
1144
+ breadcrumbs = breadcrumbs_bar(crumbs, link_prefix="../../")
1145
+
1146
+ # v0.4: machine-readable metadata appendix (HTML comment that AI agents
1147
+ # scraping HTML can parse without fetching the .json sibling).
1148
+ metadata_comment = _build_metadata_comment(meta, slug, project_slug, reading_min, html_stem=html_stem)
1149
+
1150
+ # v0.4: page_head with schema.org article microdata + canonical link.
1151
+ # Canonical is relative to the current page (same dir) so the link checker
1152
+ # resolves it correctly whether served from a subdomain root or any path.
1153
+ page = (
1154
+ page_head_article(
1155
+ title=f"{title_raw} — sessionwiki",
1156
+ description=f"Session transcript from {meta.get('project', '')} on {date}",
1157
+ css_prefix="../../",
1158
+ canonical=f"{html_stem}.html",
1159
+ date=str(meta.get("started") or date),
1160
+ metadata_comment=metadata_comment,
1161
+ )
1162
+ + nav_bar("sessions", link_prefix="../../")
1163
+ + hero(str(title_raw), meta_strip, size="hero-sm", subtitle_is_html=True)
1164
+ # #471: human-readable description rendered as a subtitle below
1165
+ # the hero, before the meta-strip. Only emit if frontmatter
1166
+ # carries the field; older sessions skip this block cleanly.
1167
+ + (
1168
+ f'<div class="container session-description"><p>{html.escape(str(meta["description"]))}</p></div>'
1169
+ if meta.get("description") else ""
1170
+ )
1171
+ + f'<section class="section">\n <div class="container">\n{breadcrumbs}\n{tools_preview}\n{actions_html}\n{tool_chart_block}\n{token_card_block}\n <article class="content" itemscope itemtype="https://schema.org/Article">\n'
1172
+ + f'<meta itemprop="headline" content="{html.escape(str(title_raw))}">\n'
1173
+ + f'<meta itemprop="datePublished" content="{html.escape(str(meta.get("started") or date))}">\n'
1174
+ + f'<meta itemprop="inLanguage" content="en">\n'
1175
+ + body_html
1176
+ + '\n </article>\n </div>\n</section>\n</main>\n'
1177
+ + page_foot(js_prefix="../../")
1178
+ )
1179
+
1180
+ out_path = out_dir / "sessions" / project_slug / f"{path.stem}.html"
1181
+ out_path.parent.mkdir(parents=True, exist_ok=True)
1182
+ out_path.write_text(page, encoding="utf-8")
1183
+ return out_path
1184
+
1185
+
1186
+ def render_project_page(
1187
+ project_slug: str,
1188
+ sessions: list[tuple[Path, dict[str, Any], str]],
1189
+ out_dir: Path,
1190
+ ) -> Path:
1191
+ main_sessions = [s for s in sessions if not _is_subagent(s[1], s[0])]
1192
+ subagent_sessions = [s for s in sessions if s not in main_sessions]
1193
+
1194
+ def card(p: Path, meta: dict[str, Any]) -> str:
1195
+ slug = meta.get("slug", p.stem)
1196
+ title = meta.get("title", slug)
1197
+ # Strip "Session: project/" prefix for cleaner display
1198
+ if title.startswith("Session: "):
1199
+ title = title[9:]
1200
+ date = meta.get("date", "")
1201
+ model = meta.get("model", "")
1202
+ umsgs = meta.get("user_messages", "")
1203
+ tcalls = meta.get("tool_calls", "")
1204
+ href = f"../sessions/{project_slug}/{p.stem}.html"
1205
+ badge = render_freshness(meta)
1206
+ return f""" <a class="card" href="{href}">
1207
+ <div class="card-title">{html.escape(str(title))}</div>
1208
+ <div class="card-meta">{html.escape(str(date))} · {html.escape(str(model))}</div>
1209
+ <div class="card-stats muted">{html.escape(str(umsgs))} messages · {html.escape(str(tcalls))} tool calls</div>
1210
+ <div class="card-badge">{badge}</div>
1211
+ </a>"""
1212
+
1213
+ cards_main = "\n".join(card(p, m) for p, m, _ in main_sessions)
1214
+ cards_sub = "\n".join(card(p, m) for p, m, _ in subagent_sessions)
1215
+
1216
+ sub_section = ""
1217
+ if subagent_sessions:
1218
+ sub_section = (
1219
+ '<details class="sub-section"><summary>Sub-agent runs ('
1220
+ + str(len(subagent_sessions))
1221
+ + ")</summary>\n<div class=\"card-grid\">\n"
1222
+ + cards_sub
1223
+ + "\n</div>\n</details>\n"
1224
+ )
1225
+
1226
+ crumbs = breadcrumbs_bar(
1227
+ [("Home", "index.html"), ("Projects", "projects/index.html"), (project_slug, "")],
1228
+ link_prefix="../",
1229
+ )
1230
+
1231
+ # v0.8 (#64, #72): per-project 365-day heatmap — same window as the
1232
+ # aggregate on the home page, but filtered to just this project's
1233
+ # sessions. Sparse projects (only a handful of sessions) still render
1234
+ # the full grid with the rest as level-0 cells, so the shape matches.
1235
+ proj_entries = [m for _, m, _ in sessions]
1236
+ proj_counts = collect_session_counts(proj_entries, project_slug=project_slug)
1237
+ proj_heatmap = render_heatmap(proj_counts, title_prefix=f"{project_slug} activity")
1238
+ heatmap_block = f"""<section class="section heatmap-section">
1239
+ <div class="container">
1240
+ <div class="activity-heatmap">
1241
+ <div class="heatmap-label muted">Activity · last 365 days · {html.escape(project_slug)}</div>
1242
+ {proj_heatmap}
1243
+ </div>
1244
+ </div>
1245
+ </section>"""
1246
+
1247
+ # v0.8 (#65): aggregate tool-call bar chart across all sessions in
1248
+ # this project. Projects with no recorded tool calls render nothing.
1249
+ proj_tool_chart = render_project_tool_chart(proj_entries, project_slug)
1250
+ tool_chart_block = ""
1251
+ if proj_tool_chart:
1252
+ tool_chart_block = f"""<section class="section tool-chart-section">
1253
+ <div class="container">
1254
+ <div class="tool-chart-card">
1255
+ <div class="tool-chart-label muted">Tool calls · {html.escape(project_slug)} aggregate</div>
1256
+ {proj_tool_chart}
1257
+ </div>
1258
+ </div>
1259
+ </section>"""
1260
+
1261
+ # v0.8 (#66): project token timeline card (log-scale area chart of
1262
+ # total tokens per session date + aggregate cache hit ratio in the
1263
+ # header). Empty for projects without any token data.
1264
+ proj_token_card_html = render_project_token_card(proj_entries, project_slug)
1265
+ token_timeline_block = ""
1266
+ if proj_token_card_html:
1267
+ token_timeline_block = f"""<section class="section token-timeline-section">
1268
+ <div class="container">
1269
+ {proj_token_card_html}
1270
+ </div>
1271
+ </section>"""
1272
+
1273
+ # Project topics strip — renders below the hero, above the heatmap.
1274
+ # Explicit profile via wiki/projects/<slug>.md wins over the
1275
+ # session-tag fallback. Projects with no topics render an empty
1276
+ # strip (no chip row at all).
1277
+ proj_profile = load_project_profile(PROJECTS_META_DIR, project_slug)
1278
+ proj_topics = get_project_topics(PROJECTS_META_DIR, project_slug, proj_entries)
1279
+ topics_html = render_topic_chips(
1280
+ proj_topics, max_visible=12, classname="project-topics project-hero-topics"
1281
+ )
1282
+ description_html = ""
1283
+ if proj_profile and proj_profile.get("description"):
1284
+ description_html = (
1285
+ f'<p class="project-description muted">'
1286
+ f'{html.escape(proj_profile["description"])}</p>'
1287
+ )
1288
+ homepage_html = ""
1289
+ if proj_profile and proj_profile.get("homepage"):
1290
+ hp = proj_profile["homepage"]
1291
+ homepage_html = (
1292
+ f'<a class="project-homepage" href="{html.escape(hp)}" '
1293
+ f'rel="noopener">{html.escape(hp)} ↗</a>'
1294
+ )
1295
+ topics_strip = ""
1296
+ if topics_html or description_html or homepage_html:
1297
+ topics_strip = (
1298
+ '<section class="section project-topics-section">\n'
1299
+ ' <div class="container">\n'
1300
+ f' {description_html}\n'
1301
+ f' {topics_html}\n'
1302
+ f' {homepage_html}\n'
1303
+ ' </div>\n'
1304
+ '</section>\n'
1305
+ )
1306
+
1307
+ body = f"""{topics_strip}
1308
+ {heatmap_block}
1309
+ {tool_chart_block}
1310
+ {token_timeline_block}
1311
+ <section class="section">
1312
+ <div class="container">
1313
+ {crumbs}
1314
+ <h2>Main sessions ({len(main_sessions)})</h2>
1315
+ <div class="card-grid">
1316
+ {cards_main}
1317
+ </div>
1318
+ {sub_section}
1319
+ </div>
1320
+ </section>
1321
+ </main>
1322
+ """
1323
+
1324
+ page = (
1325
+ page_head(
1326
+ f"{project_slug} — sessionwiki",
1327
+ f"{len(sessions)} Claude Code sessions from {project_slug}",
1328
+ css_prefix="../",
1329
+ )
1330
+ + nav_bar("projects", link_prefix="../")
1331
+ + hero(
1332
+ project_slug,
1333
+ f"{len(main_sessions)} main sessions · {len(subagent_sessions)} sub-agent runs",
1334
+ )
1335
+ + body
1336
+ + page_foot(js_prefix="../")
1337
+ )
1338
+
1339
+ out_path = out_dir / "projects" / f"{project_slug}.html"
1340
+ out_path.parent.mkdir(parents=True, exist_ok=True)
1341
+ out_path.write_text(page, encoding="utf-8")
1342
+ return out_path
1343
+
1344
+
1345
+ def render_projects_index(
1346
+ groups: dict[str, list[tuple[Path, dict[str, Any], str]]],
1347
+ out_dir: Path,
1348
+ ) -> Path:
1349
+ cards = []
1350
+ for project, sessions in sorted(groups.items(), key=lambda x: -len(x[1])):
1351
+ main_count = sum(1 for p, m, _ in sessions if not _is_subagent(m, p))
1352
+ sub_count = len(sessions) - main_count
1353
+ # Freshness reflects the newest session in the project.
1354
+ newest_meta = max(
1355
+ (m for _, m, _ in sessions),
1356
+ key=lambda m: str(m.get("ended") or m.get("started") or m.get("date") or ""),
1357
+ default={},
1358
+ )
1359
+ badge = render_freshness(newest_meta)
1360
+ cards.append(
1361
+ f""" <a class="card" href="{html.escape(project)}.html">
1362
+ <div class="card-title">{html.escape(project)}</div>
1363
+ <div class="card-meta">{main_count} main · {sub_count} sub-agent</div>
1364
+ <div class="card-badge">{badge}</div>
1365
+ </a>"""
1366
+ )
1367
+
1368
+ crumbs = breadcrumbs_bar(
1369
+ [("Home", "index.html"), ("Projects", "")], link_prefix="../"
1370
+ )
1371
+
1372
+ body = f"""<section class="section">
1373
+ <div class="container">
1374
+ {crumbs}
1375
+ <div class="card-grid">
1376
+ {chr(10).join(cards)}
1377
+ </div>
1378
+ </div>
1379
+ </section>
1380
+ </main>
1381
+ """
1382
+
1383
+ page = (
1384
+ page_head("Projects — sessionwiki", "All projects with AI coding session history", css_prefix="../")
1385
+ + nav_bar("projects", link_prefix="../")
1386
+ + hero("Projects", _pluralize(len(groups), "project"))
1387
+ + body
1388
+ + page_foot(js_prefix="../")
1389
+ )
1390
+
1391
+ out_path = out_dir / "projects" / "index.html"
1392
+ out_path.parent.mkdir(parents=True, exist_ok=True)
1393
+ out_path.write_text(page, encoding="utf-8")
1394
+ return out_path
1395
+
1396
+
1397
+ def render_sessions_index(
1398
+ sources: list[tuple[Path, dict[str, Any], str]],
1399
+ groups: dict[str, list[tuple[Path, dict[str, Any], str]]],
1400
+ out_dir: Path,
1401
+ ) -> Path:
1402
+ rows = []
1403
+
1404
+ def key(t: tuple[Path, dict[str, Any], str]) -> str:
1405
+ return str(t[1].get("started", "")) or str(t[0].name)
1406
+
1407
+ for p, meta, _ in sorted(sources, key=key, reverse=True):
1408
+ project = meta.get("project", p.parent.name)
1409
+ slug = meta.get("slug", p.stem)
1410
+ title = meta.get("title", slug)
1411
+ # Strip "Session: " prefix for cleaner display
1412
+ if title.startswith("Session: "):
1413
+ title = title[9:]
1414
+ # #452: titles auto-generated from session frontmatter follow the
1415
+ # pattern "<slug> — <date>" but the table already has a dedicated
1416
+ # Date column, so the trailing date is redundant. Strip it so the
1417
+ # Session cell shows just the slug (or whatever custom title the
1418
+ # user gave) and the Date column carries the date alone.
1419
+ date = meta.get("date", "")
1420
+ if date and title.endswith(f" — {date}"):
1421
+ title = title[: -(len(date) + 3)]
1422
+ # Truncate long titles for table display
1423
+ display_title = title[:70] + "..." if len(title) > 70 else title
1424
+ # #471: human-readable description from the first user turn —
1425
+ # if frontmatter carries one, render it as a small muted line
1426
+ # below the slug. Falls back to no second line for older
1427
+ # sessions without the field.
1428
+ description = str(meta.get("description") or "").strip()
1429
+ desc_line = (
1430
+ f'<div class="session-cell-desc muted">{html.escape(description)}</div>'
1431
+ if description else ""
1432
+ )
1433
+ model = meta.get("model", "")
1434
+ umsgs = meta.get("user_messages", "")
1435
+ tcalls = meta.get("tool_calls", "")
1436
+ href = f"{project}/{p.stem}.html"
1437
+ rows.append(
1438
+ f""" <tr data-project="{html.escape(str(project))}" data-model="{html.escape(str(model))}" data-date="{html.escape(str(date))}" data-slug="{html.escape(str(slug))}">
1439
+ <td><a href="{html.escape(str(href))}">{html.escape(str(display_title))}</a>{desc_line}</td>
1440
+ <td>{render_agent_badge(meta)}</td>
1441
+ <td><a href="../projects/{html.escape(str(project))}.html">{html.escape(str(project))}</a></td>
1442
+ <td>{html.escape(str(date))}</td>
1443
+ <td><code>{html.escape(str(model))}</code></td>
1444
+ <td class="num">{html.escape(str(umsgs))}</td>
1445
+ <td class="num">{html.escape(str(tcalls))}</td>
1446
+ </tr>"""
1447
+ )
1448
+
1449
+ project_options = "\n".join(
1450
+ f' <option value="{html.escape(p)}">{html.escape(p)}</option>'
1451
+ for p in sorted(groups.keys())
1452
+ )
1453
+
1454
+ models = sorted(
1455
+ {str(m.get("model", "")) for _, m, _ in sources if m.get("model")}
1456
+ )
1457
+ model_options = "\n".join(
1458
+ f' <option value="{html.escape(m)}">{html.escape(m)}</option>'
1459
+ for m in models
1460
+ )
1461
+
1462
+ crumbs = breadcrumbs_bar(
1463
+ [("Home", "index.html"), ("Sessions", "")], link_prefix="../"
1464
+ )
1465
+
1466
+ body = f"""<section class="section">
1467
+ <div class="container">
1468
+ {crumbs}
1469
+ <div class="filter-bar">
1470
+ <label>Project
1471
+ <select id="filter-project">
1472
+ <option value="">All projects</option>
1473
+ {project_options}
1474
+ </select>
1475
+ </label>
1476
+ <label>Model
1477
+ <select id="filter-model">
1478
+ <option value="">All models</option>
1479
+ {model_options}
1480
+ </select>
1481
+ </label>
1482
+ <label>From
1483
+ <input type="date" id="filter-date-from">
1484
+ </label>
1485
+ <label>To
1486
+ <input type="date" id="filter-date-to">
1487
+ </label>
1488
+ <label>Slug
1489
+ <input type="text" id="filter-text" placeholder="part of slug…">
1490
+ </label>
1491
+ <button class="btn" id="filter-clear">Clear</button>
1492
+ <span class="filter-count muted" id="filter-count"></span>
1493
+ </div>
1494
+ <div class="table-wrap">
1495
+ <table class="sessions-table">
1496
+ <colgroup>
1497
+ <col style="width: 30%">
1498
+ <col style="width: 8%">
1499
+ <col style="width: 16%">
1500
+ <col style="width: 10%">
1501
+ <col style="width: 22%">
1502
+ <col style="width: 7%">
1503
+ <col style="width: 7%">
1504
+ </colgroup>
1505
+ <thead>
1506
+ <tr><th>Session</th><th>Agent</th><th>Project</th><th>Date</th><th>Model</th><th>Msgs</th><th>Tools</th></tr>
1507
+ </thead>
1508
+ <tbody id="sessions-tbody">
1509
+ {chr(10).join(rows)}
1510
+ </tbody>
1511
+ </table>
1512
+ </div>
1513
+ </div>
1514
+ </section>
1515
+ </main>
1516
+ """
1517
+
1518
+ page = (
1519
+ page_head("Sessions — sessionwiki", "All AI coding sessions, newest first", css_prefix="../")
1520
+ + nav_bar("sessions", link_prefix="../")
1521
+ + hero("All sessions", _pluralize(len(sources), "session") + " total")
1522
+ + body
1523
+ + page_foot(js_prefix="../")
1524
+ )
1525
+
1526
+ out_path = out_dir / "sessions" / "index.html"
1527
+ out_path.parent.mkdir(parents=True, exist_ok=True)
1528
+ out_path.write_text(page, encoding="utf-8")
1529
+ return out_path
1530
+
1531
+
1532
+ def render_index(
1533
+ groups: dict[str, list[tuple[Path, dict[str, Any], str]]],
1534
+ all_sources: list[tuple[Path, dict[str, Any], str]],
1535
+ out_dir: Path,
1536
+ synthesis: Optional[str] = None,
1537
+ ) -> Path:
1538
+ total = len(all_sources)
1539
+ mains = sum(1 for p, m, _ in all_sources if not _is_subagent(m, p))
1540
+ subs = total - mains
1541
+
1542
+ synth_block = ""
1543
+ if synthesis:
1544
+ synth_block = f"""<section class="section">
1545
+ <div class="container">
1546
+ <div class="synthesis">
1547
+ <h2>Overview</h2>
1548
+ {md_to_html(synthesis)}
1549
+ </div>
1550
+ </div>
1551
+ </section>"""
1552
+
1553
+ # v0.8 (#64, #72): aggregate 365-day GitHub-style heatmap. Counts all
1554
+ # main sessions across all projects. Rendered as a pure-SVG block just
1555
+ # above the projects grid so the landing page gives a glanceable
1556
+ # "last year of activity" view.
1557
+ heatmap_entries = [m for _, m, _ in all_sources]
1558
+ heatmap_counts = collect_session_counts(heatmap_entries)
1559
+ heatmap_svg = render_heatmap(heatmap_counts, title_prefix="Activity")
1560
+ heatmap_block = f"""<section class="section heatmap-section">
1561
+ <div class="container">
1562
+ <div class="activity-heatmap">
1563
+ <div class="heatmap-label muted">Activity · last 365 days</div>
1564
+ {heatmap_svg}
1565
+ </div>
1566
+ </div>
1567
+ </section>"""
1568
+
1569
+ # v0.8 (#66): site-wide token summary stats — four cards showing
1570
+ # total tokens processed, average per session, best cache hit project,
1571
+ # heaviest project. Empty if no session has token_totals data.
1572
+ metas_by_project: dict[str, list[dict[str, Any]]] = {}
1573
+ for project, sessions in groups.items():
1574
+ metas_by_project[project] = [m for _, m, _ in sessions]
1575
+ token_stats_block = render_site_token_stats(metas_by_project, link_prefix="")
1576
+
1577
+ # Recently updated — show last 10 entries from wiki/log.md.
1578
+ log_events = _recent_log_events(
1579
+ REPO_ROOT / "wiki" / "log.md", limit=10
1580
+ )
1581
+ recent_block_inner = render_recent_activity(log_events)
1582
+ recent_block = (
1583
+ f'<section class="section recently-updated-section">\n'
1584
+ f' <div class="container">\n'
1585
+ f' {recent_block_inner}\n'
1586
+ f' </div>\n'
1587
+ f'</section>\n'
1588
+ ) if recent_block_inner else ""
1589
+
1590
+ cards = []
1591
+ for project, sessions in sorted(groups.items(), key=lambda x: -len(x[1])):
1592
+ main_count = sum(1 for p, m, _ in sessions if not _is_subagent(m, p))
1593
+ # Project topics — explicit profile in wiki/projects/<slug>.md
1594
+ # takes precedence, falls back to aggregated session tags with
1595
+ # noise filtered out. Rendered as chips below the card meta.
1596
+ proj_metas = [m for _, m, _ in sessions]
1597
+ topics = get_project_topics(PROJECTS_META_DIR, project, proj_metas)
1598
+ topics_html = render_topic_chips(topics, max_visible=4,
1599
+ classname="project-topics card-topics")
1600
+ # #455: render the activity date range under the meta line so
1601
+ # users can spot fresh vs stale projects without clicking. Pull
1602
+ # `date:` from frontmatter (already YYYY-MM-DD strings); ignore
1603
+ # missing/blank values; format as `2026-03-12 → 2026-04-01` for
1604
+ # multi-day, just `2026-04-01` if first == last.
1605
+ dates = sorted(
1606
+ {str(m.get("date", "")) for _, m, _ in sessions if m.get("date")}
1607
+ )
1608
+ if dates:
1609
+ if dates[0] == dates[-1]:
1610
+ date_range_html = (
1611
+ f'<div class="card-date-range">{html.escape(dates[0])}</div>'
1612
+ )
1613
+ else:
1614
+ date_range_html = (
1615
+ f'<div class="card-date-range">'
1616
+ f'{html.escape(dates[0])} → {html.escape(dates[-1])}'
1617
+ f'</div>'
1618
+ )
1619
+ else:
1620
+ date_range_html = ""
1621
+ cards.append(
1622
+ f""" <a class="card card-project" href="projects/{html.escape(project)}.html">
1623
+ <div class="card-title">{html.escape(project)}</div>
1624
+ <div class="card-meta">{main_count} main · {len(sessions) - main_count} sub-agent</div>
1625
+ {date_range_html}
1626
+ {topics_html}
1627
+ </a>"""
1628
+ )
1629
+
1630
+ body = f"""{heatmap_block}
1631
+ {token_stats_block}
1632
+ {recent_block}
1633
+ <section class="section">
1634
+ <div class="container">
1635
+ <h2>Projects</h2>
1636
+ <div class="card-grid">
1637
+ {chr(10).join(cards)}
1638
+ </div>
1639
+ </div>
1640
+ </section>
1641
+ </main>
1642
+ """
1643
+
1644
+ page = (
1645
+ page_head("sessionwiki", "Karpathy-style LLM Wiki of your AI coding sessions", css_prefix="")
1646
+ + nav_bar("home", link_prefix="")
1647
+ + hero(
1648
+ "sessionwiki",
1649
+ f"{_pluralize(mains, 'main session')} · {_pluralize(subs, 'sub-agent run')} · {_pluralize(len(groups), 'project')}",
1650
+ )
1651
+ + synth_block
1652
+ + body
1653
+ + page_foot(js_prefix="")
1654
+ )
1655
+
1656
+ out_path = out_dir / "index.html"
1657
+ out_path.write_text(page, encoding="utf-8")
1658
+ return out_path
1659
+
1660
+
1661
+ def _render_root_md_page(
1662
+ src_name: str,
1663
+ out_name: str,
1664
+ title: str,
1665
+ subtitle: str,
1666
+ meta_description: str,
1667
+ out_dir: Path,
1668
+ *,
1669
+ active_nav: str = "docs",
1670
+ ) -> Optional[Path]:
1671
+ """Compile a repo-root ``.md`` file to a standalone site page (#284).
1672
+
1673
+ Used for ``README.md`` and ``CONTRIBUTING.md`` so visitors don't get
1674
+ bounced out to GitHub for content we're already shipping as HTML.
1675
+ """
1676
+ src = REPO_ROOT / src_name
1677
+ if not src.is_file():
1678
+ return None
1679
+ raw = src.read_text(encoding="utf-8")
1680
+ body_md = raw
1681
+ lines = raw.splitlines()
1682
+ if lines and lines[0].lstrip().startswith("# "):
1683
+ body_md = "\n".join(lines[1:]).lstrip("\n")
1684
+ content_html = md_to_html(body_md)
1685
+ # #270: route embedded source-code + repo-root links to GitHub, then
1686
+ # the generic .md→.html pass for anything remaining. README has
1687
+ # plenty of such links.
1688
+ from sessionwiki.docs_pages import (
1689
+ rewrite_md_links_to_html,
1690
+ rewrite_source_code_links_to_github,
1691
+ )
1692
+ content_html = rewrite_source_code_links_to_github(content_html)
1693
+ content_html = rewrite_md_links_to_html(content_html)
1694
+
1695
+ body = f"""<section class="section docs-body">
1696
+ <div class="container narrow">
1697
+ <article class="article docs-article">
1698
+ {content_html}
1699
+ </article>
1700
+ </div>
1701
+ </section>
1702
+ </main>
1703
+ """
1704
+ page = (
1705
+ page_head(
1706
+ f"{title} — sessionwiki",
1707
+ meta_description,
1708
+ css_prefix="",
1709
+ )
1710
+ + nav_bar(active_nav, link_prefix="")
1711
+ + hero(title, subtitle)
1712
+ + body
1713
+ + page_foot(js_prefix="")
1714
+ )
1715
+ out_path = out_dir / out_name
1716
+ out_path.write_text(page, encoding="utf-8")
1717
+ return out_path
1718
+
1719
+
1720
+ def render_404(out_dir: Path) -> Path:
1721
+ """Emit ``site/404.html`` with the standard site chrome and a "Page not
1722
+ found" panel. Closes #387 U8 — without this, ``sessionwiki serve`` falls
1723
+ back to the stdlib ``http.server`` default 404 (an unstyled error string
1724
+ with no nav). The page itself is not linked from the index, but
1725
+ ``serve.py`` injects it as the body of every 404 response.
1726
+ """
1727
+ head = page_head(
1728
+ title="Page not found · sessionwiki",
1729
+ description="The page you tried to open doesn't exist on this site.",
1730
+ )
1731
+ nav = nav_bar(active="")
1732
+ foot = page_foot()
1733
+ body = """<main id="main-content">
1734
+ <section class="hero">
1735
+ <div class="container">
1736
+ <h1>Page not found</h1>
1737
+ <p class="hero-sub">The page you tried to open doesn't exist on this site. The link may be stale, the page may have been removed, or the URL may have a typo.</p>
1738
+ </div>
1739
+ </section>
1740
+ <section class="section">
1741
+ <div class="container">
1742
+ <p>Try one of these:</p>
1743
+ <ul class="not-found-links">
1744
+ <li><a href="index.html">Home</a> — overview and recent activity</li>
1745
+ <li><a href="projects/index.html">Projects</a> — every project with sessions</li>
1746
+ <li><a href="sessions/index.html">Sessions</a> — every session, sortable + filterable</li>
1747
+ <li><a href="changelog.html">Changelog</a> — what's shipped recently</li>
1748
+ </ul>
1749
+ <p class="muted">Or press <kbd>⌘K</kbd> / <kbd>Ctrl+K</kbd> to open the command palette and search.</p>
1750
+ </div>
1751
+ </section>
1752
+ </main>
1753
+ """
1754
+ page = head + nav + body + foot
1755
+ out_path = out_dir / "404.html"
1756
+ out_path.write_text(page, encoding="utf-8")
1757
+ return out_path
1758
+
1759
+
1760
+ def render_readme_page(out_dir: Path) -> Optional[Path]:
1761
+ """Compile ``README.md`` to ``site/README.html`` (#284)."""
1762
+ return _render_root_md_page(
1763
+ "README.md", "README.html",
1764
+ title="README",
1765
+ subtitle="The public README of sessionwiki, rendered from `README.md`.",
1766
+ meta_description="sessionwiki — Karpathy-style LLM wiki from your Claude Code, Codex CLI, Cursor, and Obsidian sessions.",
1767
+ out_dir=out_dir,
1768
+ )
1769
+
1770
+
1771
+ def render_contributing_page(out_dir: Path) -> Optional[Path]:
1772
+ """Compile ``CONTRIBUTING.md`` to ``site/CONTRIBUTING.html`` (#284)."""
1773
+ return _render_root_md_page(
1774
+ "CONTRIBUTING.md", "CONTRIBUTING.html",
1775
+ title="Contributing",
1776
+ subtitle="The 8 rules + review bar for contributing to sessionwiki.",
1777
+ meta_description="Contribution rules, PR checklist, and review bar for sessionwiki.",
1778
+ out_dir=out_dir,
1779
+ )
1780
+
1781
+
1782
+ # ─── changelog page ────────────────────────────────────────────────────────
1783
+
1784
+ def render_changelog(out_dir: Path) -> Optional[Path]:
1785
+ """Render ``CHANGELOG.md`` (repo root) to ``site/changelog.html``.
1786
+
1787
+ Returns None if CHANGELOG.md is missing. Shown as its own top-level page
1788
+ so visitors can see what's new / what shipped without clicking through to
1789
+ GitHub. Keep-a-changelog headings become an in-page TOC via the existing
1790
+ `toc` markdown extension.
1791
+ """
1792
+ src = REPO_ROOT / "CHANGELOG.md"
1793
+ if not src.exists():
1794
+ return None
1795
+ raw = src.read_text(encoding="utf-8")
1796
+
1797
+ # Pull the top H1 ("Changelog") and use it as the hero title; render
1798
+ # everything else as the body. Strip the leading H1 line to avoid a
1799
+ # duplicate title.
1800
+ body_md = raw
1801
+ lines = raw.splitlines()
1802
+ if lines and lines[0].lstrip().startswith("# "):
1803
+ body_md = "\n".join(lines[1:]).lstrip("\n")
1804
+
1805
+ content_html = md_to_html(body_md)
1806
+
1807
+ body = f"""<section class="section changelog-body">
1808
+ <div class="container narrow">
1809
+ <article class="article">
1810
+ {content_html}
1811
+ </article>
1812
+ </div>
1813
+ </section>
1814
+ </main>
1815
+ """
1816
+
1817
+ page = (
1818
+ page_head(
1819
+ "Changelog — sessionwiki",
1820
+ "Release notes for sessionwiki — features, fixes, and version history.",
1821
+ css_prefix="",
1822
+ )
1823
+ + nav_bar("changelog", link_prefix="")
1824
+ + hero(
1825
+ "Changelog",
1826
+ "Every release, every fix. Keep-a-changelog format, semver.",
1827
+ )
1828
+ + body
1829
+ + page_foot(js_prefix="")
1830
+ )
1831
+
1832
+ out_path = out_dir / "changelog.html"
1833
+ out_path.write_text(page, encoding="utf-8")
1834
+ return out_path
1835
+
1836
+
1837
+ # ─── v0.7 (#55) models section ─────────────────────────────────────────────
1838
+
1839
+ def render_models_section(out_dir: Path) -> tuple[Optional[Path], int]:
1840
+ """Discover `wiki/entities/*.md` pages with `entity_kind: ai-model`,
1841
+ render one detail page per model + a sortable `/models/index.html`.
1842
+
1843
+ Returns `(index_path_or_None, model_count)`. If there's no
1844
+ `wiki/entities/` directory OR no model pages there, we still write
1845
+ an empty-state index so the nav link doesn't 404.
1846
+ """
1847
+ # Post-review: imports lazily so this function actually works the
1848
+ # next time someone wires it from the CLI. Previously these names
1849
+ # were referenced but never imported — function body was reachable
1850
+ # but would crash with NameError on first call.
1851
+ from sessionwiki.models_page import ( # noqa: F401
1852
+ discover_model_entities_with_meta,
1853
+ render_models_index,
1854
+ render_model_info_card,
1855
+ )
1856
+ entities_dir = REPO_ROOT / "wiki" / "entities"
1857
+ entries_with_meta = discover_model_entities_with_meta(entities_dir)
1858
+ # Backwards-compatible list without meta for render_models_index.
1859
+ entries = [
1860
+ (path, profile, warnings, body)
1861
+ for path, _meta, profile, warnings, body in entries_with_meta
1862
+ ]
1863
+ models_out = out_dir / "models"
1864
+ models_out.mkdir(parents=True, exist_ok=True)
1865
+
1866
+ # Index page — always write it so the nav link resolves.
1867
+ index_body = render_models_index(entries)
1868
+ index_page = (
1869
+ page_head(
1870
+ "Models — sessionwiki",
1871
+ "Directory of AI-model entities tracked by the wiki with pricing, "
1872
+ "context windows, and benchmark scores.",
1873
+ css_prefix="../",
1874
+ )
1875
+ + nav_bar("models", link_prefix="../")
1876
+ + hero("Models", f"{len(entries)} model entities tracked")
1877
+ + index_body
1878
+ + "</main>\n"
1879
+ + page_foot(js_prefix="../")
1880
+ )
1881
+ index_path = models_out / "index.html"
1882
+ index_path.write_text(index_page, encoding="utf-8")
1883
+
1884
+ # Per-model detail page — info card + body markdown rendered normally.
1885
+ for path, meta, profile, warnings, body in entries_with_meta:
1886
+ slug = path.stem
1887
+ title = profile.get("title", slug)
1888
+ info_card = render_model_info_card(profile)
1889
+
1890
+ # v0.7 (#56): changelog timeline + pricing sparkline below the
1891
+ # info card. The sparkline only shows if there are ≥2 dated
1892
+ # input-price changes in the changelog.
1893
+ changelog_entries, changelog_warnings = parse_changelog(meta)
1894
+ warnings = list(warnings) + changelog_warnings
1895
+ timeline_html = render_changelog_timeline(changelog_entries)
1896
+ timeline_block = ""
1897
+ if timeline_html:
1898
+ price_pts = extract_price_points(
1899
+ changelog_entries, field_suffix="pricing.input_per_1m"
1900
+ )
1901
+ sparkline = render_price_sparkline(price_pts)
1902
+ sparkline_block = (
1903
+ f'<div class="timeline-sparkline">'
1904
+ f'<span class="muted">Input pricing trend</span> {sparkline}'
1905
+ f'</div>'
1906
+ if sparkline else ""
1907
+ )
1908
+ timeline_block = (
1909
+ '<div class="timeline-card">'
1910
+ '<div class="timeline-card-title">Changelog</div>'
1911
+ + sparkline_block
1912
+ + timeline_html
1913
+ + '</div>'
1914
+ )
1915
+
1916
+ body_html = md_to_html(body)
1917
+ warnings_html = ""
1918
+ if warnings:
1919
+ items = "".join(f"<li>{html.escape(w)}</li>" for w in warnings)
1920
+ warnings_html = (
1921
+ '<details class="model-warnings"><summary>Schema warnings '
1922
+ f'({len(warnings)})</summary><ul>{items}</ul></details>'
1923
+ )
1924
+ page = (
1925
+ page_head(
1926
+ f"{title} — sessionwiki",
1927
+ f"AI-model entity: {title}",
1928
+ css_prefix="../",
1929
+ )
1930
+ + nav_bar("models", link_prefix="../")
1931
+ + hero(title, profile.get("provider", ""))
1932
+ + f'<section class="section">\n <div class="container narrow">\n'
1933
+ + info_card
1934
+ + timeline_block
1935
+ + warnings_html
1936
+ + f' <article class="article content">\n {body_html}\n </article>\n'
1937
+ + ' </div>\n</section>\n</main>\n'
1938
+ + page_foot(js_prefix="../")
1939
+ )
1940
+ (models_out / f"{slug}.html").write_text(page, encoding="utf-8")
1941
+
1942
+ return index_path, len(entries)
1943
+
1944
+
1945
+ # ─── v0.7 (#58) auto-generated vs-comparison pages ────────────────────────
1946
+
1947
+ def render_vs_section(
1948
+ out_dir: Path,
1949
+ max_pairs: int = 500,
1950
+ min_shared_fields: int = 3,
1951
+ ) -> tuple[Optional[Path], int]:
1952
+ """Generate `/vs/<slug_a>-vs-<slug_b>.html` for every pair of
1953
+ comparable model entities + an index at `/vs/index.html`.
1954
+
1955
+ Honors user overrides under `wiki/vs/<slug>.md` — a hand-written
1956
+ comparison replaces the auto-gen for that URL. Returns
1957
+ `(index_path, pair_count)`. Always writes the index so the nav
1958
+ link resolves even when no entities exist.
1959
+ """
1960
+ # Post-review: lazy imports so this function actually works the
1961
+ # next time someone wires it. Previously these names were referenced
1962
+ # but never imported — first call would have crashed with NameError.
1963
+ from sessionwiki.models_page import discover_model_entities # noqa: F401
1964
+ from sessionwiki.compare import ( # noqa: F401
1965
+ discover_user_overrides,
1966
+ generate_pairs,
1967
+ pair_slug,
1968
+ render_comparison_body,
1969
+ render_comparisons_index,
1970
+ )
1971
+ entities_dir = REPO_ROOT / "wiki" / "entities"
1972
+ overrides_dir = REPO_ROOT / "wiki" / "vs"
1973
+ entries = discover_model_entities(entities_dir)
1974
+ # Strip down to (path, profile) for compare.generate_pairs
1975
+ pair_entries = [(p, profile) for p, profile, _w, _b in entries]
1976
+ pairs = generate_pairs(
1977
+ pair_entries,
1978
+ min_shared_fields=min_shared_fields,
1979
+ max_pairs=max_pairs,
1980
+ )
1981
+
1982
+ vs_out = out_dir / "vs"
1983
+ vs_out.mkdir(parents=True, exist_ok=True)
1984
+
1985
+ # Index
1986
+ index_body = render_comparisons_index(pairs)
1987
+ index_page = (
1988
+ page_head(
1989
+ "Model comparisons — sessionwiki",
1990
+ "Auto-generated side-by-side comparisons of AI-model entities.",
1991
+ css_prefix="../",
1992
+ )
1993
+ + nav_bar("vs", link_prefix="../")
1994
+ + hero("Model comparisons", f"{len(pairs)} auto-generated pairs")
1995
+ + index_body
1996
+ + "</main>\n"
1997
+ + page_foot(js_prefix="../")
1998
+ )
1999
+ index_path = vs_out / "index.html"
2000
+ index_path.write_text(index_page, encoding="utf-8")
2001
+
2002
+ # User overrides replace the auto-gen for matching slugs
2003
+ overrides = discover_user_overrides(overrides_dir)
2004
+
2005
+ for pair in pairs:
2006
+ slug = pair_slug(pair)
2007
+ if slug in overrides:
2008
+ # User override — render the raw body through md_to_html
2009
+ body_html = md_to_html(overrides[slug])
2010
+ article_body = (
2011
+ '<section class="section"><div class="container narrow">'
2012
+ f'<article class="article content">{body_html}</article>'
2013
+ '</div></section>'
2014
+ )
2015
+ else:
2016
+ # Auto-gen — three structured sections
2017
+ comparison_body = render_comparison_body(pair)
2018
+ article_body = (
2019
+ '<section class="section"><div class="container narrow">'
2020
+ f'{comparison_body}'
2021
+ '</div></section>'
2022
+ )
2023
+
2024
+ title = f"{pair['title_a']} vs {pair['title_b']}"
2025
+ page = (
2026
+ page_head(
2027
+ f"{title} — sessionwiki",
2028
+ f"Side-by-side comparison of {title}.",
2029
+ css_prefix="../",
2030
+ )
2031
+ + nav_bar("vs", link_prefix="../")
2032
+ + hero(title, f"{pair['score']} shared structured fields")
2033
+ + article_body
2034
+ + "</main>\n"
2035
+ + page_foot(js_prefix="../")
2036
+ )
2037
+ (vs_out / f"{slug}.html").write_text(page, encoding="utf-8")
2038
+
2039
+ return index_path, len(pairs)
2040
+
2041
+
2042
+ # ─── search index ──────────────────────────────────────────────────────────
2043
+
2044
+ def build_search_index(
2045
+ sources: list[tuple[Path, dict[str, Any], str]],
2046
+ groups: dict[str, list[tuple[Path, dict[str, Any], str]]],
2047
+ out_dir: Path,
2048
+ *,
2049
+ search_mode: str = "auto",
2050
+ ) -> Path:
2051
+ """Build a chunked search index for lazy loading (#47).
2052
+
2053
+ Writes:
2054
+ search-index.json — meta entries (projects + pages) + _chunks manifest
2055
+ + _mode + _tree_eligible_ratio (#53)
2056
+ search-chunks/<project>.json — session entries per project, each
2057
+ carrying heading_max_depth + count_by_depth
2058
+
2059
+ `search_mode` accepts ``auto`` (default, heuristic), ``tree``, or
2060
+ ``flat`` — matches the `sessionwiki build --search-mode` flag.
2061
+ """
2062
+ from sessionwiki.search_tree import (
2063
+ annotate_entry_headings,
2064
+ decide_search_mode,
2065
+ search_index_footer_badge,
2066
+ )
2067
+ # ── session entries grouped by project ──
2068
+ from sessionwiki.search_facets import enrich_entry
2069
+ chunks: dict[str, list[dict[str, Any]]] = {}
2070
+ for p, meta, body in sources:
2071
+ project = str(meta.get("project") or p.parent.name)
2072
+ slug = str(meta.get("slug", p.stem))
2073
+ plain = md_to_plain_text(body)[:1200]
2074
+ entry = {
2075
+ "id": f"session:{project}/{p.stem}",
2076
+ "url": f"sessions/{project}/{p.stem}.html",
2077
+ "title": slug,
2078
+ "type": "session",
2079
+ "project": project,
2080
+ "date": str(meta.get("date", "")),
2081
+ "model": str(meta.get("model", "")),
2082
+ "body": plain,
2083
+ }
2084
+ # v1.0 (#161): enrich with facet fields.
2085
+ enrich_entry(entry, meta)
2086
+ # v1.2 (#53): inject heading depth so the client can tree-walk.
2087
+ annotate_entry_headings(entry, body)
2088
+ chunks.setdefault(project, []).append(entry)
2089
+
2090
+ # ── write per-project chunks ──
2091
+ chunks_dir = out_dir / "search-chunks"
2092
+ chunks_dir.mkdir(exist_ok=True)
2093
+ chunk_manifest: list[str] = []
2094
+ total_chunk_bytes = 0
2095
+ for project_slug, entries in sorted(chunks.items()):
2096
+ chunk_path = chunks_dir / f"{project_slug}.json"
2097
+ data = json.dumps(entries, ensure_ascii=False)
2098
+ chunk_path.write_text(data, encoding="utf-8")
2099
+ chunk_manifest.append(f"search-chunks/{project_slug}.json")
2100
+ total_chunk_bytes += len(data.encode("utf-8"))
2101
+
2102
+ # ── meta index: projects + static pages + chunk manifest ──
2103
+ meta_entries: list[dict[str, Any]] = []
2104
+
2105
+ for project, sessions in groups.items():
2106
+ meta_entries.append(
2107
+ {
2108
+ "id": f"project:{project}",
2109
+ "url": f"projects/{project}.html",
2110
+ "title": project,
2111
+ "type": "project",
2112
+ "project": project,
2113
+ "date": "",
2114
+ "model": "",
2115
+ "body": f"{len(sessions)} sessions",
2116
+ }
2117
+ )
2118
+
2119
+ meta_entries.append(
2120
+ {"id": "home", "url": "index.html", "title": "Home", "type": "page",
2121
+ "project": "", "date": "", "model": "", "body": "overview index"}
2122
+ )
2123
+ meta_entries.append(
2124
+ {"id": "projects-index", "url": "projects/index.html", "title": "Projects",
2125
+ "type": "page", "project": "", "date": "", "model": "", "body": "all projects"}
2126
+ )
2127
+ meta_entries.append(
2128
+ {"id": "sessions-index", "url": "sessions/index.html", "title": "All sessions",
2129
+ "type": "page", "project": "", "date": "", "model": "", "body": "sortable sessions table"}
2130
+ )
2131
+
2132
+ # #277: index every docs/ page + every slash command so the palette
2133
+ # becomes a universal quick-find (not just sessions + projects).
2134
+ from sessionwiki.docs_pages import iter_docs_pages, _first_paragraph
2135
+ docs_dir = REPO_ROOT / "docs"
2136
+ if docs_dir.is_dir():
2137
+ for page in iter_docs_pages(docs_dir):
2138
+ meta_entries.append({
2139
+ "id": f"docs:{page.rel}",
2140
+ "url": f"docs/{page.rel.replace('.md', '.html')}",
2141
+ "title": page.title,
2142
+ "type": "docs",
2143
+ "project": "",
2144
+ "date": "",
2145
+ "model": "",
2146
+ "body": _first_paragraph(page.body)[:300],
2147
+ })
2148
+
2149
+ # Slash commands — read the first non-empty line of each .md as
2150
+ # the description so the palette shows what each /wiki-* does.
2151
+ slash_dir = REPO_ROOT / ".claude" / "commands"
2152
+ if slash_dir.is_dir():
2153
+ for p in sorted(slash_dir.glob("*.md")):
2154
+ try:
2155
+ text = p.read_text(encoding="utf-8")
2156
+ except OSError:
2157
+ continue
2158
+ first_para = next(
2159
+ (ln.strip() for ln in text.splitlines() if ln.strip()),
2160
+ "",
2161
+ )
2162
+ meta_entries.append({
2163
+ "id": f"slash:{p.stem}",
2164
+ # Slashes aren't URLs — the palette shows a non-clickable
2165
+ # entry with the command to type inside Claude Code.
2166
+ "url": "",
2167
+ "title": f"/{p.stem}",
2168
+ "type": "slash",
2169
+ "project": "",
2170
+ "date": "",
2171
+ "model": "",
2172
+ "body": first_para[:300],
2173
+ })
2174
+
2175
+ # v1.0 (#161): aggregate facet counts across all session chunks so the
2176
+ # client can render filter checkboxes without scanning the full index.
2177
+ from sessionwiki.search_facets import aggregate_facets
2178
+ all_entries: list[dict[str, Any]] = []
2179
+ for chunk_entries in chunks.values():
2180
+ all_entries.extend(chunk_entries)
2181
+ facets = aggregate_facets(all_entries)
2182
+
2183
+ # v1.2 (#53): decide tree vs flat across *all* entries, surface
2184
+ # the ratio so the client can show it in the palette footer.
2185
+ mode, tree_ratio = decide_search_mode(all_entries, override=search_mode)
2186
+ mode_badge = search_index_footer_badge(mode, tree_ratio)
2187
+
2188
+ index_obj = {
2189
+ "entries": meta_entries,
2190
+ "_chunks": chunk_manifest,
2191
+ "_facets": facets,
2192
+ "_mode": mode,
2193
+ "_tree_eligible_ratio": round(tree_ratio, 4),
2194
+ "_mode_badge": mode_badge,
2195
+ }
2196
+ out_path = out_dir / "search-index.json"
2197
+ out_path.write_text(json.dumps(index_obj, ensure_ascii=False), encoding="utf-8")
2198
+
2199
+ meta_kb = len(json.dumps(index_obj).encode("utf-8")) // 1024
2200
+ chunks_kb = total_chunk_bytes // 1024
2201
+ print(
2202
+ f" wrote search-index.json ({meta_kb} KB meta) + "
2203
+ f"{len(chunk_manifest)} chunks ({chunks_kb} KB total) · {mode_badge}"
2204
+ )
2205
+
2206
+ return out_path
2207
+
2208
+
2209
+ # ─── css + js constants ────────────────────────────────────────────────────
2210
+
2211
+ # ─── css + js ────────────────────────────────────────────────────────────
2212
+ # CSS + JS constants live in sessionwiki/render/ since v1.1 (#217). Re-export
2213
+ # here for backwards compatibility — any external caller still doing
2214
+ # `from sessionwiki.build import CSS` keeps working.
2215
+ from sessionwiki.render.css import CSS # noqa: F401 (re-exported)
2216
+ from sessionwiki.render.js import JS # noqa: F401 (re-exported)
2217
+
2218
+
2219
+
2220
+ # ─── claude synthesis (optional) ───────────────────────────────────────────
2221
+
2222
+ # #421: shell metacharacters that have no business in a path-to-an-
2223
+ # executable. We refuse paths containing any of these rather than
2224
+ # trying to escape them — the CLI argv is never shell-interpreted
2225
+ # (we use list-form subprocess.run), but the same path may end up in
2226
+ # user-facing logs, scripts, or future code paths that *do* interpolate.
2227
+ # Reject loudly to keep hygiene tight.
2228
+ # #sec-6 (#550): extended to reject NUL + control chars + unprintable
2229
+ # bytes. The original list caught the obvious shell-special characters;
2230
+ # control chars (0x00–0x1F minus tab) get rejected too because they
2231
+ # survive the rejection of `\n` and `\r` only by accident, and can
2232
+ # break log parsers / shell prompts in subtle ways.
2233
+ _PATH_SHELL_METACHARS = re.compile(r"[;&|`$<>\n\r\x00-\x08\x0b\x0c\x0e-\x1f\x7f]")
2234
+
2235
+
2236
+ def _resolve_claude_path(claude_path: Optional[str]) -> Optional[Path]:
2237
+ """Resolve and validate the ``--claude`` path (#421).
2238
+
2239
+ Returns ``None`` when:
2240
+ - The path is empty or contains shell metacharacters (rejected loudly).
2241
+ - ``shutil.which`` can't find the binary on PATH (when no path passed).
2242
+ - The resolved path doesn't exist on disk.
2243
+
2244
+ Returns a ``Path`` when the binary is found and looks safe. Callers
2245
+ should treat ``None`` as "skip synthesis" — the synth step is best-
2246
+ effort and never fatal.
2247
+ """
2248
+ if claude_path:
2249
+ # Reject explicit paths containing shell metacharacters even
2250
+ # though argv is list-form — this keeps the path safe to log.
2251
+ if _PATH_SHELL_METACHARS.search(claude_path):
2252
+ print(
2253
+ f" warning: refusing claude path with shell metacharacters: "
2254
+ f"{claude_path!r}",
2255
+ file=sys.stderr,
2256
+ )
2257
+ return None
2258
+ candidate = Path(claude_path)
2259
+ else:
2260
+ # No explicit path: use shutil.which so PATH-based lookups
2261
+ # (homebrew, asdf, npm-global, Windows %PATH%) all just work.
2262
+ import shutil as _shutil
2263
+ found = _shutil.which("claude")
2264
+ if not found:
2265
+ return None
2266
+ candidate = Path(found)
2267
+ if not candidate.exists():
2268
+ print(f" warning: claude CLI not found at {candidate}", file=sys.stderr)
2269
+ return None
2270
+ return candidate
2271
+
2272
+
2273
+ # #486: validate slug shape before it lands in the synthesize_overview
2274
+ # prompt. Anything that doesn't match this is replaced by `_invalid_`.
2275
+ # - Length cap 80 chars (real slugs are far shorter)
2276
+ # - Charset: alphanumerics + `.`, `_`, `-` (no whitespace, no shell
2277
+ # metachars, no NUL bytes, no unicode-confusable categories)
2278
+ _SAFE_SLUG_RE = re.compile(r"^[A-Za-z0-9._-]{1,80}$")
2279
+
2280
+ # #486: cap the total prompt size sent to the claude CLI. 32 KB is well
2281
+ # inside any LLM's context, well inside macOS's ~256 KB argv limit, and
2282
+ # small enough that a malicious large slug list can't push the prompt
2283
+ # past the OS argv limit. (We also pass via stdin below so this is
2284
+ # defence-in-depth.)
2285
+ _MAX_OVERVIEW_PROMPT_BYTES = 32_000
2286
+
2287
+
2288
+ def _validate_overview_slug(s: Any) -> str:
2289
+ """Return ``s`` if it's a safe slug, ``"_invalid_"`` otherwise.
2290
+
2291
+ #486: a malicious .jsonl could land arbitrary content in the
2292
+ `slug` field of a session's frontmatter. That string then ends
2293
+ up inside the prompt sent to the claude CLI for overview
2294
+ synthesis. Without validation, an attacker-controlled slug could:
2295
+ - inject prompt text ("ignore previous instructions, …")
2296
+ - contain `\\x00` and crash subprocess.run with a ValueError
2297
+ - balloon argv past the OS limit (~256 KB on macOS)
2298
+ Replace anything sketchy with the literal `_invalid_` so the
2299
+ prompt stays well-formed and the synthesis output stays
2300
+ trustworthy.
2301
+ """
2302
+ if not isinstance(s, str):
2303
+ return "_invalid_"
2304
+ if not _SAFE_SLUG_RE.match(s):
2305
+ return "_invalid_"
2306
+ return s
2307
+
2308
+
2309
+ def synthesize_overview(
2310
+ groups: dict[str, list[tuple[Path, dict[str, Any], str]]],
2311
+ claude_path: str,
2312
+ ) -> Optional[str]:
2313
+ resolved = _resolve_claude_path(claude_path)
2314
+ if resolved is None:
2315
+ return None
2316
+ claude_path = str(resolved)
2317
+
2318
+ lines: list[str] = [
2319
+ "You are writing a short (200-300 word) overview for a personal knowledge-base",
2320
+ "landing page. Below is a JSON summary of the user's Claude Code session history",
2321
+ "across multiple projects. Write 2-3 paragraphs of prose in markdown that:",
2322
+ " 1. Names the main projects and what each is about (infer from session slugs and tool usage)",
2323
+ " 2. Highlights the busiest project(s) and the overall scale of the work",
2324
+ " 3. Is written in third person, referring to the user as 'the developer'",
2325
+ "Do NOT use bullet points. Just 2-3 short paragraphs of prose.",
2326
+ "",
2327
+ "Data:",
2328
+ "",
2329
+ ]
2330
+ brief: dict[str, Any] = {}
2331
+ for project, sessions in sorted(groups.items()):
2332
+ brief[project] = {
2333
+ "session_count": len(sessions),
2334
+ "main_sessions": sum(1 for p, m, _ in sessions if not _is_subagent(m, p)),
2335
+ "dates": sorted({str(m.get("date", "")) for _, m, _ in sessions if m.get("date")}),
2336
+ "models": sorted({str(m.get("model", "")) for _, m, _ in sessions if m.get("model")}),
2337
+ # #486: every slug filtered through the safe-slug regex so
2338
+ # malicious .jsonl content can't prompt-inject or crash
2339
+ # subprocess.run via embedded NUL bytes.
2340
+ "slugs": [_validate_overview_slug(m.get("slug", p.stem)) for p, m, _ in sessions[:8]],
2341
+ }
2342
+ prompt = "\n".join(lines) + json.dumps(brief, indent=2)
2343
+
2344
+ # #486: cap total prompt size so a malicious large slug list can't
2345
+ # push the prompt past the OS argv limit. Truncation is fine here —
2346
+ # the LLM gets the head of the JSON and produces a partial overview;
2347
+ # better than a build that silently fails.
2348
+ if len(prompt.encode("utf-8")) > _MAX_OVERVIEW_PROMPT_BYTES:
2349
+ prompt = prompt.encode("utf-8")[:_MAX_OVERVIEW_PROMPT_BYTES].decode(
2350
+ "utf-8", errors="ignore"
2351
+ )
2352
+
2353
+ print(" calling claude CLI for overview synthesis…")
2354
+ try:
2355
+ # #486: pass the prompt via stdin (`-p -`) instead of argv so we
2356
+ # dodge the OS argv-length limit entirely. The byte cap above is
2357
+ # defence-in-depth — argv-length DoS path closed regardless.
2358
+ result = subprocess.run(
2359
+ [claude_path, "-p", "-", "--model", "claude-haiku-4-5-20251001"],
2360
+ input=prompt,
2361
+ capture_output=True, text=True, timeout=120,
2362
+ )
2363
+ except subprocess.TimeoutExpired:
2364
+ print(" warning: claude CLI timed out after 120s", file=sys.stderr)
2365
+ return None
2366
+ # #py-m4 (#590): narrow `except Exception` to the families we
2367
+ # actually expect from a subprocess call. Catching MemoryError or
2368
+ # ImportError silently here would mask real failures.
2369
+ except (OSError, subprocess.SubprocessError) as e:
2370
+ print(f" warning: claude CLI failed: {e}", file=sys.stderr)
2371
+ return None
2372
+ if result.returncode != 0:
2373
+ print(f" warning: claude CLI exited {result.returncode}", file=sys.stderr)
2374
+ return None
2375
+ return result.stdout.strip() or None
2376
+
2377
+
2378
+ # ─── main ──────────────────────────────────────────────────────────────────
2379
+
2380
+ def build_site(
2381
+ out_dir: Path = DEFAULT_OUT_DIR,
2382
+ synthesize: bool = False,
2383
+ claude_path: str = "",
2384
+ search_mode: str = "auto",
2385
+ seed_project_stubs: bool = False,
2386
+ ) -> int:
2387
+ if not RAW_SESSIONS.exists():
2388
+ print(
2389
+ f"error: {RAW_SESSIONS} does not exist. Run `sessionwiki init` + `sessionwiki sync` first.",
2390
+ file=sys.stderr,
2391
+ )
2392
+ return 2
2393
+
2394
+ print(f"==> scanning {RAW_SESSIONS}")
2395
+ sources = discover_sources(RAW_SESSIONS)
2396
+ if not sources:
2397
+ print(" no sources found.", file=sys.stderr)
2398
+ return 2
2399
+ print(f" found {len(sources)} source markdowns")
2400
+ groups = group_by_project(sources)
2401
+ print(f" grouped into {len(groups)} projects")
2402
+
2403
+ # #414: stub-seeding used to be unconditional. `build` is documented
2404
+ # as read-only on `wiki/`, but seeding wrote to `wiki/projects/` —
2405
+ # CI users running `sessionwiki build` on a curated checkout discovered
2406
+ # surprise commits in their working tree. Now opt-in: callers that
2407
+ # have already accepted mutation (sync, the new `--seed-project-stubs`
2408
+ # flag) request seeding explicitly; the default `build` is pure.
2409
+ if seed_project_stubs:
2410
+ stubs_written = ensure_project_stubs(groups, PROJECTS_META_DIR)
2411
+ if stubs_written:
2412
+ print(f" seeded {len(stubs_written)} new wiki/projects/ stubs")
2413
+ index_written = ensure_projects_index(PROJECTS_META_DIR)
2414
+ if index_written:
2415
+ print(f" updated {index_written.relative_to(REPO_ROOT)}")
2416
+
2417
+ # Reset output dir (clear contents only — the HTTP server may be cwd'd here)
2418
+ # #py-m12 (#598): drop ignore_errors=True. A failure to remove a
2419
+ # site/ subtree means we'll write a corrupted partial site on top
2420
+ # of stale files; users have hit this when one CI runner left a
2421
+ # read-only directory behind. Surface OSError instead so the build
2422
+ # halts with a clear message.
2423
+ if out_dir.exists():
2424
+ rmtree_errors: list[str] = []
2425
+
2426
+ def _on_rmtree_error(func, path, exc_info):
2427
+ err = exc_info[1] if isinstance(exc_info, tuple) else exc_info
2428
+ rmtree_errors.append(f"{path}: {err}")
2429
+
2430
+ for child in out_dir.iterdir():
2431
+ if child.is_dir():
2432
+ # Python 3.12+ uses onexc; pre-3.12 uses onerror. Use
2433
+ # onerror for back-compat with the 3.9 floor.
2434
+ shutil.rmtree(child, onerror=_on_rmtree_error)
2435
+ else:
2436
+ try:
2437
+ child.unlink()
2438
+ except OSError as e:
2439
+ rmtree_errors.append(f"{child}: {e}")
2440
+ if rmtree_errors:
2441
+ raise OSError(
2442
+ "could not reset site dir " + str(out_dir) + ":\n "
2443
+ + "\n ".join(rmtree_errors)
2444
+ )
2445
+ else:
2446
+ out_dir.mkdir(parents=True)
2447
+
2448
+ # CSS + JS
2449
+ (out_dir / "style.css").write_text(CSS, encoding="utf-8")
2450
+ (out_dir / "script.js").write_text(JS, encoding="utf-8")
2451
+ print(" wrote style.css, script.js")
2452
+
2453
+ # Copy raw markdown files for "Download .md" links
2454
+ sources_out = out_dir / "sources"
2455
+ if sources_out.exists():
2456
+ shutil.rmtree(sources_out)
2457
+ shutil.copytree(RAW_SESSIONS, sources_out)
2458
+ print(f" copied raw .md sources to sources/")
2459
+
2460
+ # v0.7 (#96): copy downloaded image assets into site/assets/
2461
+ raw_assets = RAW_DIR / "assets"
2462
+ if raw_assets.exists() and any(raw_assets.iterdir()):
2463
+ site_assets = out_dir / "assets"
2464
+ if site_assets.exists():
2465
+ shutil.rmtree(site_assets)
2466
+ shutil.copytree(raw_assets, site_assets)
2467
+ asset_count = sum(1 for _ in site_assets.iterdir() if _.is_file())
2468
+ print(f" copied {asset_count} image assets to assets/")
2469
+
2470
+ # Synthesis
2471
+ synthesis = None
2472
+ if synthesize:
2473
+ synthesis = synthesize_overview(groups, claude_path)
2474
+ if synthesis:
2475
+ print(f" synthesis: {len(synthesis)} chars")
2476
+
2477
+ # Render pages — single pass over `sources` (#py-m8 / #594).
2478
+ # Sibling writes (.txt + .json) happen inside the same iteration so
2479
+ # we don't re-walk `sources` later. The exporter import is hoisted
2480
+ # ABOVE the loop with try/except: a missing module degrades to "no
2481
+ # siblings on any session" (the import-fail case is truly fatal —
2482
+ # no sense retrying it per-source).
2483
+ #
2484
+ # #v1378-review (architect + python-reviewer): per-source write
2485
+ # failures are now isolated. The previous version set a single
2486
+ # `siblings_failed` flag on the first OSError/ValueError/
2487
+ # RuntimeError and silently dropped sibling writes for EVERY
2488
+ # subsequent session in the loop — a single bad body on session 3
2489
+ # of 500 produced 497 silently missing siblings. Now each source's
2490
+ # sibling write is wrapped individually and collects errors into
2491
+ # a list; the build proceeds and we print a summary at the end.
2492
+ sibling_writers_loaded = True
2493
+ sibling_import_error: Optional[BaseException] = None
2494
+ try:
2495
+ from sessionwiki.exporters import write_page_json, write_page_txt
2496
+ except (ImportError, OSError, ValueError, RuntimeError) as e:
2497
+ sibling_writers_loaded = False
2498
+ sibling_import_error = e
2499
+
2500
+ n_sessions = 0
2501
+ n_siblings = 0
2502
+ sibling_failures: list[tuple[str, BaseException]] = []
2503
+ _wikilink_re = re.compile(r"\[\[([^\]|]+)(?:\|[^\]]*)?\]\]")
2504
+ for path, meta, body in sources:
2505
+ project = str(meta.get("project") or path.parent.name)
2506
+ render_session(path, meta, body, out_dir, project)
2507
+ n_sessions += 1
2508
+
2509
+ if not sibling_writers_loaded:
2510
+ continue
2511
+ # render_session writes site/sessions/<project>/<stem>.html;
2512
+ # mirror its path here so the siblings land alongside it.
2513
+ html_path = out_dir / "sessions" / project / f"{path.stem}.html"
2514
+ if not html_path.exists():
2515
+ continue
2516
+ try:
2517
+ body_stripped = strip_leading_h1(body)
2518
+ wikilinks_out = _wikilink_re.findall(body_stripped)
2519
+ write_page_txt(html_path, body_stripped)
2520
+ n_siblings += 1
2521
+ meta_copy = dict(meta)
2522
+ meta_copy.setdefault("slug", str(meta.get("slug", path.stem)))
2523
+ meta_copy.setdefault("project", project)
2524
+ write_page_json(html_path, meta_copy, body_stripped, wikilinks_out)
2525
+ n_siblings += 1
2526
+ except (OSError, ValueError, RuntimeError) as e:
2527
+ # Isolate to this source — the next iteration still attempts
2528
+ # both writes. n_siblings increments only after each
2529
+ # successful write so the count never over-reports.
2530
+ sibling_failures.append((str(html_path), e))
2531
+
2532
+ # Print warnings BEFORE the success line so a CI log scanner sees
2533
+ # them in the right order (architect-review feedback).
2534
+ if not sibling_writers_loaded:
2535
+ print(
2536
+ f" warning: per-page siblings disabled (exporter import failed): "
2537
+ f"{sibling_import_error}",
2538
+ file=sys.stderr,
2539
+ )
2540
+ if sibling_failures:
2541
+ first_path, first_err = sibling_failures[0]
2542
+ print(
2543
+ f" warning: per-page sibling write failed on "
2544
+ f"{len(sibling_failures)} of {n_sessions} sessions; "
2545
+ f"first failure: {first_path}: {first_err}",
2546
+ file=sys.stderr,
2547
+ )
2548
+ print(f" wrote {n_sessions} session pages")
2549
+ if sibling_writers_loaded:
2550
+ print(f" wrote {n_siblings} per-page siblings (.txt + .json)")
2551
+
2552
+ for project, sessions in groups.items():
2553
+ render_project_page(project, sessions, out_dir)
2554
+ print(f" wrote {len(groups)} project pages")
2555
+
2556
+ render_projects_index(groups, out_dir)
2557
+ render_sessions_index(sources, groups, out_dir)
2558
+ render_index(groups, sources, out_dir, synthesis=synthesis)
2559
+ cl_path = render_changelog(out_dir)
2560
+ # #387 U8: branded 404 page that serve.py returns as the body of any
2561
+ # 404 response, instead of the stdlib http.server default.
2562
+ not_found_path = render_404(out_dir)
2563
+ # #284: compile README + CONTRIBUTING as standalone site pages so
2564
+ # they don't bounce visitors out to GitHub for content we're already
2565
+ # shipping as HTML.
2566
+ readme_path = render_readme_page(out_dir)
2567
+ contributing_path = render_contributing_page(out_dir)
2568
+ print(
2569
+ " wrote index.html, projects/index.html, sessions/index.html, 404.html"
2570
+ + (", changelog.html" if cl_path else "")
2571
+ )
2572
+
2573
+ # Search index (chunked — #47) + tree/flat auto-routing (#53)
2574
+ idx_path = build_search_index(sources, groups, out_dir, search_mode=search_mode)
2575
+
2576
+ # v0.4: AI-consumable exports (llms.txt, llms-full.txt, graph.jsonld,
2577
+ # sitemap.xml, rss.xml, robots.txt, ai-readme.md)
2578
+ # #py-m4 (#590): narrow the catch so MemoryError, ImportError,
2579
+ # and KeyboardInterrupt aren't silently swallowed into a warning
2580
+ # line. ImportError in particular hides a broken module — the
2581
+ # build should crash loud, not log "warning: AI exports failed:
2582
+ # No module named ..." and ship a half-built site.
2583
+ try:
2584
+ from sessionwiki.exporters import export_all
2585
+ ai_paths = export_all(out_dir, groups, sources)
2586
+ print(f" wrote {len(ai_paths)} AI-consumable exports: {', '.join(sorted(ai_paths.keys()))}")
2587
+ except (OSError, ValueError, RuntimeError) as e:
2588
+ print(f" warning: AI exports failed: {e}", file=sys.stderr)
2589
+
2590
+ # v1.1 (#118): copy the interactive knowledge graph into the site
2591
+ # so the "Graph" nav link works without a separate `sessionwiki graph` step.
2592
+ try:
2593
+ from sessionwiki.graph import build_session_graph, copy_to_site as copy_graph_to_site
2594
+ session_graph = build_session_graph(raw_dir=RAW_SESSIONS, verify_site_dir=out_dir)
2595
+ graph_path = copy_graph_to_site(out_dir, graph=session_graph)
2596
+ if graph_path:
2597
+ print(f" wrote {graph_path.relative_to(out_dir.parent)} (interactive graph viewer)")
2598
+ except (OSError, ValueError, RuntimeError) as e:
2599
+ print(f" warning: graph viewer copy failed: {e}", file=sys.stderr)
2600
+
2601
+ # v1.2 (#265): compile the editorial docs (tutorials + hub) under
2602
+ # site/docs/. Only pages with `docs_shell: true` in frontmatter
2603
+ # are included — reference docs that stay GitHub-rendered aren't
2604
+ # touched.
2605
+ try:
2606
+ from sessionwiki.docs_pages import compile_docs_site
2607
+ docs_dir = REPO_ROOT / "docs"
2608
+
2609
+ # nav_builder gets called per-page with the right link_prefix so
2610
+ # the nav bar's hrefs resolve from whatever depth the page sits at.
2611
+ def _docs_nav(link_prefix: str) -> str:
2612
+ return nav_bar(active="docs", link_prefix=link_prefix)
2613
+
2614
+ docs_written = compile_docs_site(
2615
+ docs_dir,
2616
+ out_dir,
2617
+ md_to_html=md_to_html,
2618
+ page_head=page_head,
2619
+ nav_builder=_docs_nav,
2620
+ )
2621
+ if docs_written:
2622
+ print(
2623
+ f" wrote site/docs/ ({len(docs_written)} editorial pages: "
2624
+ "hub + tutorials + style guide)"
2625
+ )
2626
+ # #py-m4: same narrow-catch pattern as above.
2627
+ except (OSError, ValueError, RuntimeError) as e:
2628
+ print(f" warning: docs compile failed: {e}", file=sys.stderr)
2629
+
2630
+ # v0.4 per-page sibling .txt and .json — collapsed into the
2631
+ # render_session loop above (#py-m8 / #594). The duplicate walk and
2632
+ # html_path.exists() guard are gone; siblings now write inside the
2633
+ # same iteration that produced the HTML.
2634
+
2635
+ # v0.4: Build manifest with SHA-256 hashes
2636
+ try:
2637
+ from sessionwiki.manifest import write_manifest
2638
+ manifest_path = write_manifest(out_dir)
2639
+ print(f" wrote {manifest_path.relative_to(out_dir.parent) if manifest_path.is_relative_to(out_dir.parent) else manifest_path.name}")
2640
+ except (OSError, ValueError, RuntimeError) as e:
2641
+ print(f" warning: manifest failed: {e}", file=sys.stderr)
2642
+
2643
+ total_files = sum(1 for _ in out_dir.rglob("*.html"))
2644
+ total_bytes = sum(p.stat().st_size for p in out_dir.rglob("*") if p.is_file())
2645
+ print(
2646
+ f"==> build complete: {total_files} HTML files, {total_bytes / 1024:.0f} KB total"
2647
+ )
2648
+ print(f" output: {out_dir}")
2649
+ return 0
2650
+
2651
+
2652
+ def parse_args() -> argparse.Namespace:
2653
+ p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
2654
+ p.add_argument("--out", type=Path, default=DEFAULT_OUT_DIR)
2655
+ p.add_argument("--synthesize", action="store_true")
2656
+ p.add_argument("--claude", type=str, default="")
2657
+ return p.parse_args()
2658
+
2659
+
2660
+ def main() -> int:
2661
+ args = parse_args()
2662
+ return build_site(
2663
+ out_dir=args.out,
2664
+ synthesize=args.synthesize,
2665
+ claude_path=args.claude,
2666
+ )
2667
+
2668
+
2669
+ if __name__ == "__main__":
2670
+ sys.exit(main())
2671
+
2672
+ # ─── agent label detection ────────────────────────────────────────────────
2673
+ # v0.9: Detect which AI agent produced a session from the model name,
2674
+ # source_file path, or explicit frontmatter field. Returns a short label
2675
+ # + CSS class for badge rendering.
2676
+
2677
+ def detect_agent_label(meta: dict) -> tuple[str, str]:
2678
+ """Return (label, css_class) for the agent that produced this session.
2679
+
2680
+ Detection order:
2681
+ 1. Explicit `agent:` frontmatter field (set by adapters)
2682
+ 2. Model name patterns (claude-* → Claude, gpt-* → Codex/Copilot, etc.)
2683
+ 3. Source file path patterns (codex → Codex, copilot → Copilot)
2684
+ 4. Default: "Unknown"
2685
+ """
2686
+ # 1. Explicit field
2687
+ agent = str(meta.get("agent", "")).strip().lower()
2688
+ if agent:
2689
+ return _agent_map(agent)
2690
+
2691
+ # 1b. Adapter tag for tools that run many vendors' models. ZCode and
2692
+ # OpenCode pages carry `zcode` / `opencode` as a tag but no
2693
+ # `agent:` field, and their model can be Claude, GPT, GLM, Qwen…
2694
+ # so the tag has to win over the model check below. Whole-tag
2695
+ # match only: a project tag like `opencode-plugin` does not count.
2696
+ adapter_tag = _multi_model_adapter_tag(meta.get("tags", []))
2697
+ if adapter_tag:
2698
+ return _agent_map(adapter_tag)
2699
+
2700
+ # 2. Model name
2701
+ model = str(meta.get("model", "")).lower()
2702
+ if "claude" in model:
2703
+ return ("Claude", "agent-claude")
2704
+ if "gpt" in model or "o1" in model or "o3" in model or "o4" in model:
2705
+ return ("Codex", "agent-codex")
2706
+ if "gemini" in model:
2707
+ return ("Gemini", "agent-gemini")
2708
+ if "copilot" in model:
2709
+ return ("Copilot", "agent-copilot")
2710
+
2711
+ # 3. Source file path
2712
+ source = str(meta.get("source_file", "")).lower()
2713
+ if "codex" in source or ".codex" in source:
2714
+ return ("Codex", "agent-codex")
2715
+ if "copilot" in source:
2716
+ return ("Copilot", "agent-copilot")
2717
+ if "cursor" in source:
2718
+ return ("Cursor", "agent-cursor")
2719
+ if "gemini" in source:
2720
+ return ("Gemini", "agent-gemini")
2721
+ if "claude" in source or ".claude" in source:
2722
+ return ("Claude", "agent-claude")
2723
+
2724
+ # 4. Tags fallback
2725
+ tags = meta.get("tags", [])
2726
+ if isinstance(tags, list):
2727
+ tag_str = " ".join(str(t).lower() for t in tags)
2728
+ else:
2729
+ tag_str = str(tags).lower()
2730
+ if "codex" in tag_str:
2731
+ return ("Codex", "agent-codex")
2732
+ if "copilot" in tag_str:
2733
+ return ("Copilot", "agent-copilot")
2734
+ if "claude" in tag_str:
2735
+ return ("Claude", "agent-claude")
2736
+
2737
+ return ("Agent", "agent-unknown")
2738
+
2739
+
2740
+ _MULTI_MODEL_ADAPTER_TAGS = ("zcode", "opencode")
2741
+
2742
+
2743
+ def _multi_model_adapter_tag(tags: object) -> str:
2744
+ """Return ``zcode`` / ``opencode`` when that exact tag is present."""
2745
+ if isinstance(tags, (list, tuple)):
2746
+ items = [str(t) for t in tags]
2747
+ else:
2748
+ items = str(tags or "").strip().strip("[]").split(",")
2749
+ names = {item.strip().strip("'\"").lower() for item in items}
2750
+ for tag in _MULTI_MODEL_ADAPTER_TAGS:
2751
+ if tag in names:
2752
+ return tag
2753
+ return ""
2754
+
2755
+
2756
+ def _agent_map(agent: str) -> tuple[str, str]:
2757
+ """Map an explicit agent name to (label, css_class)."""
2758
+ m = {
2759
+ "claude": ("Claude", "agent-claude"),
2760
+ "claude-code": ("Claude", "agent-claude"),
2761
+ "codex": ("Codex", "agent-codex"),
2762
+ "codex-cli": ("Codex", "agent-codex"),
2763
+ "copilot": ("Copilot", "agent-copilot"),
2764
+ "copilot-chat": ("Copilot", "agent-copilot"),
2765
+ "copilot-cli": ("Copilot", "agent-copilot"),
2766
+ "cursor": ("Cursor", "agent-cursor"),
2767
+ "gemini": ("Gemini", "agent-gemini"),
2768
+ "gemini-cli": ("Gemini", "agent-gemini"),
2769
+ "obsidian": ("Obsidian", "agent-obsidian"),
2770
+ "zcode": ("ZCode", "agent-zcode"),
2771
+ "opencode": ("OpenCode", "agent-opencode"),
2772
+ # Simplification sweep removed the PDF adapter. The "pdf" entry
2773
+ # used to live here; left as a comment so a future grep sees
2774
+ # the rationale instead of guessing why the agent-pdf badge is
2775
+ # gone. CSS class .agent-pdf is also removed (see render/css.py).
2776
+ }
2777
+ return m.get(agent, (agent.title(), "agent-unknown"))
2778
+
2779
+
2780
+ def render_agent_badge(meta: dict) -> str:
2781
+ """Render an inline agent badge chip."""
2782
+ label, css_class = detect_agent_label(meta)
2783
+ return f'<span class="agent-badge {html.escape(css_class)}">{html.escape(label)}</span>'
2784
+
2785
+ # Inject agent badge CSS into the CSS constant
2786
+ # (This is appended at the module level — it'll be picked up on next build)