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.
- brain/__init__.py +39 -0
- brain/__main__.py +14 -0
- brain/_assets/AGENTS.md +564 -0
- brain/_assets/overlay/template/brand/brand-guide.md +24 -0
- brain/_assets/overlay/template/keywords/glossary.md +15 -0
- brain/_assets/overlay/template/people/roster.md +14 -0
- brain/_assets/overlay/template/voice/voice-profile.md +34 -0
- brain/_assets/routines/manifest.json +257 -0
- brain/_assets/scripts/brain-brief-mac.plist +59 -0
- brain/_assets/scripts/brain-brief.sh +74 -0
- brain/_assets/scripts/brain-synthesis-mac.plist +48 -0
- brain/_assets/scripts/brain-synthesis.sh +214 -0
- brain/_assets/scripts/install-brief-mac.sh +152 -0
- brain/_assets/scripts/install-brief-windows.ps1 +97 -0
- brain/_assets/scripts/register_tasks.py +386 -0
- brain/_assets/templates/company.md +21 -0
- brain/_assets/templates/concept.md +27 -0
- brain/_assets/templates/daily.md +20 -0
- brain/_assets/templates/decision.md +42 -0
- brain/_assets/templates/meeting.md +33 -0
- brain/_assets/templates/person.md +20 -0
- brain/_assets/templates/project.md +23 -0
- brain/_assets/templates/state-moc.md +40 -0
- brain/_version.py +12 -0
- brain/anchor.py +121 -0
- brain/audit.py +422 -0
- brain/backup.py +210 -0
- brain/brief.py +417 -0
- brain/capture.py +117 -0
- brain/chunk.py +249 -0
- brain/classification.py +134 -0
- brain/cli.py +1906 -0
- brain/config.py +368 -0
- brain/connect.py +362 -0
- brain/context.py +108 -0
- brain/core.py +3018 -0
- brain/doctor.py +1161 -0
- brain/egress.py +148 -0
- brain/embed.py +857 -0
- brain/encryption.py +217 -0
- brain/frontmatter.py +102 -0
- brain/golden_probe.py +678 -0
- brain/graph.py +369 -0
- brain/graphify.py +352 -0
- brain/index.py +1576 -0
- brain/ingest/__init__.py +19 -0
- brain/ingest/handlers/__init__.py +43 -0
- brain/ingest/handlers/base.py +95 -0
- brain/ingest/handlers/docx.py +78 -0
- brain/ingest/handlers/email.py +228 -0
- brain/ingest/handlers/html.py +142 -0
- brain/ingest/handlers/image.py +91 -0
- brain/ingest/handlers/pdf.py +99 -0
- brain/ingest/handlers/pptx.py +69 -0
- brain/ingest/handlers/tables.py +41 -0
- brain/ingest/handlers/text.py +43 -0
- brain/ingest/handlers/xlsx.py +100 -0
- brain/ingest/handlers/zip.py +163 -0
- brain/ingest/pipeline.py +839 -0
- brain/ingest/transcript.py +158 -0
- brain/init.py +870 -0
- brain/maintenance.py +2266 -0
- brain/mcp_adapter.py +217 -0
- brain/multihop.py +232 -0
- brain/notes.py +195 -0
- brain/overlay.py +183 -0
- brain/projection.py +79 -0
- brain/rerank.py +425 -0
- brain/snapshot.py +231 -0
- brain/update.py +743 -0
- brain/vectors.py +225 -0
- brainiac_cli-0.16.0.dist-info/METADATA +306 -0
- brainiac_cli-0.16.0.dist-info/RECORD +77 -0
- brainiac_cli-0.16.0.dist-info/WHEEL +5 -0
- brainiac_cli-0.16.0.dist-info/entry_points.txt +4 -0
- brainiac_cli-0.16.0.dist-info/licenses/LICENSE +202 -0
- 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 · {_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">⚠ 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">⚠ 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">⚠ {_esc(brief.get("tripwire", ""))}</p>'
|
|
298
|
+
elif brief.get("drain_note"):
|
|
299
|
+
pending_html = f'<p class="ok">✓ {_esc(brief["drain_note"])}</p>'
|
|
300
|
+
elif pending == 0:
|
|
301
|
+
pending_html = '<p class="ok">✓ 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'→ <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 · {_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 · {_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 · '
|
|
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)
|