sessionwiki 2.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (118) hide show
  1. sessionwiki/__init__.py +74 -0
  2. sessionwiki/__main__.py +6 -0
  3. sessionwiki/_frontmatter.py +118 -0
  4. sessionwiki/_system_pages.py +44 -0
  5. sessionwiki/adapter_config.py +124 -0
  6. sessionwiki/adapters/__init__.py +134 -0
  7. sessionwiki/adapters/base.py +191 -0
  8. sessionwiki/adapters/claude_code.py +77 -0
  9. sessionwiki/adapters/codex_cli.py +226 -0
  10. sessionwiki/adapters/contrib/__init__.py +1 -0
  11. sessionwiki/adapters/contrib/chatgpt.py +252 -0
  12. sessionwiki/adapters/contrib/copilot_chat.py +121 -0
  13. sessionwiki/adapters/contrib/copilot_cli.py +115 -0
  14. sessionwiki/adapters/contrib/cursor.py +100 -0
  15. sessionwiki/adapters/contrib/gemini_cli.py +74 -0
  16. sessionwiki/adapters/contrib/obsidian.py +121 -0
  17. sessionwiki/adapters/opencode.py +169 -0
  18. sessionwiki/adapters/status.py +58 -0
  19. sessionwiki/adapters/zcode.py +45 -0
  20. sessionwiki/audit.py +174 -0
  21. sessionwiki/backlinks.py +224 -0
  22. sessionwiki/build.py +2786 -0
  23. sessionwiki/cache.py +401 -0
  24. sessionwiki/candidates.py +309 -0
  25. sessionwiki/categories.py +158 -0
  26. sessionwiki/changelog_timeline.py +422 -0
  27. sessionwiki/cli.py +1687 -0
  28. sessionwiki/compare.py +404 -0
  29. sessionwiki/completion.py +191 -0
  30. sessionwiki/confidence.py +179 -0
  31. sessionwiki/config.py +74 -0
  32. sessionwiki/config_schedule.py +77 -0
  33. sessionwiki/context_md.py +182 -0
  34. sessionwiki/convert.py +1746 -0
  35. sessionwiki/docs_pages.py +725 -0
  36. sessionwiki/doctor.py +113 -0
  37. sessionwiki/exporters.py +680 -0
  38. sessionwiki/freshness.py +153 -0
  39. sessionwiki/graph.py +1058 -0
  40. sessionwiki/graphify_bridge.py +509 -0
  41. sessionwiki/ingest_queue.py +101 -0
  42. sessionwiki/lifecycle.py +167 -0
  43. sessionwiki/link_checker.py +148 -0
  44. sessionwiki/lint/__init__.py +170 -0
  45. sessionwiki/lint/rules/__init__.py +69 -0
  46. sessionwiki/lint/rules/_helpers.py +119 -0
  47. sessionwiki/lint/rules/claim_verification.py +44 -0
  48. sessionwiki/lint/rules/content_freshness.py +41 -0
  49. sessionwiki/lint/rules/contradiction_detection.py +45 -0
  50. sessionwiki/lint/rules/duplicate_detection.py +164 -0
  51. sessionwiki/lint/rules/entity_consistency.py +29 -0
  52. sessionwiki/lint/rules/frontmatter_completeness.py +46 -0
  53. sessionwiki/lint/rules/frontmatter_count_consistency.py +61 -0
  54. sessionwiki/lint/rules/frontmatter_validity.py +71 -0
  55. sessionwiki/lint/rules/index_sync.py +68 -0
  56. sessionwiki/lint/rules/link_integrity.py +34 -0
  57. sessionwiki/lint/rules/orphan_detection.py +43 -0
  58. sessionwiki/lint/rules/stale_candidates.py +59 -0
  59. sessionwiki/lint/rules/stale_reference_detection.py +42 -0
  60. sessionwiki/lint/rules/summary_accuracy.py +36 -0
  61. sessionwiki/lint/rules/tags_topics_convention.py +58 -0
  62. sessionwiki/lint/rules/tools_consistency.py +65 -0
  63. sessionwiki/lock.py +126 -0
  64. sessionwiki/log_reader.py +129 -0
  65. sessionwiki/manifest.py +132 -0
  66. sessionwiki/mcp/__init__.py +29 -0
  67. sessionwiki/mcp/__main__.py +5 -0
  68. sessionwiki/mcp/server.py +1061 -0
  69. sessionwiki/models_page.py +291 -0
  70. sessionwiki/obsidian_output.py +208 -0
  71. sessionwiki/opencode/__init__.py +1 -0
  72. sessionwiki/opencode/autosync.py +102 -0
  73. sessionwiki/opencode/export.py +419 -0
  74. sessionwiki/opencode/install.py +152 -0
  75. sessionwiki/pipeline.py +150 -0
  76. sessionwiki/project_topics.py +213 -0
  77. sessionwiki/py.typed +0 -0
  78. sessionwiki/quarantine.py +273 -0
  79. sessionwiki/queue.py +29 -0
  80. sessionwiki/references.py +224 -0
  81. sessionwiki/render/__init__.py +20 -0
  82. sessionwiki/render/css.py +988 -0
  83. sessionwiki/render/docs_css.py +463 -0
  84. sessionwiki/render/js.py +1337 -0
  85. sessionwiki/schema.py +270 -0
  86. sessionwiki/search_facets.py +150 -0
  87. sessionwiki/search_tree.py +184 -0
  88. sessionwiki/serve.py +91 -0
  89. sessionwiki/session_export.py +333 -0
  90. sessionwiki/skill_installer.py +112 -0
  91. sessionwiki/sync/__init__.py +13 -0
  92. sessionwiki/sync/status.py +120 -0
  93. sessionwiki/synth/__init__.py +14 -0
  94. sessionwiki/synth/agent_delegate.py +327 -0
  95. sessionwiki/synth/base.py +125 -0
  96. sessionwiki/synth/cli_helpers.py +98 -0
  97. sessionwiki/synth/estimate.py +146 -0
  98. sessionwiki/synth/ollama.py +379 -0
  99. sessionwiki/synth/openai_compat.py +325 -0
  100. sessionwiki/synth/pipeline.py +1489 -0
  101. sessionwiki/tag_utils.py +62 -0
  102. sessionwiki/tags.py +359 -0
  103. sessionwiki/vault.py +347 -0
  104. sessionwiki/viz_heatmap.py +305 -0
  105. sessionwiki/viz_tokens.py +429 -0
  106. sessionwiki/viz_tools.py +241 -0
  107. sessionwiki/watch.py +154 -0
  108. sessionwiki/zcode/__init__.py +43 -0
  109. sessionwiki/zcode/autosync.py +150 -0
  110. sessionwiki/zcode/export.py +568 -0
  111. sessionwiki/zcode/install.py +386 -0
  112. sessionwiki-2.0.0.dist-info/METADATA +303 -0
  113. sessionwiki-2.0.0.dist-info/RECORD +118 -0
  114. sessionwiki-2.0.0.dist-info/WHEEL +5 -0
  115. sessionwiki-2.0.0.dist-info/entry_points.txt +2 -0
  116. sessionwiki-2.0.0.dist-info/licenses/LICENSE +22 -0
  117. sessionwiki-2.0.0.dist-info/licenses/NOTICE +14 -0
  118. sessionwiki-2.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,422 @@
