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.
- sessionwiki/__init__.py +74 -0
- sessionwiki/__main__.py +6 -0
- sessionwiki/_frontmatter.py +118 -0
- sessionwiki/_system_pages.py +44 -0
- sessionwiki/adapter_config.py +124 -0
- sessionwiki/adapters/__init__.py +134 -0
- sessionwiki/adapters/base.py +191 -0
- sessionwiki/adapters/claude_code.py +77 -0
- sessionwiki/adapters/codex_cli.py +226 -0
- sessionwiki/adapters/contrib/__init__.py +1 -0
- sessionwiki/adapters/contrib/chatgpt.py +252 -0
- sessionwiki/adapters/contrib/copilot_chat.py +121 -0
- sessionwiki/adapters/contrib/copilot_cli.py +115 -0
- sessionwiki/adapters/contrib/cursor.py +100 -0
- sessionwiki/adapters/contrib/gemini_cli.py +74 -0
- sessionwiki/adapters/contrib/obsidian.py +121 -0
- sessionwiki/adapters/opencode.py +169 -0
- sessionwiki/adapters/status.py +58 -0
- sessionwiki/adapters/zcode.py +45 -0
- sessionwiki/audit.py +174 -0
- sessionwiki/backlinks.py +224 -0
- sessionwiki/build.py +2786 -0
- sessionwiki/cache.py +401 -0
- sessionwiki/candidates.py +309 -0
- sessionwiki/categories.py +158 -0
- sessionwiki/changelog_timeline.py +422 -0
- sessionwiki/cli.py +1687 -0
- sessionwiki/compare.py +404 -0
- sessionwiki/completion.py +191 -0
- sessionwiki/confidence.py +179 -0
- sessionwiki/config.py +74 -0
- sessionwiki/config_schedule.py +77 -0
- sessionwiki/context_md.py +182 -0
- sessionwiki/convert.py +1746 -0
- sessionwiki/docs_pages.py +725 -0
- sessionwiki/doctor.py +113 -0
- sessionwiki/exporters.py +680 -0
- sessionwiki/freshness.py +153 -0
- sessionwiki/graph.py +1058 -0
- sessionwiki/graphify_bridge.py +509 -0
- sessionwiki/ingest_queue.py +101 -0
- sessionwiki/lifecycle.py +167 -0
- sessionwiki/link_checker.py +148 -0
- sessionwiki/lint/__init__.py +170 -0
- sessionwiki/lint/rules/__init__.py +69 -0
- sessionwiki/lint/rules/_helpers.py +119 -0
- sessionwiki/lint/rules/claim_verification.py +44 -0
- sessionwiki/lint/rules/content_freshness.py +41 -0
- sessionwiki/lint/rules/contradiction_detection.py +45 -0
- sessionwiki/lint/rules/duplicate_detection.py +164 -0
- sessionwiki/lint/rules/entity_consistency.py +29 -0
- sessionwiki/lint/rules/frontmatter_completeness.py +46 -0
- sessionwiki/lint/rules/frontmatter_count_consistency.py +61 -0
- sessionwiki/lint/rules/frontmatter_validity.py +71 -0
- sessionwiki/lint/rules/index_sync.py +68 -0
- sessionwiki/lint/rules/link_integrity.py +34 -0
- sessionwiki/lint/rules/orphan_detection.py +43 -0
- sessionwiki/lint/rules/stale_candidates.py +59 -0
- sessionwiki/lint/rules/stale_reference_detection.py +42 -0
- sessionwiki/lint/rules/summary_accuracy.py +36 -0
- sessionwiki/lint/rules/tags_topics_convention.py +58 -0
- sessionwiki/lint/rules/tools_consistency.py +65 -0
- sessionwiki/lock.py +126 -0
- sessionwiki/log_reader.py +129 -0
- sessionwiki/manifest.py +132 -0
- sessionwiki/mcp/__init__.py +29 -0
- sessionwiki/mcp/__main__.py +5 -0
- sessionwiki/mcp/server.py +1061 -0
- sessionwiki/models_page.py +291 -0
- sessionwiki/obsidian_output.py +208 -0
- sessionwiki/opencode/__init__.py +1 -0
- sessionwiki/opencode/autosync.py +102 -0
- sessionwiki/opencode/export.py +419 -0
- sessionwiki/opencode/install.py +152 -0
- sessionwiki/pipeline.py +150 -0
- sessionwiki/project_topics.py +213 -0
- sessionwiki/py.typed +0 -0
- sessionwiki/quarantine.py +273 -0
- sessionwiki/queue.py +29 -0
- sessionwiki/references.py +224 -0
- sessionwiki/render/__init__.py +20 -0
- sessionwiki/render/css.py +988 -0
- sessionwiki/render/docs_css.py +463 -0
- sessionwiki/render/js.py +1337 -0
- sessionwiki/schema.py +270 -0
- sessionwiki/search_facets.py +150 -0
- sessionwiki/search_tree.py +184 -0
- sessionwiki/serve.py +91 -0
- sessionwiki/session_export.py +333 -0
- sessionwiki/skill_installer.py +112 -0
- sessionwiki/sync/__init__.py +13 -0
- sessionwiki/sync/status.py +120 -0
- sessionwiki/synth/__init__.py +14 -0
- sessionwiki/synth/agent_delegate.py +327 -0
- sessionwiki/synth/base.py +125 -0
- sessionwiki/synth/cli_helpers.py +98 -0
- sessionwiki/synth/estimate.py +146 -0
- sessionwiki/synth/ollama.py +379 -0
- sessionwiki/synth/openai_compat.py +325 -0
- sessionwiki/synth/pipeline.py +1489 -0
- sessionwiki/tag_utils.py +62 -0
- sessionwiki/tags.py +359 -0
- sessionwiki/vault.py +347 -0
- sessionwiki/viz_heatmap.py +305 -0
- sessionwiki/viz_tokens.py +429 -0
- sessionwiki/viz_tools.py +241 -0
- sessionwiki/watch.py +154 -0
- sessionwiki/zcode/__init__.py +43 -0
- sessionwiki/zcode/autosync.py +150 -0
- sessionwiki/zcode/export.py +568 -0
- sessionwiki/zcode/install.py +386 -0
- sessionwiki-2.0.0.dist-info/METADATA +303 -0
- sessionwiki-2.0.0.dist-info/RECORD +118 -0
- sessionwiki-2.0.0.dist-info/WHEEL +5 -0
- sessionwiki-2.0.0.dist-info/entry_points.txt +2 -0
- sessionwiki-2.0.0.dist-info/licenses/LICENSE +22 -0
- sessionwiki-2.0.0.dist-info/licenses/NOTICE +14 -0
- 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"<![CDATA[", part)
|
|
481
|
+
rebuilt.append(_TAG_START_RE.sub(r"<\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:>2026-03-01</code></td><td>Sessions after a date</td></tr>
|
|
991
|
+
<tr><td><code>date:<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:>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)
|