brainiac-cli 0.16.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 (77) hide show
  1. brain/__init__.py +39 -0
  2. brain/__main__.py +14 -0
  3. brain/_assets/AGENTS.md +564 -0
  4. brain/_assets/overlay/template/brand/brand-guide.md +24 -0
  5. brain/_assets/overlay/template/keywords/glossary.md +15 -0
  6. brain/_assets/overlay/template/people/roster.md +14 -0
  7. brain/_assets/overlay/template/voice/voice-profile.md +34 -0
  8. brain/_assets/routines/manifest.json +257 -0
  9. brain/_assets/scripts/brain-brief-mac.plist +59 -0
  10. brain/_assets/scripts/brain-brief.sh +74 -0
  11. brain/_assets/scripts/brain-synthesis-mac.plist +48 -0
  12. brain/_assets/scripts/brain-synthesis.sh +214 -0
  13. brain/_assets/scripts/install-brief-mac.sh +152 -0
  14. brain/_assets/scripts/install-brief-windows.ps1 +97 -0
  15. brain/_assets/scripts/register_tasks.py +386 -0
  16. brain/_assets/templates/company.md +21 -0
  17. brain/_assets/templates/concept.md +27 -0
  18. brain/_assets/templates/daily.md +20 -0
  19. brain/_assets/templates/decision.md +42 -0
  20. brain/_assets/templates/meeting.md +33 -0
  21. brain/_assets/templates/person.md +20 -0
  22. brain/_assets/templates/project.md +23 -0
  23. brain/_assets/templates/state-moc.md +40 -0
  24. brain/_version.py +12 -0
  25. brain/anchor.py +121 -0
  26. brain/audit.py +422 -0
  27. brain/backup.py +210 -0
  28. brain/brief.py +417 -0
  29. brain/capture.py +117 -0
  30. brain/chunk.py +249 -0
  31. brain/classification.py +134 -0
  32. brain/cli.py +1906 -0
  33. brain/config.py +368 -0
  34. brain/connect.py +362 -0
  35. brain/context.py +108 -0
  36. brain/core.py +3018 -0
  37. brain/doctor.py +1161 -0
  38. brain/egress.py +148 -0
  39. brain/embed.py +857 -0
  40. brain/encryption.py +217 -0
  41. brain/frontmatter.py +102 -0
  42. brain/golden_probe.py +678 -0
  43. brain/graph.py +369 -0
  44. brain/graphify.py +352 -0
  45. brain/index.py +1576 -0
  46. brain/ingest/__init__.py +19 -0
  47. brain/ingest/handlers/__init__.py +43 -0
  48. brain/ingest/handlers/base.py +95 -0
  49. brain/ingest/handlers/docx.py +78 -0
  50. brain/ingest/handlers/email.py +228 -0
  51. brain/ingest/handlers/html.py +142 -0
  52. brain/ingest/handlers/image.py +91 -0
  53. brain/ingest/handlers/pdf.py +99 -0
  54. brain/ingest/handlers/pptx.py +69 -0
  55. brain/ingest/handlers/tables.py +41 -0
  56. brain/ingest/handlers/text.py +43 -0
  57. brain/ingest/handlers/xlsx.py +100 -0
  58. brain/ingest/handlers/zip.py +163 -0
  59. brain/ingest/pipeline.py +839 -0
  60. brain/ingest/transcript.py +158 -0
  61. brain/init.py +870 -0
  62. brain/maintenance.py +2266 -0
  63. brain/mcp_adapter.py +217 -0
  64. brain/multihop.py +232 -0
  65. brain/notes.py +195 -0
  66. brain/overlay.py +183 -0
  67. brain/projection.py +79 -0
  68. brain/rerank.py +425 -0
  69. brain/snapshot.py +231 -0
  70. brain/update.py +743 -0
  71. brain/vectors.py +225 -0
  72. brainiac_cli-0.16.0.dist-info/METADATA +306 -0
  73. brainiac_cli-0.16.0.dist-info/RECORD +77 -0
  74. brainiac_cli-0.16.0.dist-info/WHEEL +5 -0
  75. brainiac_cli-0.16.0.dist-info/entry_points.txt +4 -0
  76. brainiac_cli-0.16.0.dist-info/licenses/LICENSE +202 -0
  77. brainiac_cli-0.16.0.dist-info/top_level.txt +1 -0