1
+ """Append-only page changelog + timeline renderer (v0.7 · closes #56).
2
+
3
+ An entity page (especially AI-model pages from #55) can declare a
4
+ `changelog:` list in its frontmatter. Each entry records a dated change
5
+ to a specific field, so sessionwiki can render:
6
+
7
+ 1. A **timeline widget** on the page showing what changed when
8
+ 2. An inline **pricing sparkline** if the changelog contains ≥2 numeric
9
+ price changes
10
+ 3. A **"Recently updated"** list on the home page surfacing entities
11
+ that changed in the last 30 days
12
+
13
+ Append-only by design — entries are never edited, only added. If a
14
+ change is wrong, you add a new correcting entry rather than rewriting
15
+ history. The wiki log doubles as an audit trail.
16
+
17
+ Schema (inline JSON per entry, list of objects in YAML):
18
+
19
+ ```yaml
20
+ changelog: [
21
+ {"date": "2026-01-15", "event": "Input pricing reduced", "field": "model.pricing.input_per_1m", "from": 4.00, "to": 3.00},
22
+ {"date": "2026-02-02", "event": "Context window expanded", "field": "model.context_window", "from": 100000, "to": 200000}
23
+ ]
24
+ ```
25
+
26
+ Stdlib-only. No new dependencies.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import html
32
+ import json
33
+ from datetime import date, datetime, timedelta, timezone
34
+ from typing import Any, Iterable, Mapping, Optional, TypedDict
35
+
36
+ # ─── types ───────────────────────────────────────────────────────────────
37
+
38
+
39
+ class ChangelogEntry(TypedDict, total=False):
40
+ date: str # ISO date
41
+ event: str # Human-readable description
42
+ field: str # Dotted path into the entity's schema
43
+ # `from` and `to` can be numeric OR string — e.g. a license change
44
+ # goes from `"proprietary"` to `"apache-2.0"`, pricing goes from 4.00
45
+ # to 3.00. We keep them as raw values and leave interpretation to the
46
+ # renderer.
47
+ from_value: Any
48
+ to_value: Any
49
+
50
+
51
+ # ─── parsing ─────────────────────────────────────────────────────────────
52
+
53
+
54
+ def parse_changelog(meta: Mapping[str, Any]) -> tuple[list[ChangelogEntry], list[str]]:
55
+ """Pull a `changelog` list from frontmatter.
56
+
57
+ Returns `(entries, warnings)`. Entries are sorted ascending by date.
58
+ Malformed entries (missing date, unparseable date, missing event)
59
+ are dropped with a warning — the rest still render.
60
+
61
+ The frontmatter parser stores the value as either a JSON string
62
+ (our inline-JSON convention) or a Python list (tests / programmatic
63
+ callers). Both are handled.
64
+ """
65
+ raw = meta.get("changelog")
66
+ if not raw:
67
+ return [], []
68
+
69
+ data: Any = raw
70
+ if isinstance(raw, str):
71
+ try:
72
+ data = json.loads(raw)
73
+ except ValueError:
74
+ return [], ["changelog must be a JSON list"]
75
+
76
+ # The lightweight frontmatter parser in `build.py` (and
77
+ # `models_page._parse_frontmatter`) naively splits bracketed list
78
+ # values on commas. That mangles a JSON array of objects like
79
+ # `[{"a":1, "b":2}, {"c":3}]` into
80
+ # `['{"a":1', '"b":2}', '{"c":3}']`. If we see a list of
81
+ # string fragments that don't parse as JSON objects on their own,
82
+ # stitch them back together and re-parse as a JSON array.
83
+ if (
84
+ isinstance(data, list)
85
+ and data
86
+ and all(isinstance(x, str) for x in data)
87
+ ):
88
+ stitched = "[" + ", ".join(data) + "]"
89
+ try:
90
+ reparsed = json.loads(stitched)
91
+ if isinstance(reparsed, list):
92
+ data = reparsed
93
+ except ValueError:
94
+ pass # fall through; the element-level validator will reject
95
+
96
+ if not isinstance(data, list):
97
+ return [], ["changelog must be a list"]
98
+
99
+ warnings: list[str] = []
100
+ entries: list[ChangelogEntry] = []
101
+ for i, item in enumerate(data):
102
+ if not isinstance(item, dict):
103
+ warnings.append(f"changelog[{i}] must be an object")
104
+ continue
105
+ raw_date = item.get("date")
106
+ if not raw_date:
107
+ warnings.append(f"changelog[{i}] missing required field: date")
108
+ continue
109
+ try:
110
+ date.fromisoformat(str(raw_date)[:10])
111
+ except (ValueError, TypeError):
112
+ warnings.append(f"changelog[{i}].date is not a valid ISO date")
113
+ continue
114
+ event = item.get("event")
115
+ if not event:
116
+ warnings.append(f"changelog[{i}] missing required field: event")
117
+ continue
118
+ entry: ChangelogEntry = {
119
+ "date": str(raw_date)[:10],
120
+ "event": str(event),
121
+ }
122
+ if "field" in item:
123
+ entry["field"] = str(item["field"])
124
+ if "from" in item:
125
+ entry["from_value"] = item["from"]
126
+ if "to" in item:
127
+ entry["to_value"] = item["to"]
128
+ entries.append(entry)
129
+
130
+ entries.sort(key=lambda e: e["date"])
131
+ return entries, warnings
132
+
133
+
134
+ # ─── timeline render ─────────────────────────────────────────────────────
135
+
136
+
137
+ def render_changelog_timeline(entries: list[ChangelogEntry]) -> str:
138
+ """Render a vertical timeline of changelog entries. Empty input
139
+ returns an empty string so callers can `if html:` gate on whether
140
+ to show the surrounding section."""
141
+ if not entries:
142
+ return ""
143
+ # Timeline is ordered newest-first for reader flow
144
+ rows: list[str] = []
145
+ for entry in reversed(entries):
146
+ delta_html = _format_delta(entry)
147
+ field_html = (
148
+ f'<code class="timeline-field">{html.escape(entry["field"])}</code>'
149
+ if entry.get("field") else ""
150
+ )
151
+ rows.append(
152
+ '<li class="timeline-item">'
153
+ f'<span class="timeline-date">{html.escape(entry["date"])}</span>'
154
+ f'<span class="timeline-dot"></span>'
155
+ '<div class="timeline-body">'
156
+ f'<div class="timeline-event">{html.escape(entry["event"])}</div>'
157
+ + (f'<div class="timeline-detail">{field_html} {delta_html}</div>'
158
+ if field_html or delta_html else "")
159
+ + '</div>'
160
+ '</li>'
161
+ )
162
+ return (
163
+ '<ol class="changelog-timeline">'
164
+ + "".join(rows)
165
+ + '</ol>'
166
+ )
167
+
168
+
169
+ def _format_delta(entry: ChangelogEntry) -> str:
170
+ """Format a from→to delta as HTML, picking a numeric or string
171
+ representation based on what the values look like."""
172
+ if "from_value" not in entry and "to_value" not in entry:
173
+ return ""
174
+ frm = entry.get("from_value")
175
+ to = entry.get("to_value")
176
+
177
+ def render_value(v: Any) -> str:
178
+ if v is None:
179
+ return '<span class="muted">null</span>'
180
+ if isinstance(v, bool):
181
+ return str(v).lower()
182
+ if isinstance(v, (int, float)):
183
+ return _format_number(v)
184
+ return html.escape(str(v))
185
+
186
+ frm_html = render_value(frm)
187
+ to_html = render_value(to)
188
+ # Add a color hint for numeric changes: up = positive delta in
189
+ # price is bad, in benchmark is good — so we stay neutral and
190
+ # just show the arrow direction.
191
+ arrow_cls = "timeline-arrow"
192
+ if isinstance(frm, (int, float)) and isinstance(to, (int, float)):
193
+ if to > frm:
194
+ arrow_cls += " timeline-arrow-up"
195
+ elif to < frm:
196
+ arrow_cls += " timeline-arrow-down"
197
+ return (
198
+ f'<span class="timeline-delta">'
199
+ f'<span class="timeline-from">{frm_html}</span>'
200
+ f'<span class="{arrow_cls}">→</span>'
201
+ f'<span class="timeline-to">{to_html}</span>'
202
+ f'</span>'
203
+ )
204
+
205
+
206
+ def _format_number(n: float) -> str:
207
+ """Compact numeric formatting for timeline values.
208
+
209
+ * Large counts (≥1000) get K/M suffix.
210
+ * Small counts render as-is.
211
+ * Floats with 2+ decimals stay as-is (pricing).
212
+ * Whole-number floats drop the decimal.
213
+ """
214
+ if isinstance(n, float):
215
+ if n.is_integer() and abs(n) < 1000:
216
+ return str(int(n))
217
+ if abs(n) < 1:
218
+ return f"{n:.3f}".rstrip("0").rstrip(".")
219
+ if abs(n) < 1000:
220
+ return f"{n:.2f}".rstrip("0").rstrip(".")
221
+ if abs(n) >= 1_000_000:
222
+ return f"{n / 1_000_000:.1f}M"
223
+ if abs(n) >= 1000:
224
+ return f"{n / 1000:.1f}K"
225
+ return str(n)
226
+
227
+
228
+ # ─── pricing sparkline ───────────────────────────────────────────────────
229
+
230
+
231
+ def extract_price_points(
232
+ entries: list[ChangelogEntry],
233
+ field_suffix: str = "pricing.input_per_1m",
234
+ ) -> list[tuple[date, float]]:
235
+ """Return `[(date, value), …]` for every numeric `to_value` whose
236
+ `field` ends with `field_suffix`. Used to feed the pricing sparkline.
237
+
238
+ Matches on suffix so `model.pricing.input_per_1m` and
239
+ `pricing.input_per_1m` both hit — schema callers sometimes use
240
+ different path roots.
241
+ """
242
+ out: list[tuple[date, float]] = []
243
+ for e in entries:
244
+ field = e.get("field", "")
245
+ if not field.endswith(field_suffix):
246
+ continue
247
+ to_value = e.get("to_value")
248
+ if to_value is None:
249
+ continue
250
+ try:
251
+ val = float(to_value)
252
+ except (TypeError, ValueError):
253
+ continue
254
+ try:
255
+ d = date.fromisoformat(e["date"])
256
+ except ValueError:
257
+ continue
258
+ out.append((d, val))
259
+ return out
260
+
261
+
262
+ def render_price_sparkline(
263
+ points: list[tuple[date, float]],
264
+ width: int = 220,
265
+ height: int = 40,
266
+ ) -> str:
267
+ """Render an inline SVG sparkline for a price series. Empty input
268
+ returns an empty string. Fewer than 2 points returns an empty
269
+ string (can't draw a line through 1 point)."""
270
+ if len(points) < 2:
271
+ return ""
272
+ values = [v for _, v in points]
273
+ min_v = min(values)
274
+ max_v = max(values)
275
+ pad_x = 4
276
+ pad_y = 4
277
+ plot_w = width - 2 * pad_x
278
+ plot_h = height - 2 * pad_y
279
+
280
+ if max_v == min_v:
281
+ # Flat series — draw a horizontal line at the middle
282
+ midy = pad_y + plot_h / 2
283
+ path_d = f"M {pad_x} {midy:.1f} L {pad_x + plot_w} {midy:.1f}"
284
+ else:
285
+ n = len(points)
286
+ xs = [pad_x + (i / (n - 1)) * plot_w for i in range(n)]
287
+ ys = [
288
+ pad_y + plot_h - ((v - min_v) / (max_v - min_v)) * plot_h
289
+ for v in values
290
+ ]
291
+ parts = [f"M {xs[0]:.1f} {ys[0]:.1f}"]
292
+ for x, y in zip(xs[1:], ys[1:]):
293
+ parts.append(f"L {x:.1f} {y:.1f}")
294
+ path_d = " ".join(parts)
295
+
296
+ # End dots: first + last
297
+ first_x = pad_x
298
+ first_y = pad_y + plot_h - (
299
+ (values[0] - min_v) / (max_v - min_v) * plot_h if max_v != min_v else plot_h / 2
300
+ )
301
+ last_x = pad_x + plot_w
302
+ last_y = pad_y + plot_h - (
303
+ (values[-1] - min_v) / (max_v - min_v) * plot_h if max_v != min_v else plot_h / 2
304
+ )
305
+
306
+ tooltip = (
307
+ f"{points[0][0].isoformat()}: {values[0]:.2f} → "
308
+ f"{points[-1][0].isoformat()}: {values[-1]:.2f}"
309
+ )
310
+ return (
311
+ f'<svg class="price-sparkline" xmlns="http://www.w3.org/2000/svg" '
312
+ f'viewBox="0 0 {width} {height}" width="{width}" height="{height}" '
313
+ f'role="img" aria-label="{html.escape(tooltip)}">'
314
+ f'<title>{html.escape(tooltip)}</title>'
315
+ f'<path d="{path_d}" fill="none" '
316
+ 'stroke="var(--accent, #7C3AED)" stroke-width="1.5" '
317
+ 'stroke-linecap="round" stroke-linejoin="round"/>'
318
+ f'<circle cx="{first_x:.1f}" cy="{first_y:.1f}" r="2.2" fill="var(--accent, #7C3AED)"/>'
319
+ f'<circle cx="{last_x:.1f}" cy="{last_y:.1f}" r="2.2" fill="var(--accent, #7C3AED)"/>'
320
+ '</svg>'
321
+ )
322
+
323
+
324
+ # ─── "recently updated" aggregator ───────────────────────────────────────
325
+
326
+
327
+ def find_recently_updated(
328
+ pages: Iterable[tuple[str, Mapping[str, Any]]],
329
+ now: Optional[date] = None,
330
+ within_days: int = 30,
331
+ ) -> list[tuple[str, ChangelogEntry]]:
332
+ """Given an iterable of `(slug, meta)` pairs, return a list of
333
+ `(slug, latest_changelog_entry)` for pages that have a changelog
334
+ entry dated within the last `within_days` days, sorted by latest
335
+ change descending.
336
+
337
+ Used by the home-page "Recently updated" widget so readers can see
338
+ which model pages have had their pricing, context, or benchmark
339
+ scores revised lately.
340
+ """
341
+ if now is None:
342
+ now = datetime.now(timezone.utc).date()
343
+ cutoff = now - timedelta(days=within_days)
344
+ out: list[tuple[str, ChangelogEntry, date]] = []
345
+ for slug, meta in pages:
346
+ entries, _ = parse_changelog(meta)
347
+ if not entries:
348
+ continue
349
+ latest = entries[-1]
350
+ try:
351
+ d = date.fromisoformat(latest["date"])
352
+ except ValueError:
353
+ continue
354
+ if d < cutoff:
355
+ continue
356
+ out.append((slug, latest, d))
357
+ out.sort(key=lambda x: x[2], reverse=True)
358
+ return [(slug, entry) for slug, entry, _ in out]
359
+
360
+
361
+ def render_recently_updated(
362
+ items: list[tuple[str, ChangelogEntry]],
363
+ link_prefix: str = "models/",
364
+ ) -> str:
365
+ """Render the "Recently updated" home-page list from the output of
366
+ `find_recently_updated`. Empty input returns an empty string."""
367
+ if not items:
368
+ return ""
369
+ rows: list[str] = []
370
+ for slug, entry in items:
371
+ rows.append(
372
+ f'<li class="recently-updated-item">'
373
+ f'<a href="{html.escape(link_prefix)}{html.escape(slug)}.html">'
374
+ f'<span class="recently-updated-slug">{html.escape(slug)}</span>'
375
+ f'</a>'
376
+ f'<span class="recently-updated-date muted">{html.escape(entry["date"])}</span>'
377
+ f'<span class="recently-updated-event">{html.escape(entry["event"])}</span>'
378
+ f'</li>'
379
+ )
380
+ return (
381
+ '<div class="recently-updated-card">'
382
+ '<div class="recently-updated-title muted">Recently updated · last 30 days</div>'
383
+ '<ul class="recently-updated-list">' + "".join(rows) + '</ul>'
384
+ '</div>'
385
+ )
386
+
387
+
388
+ def render_recent_activity(log_events: list[Any]) -> str:
389
+ """Render a "Recent activity" card driven from ``wiki/log.md`` (G-18 · #304).
390
+
391
+ ``log_events`` is a list of :class:`sessionwiki.log_reader.LogEvent`
392
+ records — passed as ``Any`` here to avoid an import cycle. The home
393
+ page uses this as a fallback when no model-changelog updates are
394
+ available, so the "Recently updated" surface shows real activity
395
+ instead of sitting empty on corpora without model pages.
396
+ """
397
+ if not log_events:
398
+ return ""
399
+ rows: list[str] = []
400
+ for ev in log_events:
401
+ # Prefer a processed-count detail when present; otherwise show title.
402
+ processed = ev.details.get("Processed") if hasattr(ev, "details") else None
403
+ right_label = f"{processed} processed" if processed else ev.title
404
+ # #ui-l4 (#580): the event label is truncated visually via CSS
405
+ # `text-overflow: ellipsis` when it exceeds the column width.
406
+ # Add `title=` so a hover surfaces the full text — keyboard
407
+ # users can read the full label without measuring columns.
408
+ full_label = ev.title if processed else right_label
409
+ rows.append(
410
+ f'<li class="recently-updated-item">'
411
+ f'<span class="recently-updated-slug">{html.escape(ev.operation)}</span>'
412
+ f'<span class="recently-updated-date muted">{html.escape(ev.date.isoformat())}</span>'
413
+ f'<span class="recently-updated-event" title="{html.escape(full_label)}">'
414
+ f'{html.escape(right_label)}</span>'
415
+ f'</li>'
416
+ )
417
+ return (
418
+ '<div class="recently-updated-card">'
419
+ f'<div class="recently-updated-title muted">Recent activity · last {len(log_events)} operations</div>'
420
+ '<ul class="recently-updated-list">' + "".join(rows) + '</ul>'
421
+ '</div>'
422
+ )