brain/backup.py ADDED
@@ -0,0 +1,210 @@
1
+ """Encrypted off-device backup + restore (SEC-03).
2
+
3
+ The off-device backup is the ONE place encryption genuinely matters (design v5
4
+ §3.3): the at-rest baseline stays FDE-only, but a copy that leaves the machine
5
+ loses FDE's protection, so the backup is encrypted regardless of the dormant
6
+ at-rest flag (brain.encryption.is_enabled()).
7
+
8
+ What is backed up: the **Markdown truth** under ``vault/`` (the second brain).
9
+ The derived ``.brain/`` index is deliberately EXCLUDED — it is disposable and
10
+ rebuildable from Markdown, so backing it up wastes space and risks shipping a
11
+ stale/locked SQLite WAL off-device. ``.git/`` is excluded too.
12
+
13
+ Format: a gzip tar of the vault, then AES-256-GCM-encrypted into one
14
+ ``<name>.tar.gz.enc`` token (brain.encryption.encrypt_bytes(force=True)), plus a
15
+ sidecar ``<name>.manifest.json`` recording counts + the sha256 of the plaintext
16
+ archive so a restore can prove byte-identity. Fails closed: if no encryption key
17
+ resolves, the backup refuses (never writes a plaintext copy off-device).
18
+ """
19
+ from __future__ import annotations
20
+
21
+ import hashlib
22
+ import io
23
+ import json
24
+ import tarfile
25
+ import time
26
+ from dataclasses import asdict, dataclass
27
+ from pathlib import Path
28
+ from typing import Any
29
+
30
+ from . import encryption as enc
31
+
32
+ EXCLUDE_DIRS = {".brain", ".git", "__pycache__", ".pytest_cache"}
33
+
34
+
35
+ @dataclass
36
+ class BackupManifest:
37
+ schema_version: str
38
+ created_iso: str
39
+ source_vault: str
40
+ archive: str
41
+ encrypted: bool
42
+ files: int
43
+ plaintext_sha256: str
44
+ plaintext_bytes: int
45
+ ciphertext_bytes: int
46
+
47
+ def to_dict(self) -> dict[str, Any]:
48
+ return asdict(self)
49
+
50
+
51
+ def _included(path: Path, vault: Path) -> bool:
52
+ rel = path.relative_to(vault)
53
+ return not any(part in EXCLUDE_DIRS for part in rel.parts)
54
+
55
+
56
+ def _build_tar_gz(vault: Path) -> tuple[bytes, int]:
57
+ """Deterministic-ish gzip tar of vault Markdown truth. Returns (bytes, file_count)."""
58
+ buf = io.BytesIO()
59
+ count = 0
60
+ # mtime fixed so the gzip header is stable across runs of the same content.
61
+ with tarfile.open(fileobj=buf, mode="w:gz") as tar:
62
+ for p in sorted(vault.rglob("*")):
63
+ if p.is_file() and _included(p, vault):
64
+ tar.add(p, arcname=str(p.relative_to(vault)))
65
+ count += 1
66
+ return buf.getvalue(), count
67
+
68
+
69
+ def create_backup(vault: Path, dest_dir: Path, *, name: str | None = None,
70
+ encrypt: bool = True) -> BackupManifest:
71
+ """Create an encrypted off-device backup of ``vault`` into ``dest_dir``.
72
+
73
+ ``encrypt=True`` (default) forces AES-256-GCM encryption (fails closed with
74
+ no key). ``encrypt=False`` is for the rare in-VM / already-encrypted-target
75
+ case and writes a plaintext ``.tar.gz`` with a loud manifest flag — discouraged
76
+ for anything leaving the machine.
77
+ """
78
+ vault = Path(vault)
79
+ dest_dir = Path(dest_dir)
80
+ if not vault.is_dir():
81
+ raise FileNotFoundError(f"vault not found: {vault}")
82
+ dest_dir.mkdir(parents=True, exist_ok=True)
83
+
84
+ plaintext, file_count = _build_tar_gz(vault)
85
+ pt_sha = hashlib.sha256(plaintext).hexdigest()
86
+ stamp = name or f"brain-backup-{time.strftime('%Y%m%dT%H%M%SZ', time.gmtime())}"
87
+
88
+ if encrypt:
89
+ token = enc.encrypt_bytes(plaintext, force=True) # force: off-device => encrypt
90
+ archive = dest_dir / f"{stamp}.tar.gz.enc"
91
+ archive.write_bytes(token)
92
+ ct_bytes = len(token)
93
+ else:
94
+ archive = dest_dir / f"{stamp}.tar.gz"
95
+ archive.write_bytes(plaintext)
96
+ ct_bytes = len(plaintext)
97
+
98
+ manifest = BackupManifest(
99
+ schema_version="brain-backup-v1",
100
+ created_iso=time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
101
+ source_vault=str(vault.resolve()),
102
+ archive=str(archive),
103
+ encrypted=encrypt,
104
+ files=file_count,
105
+ plaintext_sha256=pt_sha,
106
+ plaintext_bytes=len(plaintext),
107
+ ciphertext_bytes=ct_bytes,
108
+ )
109
+ (dest_dir / f"{stamp}.manifest.json").write_text(
110
+ json.dumps(manifest.to_dict(), indent=2) + "\n", encoding="utf-8")
111
+ return manifest
112
+
113
+
114
+ # Python's stdlib gained extraction *filters* (PEP 706) that reject path
115
+ # traversal, absolute paths, device/fifo members, and unsafe symlink/hardlink
116
+ # targets at the C level. Available natively on 3.12+, and backported to the
117
+ # 3.8.17 / 3.9.17 / 3.10.12 / 3.11.4 security-release lines — detect via the
118
+ # presence of ``tarfile.data_filter`` rather than a bare version check, since
119
+ # that covers both cases in one test.
120
+ _HAS_TAR_DATA_FILTER = hasattr(tarfile, "data_filter")
121
+
122
+
123
+ def _member_target_or_raise(member: "tarfile.TarInfo", dest_dir: Path) -> Path:
124
+ """Resolve ``member``'s extraction target and raise if it escapes ``dest_dir``.
125
+
126
+ Guards against path traversal in archive member *names* (``../../etc/passwd``
127
+ style, or an absolute path) regardless of Python version / filter support —
128
+ this check runs even when ``filter="data"`` will ALSO reject it, so the error
129
+ message is consistent across interpreters.
130
+ """
131
+ dest_resolved = dest_dir.resolve()
132
+ target = (dest_dir / member.name).resolve()
133
+ if target != dest_resolved and dest_resolved not in target.parents:
134
+ raise ValueError(f"archive member escapes dest: {member.name}")
135
+ return target
136
+
137
+
138
+ def _reject_unsafe_member_pre312(member: "tarfile.TarInfo", dest_dir: Path) -> None:
139
+ """Fallback validation for interpreters WITHOUT ``tarfile.data_filter``
140
+ (pre-3.12, or a pre-security-release patch level).
141
+
142
+ Rejects anything that is not a plain file or directory — no symlinks,
143
+ hardlinks, device nodes, or FIFOs — and, belt-and-suspenders, validates that
144
+ any link member's ``linkname`` would resolve inside ``dest_dir``. A crafted
145
+ archive with a symlink member that points outside ``dest_dir`` (the classic
146
+ tar-extraction escape) is rejected here rather than silently followed.
147
+ """
148
+ if not (member.isfile() or member.isdir()):
149
+ raise ValueError(
150
+ f"archive member {member.name!r} is not a regular file or directory "
151
+ f"(tar type {member.type!r}); refusing to extract — symlink / hardlink / "
152
+ "device / fifo members are not allowed in a restored backup"
153
+ )
154
+ if member.issym() or member.islnk(): # pragma: no cover - unreachable after
155
+ # the isfile/isdir check above on current CPython tarfile classification,
156
+ # kept as an explicit second guard in case that classification ever
157
+ # changes so a link target is never silently trusted.
158
+ link_target = ((dest_dir / member.name).parent / member.linkname).resolve()
159
+ dest_resolved = dest_dir.resolve()
160
+ if link_target != dest_resolved and dest_resolved not in link_target.parents:
161
+ raise ValueError(
162
+ f"archive link member escapes dest: {member.name} -> {member.linkname}"
163
+ )
164
+
165
+
166
+ def restore_backup(archive: Path, dest_dir: Path) -> dict[str, Any]:
167
+ """Restore a backup archive into ``dest_dir`` (decrypting if needed).
168
+
169
+ Returns a verdict with the restored file count and the plaintext sha256 (so a
170
+ caller can assert byte-identity against the backup manifest). Authenticated
171
+ decryption — a tampered ``.enc`` raises rather than restoring garbage.
172
+
173
+ Extraction is safe against path traversal AND symlink/hardlink escapes:
174
+ Python 3.12+ (and the 3.8.17/3.9.17/3.10.12/3.11.4+ backports) use the
175
+ stdlib ``filter="data"`` extraction filter; older interpreters fall back to
176
+ manual per-member validation (regular file/dir only + traversal check).
177
+ """
178
+ archive = Path(archive)
179
+ dest_dir = Path(dest_dir)
180
+ dest_dir.mkdir(parents=True, exist_ok=True)
181
+ blob = archive.read_bytes()
182
+
183
+ if archive.suffix == ".enc" or blob.startswith(enc.MAGIC):
184
+ plaintext = enc.decrypt_bytes(blob)
185
+ encrypted = True
186
+ else:
187
+ plaintext, encrypted = blob, False
188
+
189
+ with tarfile.open(fileobj=io.BytesIO(plaintext), mode="r:gz") as tar:
190
+ members = tar.getmembers()
191
+ for member in members:
192
+ # Name-based traversal guard runs on EVERY interpreter — belt and
193
+ # suspenders alongside filter="data" on 3.12+, the sole guard pre-3.12.
194
+ _member_target_or_raise(member, dest_dir)
195
+ if not _HAS_TAR_DATA_FILTER:
196
+ _reject_unsafe_member_pre312(member, dest_dir)
197
+
198
+ if _HAS_TAR_DATA_FILTER:
199
+ tar.extractall(dest_dir, filter="data") # nosec B202 - safe filter + pre-validated above
200
+ else:
201
+ tar.extractall(dest_dir) # nosec B202 - members pre-validated above (file/dir only, no traversal)
202
+ restored = sum(1 for m in members if m.isfile())
203
+
204
+ return {
205
+ "restored": True,
206
+ "files": restored,
207
+ "encrypted": encrypted,
208
+ "plaintext_sha256": hashlib.sha256(plaintext).hexdigest(),
209
+ "dest": str(dest_dir),
210
+ }
brain/brief.py ADDED
@@ -0,0 +1,417 @@
1
+ """Morning brief + weekly digest generators (UX-02).
2
+
3
+ Pure functions — no I/O. The caller (BrainCore) passes pre-collected data;
4
+ these assemble and format the output.
5
+
6
+ The scheduled morning brief is the ONE sanctioned scheduled task and the
7
+ guaranteed daily drain FLOOR. The tripwire line surfaces a stalled drain so
8
+ it is visible next morning rather than silently losing notes.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import datetime
13
+ import html as _html
14
+ from typing import Any
15
+
16
+
17
+ def _today() -> str:
18
+ return datetime.date.today().isoformat()
19
+
20
+
21
+ def _days_ago(n: int) -> str:
22
+ return (datetime.date.today() - datetime.timedelta(days=n)).isoformat()
23
+
24
+
25
+ def build_brief(
26
+ *,
27
+ index_stats: dict[str, Any],
28
+ recent_notes: list[dict[str, Any]],
29
+ pending_before_drain: int,
30
+ drain_result: dict[str, Any],
31
+ snapshot_age_hours: float | None,
32
+ max_recent: int = 5,
33
+ ) -> dict[str, Any]:
34
+ """Build the morning brief data structure.
35
+
36
+ Tripwire logic:
37
+ - ``pending_before_drain > 0`` AND ``drain_result.promoted == 0``
38
+ AND ``drain_result.skipped > 0`` → stalled drain: emit tripwire line.
39
+ - ``drain_result.promoted > 0`` → drain ran successfully, tripwire cleared.
40
+ - ``pending_before_drain == 0`` → nothing to drain, clean.
41
+ """
42
+ drain_promoted = int(drain_result.get("promoted", 0))
43
+ drain_skipped = int(drain_result.get("skipped", 0))
44
+ drain_stalled = (
45
+ pending_before_drain > 0 and drain_promoted == 0 and drain_skipped > 0
46
+ )
47
+
48
+ snap_age: str | None = None
49
+ if snapshot_age_hours is not None:
50
+ if snapshot_age_hours < 1:
51
+ snap_age = f"{int(snapshot_age_hours * 60)}m"
52
+ elif snapshot_age_hours < 24:
53
+ snap_age = f"{snapshot_age_hours:.1f}h"
54
+ else:
55
+ snap_age = f"{snapshot_age_hours / 24:.1f}d"
56
+
57
+ tripwire: str | None = None
58
+ drain_note: str | None = None
59
+ if drain_stalled:
60
+ tripwire = (
61
+ f"{pending_before_drain} captures pending · "
62
+ "last successful drain: stalled (no key?)"
63
+ )
64
+ elif drain_promoted > 0:
65
+ drain_note = f"drained {drain_promoted} capture(s)"
66
+
67
+ return {
68
+ "date": _today(),
69
+ "notes": int(index_stats.get("notes", 0)),
70
+ "chunks": int(index_stats.get("chunks", 0)),
71
+ "pending_before_drain": pending_before_drain,
72
+ "drain": {
73
+ "promoted": drain_promoted,
74
+ "skipped": drain_skipped,
75
+ "stalled": drain_stalled,
76
+ },
77
+ "snapshot_age": snap_age,
78
+ "recent": recent_notes[:max_recent],
79
+ "tripwire": tripwire,
80
+ "drain_note": drain_note,
81
+ }
82
+
83
+
84
+ def format_brief(brief: dict[str, Any]) -> str:
85
+ """Human-readable morning brief. Quiet — no plumbing noise."""
86
+ lines = [f"brain brief · {brief['date']}"]
87
+ lines.append(f" {brief['notes']} notes {brief['chunks']} chunks")
88
+
89
+ tw = brief.get("tripwire")
90
+ dn = brief.get("drain_note")
91
+ if tw:
92
+ lines.append(f" ⚠ {tw}")
93
+ elif dn:
94
+ lines.append(f" ✓ {dn}")
95
+ elif brief.get("pending_before_drain", 0) == 0:
96
+ lines.append(" ✓ no pending captures")
97
+
98
+ snap = brief.get("snapshot_age")
99
+ if snap:
100
+ lines.append(f" snapshot age: {snap}")
101
+
102
+ if brief.get("recent"):
103
+ lines.append(" recent:")
104
+ for n in brief["recent"]:
105
+ lines.append(
106
+ f" {str(n.get('updated', ''))[:10]} {n.get('id', '')} "
107
+ f"({n.get('classification') or 'UNLABELLED'})"
108
+ )
109
+
110
+ return "\n".join(lines)
111
+
112
+
113
+ def build_digest(
114
+ *,
115
+ index_stats: dict[str, Any],
116
+ recent_notes: list[dict[str, Any]],
117
+ days: int = 7,
118
+ ) -> dict[str, Any]:
119
+ """Build the weekly digest data structure."""
120
+ cutoff = _days_ago(days)
121
+ in_period = [n for n in recent_notes if str(n.get("updated") or "") >= cutoff]
122
+ return {
123
+ "date": _today(),
124
+ "period_days": days,
125
+ "period_start": cutoff,
126
+ "notes_total": int(index_stats.get("notes", 0)),
127
+ "notes_in_period": len(in_period),
128
+ "notes": in_period[:20],
129
+ }
130
+
131
+
132
+ def format_digest(digest: dict[str, Any]) -> str:
133
+ """Human-readable weekly digest. Quiet."""
134
+ lines = [
135
+ f"brain digest · {digest['date']} (past {digest['period_days']}d)",
136
+ f" {digest['notes_total']} notes total "
137
+ f" {digest['notes_in_period']} in period",
138
+ ]
139
+ if digest.get("notes"):
140
+ lines.append(" added/updated:")
141
+ for n in digest["notes"]:
142
+ lines.append(
143
+ f" {str(n.get('updated', ''))[:10]} {n.get('id', '')} "
144
+ f"({n.get('classification') or 'UNLABELLED'})"
145
+ )
146
+ return "\n".join(lines)
147
+
148
+
149
+ def parse_hot_entries(text: str) -> list[str]:
150
+ """Pull the header line of each ``hot.md`` entry (``## <date> — <title>``)
151
+ into a flat display list, oldest-first (hot.md is append-only). A caller
152
+ wanting the most-recent head takes ``[-n:]``."""
153
+ return [line[3:].strip() for line in (text or "").splitlines()
154
+ if line.strip().startswith("## ")]
155
+
156
+
157
+ # ---------------------------------------------------------------------------
158
+ # HTML brief/digest renderers (AUT-01/AUT-03, ADR-0003 Ruling c).
159
+ #
160
+ # PURE RENDER ONLY: every function below takes an already-assembled,
161
+ # already-egress-gated data structure and formats it. No index queries, no
162
+ # note reads, no overlay reads, no filesystem access of any kind — the caller
163
+ # (BrainCore.brief_html / digest_html) does every read and every
164
+ # egress.apply_gate call BEFORE handing data in here. This is what makes a
165
+ # renderer smoke test possible with plain dicts and no fixtures.
166
+ #
167
+ # Every piece of dynamic text (note ids/titles, snippets, hot-queue lines,
168
+ # recommendation text, overlay brand fields) goes through ``_esc`` before it
169
+ # touches the returned string. No <script>, no inline event handlers, no
170
+ # external assets — self-contained, light+dark safe via
171
+ # ``prefers-color-scheme``.
172
+ # ---------------------------------------------------------------------------
173
+ _NEUTRAL_BRAND: dict[str, Any] = {
174
+ "present": False, "title": "Brain Brief", "owner_name": None,
175
+ "accent_color": "#2563eb",
176
+ }
177
+ _ZONE_ORDER = ("projects", "areas", "resources", "archive")
178
+
179
+
180
+ def _esc(value: Any) -> str:
181
+ """Centralised HTML-escaping chokepoint — every dynamic value rendered
182
+ into the brief/digest HTML MUST pass through this (codex-verify-r2)."""
183
+ return _html.escape(str(value if value is not None else ""), quote=True)
184
+
185
+
186
+ def _section(title: str, inner_html: str) -> str:
187
+ """``inner_html`` must already be composed of ``_esc``-safe pieces — this
188
+ helper only escapes the section title, never the body it is handed."""
189
+ return f'<section class="card"><h2>{_esc(title)}</h2>{inner_html}</section>'
190
+
191
+
192
+ def _zone_rank(zone: Any) -> int:
193
+ z = str(zone or "").strip().lower()
194
+ return _ZONE_ORDER.index(z) if z in _ZONE_ORDER else len(_ZONE_ORDER)
195
+
196
+
197
+ def _html_page(*, title: str, accent: str, body: str) -> str:
198
+ """The shared self-contained HTML5 shell: inline CSS only, zero external
199
+ assets, zero <script>, light+dark via ``prefers-color-scheme``."""
200
+ return f"""<!DOCTYPE html>
201
+ <html lang="en">
202
+ <head>
203
+ <meta charset="utf-8">
204
+ <meta name="viewport" content="width=device-width, initial-scale=1">
205
+ <title>{_esc(title)}</title>
206
+ <style>
207
+ :root {{ --accent: {_esc(accent)}; --bg: #ffffff; --fg: #111827; --muted: #6b7280;
208
+ --card: #f9fafb; --border: #e5e7eb; }}
209
+ @media (prefers-color-scheme: dark) {{
210
+ :root {{ --bg: #0b0f19; --fg: #e5e7eb; --muted: #9ca3af; --card: #131a29; --border: #232a3b; }}
211
+ }}
212
+ * {{ box-sizing: border-box; }}
213
+ body {{ margin: 0; padding: 2rem 1rem; background: var(--bg); color: var(--fg);
214
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
215
+ line-height: 1.5; }}
216
+ .wrap {{ max-width: 720px; margin: 0 auto; }}
217
+ header.brief-header h1 {{ margin: 0 0 0.25rem; color: var(--accent); font-size: 1.6rem; }}
218
+ header.brief-header .meta {{ margin: 0 0 1.5rem; color: var(--muted); font-size: 0.9rem; }}
219
+ section.card {{ background: var(--card); border: 1px solid var(--border); border-radius: 10px;
220
+ padding: 1rem 1.25rem; margin-bottom: 1rem; }}
221
+ section.card h2 {{ margin: 0 0 0.6rem; font-size: 1.05rem; border-left: 4px solid var(--accent);
222
+ padding-left: 0.5rem; }}
223
+ h3 {{ font-size: 0.95rem; margin: 0.8rem 0 0.4rem; }}
224
+ ul.list {{ list-style: none; margin: 0; padding: 0; }}
225
+ ul.list li {{ padding: 0.35rem 0; border-bottom: 1px solid var(--border); font-size: 0.92rem;
226
+ overflow-wrap: anywhere; }}
227
+ ul.list li:last-child {{ border-bottom: none; }}
228
+ .id, .zone {{ font-weight: 600; }}
229
+ .tag {{ display: inline-block; font-size: 0.75rem; color: var(--muted); border: 1px solid var(--border);
230
+ border-radius: 999px; padding: 0.05rem 0.5rem; margin-left: 0.3rem; }}
231
+ .date {{ color: var(--muted); font-size: 0.85rem; }}
232
+ .ok {{ color: #059669; }}
233
+ .warn {{ color: #b45309; font-weight: 600; }}
234
+ .empty {{ color: var(--muted); font-style: italic; }}
235
+ </style>
236
+ </head>
237
+ <body>
238
+ <div class="wrap">
239
+ {body}
240
+ </div>
241
+ </body>
242
+ </html>
243
+ """
244
+
245
+
246
+ def render_brief_html(
247
+ brief: dict[str, Any],
248
+ *,
249
+ stale_links: list[dict[str, Any]] | None = None,
250
+ revisit_sample: list[dict[str, Any]] | None = None,
251
+ open_recommendations: list[dict[str, Any]] | None = None,
252
+ hot_head: list[str] | None = None,
253
+ autoresearch: dict[str, Any] | None = None,
254
+ brand: dict[str, Any] | None = None,
255
+ ) -> str:
256
+ """Render the branded HTML morning brief (AUT-01).
257
+
258
+ Sections: pending capture drafts, notes added/updated, revisit/stale
259
+ sample, open recommendations + hot-queue head, index health (snapshot
260
+ age + stats). All list arguments default to empty — a bare ``brief``
261
+ dict (from ``build_brief``) still renders a valid, complete page.
262
+ """
263
+ brand = brand or _NEUTRAL_BRAND
264
+ title = brand.get("title") or _NEUTRAL_BRAND["title"]
265
+ owner = brand.get("owner_name")
266
+ accent = brand.get("accent_color") or _NEUTRAL_BRAND["accent_color"]
267
+
268
+ stale_links = stale_links or []
269
+ revisit_sample = revisit_sample or []
270
+ open_recommendations = open_recommendations or []
271
+ hot_head = hot_head or []
272
+
273
+ subtitle = f" for {_esc(owner)}" if owner else ""
274
+ header = (
275
+ f'<header class="brief-header"><h1>{_esc(title)}</h1>'
276
+ f'<p class="meta">Morning brief &middot; {_esc(brief.get("date", ""))}{subtitle}</p>'
277
+ f"</header>"
278
+ )
279
+
280
+ maintenance_line = ""
281
+ if autoresearch and autoresearch.get("stale"):
282
+ if autoresearch.get("never_run"):
283
+ maintenance_line = (
284
+ '<p class="warn">&#9888; autoresearch has never run yet — '
285
+ "the quarterly self-tuning convention has not started.</p>"
286
+ )
287
+ else:
288
+ maintenance_line = (
289
+ f'<p class="warn">&#9888; last autoresearch run was '
290
+ f'{_esc(autoresearch.get("age_days"))} day(s) ago '
291
+ f'({_esc(autoresearch.get("last_run"))}) — overdue for its quarterly poke.</p>'
292
+ )
293
+
294
+ pending = int(brief.get("pending_before_drain", 0) or 0)
295
+ drain = brief.get("drain") or {}
296
+ if drain.get("stalled"):
297
+ pending_html = f'<p class="warn">&#9888; {_esc(brief.get("tripwire", ""))}</p>'
298
+ elif brief.get("drain_note"):
299
+ pending_html = f'<p class="ok">&#10003; {_esc(brief["drain_note"])}</p>'
300
+ elif pending == 0:
301
+ pending_html = '<p class="ok">&#10003; no pending captures</p>'
302
+ else:
303
+ pending_html = f"<p>{_esc(pending)} capture(s) pending</p>"
304
+ sec_pending = _section("Pending captures", pending_html)
305
+
306
+ recent = brief.get("recent") or []
307
+ if recent:
308
+ rows = "".join(
309
+ f'<li><span class="id">{_esc(n.get("id", ""))}</span> '
310
+ f'<span class="tag">{_esc(n.get("classification") or "UNLABELLED")}</span> '
311
+ f'<span class="date">{_esc(str(n.get("updated", ""))[:10])}</span></li>'
312
+ for n in recent
313
+ )
314
+ recent_html = f'<ul class="list">{rows}</ul>'
315
+ else:
316
+ recent_html = '<p class="empty">no recent notes</p>'
317
+ sec_recent = _section("Notes added / updated", recent_html)
318
+
319
+ stale_items = "".join(
320
+ f'<li>stale link: <span class="id">{_esc((s.get("from") or {}).get("id", ""))}</span> '
321
+ f'&rarr; <span class="target">{_esc(s.get("target_text", ""))}</span> '
322
+ f'<span class="tag">{_esc(s.get("reason", ""))}</span></li>'
323
+ for s in stale_links[:10]
324
+ )
325
+ revisit_items = "".join(
326
+ f'<li>revisit: <span class="id">{_esc(r.get("id", ""))}</span> '
327
+ f'(updated {_esc(str(r.get("updated", ""))[:10])}, age {_esc(r.get("age_days", ""))}d, '
328
+ f'score {_esc(r.get("score", ""))})</li>'
329
+ for r in revisit_sample[:10]
330
+ )
331
+ if stale_items or revisit_items:
332
+ revisit_html = f'<ul class="list">{stale_items}{revisit_items}</ul>'
333
+ else:
334
+ revisit_html = '<p class="empty">nothing overdue for a re-read</p>'
335
+ sec_revisit = _section("What needs a re-read", revisit_html)
336
+
337
+ rec_items = "".join(
338
+ f'<li>{_esc((r.get("text") or "").splitlines()[0][:120] if r.get("text") else r.get("id", ""))} '
339
+ f'<span class="tag">{_esc(r.get("status", "open"))}</span></li>'
340
+ for r in open_recommendations[:10]
341
+ )
342
+ hot_items = "".join(f"<li>{_esc(h)}</li>" for h in hot_head[:5])
343
+ recs_parts = []
344
+ if rec_items:
345
+ recs_parts.append(f'<h3>Open recommendations</h3><ul class="list">{rec_items}</ul>')
346
+ if hot_items:
347
+ recs_parts.append(f'<h3>Hot queue (recent)</h3><ul class="list">{hot_items}</ul>')
348
+ recs_html = "".join(recs_parts) or '<p class="empty">nothing queued</p>'
349
+ sec_recs = _section("Open recommendations", recs_html)
350
+
351
+ stats_html = (
352
+ f'<p>{_esc(brief.get("notes", 0))} notes &middot; {_esc(brief.get("chunks", 0))} chunks</p>'
353
+ f'<p>snapshot age: {_esc(brief.get("snapshot_age") or "n/a")}</p>'
354
+ )
355
+ sec_stats = _section("Index health", stats_html)
356
+
357
+ body = (
358
+ header + maintenance_line + sec_pending + sec_recent + sec_revisit
359
+ + sec_recs + sec_stats
360
+ )
361
+ return _html_page(title=f"{title} · {brief.get('date', '')}", accent=accent, body=body)
362
+
363
+
364
+ def render_digest_html(
365
+ digest: dict[str, Any], *, brand: dict[str, Any] | None = None,
366
+ ) -> str:
367
+ """Render the branded HTML weekly digest (AUT-03).
368
+
369
+ Importance framing is generic — zone bucket (projects > areas > resources
370
+ > archive), then classification tier, then recency — never an
371
+ owner-specific score. ``digest["notes"]`` is expected already
372
+ egress-gated by the caller.
373
+ """
374
+ from . import classification as cls
375
+
376
+ brand = brand or _NEUTRAL_BRAND
377
+ title = brand.get("title") or _NEUTRAL_BRAND["title"]
378
+ # A digest-specific neutral title reads better than reusing the brief's,
379
+ # but an owner-branded title (present=True) should stay as authored.
380
+ if not brand.get("present") and title == _NEUTRAL_BRAND["title"]:
381
+ title = "Brain Digest"
382
+ owner = brand.get("owner_name")
383
+ accent = brand.get("accent_color") or _NEUTRAL_BRAND["accent_color"]
384
+
385
+ notes = list(digest.get("notes") or [])
386
+ notes.sort(key=lambda n: str(n.get("updated") or ""), reverse=True)
387
+ notes.sort(key=lambda n: cls.rank(n.get("classification")), reverse=True)
388
+ notes.sort(key=lambda n: _zone_rank(n.get("zone")))
389
+
390
+ subtitle = f" for {_esc(owner)}" if owner else ""
391
+ header = (
392
+ f'<header class="brief-header"><h1>{_esc(title)}</h1>'
393
+ f'<p class="meta">Weekly digest &middot; {_esc(digest.get("date", ""))} '
394
+ f'(past {_esc(digest.get("period_days", 7))}d){subtitle}</p></header>'
395
+ )
396
+
397
+ if notes:
398
+ rows = "".join(
399
+ f'<li><span class="zone">{_esc(n.get("zone") or "—")}</span> '
400
+ f'<span class="id">{_esc(n.get("id", ""))}</span> '
401
+ f'<span class="title">{_esc(n.get("title") or "")}</span> '
402
+ f'<span class="tag">{_esc(n.get("classification") or "UNLABELLED")}</span> '
403
+ f'<span class="date">{_esc(str(n.get("updated", ""))[:10])}</span></li>'
404
+ for n in notes
405
+ )
406
+ notes_html = f'<ul class="list">{rows}</ul>'
407
+ else:
408
+ notes_html = '<p class="empty">nothing entered the brain this period</p>'
409
+
410
+ summary = (
411
+ f'<p>{_esc(digest.get("notes_total", 0))} notes total &middot; '
412
+ f'{_esc(digest.get("notes_in_period", 0))} in the past '
413
+ f'{_esc(digest.get("period_days", 7))} day(s), since {_esc(digest.get("period_start", ""))}</p>'
414
+ )
415
+
416
+ body = header + _section("This week", summary + notes_html)
417
+ return _html_page(title=f"{title} · {digest.get('date', '')}", accent=accent, body=body)