secondbrain-py 0.2.1__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 (273) hide show
  1. brain/__init__.py +0 -0
  2. brain/__main__.py +18 -0
  3. brain/_capture_command.py +445 -0
  4. brain/_compose.py +52 -0
  5. brain/activity.py +206 -0
  6. brain/ask.py +631 -0
  7. brain/audio.py +591 -0
  8. brain/backfill/__init__.py +12 -0
  9. brain/backfill/search_extras.py +141 -0
  10. brain/backfill/source_rows.py +101 -0
  11. brain/bin/__init__.py +1 -0
  12. brain/bin/_launcher.py +107 -0
  13. brain/bin/down.py +8 -0
  14. brain/bin/launchd.py +268 -0
  15. brain/bin/monitor.py +570 -0
  16. brain/bin/rebuild.py +8 -0
  17. brain/bin/status.py +8 -0
  18. brain/bin/up.py +8 -0
  19. brain/brief.py +272 -0
  20. brain/capture.py +49 -0
  21. brain/chat.py +293 -0
  22. brain/cli.py +9760 -0
  23. brain/cli_claude.py +81 -0
  24. brain/cli_connect.py +285 -0
  25. brain/cli_demo.py +266 -0
  26. brain/config.py +1949 -0
  27. brain/connect.py +925 -0
  28. brain/db.py +540 -0
  29. brain/demo/__init__.py +452 -0
  30. brain/demo/corpus/manifest.json +403 -0
  31. brain/demo/embedder.py +74 -0
  32. brain/durations.py +84 -0
  33. brain/edit_session.py +156 -0
  34. brain/editor.py +67 -0
  35. brain/elicit/__init__.py +16 -0
  36. brain/elicit/detectors.py +250 -0
  37. brain/elicit/drafter.py +70 -0
  38. brain/elicit/queue.py +220 -0
  39. brain/elicit/schema.py +48 -0
  40. brain/elicit/session.py +445 -0
  41. brain/embedding_targets.py +54 -0
  42. brain/embeddings.py +424 -0
  43. brain/enrichment.py +808 -0
  44. brain/errors.py +357 -0
  45. brain/eval/__init__.py +129 -0
  46. brain/eval/answer_eval.py +281 -0
  47. brain/eval/baseline.py +265 -0
  48. brain/eval/concept_extraction.py +378 -0
  49. brain/eval/corpus.py +152 -0
  50. brain/eval/errors.py +19 -0
  51. brain/eval/graph_baseline.py +226 -0
  52. brain/eval/graph_retrieval.py +202 -0
  53. brain/eval/graph_runner.py +319 -0
  54. brain/eval/metrics.py +101 -0
  55. brain/eval/runner.py +223 -0
  56. brain/format.py +783 -0
  57. brain/gaps.py +390 -0
  58. brain/graph_rag/__init__.py +94 -0
  59. brain/graph_rag/_retrieval_common.py +113 -0
  60. brain/graph_rag/aggregates.py +303 -0
  61. brain/graph_rag/aliases/__init__.py +583 -0
  62. brain/graph_rag/backends/__init__.py +10 -0
  63. brain/graph_rag/backends/_age_helpers.py +473 -0
  64. brain/graph_rag/backends/age.py +782 -0
  65. brain/graph_rag/backends/base.py +272 -0
  66. brain/graph_rag/build.py +344 -0
  67. brain/graph_rag/communities.py +644 -0
  68. brain/graph_rag/communities_summary.py +437 -0
  69. brain/graph_rag/concepts.py +202 -0
  70. brain/graph_rag/cooccur.py +193 -0
  71. brain/graph_rag/cross_type.py +312 -0
  72. brain/graph_rag/extract.py +885 -0
  73. brain/graph_rag/fuse.py +371 -0
  74. brain/graph_rag/global_.py +412 -0
  75. brain/graph_rag/grouping.py +372 -0
  76. brain/graph_rag/person_resolver.py +167 -0
  77. brain/graph_rag/reconcile.py +792 -0
  78. brain/graph_rag/relational.py +353 -0
  79. brain/graph_rag/retrieve.py +526 -0
  80. brain/graph_rag/router.py +288 -0
  81. brain/graph_rag/schema.py +320 -0
  82. brain/graph_rag/sync.py +237 -0
  83. brain/graph_rag/tenancy.py +43 -0
  84. brain/graph_rag/themes.py +501 -0
  85. brain/graph_rag/weighting.py +202 -0
  86. brain/ingest/__init__.py +1926 -0
  87. brain/ingest/chunker.py +249 -0
  88. brain/ingest/docx.py +40 -0
  89. brain/ingest/gmail.py +621 -0
  90. brain/ingest/markdown.py +37 -0
  91. brain/ingest/pdf.py +61 -0
  92. brain/ingest/stdin.py +22 -0
  93. brain/ingest/sub_tokens.py +91 -0
  94. brain/ingest/text.py +16 -0
  95. brain/interactions.py +205 -0
  96. brain/maintenance.py +355 -0
  97. brain/mcp_server.py +3405 -0
  98. brain/migrations/001_init.sql +43 -0
  99. brain/migrations/002_qwen3_embedding.sql +17 -0
  100. brain/migrations/003_vault_model.sql +41 -0
  101. brain/migrations/004_relax_content_hash_uniqueness.sql +18 -0
  102. brain/migrations/005_derived_links.sql +67 -0
  103. brain/migrations/006_dedup_file_by_source_path.sql +25 -0
  104. brain/migrations/007_email_thread_and_draft.sql +15 -0
  105. brain/migrations/008_gmail_thread_unique.sql +11 -0
  106. brain/migrations/009_chunks_weighted_tsv.sql +28 -0
  107. brain/migrations/010_interactions.sql +30 -0
  108. brain/migrations/011_documents_summary.sql +23 -0
  109. brain/migrations/012_graphrag.sql +171 -0
  110. brain/migrations/013_graphrag_communities.sql +125 -0
  111. brain/migrations/014_graphrag_community_summary_hash.sql +33 -0
  112. brain/migrations/015_interactions_graph_targets.sql +89 -0
  113. brain/migrations/016_index_hygiene.sql +61 -0
  114. brain/migrations/017_elicit.sql +30 -0
  115. brain/migrations/018_review_gap_signal_kinds.sql +40 -0
  116. brain/migrations/019_search_queries.sql +35 -0
  117. brain/migrations/020_link_suggestions.sql +40 -0
  118. brain/migrations/021_timeline_doc_date.sql +34 -0
  119. brain/migrations/022_link_suggestions_undirected.sql +84 -0
  120. brain/migrations/023_search_queries_fts_count.sql +28 -0
  121. brain/quartz_overrides/__init__.py +8 -0
  122. brain/quartz_overrides/quartz/bootstrap-cli.mjs +65 -0
  123. brain/quartz_overrides/quartz/build.ts +568 -0
  124. brain/quartz_overrides/quartz/cli/args.js +152 -0
  125. brain/quartz_overrides/quartz/cli/build_partial_handler.js +544 -0
  126. brain/quartz_overrides/quartz/cli/handlers.js +636 -0
  127. brain/quartz_overrides/quartz/components/CommandPalette.tsx +172 -0
  128. brain/quartz_overrides/quartz/components/Explorer.tsx +198 -0
  129. brain/quartz_overrides/quartz/components/Footer.tsx +27 -0
  130. brain/quartz_overrides/quartz/components/Graph.tsx +468 -0
  131. brain/quartz_overrides/quartz/components/PageTitle.tsx +72 -0
  132. brain/quartz_overrides/quartz/components/RelatedDocs.tsx +38 -0
  133. brain/quartz_overrides/quartz/components/Search.tsx +161 -0
  134. brain/quartz_overrides/quartz/components/SummaryLede.tsx +72 -0
  135. brain/quartz_overrides/quartz/components/index.ts +92 -0
  136. brain/quartz_overrides/quartz/components/pages/TagContent.tsx +272 -0
  137. brain/quartz_overrides/quartz/components/scripts/commandPalette.inline.ts +665 -0
  138. brain/quartz_overrides/quartz/components/scripts/explorer.inline.ts +768 -0
  139. brain/quartz_overrides/quartz/components/scripts/graph.inline.ts +2302 -0
  140. brain/quartz_overrides/quartz/components/scripts/relatedDocs.inline.ts +163 -0
  141. brain/quartz_overrides/quartz/components/scripts/search.inline.ts +1011 -0
  142. brain/quartz_overrides/quartz/plugins/emitters/contentIndex.ts +546 -0
  143. brain/quartz_overrides/quartz/plugins/transformers/codeCopy.ts +94 -0
  144. brain/quartz_overrides/quartz/plugins/transformers/derivedFenceMark.ts +302 -0
  145. brain/quartz_overrides/quartz/plugins/transformers/emailThread.ts +148 -0
  146. brain/quartz_overrides/quartz/plugins/transformers/emptyDoorFilter.ts +213 -0
  147. brain/quartz_overrides/quartz/plugins/transformers/index.ts +114 -0
  148. brain/quartz_overrides/quartz/plugins/transformers/linkKindMark.ts +205 -0
  149. brain/quartz_overrides/quartz/plugins/transformers/linkSourceTag.ts +104 -0
  150. brain/quartz_overrides/quartz/plugins/transformers/relativeDate.ts +100 -0
  151. brain/quartz_overrides/quartz/plugins/transformers/reloadSignal.ts +131 -0
  152. brain/quartz_overrides/quartz/processors/parse.ts +371 -0
  153. brain/quartz_overrides/quartz/processors/parser_cache.ts +78 -0
  154. brain/quartz_overrides/quartz/static/brain-logo-dark.png +0 -0
  155. brain/quartz_overrides/quartz/static/brain-logo-light.png +0 -0
  156. brain/quartz_overrides/quartz/static/codeCopy.js +196 -0
  157. brain/quartz_overrides/quartz/static/emailThread.js +334 -0
  158. brain/quartz_overrides/quartz/static/favicon.ico +0 -0
  159. brain/quartz_overrides/quartz/static/icon.png +0 -0
  160. brain/quartz_overrides/quartz/static/linkSourceTag.js +104 -0
  161. brain/quartz_overrides/quartz/static/relativeDate.js +142 -0
  162. brain/quartz_overrides/quartz/static/reload.js +168 -0
  163. brain/quartz_overrides/quartz/styles/brain/_article.scss +252 -0
  164. brain/quartz_overrides/quartz/styles/brain/_atmosphere.scss +113 -0
  165. brain/quartz_overrides/quartz/styles/brain/_callouts.scss +180 -0
  166. brain/quartz_overrides/quartz/styles/brain/_cmdk.scss +7 -0
  167. brain/quartz_overrides/quartz/styles/brain/_code.scss +208 -0
  168. brain/quartz_overrides/quartz/styles/brain/_command_palette.scss +369 -0
  169. brain/quartz_overrides/quartz/styles/brain/_email_thread.scss +228 -0
  170. brain/quartz_overrides/quartz/styles/brain/_explorer.scss +142 -0
  171. brain/quartz_overrides/quartz/styles/brain/_home.scss +182 -0
  172. brain/quartz_overrides/quartz/styles/brain/_links.scss +322 -0
  173. brain/quartz_overrides/quartz/styles/brain/_marginalia.scss +117 -0
  174. brain/quartz_overrides/quartz/styles/brain/_motion.scss +175 -0
  175. brain/quartz_overrides/quartz/styles/brain/_people_hub.scss +100 -0
  176. brain/quartz_overrides/quartz/styles/brain/_related_docs.scss +137 -0
  177. brain/quartz_overrides/quartz/styles/brain/_search.scss +252 -0
  178. brain/quartz_overrides/quartz/styles/brain/_sidebar.scss +468 -0
  179. brain/quartz_overrides/quartz/styles/brain/_summary_lede.scss +56 -0
  180. brain/quartz_overrides/quartz/styles/brain/_surface.scss +43 -0
  181. brain/quartz_overrides/quartz/styles/brain/_tag_content.scss +118 -0
  182. brain/quartz_overrides/quartz/styles/brain/_tokens.scss +197 -0
  183. brain/quartz_overrides/quartz/styles/brain/_typography.scss +92 -0
  184. brain/quartz_overrides/quartz/styles/custom.scss +89 -0
  185. brain/quartz_overrides/quartz/styles/graph.scss +505 -0
  186. brain/quartz_overrides/quartz/util/ctx.ts +92 -0
  187. brain/quartz_overrides/quartz/util/fastpath_manifest.ts +608 -0
  188. brain/quartz_overrides/quartz/util/path.ts +358 -0
  189. brain/quartz_overrides/quartz/util/sourceIcons.ts +55 -0
  190. brain/quartz_overrides/quartz.config.ts +270 -0
  191. brain/quartz_overrides/quartz.layout.ts +314 -0
  192. brain/queries.py +1188 -0
  193. brain/rank_fusion.py +8 -0
  194. brain/resurface.py +210 -0
  195. brain/review/__init__.py +26 -0
  196. brain/review/emit.py +27 -0
  197. brain/review/queries.py +436 -0
  198. brain/review/render.py +196 -0
  199. brain/review/scans.py +355 -0
  200. brain/review/weekly.py +413 -0
  201. brain/search.py +704 -0
  202. brain/set_similarity.py +15 -0
  203. brain/setup.py +1205 -0
  204. brain/tags.py +56 -0
  205. brain/templates/Caddyfile.j2 +9 -0
  206. brain/templates/__init__.py +1 -0
  207. brain/templates/bin/__init__.py +1 -0
  208. brain/templates/bin/_brain-brief-fg.sh +25 -0
  209. brain/templates/bin/_brain-build-fg.sh +53 -0
  210. brain/templates/bin/_brain-watcher-fg.sh +65 -0
  211. brain/templates/bin/brain-down.sh +89 -0
  212. brain/templates/bin/brain-status.sh +83 -0
  213. brain/templates/bin/brain-up.sh +221 -0
  214. brain/templates/docker/age/Dockerfile +79 -0
  215. brain/templates/docker-compose.stock.yml.j2 +26 -0
  216. brain/templates/docker-compose.yml.j2 +34 -0
  217. brain/templates/env.example +190 -0
  218. brain/templates/launchd/__init__.py +1 -0
  219. brain/templates/launchd/com.brain.brief.plist.j2 +45 -0
  220. brain/templates/launchd/com.brain.build.plist.j2 +46 -0
  221. brain/templates/launchd/com.brain.watcher.plist.j2 +46 -0
  222. brain/templates/skill/SKILL.md +63 -0
  223. brain/templates/skill/__init__.py +1 -0
  224. brain/timeline.py +834 -0
  225. brain/todo.py +124 -0
  226. brain/uninstall.py +185 -0
  227. brain/vault/__init__.py +115 -0
  228. brain/vault/_atomic.py +25 -0
  229. brain/vault/daily_index.py +228 -0
  230. brain/vault/derived_links/__init__.py +50 -0
  231. brain/vault/derived_links/directory.py +683 -0
  232. brain/vault/derived_links/fence.py +408 -0
  233. brain/vault/derived_links/gws.py +64 -0
  234. brain/vault/derived_links/participants.py +143 -0
  235. brain/vault/derived_links/pass_runner.py +362 -0
  236. brain/vault/derived_links/rules.py +137 -0
  237. brain/vault/export.py +683 -0
  238. brain/vault/frontmatter.py +165 -0
  239. brain/vault/graph.py +620 -0
  240. brain/vault/graph_format.py +388 -0
  241. brain/vault/link_rewrite.py +235 -0
  242. brain/vault/links.py +260 -0
  243. brain/vault/note_builder.py +211 -0
  244. brain/vault/paths.py +55 -0
  245. brain/vault/quartz_overlay.py +236 -0
  246. brain/vault/rename.py +591 -0
  247. brain/vault/resolver.py +304 -0
  248. brain/vault/slug.py +127 -0
  249. brain/vault/sync.py +1513 -0
  250. brain/vault/sync_summaries.py +264 -0
  251. brain/vault/templates.py +145 -0
  252. brain/vault/watch.py +1052 -0
  253. brain/wiki/__init__.py +6 -0
  254. brain/wiki/_github_slugger.py +76 -0
  255. brain/wiki/_person_name.py +314 -0
  256. brain/wiki/build_homepage.py +541 -0
  257. brain/wiki/build_partial.py +273 -0
  258. brain/wiki/build_people.py +934 -0
  259. brain/wiki/build_related.py +758 -0
  260. brain/wiki/build_swap.py +585 -0
  261. brain/wiki/build_watcher.py +975 -0
  262. brain/wiki/edit_classifier.py +215 -0
  263. brain/wiki/errors.py +10 -0
  264. brain/wiki/fastpath_manifest.py +475 -0
  265. brain/wiki/fastpath_state.py +174 -0
  266. brain/wiki/install.py +296 -0
  267. brain/wiki/slug.py +111 -0
  268. secondbrain_py-0.2.1.dist-info/METADATA +195 -0
  269. secondbrain_py-0.2.1.dist-info/RECORD +273 -0
  270. secondbrain_py-0.2.1.dist-info/WHEEL +5 -0
  271. secondbrain_py-0.2.1.dist-info/entry_points.txt +11 -0
  272. secondbrain_py-0.2.1.dist-info/licenses/LICENSE +21 -0
  273. secondbrain_py-0.2.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,934 @@
1
+ """Aggregate per-person doc rosters and emit the People Hub wiki pages.
2
+
3
+ Two layers, side-by-side in this module so the page renderer can never
4
+ drift from the data shape it's rendering.
5
+
6
+ Phase A — aggregation (pure read, no I/O):
7
+ :func:`aggregate_people` SELECTs from ``directory_entries`` and
8
+ ``documents`` and returns a sorted list of :class:`PersonRecord`,
9
+ one per emittable page.
10
+
11
+ Phase B — page emission (pure rendering + atomic-per-file writeback):
12
+ :func:`render_person_md` and :func:`render_index_md` are pure
13
+ string producers — same input always yields byte-identical output,
14
+ which is what makes :func:`emit_people_pages` idempotent.
15
+ :func:`emit_people_pages` is the I/O-side orchestrator: it
16
+ aggregates, computes the target file set, deletes any
17
+ ``<vault>/people/*.md`` no longer in the set, and writes each
18
+ page atomically through :func:`brain.vault._atomic.atomic_write_text`.
19
+
20
+ The Phase A aggregation mirrors the participant-key derivation used
21
+ by the metadata linker (see ``brain.vault.derived_links.pass_runner
22
+ ._build_snapshot``) so a person's roster on the hub is consistent
23
+ with the derived edges that link their docs to each other. The only
24
+ intentional divergence: this module does not bridge name↔email at
25
+ extraction time. Both keys are emitted per doc, and
26
+ ``directory_entries`` is consulted at *resolution* time to map each
27
+ key to a canonical display_name. The end result is identical because
28
+ every name and email a key could resolve to also lives in
29
+ ``directory_entries``.
30
+ """
31
+ import logging
32
+ from dataclasses import dataclass, field
33
+ from datetime import datetime
34
+ from email.utils import parsedate_to_datetime
35
+ from pathlib import Path
36
+ from typing import Any
37
+
38
+ import psycopg
39
+
40
+ from brain.vault._atomic import atomic_write_text
41
+ from brain.vault.derived_links.directory import _score_directory_rows
42
+ from brain.vault.derived_links.participants import extract_gmail_addresses
43
+ from brain.vault.frontmatter import dump_frontmatter
44
+ from brain.vault.paths import safe_wikilink_alias, strip_md_extension
45
+ from brain.vault.slug import slugify
46
+ from brain.wiki._person_name import (
47
+ expand_owner_keys,
48
+ humanize_person_name,
49
+ is_automated_sender,
50
+ normalize_person_name,
51
+ )
52
+
53
+ logger = logging.getLogger(__name__)
54
+
55
+ __all__ = [
56
+ "DocRef",
57
+ "EmitReport",
58
+ "PersonRecord",
59
+ "aggregate_people",
60
+ "emit_people_pages",
61
+ "humanize_display_name",
62
+ "render_index_md",
63
+ "render_person_md",
64
+ ]
65
+
66
+
67
+ @dataclass
68
+ class DocRef:
69
+ """One document associated with a person — used to render their roster.
70
+
71
+ ``vault_target`` is the doc's ``vault_path`` in POSIX form with the
72
+ trailing ``.md`` stripped (e.g.
73
+ ``_ingested/krisp/2026-05-06-3508c63e-ai-cos-jam-session``). Phase B
74
+ feeds this into a wiki-link of the form ``[[<vault_target>|<title>]]``
75
+ so :func:`brain.vault.link_rewrite.rewrite_wiki_links` resolves it
76
+ in one shot via ``_resolve_by_vault_path`` (matching the recent-rail
77
+ homepage precedent in :func:`brain.wiki.build_homepage._render_bullets`).
78
+
79
+ ``None`` for docs that have not been mirrored to the vault yet (rare
80
+ — should resolve on the next ``brain vault export`` / sync).
81
+ """
82
+
83
+ document_id: str
84
+ title: str
85
+ source_kind: str
86
+ date: datetime | None
87
+ vault_target: str | None
88
+
89
+
90
+ @dataclass
91
+ class PersonRecord:
92
+ """Aggregated roster for one person, ready for page emission.
93
+
94
+ ``display_name`` is the lowercased / normalized form stored in
95
+ ``directory_entries`` (see :func:`brain.vault.derived_links.participants
96
+ .normalize_participant`). Phase B's renderer is responsible for turning
97
+ it into a presentable form (e.g. title-casing) — keeping the canonical
98
+ matching key here means the data layer round-trips losslessly.
99
+ """
100
+
101
+ slug: str
102
+ display_name: str
103
+ primary_email: str
104
+ all_emails: list[str]
105
+ docs: list[DocRef]
106
+ in_people_yml: bool
107
+
108
+
109
+ # ---- Internal helpers -------------------------------------------------------
110
+
111
+
112
+ @dataclass
113
+ class _DirectoryIndex:
114
+ """Pre-computed views over ``directory_entries`` used during aggregation.
115
+
116
+ Built once per :func:`aggregate_people` call (one SELECT) so per-doc
117
+ resolution is dictionary lookups rather than repeated SQL.
118
+
119
+ Every view is keyed on the **canonical key** produced by
120
+ :func:`brain.wiki._person_name.normalize_person_name` — the lowercase,
121
+ separator-collapsed merge identity. Two directory rows whose raw
122
+ ``display_name`` differs only by separators / mailing-list decoration /
123
+ ``Last, First`` ordering (``Jane.Doe`` vs ``Jane Doe``) therefore collapse
124
+ to one canonical person here, merging their emails and curated flags.
125
+ Automated / org senders (no-reply, notifications, …) are dropped at build
126
+ time so they never become a person.
127
+ """
128
+
129
+ # Set of every canonical key with at least one surviving (name, email)
130
+ # pair. Empty-name rows (``display_name=''``) and automated senders are
131
+ # excluded.
132
+ known_keys: set[str]
133
+
134
+ # Per canonical key, the email with the highest sum-of-occurrences across
135
+ # all sources / merged raw names. ``people_yml`` rows win unconditionally
136
+ # (mirrors the precedence in :meth:`DirectoryStore.resolve_name_to_email`).
137
+ primary_email_by_key: dict[str, str]
138
+
139
+ # Per canonical key, the sorted distinct list of every email seen across
140
+ # sources / merged raw names.
141
+ emails_by_key: dict[str, list[str]]
142
+
143
+ # Reverse index: email → canonical key. Used to resolve email participant
144
+ # keys to a person. ``people_yml`` wins; otherwise the canonical key with
145
+ # the highest summed occurrence_count for that email.
146
+ canonical_key_by_email: dict[str, str]
147
+
148
+ # Per canonical key: True iff at least one underlying ``directory_entries``
149
+ # row has ``source='people_yml'``. Drives the curated badge on the page and
150
+ # the always-emit override below the doc-count threshold.
151
+ in_people_yml_by_key: dict[str, bool]
152
+
153
+
154
+ def _build_directory_index(
155
+ conn: psycopg.Connection[Any],
156
+ *,
157
+ sender_denylist: frozenset[str] = frozenset(),
158
+ ) -> _DirectoryIndex:
159
+ """Pull ``directory_entries`` once and project the canonical-key indexes the
160
+ resolver needs. See :class:`_DirectoryIndex` for what each index represents.
161
+
162
+ Each raw ``display_name`` is run through
163
+ :func:`brain.wiki._person_name.normalize_person_name` to derive its
164
+ canonical key, and each ``(display_name, email)`` row is dropped when
165
+ :func:`brain.wiki._person_name.is_automated_sender` flags it as a non-human
166
+ / org sender. ``sender_denylist`` (``BRAIN_GRAPH_SENDER_DENYLIST``) adds
167
+ corpus-specific entries to the always-on generic heuristic.
168
+ """
169
+ rows = conn.execute(
170
+ "SELECT display_name, email, source, occurrence_count "
171
+ "FROM directory_entries "
172
+ "WHERE display_name <> ''"
173
+ ).fetchall()
174
+
175
+ # (canonical_key, email) → summed count across sources / merged raw names,
176
+ # plus a flag tracking whether any underlying row is people_yml.
177
+ counts: dict[tuple[str, str], int] = {}
178
+ is_people_yml: dict[tuple[str, str], bool] = {}
179
+
180
+ for name, email, source, count in rows:
181
+ # Drop automated / non-human senders (no-reply, notifications, mailer,
182
+ # …) before they can ever become a person. Matches the email's local
183
+ # part only — the directory ``display_name`` is normalized separately.
184
+ if is_automated_sender(str(email), denylist=sender_denylist):
185
+ continue
186
+ normalized = normalize_person_name(str(name))
187
+ if normalized is None:
188
+ continue
189
+ key = (normalized.canonical_key, str(email))
190
+ counts[key] = counts.get(key, 0) + int(count)
191
+ if source == "people_yml":
192
+ is_people_yml[key] = True
193
+
194
+ known_keys: set[str] = set()
195
+ key_emails: dict[str, list[tuple[str, int, bool]]] = {}
196
+ email_keys: dict[str, list[tuple[str, int, bool]]] = {}
197
+
198
+ for (canonical_key, email), total in counts.items():
199
+ people_yml = is_people_yml.get((canonical_key, email), False)
200
+ known_keys.add(canonical_key)
201
+ key_emails.setdefault(canonical_key, []).append((email, total, people_yml))
202
+ email_keys.setdefault(email, []).append((canonical_key, total, people_yml))
203
+
204
+ primary_email_by_key: dict[str, str] = {}
205
+ emails_by_key: dict[str, list[str]] = {}
206
+ in_people_yml_by_key: dict[str, bool] = {}
207
+
208
+ # Both per-key primary-email picks and per-email canonical-key picks share
209
+ # the same precedence rules as ``DirectoryStore.resolve_name_to_email``
210
+ # (people_yml wins → highest summed count → alpha tiebreak); reuse that
211
+ # module's helper so the rules cannot drift. ``skip_ambiguous=False``
212
+ # because every person needs *some* primary email and every email needs
213
+ # *some* canonical key — alpha tiebreak is the deterministic fallback.
214
+ for canonical_key, items in key_emails.items():
215
+ emails_by_key[canonical_key] = sorted({email for email, _, _ in items})
216
+ in_people_yml_by_key[canonical_key] = any(p for _, _, p in items)
217
+ winner = _score_directory_rows(items, skip_ambiguous=False)
218
+ # ``items`` is non-empty by construction (the outer loop only adds a
219
+ # key when at least one (key, email) row exists); ``_score_directory_rows``
220
+ # therefore returns a non-None winner. ``assert`` keeps mypy honest.
221
+ assert winner is not None
222
+ primary_email_by_key[canonical_key] = winner
223
+
224
+ canonical_key_by_email: dict[str, str] = {}
225
+ for email, candidates in email_keys.items():
226
+ winner = _score_directory_rows(candidates, skip_ambiguous=False)
227
+ assert winner is not None
228
+ canonical_key_by_email[email] = winner
229
+
230
+ return _DirectoryIndex(
231
+ known_keys=known_keys,
232
+ primary_email_by_key=primary_email_by_key,
233
+ emails_by_key=emails_by_key,
234
+ canonical_key_by_email=canonical_key_by_email,
235
+ in_people_yml_by_key=in_people_yml_by_key,
236
+ )
237
+
238
+
239
+ def _doc_participant_keys(
240
+ *, source_kind: str, metadata: dict[str, Any]
241
+ ) -> set[str]:
242
+ """Extract the raw participant key set for one doc.
243
+
244
+ Mirrors :func:`brain.vault.derived_links.pass_runner._build_snapshot`'s
245
+ extraction step but emits *both* the email and the display name (when
246
+ present) for each Gmail header pair, leaving directory bridging to the
247
+ resolution step. Result is identical to the pass-runner's output once
248
+ resolution runs.
249
+
250
+ For Krisp, reads ``metadata['_participant_keys']`` (a sorted list set at
251
+ ingest time by :func:`extract_krisp_speakers`). Strings are returned
252
+ as-is; bridging from name to email happens during resolution.
253
+ """
254
+ keys: set[str] = set()
255
+ if source_kind == "gmail":
256
+ for display, email in extract_gmail_addresses(metadata):
257
+ keys.add(email)
258
+ if display:
259
+ keys.add(display)
260
+ elif source_kind == "krisp":
261
+ raw = metadata.get("_participant_keys")
262
+ if isinstance(raw, list):
263
+ for entry in raw:
264
+ if isinstance(entry, str) and entry.strip():
265
+ keys.add(entry.strip())
266
+ return keys
267
+
268
+
269
+ def _resolve_key_to_person(
270
+ key: str,
271
+ *,
272
+ directory: _DirectoryIndex,
273
+ ) -> str | None:
274
+ """Map a participant key to a canonical key, or None.
275
+
276
+ ``key`` is either an email (``fixture@example.com``) or a raw display name.
277
+ Emails resolve via ``canonical_key_by_email``; an email with no directory
278
+ match returns ``None`` (long-tail one-off senders are dropped, not
279
+ surfaced). Names are normalized via
280
+ :func:`brain.wiki._person_name.normalize_person_name` and resolve only when
281
+ their canonical key appears in ``known_keys`` — so a handle-style key like
282
+ ``Jane.Doe`` matches the directory's ``jane doe`` entry, while a one-off
283
+ Krisp speaker label with no directory entry is silently dropped.
284
+ """
285
+ stripped = key.strip()
286
+ if "@" in stripped:
287
+ return directory.canonical_key_by_email.get(stripped.lower())
288
+ normalized = normalize_person_name(stripped)
289
+ if normalized is None:
290
+ return None
291
+ if normalized.canonical_key in directory.known_keys:
292
+ return normalized.canonical_key
293
+ return None
294
+
295
+
296
+ def _doc_date(
297
+ *,
298
+ source_kind: str,
299
+ metadata: dict[str, Any],
300
+ sent_at: datetime | None,
301
+ ) -> datetime | None:
302
+ """Pick the best timestamp for a doc. ``sent_at`` (Gmail typed column)
303
+ wins; otherwise parse ``metadata['date']`` per source convention.
304
+
305
+ Returns ``None`` for docs we can't date — the renderer sorts these last.
306
+ """
307
+ if sent_at is not None:
308
+ return sent_at
309
+ raw = metadata.get("date")
310
+ if not isinstance(raw, str):
311
+ return None
312
+ text = raw.strip()
313
+ if not text:
314
+ return None
315
+ if source_kind == "krisp":
316
+ # Krisp ingest passes ISO date strings (YYYY-MM-DD or YYYY-MM-DDTHH:MM).
317
+ # Slice the first 10 chars so the strict ``date.fromisoformat`` grammar
318
+ # still accepts the time-suffix variant.
319
+ try:
320
+ return datetime.fromisoformat(text[:10])
321
+ except ValueError:
322
+ logger.debug("krisp date parse failed: %r", text)
323
+ return None
324
+ # Gmail (and any RFC-5322 source).
325
+ try:
326
+ parsed = parsedate_to_datetime(text)
327
+ except (TypeError, ValueError):
328
+ logger.debug("gmail date parse failed: %r", text)
329
+ return None
330
+ return parsed
331
+
332
+
333
+ def _vault_target(vault_path: str | None) -> str | None:
334
+ """Project ``vault_path`` to the inner of a wiki-link, sans ``.md``.
335
+
336
+ Phase B feeds the result into ``[[<vault_target>|<title>]]``. The full
337
+ POSIX-form path is preserved (rather than just the basename) so
338
+ :func:`brain.vault.resolver._resolve_by_vault_path` matches the link
339
+ against ``documents.vault_path`` during ``brain vault sync`` —
340
+ bare-basename inner text would only resolve in Quartz, leaving every
341
+ person-page wiki-link recorded in ``unresolved_links`` on the brain
342
+ side. Mirrors the canonical wiki-link target shape emitted by
343
+ :func:`brain.wiki.build_homepage._render_bullets` for the recent rail.
344
+
345
+ Delegates the ``.md``-stripping + POSIX normalization to
346
+ :func:`brain.vault.paths.strip_md_extension` so the three vault
347
+ renderers (homepage, people, daily-index) cannot drift on the
348
+ canonical link-target shape.
349
+
350
+ Returns ``None`` for docs without a ``vault_path`` (rare — Krisp/Gmail
351
+ rows that haven't been mirrored to the vault yet). The renderer
352
+ surfaces those as plain-text titles with no wiki-link.
353
+ """
354
+ if not vault_path:
355
+ return None
356
+ return strip_md_extension(vault_path) or None
357
+
358
+
359
+ def _sort_docs(docs: list[DocRef]) -> list[DocRef]:
360
+ """Sort by date desc, then title asc. Docs with ``date=None`` sort last."""
361
+ def key(d: DocRef) -> tuple[int, float, str]:
362
+ if d.date is None:
363
+ return (1, 0.0, d.title)
364
+ # Negate timestamp so ``sorted`` (asc) yields date-desc.
365
+ return (0, -d.date.timestamp(), d.title)
366
+
367
+ return sorted(docs, key=key)
368
+
369
+
370
+ def _assign_slugs(records: list[PersonRecord]) -> list[PersonRecord]:
371
+ """Resolve slug collisions in a deterministic alpha-by-display_name pass.
372
+
373
+ First person at a given base slug keeps it; subsequent collisions get
374
+ ``-2``, ``-3``, … per the plan. Mutates each record's ``slug`` in place
375
+ and returns the same list for caller convenience.
376
+ """
377
+ counts: dict[str, int] = {}
378
+ for rec in sorted(records, key=lambda r: r.display_name):
379
+ base = slugify(rec.display_name)
380
+ used = counts.get(base, 0)
381
+ rec.slug = base if used == 0 else f"{base}-{used + 1}"
382
+ counts[base] = used + 1
383
+ return records
384
+
385
+
386
+ # ---- Public API -------------------------------------------------------------
387
+
388
+
389
+ def aggregate_people(
390
+ conn: psycopg.Connection[Any],
391
+ *,
392
+ owner_keys: frozenset[str],
393
+ min_docs: int,
394
+ sender_denylist: frozenset[str] = frozenset(),
395
+ ) -> list[PersonRecord]:
396
+ """Aggregate per-person doc rosters from ``directory_entries`` + ``documents``.
397
+
398
+ The returned list is sorted alphabetically by ``display_name`` (the
399
+ lowercase canonical key) and ready for page emission (slugs assigned,
400
+ collisions resolved).
401
+
402
+ Arguments:
403
+ conn: Live Postgres connection. Read-only — no writes performed.
404
+ owner_keys: Identifiers (emails AND/OR display names) that count as the
405
+ corpus owner. Expanded via
406
+ :func:`brain.wiki._person_name.expand_owner_keys` to also cover
407
+ first-name-only and email-local-part variants, then stripped from
408
+ every doc's participant key set so the owner doesn't appear in
409
+ *every* doc list. Persons whose canonical identity (canonical key
410
+ OR primary_email) sits inside the expanded set are dropped from the
411
+ result — same semantics as the derived-link owner filter in
412
+ :func:`brain.vault.derived_links.pass_runner._build_snapshot`.
413
+ min_docs: Threshold for non-curated emission. Persons with strictly
414
+ fewer than ``min_docs`` docs and no ``_people.yml`` entry are
415
+ dropped. Curated persons (``in_people_yml=True``) always emit
416
+ regardless. Required positional — the default lives one layer up
417
+ in :class:`brain.config.Config` (Phase C).
418
+ sender_denylist: Extra ``BRAIN_GRAPH_SENDER_DENYLIST`` substrings /
419
+ addresses forwarded to the automated-sender filter on top of the
420
+ always-on generic heuristic. Default empty.
421
+
422
+ Raises:
423
+ ValueError: ``min_docs`` is negative.
424
+ """
425
+ if min_docs < 0:
426
+ raise ValueError(f"min_docs must be >= 0 (got {min_docs!r})")
427
+
428
+ # Expand the owner set with first-name-only / email-local-part variants so
429
+ # the owner can never leak in as a person under a partial form. The result
430
+ # is already lowercased, so downstream comparisons normalize the needle.
431
+ owner_keys = expand_owner_keys(owner_keys)
432
+
433
+ directory = _build_directory_index(conn, sender_denylist=sender_denylist)
434
+
435
+ # Pull every gmail/krisp document. Drafts (``draft=TRUE``) are excluded
436
+ # — they're already filtered from the rendered wiki, so a People Hub page
437
+ # listing a draft would surface a doc the user doesn't see anywhere else.
438
+ rows = conn.execute(
439
+ """
440
+ SELECT d.id::text, d.title, s.kind, d.metadata, d.vault_path, d.sent_at
441
+ FROM documents d
442
+ JOIN sources s ON s.id = d.source_id
443
+ WHERE s.kind IN ('gmail', 'krisp')
444
+ AND d.draft = FALSE
445
+ """
446
+ ).fetchall()
447
+
448
+ # canonical_key → list of DocRefs (deduped within a doc — multiple keys for
449
+ # the same doc resolving to one person count once).
450
+ person_docs: dict[str, list[DocRef]] = {}
451
+
452
+ for doc_id, title, source_kind, metadata, vault_path, sent_at in rows:
453
+ meta: dict[str, Any] = dict(metadata) if metadata else {}
454
+ keys = _doc_participant_keys(source_kind=source_kind, metadata=meta)
455
+ # Strip owner keys before resolution so the owner contributes nothing
456
+ # to anyone's roster — including their own (no /people/pat-owner).
457
+ keys = {k for k in keys if k.strip().lower() not in owner_keys}
458
+
459
+ persons_for_doc: set[str] = set()
460
+ for key in keys:
461
+ person = _resolve_key_to_person(key, directory=directory)
462
+ # Belt-and-suspenders: a key that survived the raw strip but
463
+ # resolves to an owner canonical (e.g. a first-name-only leak) is
464
+ # dropped here too.
465
+ if person is not None and person not in owner_keys:
466
+ persons_for_doc.add(person)
467
+
468
+ if not persons_for_doc:
469
+ continue
470
+
471
+ date = _doc_date(source_kind=source_kind, metadata=meta, sent_at=sent_at)
472
+ ref = DocRef(
473
+ document_id=doc_id,
474
+ title=title,
475
+ source_kind=source_kind,
476
+ date=date,
477
+ vault_target=_vault_target(vault_path),
478
+ )
479
+ for person in persons_for_doc:
480
+ person_docs.setdefault(person, []).append(ref)
481
+
482
+ # Build records for every canonical key in directory_entries (so curated
483
+ # 0-doc entries still emit). Skip owner-identified keys entirely — strict
484
+ # interpretation per Phase A.2: a person whose primary identifier is an
485
+ # owner key never gets a page.
486
+ records: list[PersonRecord] = []
487
+ for canonical_key in directory.known_keys:
488
+ if canonical_key in owner_keys:
489
+ continue
490
+ primary = directory.primary_email_by_key[canonical_key]
491
+ if primary.lower() in owner_keys:
492
+ # ``primary_email`` is always a member of ``all_emails`` (it's
493
+ # picked from that set), so this single check is equivalent to
494
+ # the "every email is an owner key" rule when the user has
495
+ # listed an alias as an owner. No need for a second pass over
496
+ # ``all_emails``.
497
+ continue
498
+ all_emails = directory.emails_by_key[canonical_key]
499
+ in_yml = directory.in_people_yml_by_key.get(canonical_key, False)
500
+ docs = _sort_docs(person_docs.get(canonical_key, []))
501
+ if not in_yml and len(docs) < min_docs:
502
+ continue
503
+ records.append(
504
+ PersonRecord(
505
+ slug="", # filled in by _assign_slugs
506
+ display_name=canonical_key,
507
+ primary_email=primary,
508
+ all_emails=all_emails,
509
+ docs=docs,
510
+ in_people_yml=in_yml,
511
+ )
512
+ )
513
+
514
+ _assign_slugs(records)
515
+ records.sort(key=lambda r: r.display_name)
516
+ return records
517
+
518
+
519
+ # ---- Phase B — page rendering -----------------------------------------------
520
+
521
+
522
+ # Frontmatter fields emitted on every people page. ``kind: people`` is the
523
+ # marker Phase C's Quartz overlay uses for distinct styling. ``slug`` lets
524
+ # Quartz resolve the canonical URL even when filename collisions force a
525
+ # numeric suffix on the on-disk path.
526
+ _PERSON_KIND_FRONTMATTER: str = "people"
527
+
528
+ # Frontmatter ``kind`` for the index page itself. Distinct from ``people``
529
+ # so a future Quartz override can style "directory of directories" rows
530
+ # differently from individual roster pages.
531
+ _PEOPLE_INDEX_KIND_FRONTMATTER: str = "people-index"
532
+
533
+ # Per-page directory under the vault. Kept as a module-level constant so
534
+ # the emitter, the cleanup pass, and tests refer to the same name.
535
+ _PEOPLE_DIR_NAME: str = "people"
536
+
537
+ # Plain-text placeholder for curated persons with zero matched documents.
538
+ # Phase A allows this case (a ``_people.yml`` entry the user added before
539
+ # any Krisp/Gmail data accumulated) — the page must still render so the
540
+ # user sees their entry made it into the directory.
541
+ _NO_DOCS_PLACEHOLDER: str = "*No documents yet.*"
542
+
543
+
544
+ def humanize_display_name(display_name: str) -> str:
545
+ """Re-cap the lowercased canonical name for use in headings + frontmatter.
546
+
547
+ PersonRecord stores names in the normalized lowercase form
548
+ (``"person-person-luke"``) so the matching layer can compare cleanly.
549
+ The page heading wants the human form (``"person-person-luke"``).
550
+ ``str.title()`` is the standard multi-word capitalizer; rare
551
+ apostrophe / camel-case names ("d'Arcy", "person-person-marc") are slightly
552
+ mangled to "D'Arcy" / "person-person-marc" — acceptable trade-off, given the
553
+ directory layer normalized the input in the first place. The user
554
+ can fix any specific name by adjusting their ``_people.yml`` if it
555
+ matters for their corpus.
556
+
557
+ Public so the ``brain people`` CLI (Phase C) can render the same
558
+ title-cased form in its terminal output without re-implementing
559
+ the rule. Keep the internal alias below for backwards compat
560
+ inside this module — every call site already routes through it.
561
+
562
+ Delegates to :func:`brain.wiki._person_name.humanize_person_name` so the
563
+ People Hub, the graph reconcile resolver, and the CLI share one
564
+ presentation transform.
565
+ """
566
+ return humanize_person_name(display_name)
567
+
568
+
569
+ # Internal alias — every existing call site reads from this name. Public
570
+ # helper above is the supported import surface; this private alias is
571
+ # preserved to keep the module's render functions readable.
572
+ _humanize_display_name = humanize_display_name
573
+
574
+
575
+ def _render_doc_line(doc: DocRef) -> str:
576
+ """Render one document line of a person's roster.
577
+
578
+ Format: ``### YYYY-MM-DD · [[<vault_target>|<safe_title>]] (<source_kind>)``.
579
+
580
+ H3 (rather than a bullet) so each document becomes its own anchor
581
+ in Quartz's table-of-contents — the user can jump to a specific
582
+ doc on a person's page from the right rail. Date defaults to
583
+ ``"undated"`` when ``DocRef.date is None`` (the renderer never
584
+ fabricates a date — a missing one is information).
585
+
586
+ When ``vault_target`` is ``None`` (doc not yet mirrored to the
587
+ vault), emit the title as plain text rather than a link — the
588
+ next sync that exports the doc will populate ``vault_path`` and
589
+ the next refresh cycle will turn it into a link.
590
+ """
591
+ date_str = doc.date.strftime("%Y-%m-%d") if doc.date is not None else "undated"
592
+ safe_title = safe_wikilink_alias(doc.title)
593
+ link_text = (
594
+ f"[[{doc.vault_target}|{safe_title}]]" if doc.vault_target else safe_title
595
+ )
596
+ return f"### {date_str} · {link_text} ({doc.source_kind})"
597
+
598
+
599
+ def _render_person_body(record: PersonRecord, *, title: str) -> str:
600
+ """Render the body of a single person page (everything below frontmatter).
601
+
602
+ Layout, in order:
603
+ - ``# <title>`` (H1) — the human-readable name.
604
+ - Primary email as an ``mailto:`` link.
605
+ - "Other emails:" — comma-separated list of every email in
606
+ ``all_emails`` that isn't the primary. Omitted when the person
607
+ has only one email.
608
+ - ``## Documents (N)`` (H2) — count is the doc total.
609
+ - One ``### YYYY-MM-DD · [[…|…]] (kind)`` line per document, in
610
+ the order Phase A's :func:`_sort_docs` produced (date desc,
611
+ then title asc).
612
+ - When ``record.docs`` is empty, the placeholder
613
+ :data:`_NO_DOCS_PLACEHOLDER` line.
614
+
615
+ The body always ends with a trailing newline so
616
+ :func:`brain.vault.frontmatter.dump_frontmatter` round-trips
617
+ cleanly through the parser. Same input always produces byte-
618
+ identical output (idempotency contract).
619
+ """
620
+ lines: list[str] = [f"# {title}", ""]
621
+ primary = record.primary_email
622
+ lines.append(f"**Primary email:** [{primary}](mailto:{primary})")
623
+ others = [email for email in record.all_emails if email != primary]
624
+ if others:
625
+ lines.append(f"**Other emails:** {', '.join(others)}")
626
+ lines.extend(["", f"## Documents ({len(record.docs)})", ""])
627
+ if not record.docs:
628
+ lines.append(_NO_DOCS_PLACEHOLDER)
629
+ else:
630
+ lines.extend(_render_doc_line(doc) for doc in record.docs)
631
+ lines.append("") # ensure trailing newline
632
+ return "\n".join(lines)
633
+
634
+
635
+ def render_person_md(record: PersonRecord) -> str:
636
+ """Render one ``<vault>/people/<slug>.md`` page as a Markdown string.
637
+
638
+ Output shape (frontmatter + body):
639
+
640
+ .. code-block:: markdown
641
+
642
+ ---
643
+ title: person-person-luke
644
+ slug: person-person-luke
645
+ kind: people
646
+ emails:
647
+ - person-person-luke@example.com
648
+ - person-person-luke-alt@example.com
649
+ doc_count: 12
650
+ in_people_yml: true
651
+ ---
652
+
653
+ # person-person-luke
654
+
655
+ **Primary email:** [person-person-luke@example.com](mailto:person-person-luke@example.com)
656
+ **Other emails:** person-person-luke-alt@example.com
657
+
658
+ ## Documents (12)
659
+
660
+ ### 2026-05-06 · [[_ingested/krisp/…|AI CoS Jam Session]] (krisp)
661
+ ### 2026-04-29 · [[_ingested/gmail/…|Fwd: April Hiring Thread]] (gmail)
662
+
663
+
664
+ Frontmatter notes:
665
+ - ``title`` is the title-cased rebuild of ``display_name``.
666
+ - ``slug`` mirrors ``record.slug`` — the canonical URL component,
667
+ used by the index page's wiki-links and consumed by the Phase C
668
+ Quartz overlay.
669
+ - ``kind: people`` is the page-class marker the overlay uses.
670
+ - ``emails`` is the alphabetized list from
671
+ ``record.all_emails`` (already sorted by Phase A).
672
+ - ``doc_count`` matches ``len(record.docs)`` so the heading
673
+ ``## Documents (N)`` and the frontmatter never disagree.
674
+ - ``in_people_yml`` drives the curated-badge rendering.
675
+
676
+ Pure: no I/O, no DB. Stable across runs given the same record —
677
+ that's the property :func:`emit_people_pages` relies on for its
678
+ "skip if byte-identical" idempotency gate.
679
+ """
680
+ title = _humanize_display_name(record.display_name)
681
+ fields: dict[str, Any] = {
682
+ "title": title,
683
+ "slug": record.slug,
684
+ "kind": _PERSON_KIND_FRONTMATTER,
685
+ "emails": list(record.all_emails),
686
+ "doc_count": len(record.docs),
687
+ "in_people_yml": record.in_people_yml,
688
+ }
689
+ body = _render_person_body(record, title=title)
690
+ return dump_frontmatter(fields, body)
691
+
692
+
693
+ def render_index_md(records: list[PersonRecord]) -> str:
694
+ """Render the ``<vault>/people/index.md`` roster page.
695
+
696
+ Alphabetized list of every person with a page. The plan calls for
697
+ a Markdown table; we emit a bullet list instead because Markdown
698
+ tables and Obsidian wiki-link aliases collide on the ``|``
699
+ separator (``[[X|Y]]`` inside a cell breaks the table parser, and
700
+ ``\\|`` escaping breaks the wiki-link parser). A bullet list
701
+ preserves every signal the table would have carried — name,
702
+ doc count, primary email, curated indicator — without the
703
+ syntactic conflict, and matches the recent-rail bullets in
704
+ :func:`brain.wiki.build_homepage._render_bullets`.
705
+
706
+ Each line is shaped:
707
+
708
+ .. code-block:: markdown
709
+
710
+ - [✅ ] [[people/<slug>|<Display Name>]] — N docs · email@x.com
711
+
712
+ The leading ``✅`` (always followed by a space, even when blank,
713
+ so column alignment is byte-stable across lines) flags
714
+ :attr:`PersonRecord.in_people_yml=True` rows. Curated entries
715
+ with zero docs render as ``— 0 docs · …`` rather than being hidden.
716
+
717
+ Pure: no I/O. Returns the empty-state placeholder body when
718
+ ``records`` is empty so the index page always renders.
719
+ """
720
+ fields: dict[str, Any] = {
721
+ "title": "People",
722
+ "slug": "people",
723
+ "kind": _PEOPLE_INDEX_KIND_FRONTMATTER,
724
+ }
725
+ sorted_records = sorted(records, key=lambda r: r.display_name)
726
+ lines: list[str] = ["# People", ""]
727
+ if not sorted_records:
728
+ lines.append("*No people yet — add entries to `_people.yml` "
729
+ "or wait for the directory threshold to fill in.*")
730
+ else:
731
+ for rec in sorted_records:
732
+ display = _humanize_display_name(rec.display_name)
733
+ badge = "✅ " if rec.in_people_yml else ""
734
+ lines.append(
735
+ f"- {badge}[[people/{rec.slug}|{display}]] — "
736
+ f"{len(rec.docs)} docs · {rec.primary_email}"
737
+ )
738
+ lines.append("") # ensure trailing newline
739
+ body = "\n".join(lines)
740
+ return dump_frontmatter(fields, body)
741
+
742
+
743
+ # ---- Phase B — page emission ------------------------------------------------
744
+
745
+
746
+ @dataclass
747
+ class EmitReport:
748
+ """Counters surfaced by :func:`emit_people_pages` for the CLI summary.
749
+
750
+ All four fields default to zero so callers don't need a sentinel
751
+ None-check. ``pages_written`` only counts pages whose bytes
752
+ actually changed on disk — a re-run with no DB drift returns
753
+ ``pages_written=0`` (idempotency contract). ``pages_deleted``
754
+ counts pages removed because the corresponding person was
755
+ dropped from the directory (e.g. a ``_people.yml`` entry the
756
+ user removed). ``index_written`` is a bool surfaced as int so
757
+ the CLI's "rows changed" line is one consistent format.
758
+ """
759
+
760
+ pages_written: int = 0
761
+ pages_deleted: int = 0
762
+ index_written: bool = False
763
+ skipped_unchanged: int = 0
764
+ deleted_paths: list[Path] = field(default_factory=list)
765
+
766
+
767
+ def _people_dir(vault_path: Path) -> Path:
768
+ """Return ``<vault_path>/people``. Created on demand by the emitter."""
769
+ return vault_path / _PEOPLE_DIR_NAME
770
+
771
+
772
+ def _existing_person_pages(people_dir: Path) -> set[Path]:
773
+ """Return every ``<people_dir>/<slug>.md`` currently on disk.
774
+
775
+ Excludes ``index.md`` (managed separately) and any path that isn't
776
+ a regular ``.md`` file (subdirectories, dotfiles). The set is the
777
+ "what's there now" half of the cleanup contract — the emitter
778
+ diffs it against the freshly-aggregated target set and removes
779
+ anything in the prior-set-but-not-target.
780
+
781
+ Returns an empty set when ``people_dir`` doesn't exist yet (first
782
+ run on a fresh vault) — the emitter creates the directory on the
783
+ first write.
784
+ """
785
+ if not people_dir.is_dir():
786
+ return set()
787
+ out: set[Path] = set()
788
+ for entry in people_dir.iterdir():
789
+ if not entry.is_file():
790
+ continue
791
+ if entry.suffix != ".md":
792
+ continue
793
+ if entry.name == "index.md":
794
+ continue
795
+ out.add(entry)
796
+ return out
797
+
798
+
799
+ def _write_if_changed(target: Path, rendered: str) -> bool:
800
+ """Write ``rendered`` to ``target`` iff its bytes differ from what's there.
801
+
802
+ Returns ``True`` iff the file was actually written (bytes drifted),
803
+ ``False`` on a no-op skip. The skip preserves the file's mtime so
804
+ the Quartz watcher doesn't fire a needless rebuild — same property
805
+ the recent-rail emitter relies on.
806
+
807
+ Atomic — sibling tempfile + ``os.replace`` via
808
+ :func:`brain.vault._atomic.atomic_write_text`. A crash mid-write
809
+ leaves the prior version intact.
810
+
811
+ Creates the parent directory on demand so the first run on a
812
+ fresh vault doesn't require a separate scaffold step.
813
+ """
814
+ if target.is_file():
815
+ try:
816
+ existing = target.read_text(encoding="utf-8")
817
+ except OSError as exc:
818
+ # Read failure is not fatal — we'll still attempt the write.
819
+ # Logged at DEBUG because the more interesting signal is the
820
+ # write attempt that follows.
821
+ logger.debug("people emit: could not read %s for compare: %s", target, exc)
822
+ existing = None
823
+ if existing == rendered:
824
+ return False
825
+
826
+ target.parent.mkdir(parents=True, exist_ok=True)
827
+ atomic_write_text(target, rendered)
828
+ return True
829
+
830
+
831
+ def emit_people_pages(
832
+ conn: psycopg.Connection[Any],
833
+ *,
834
+ vault_path: Path,
835
+ owner_keys: frozenset[str],
836
+ min_docs: int,
837
+ sender_denylist: frozenset[str] = frozenset(),
838
+ ) -> EmitReport:
839
+ """Aggregate, render, and write every ``<vault>/people/<slug>.md`` + index.
840
+
841
+ Pipeline (per run):
842
+
843
+ 1. **Aggregate.** Call :func:`aggregate_people` to get the
844
+ canonical roster — slug-collision-resolved, owner-filtered,
845
+ threshold-applied.
846
+ 2. **Compute the target set.** ``{<slug>.md for slug in records}``.
847
+ The index page is tracked separately (``index.md``) so its
848
+ presence never gets confused with a curated person.
849
+ 3. **Cleanup pass.** List every ``<people_dir>/*.md`` currently
850
+ on disk (excluding ``index.md``), and ``unlink`` any path not
851
+ in the target set. This is what propagates "user removed
852
+ person-person-luke from ``_people.yml``" to the wiki: the next emit drops
853
+ ``person-person-luke.md``.
854
+ 4. **Write each per-person page.** Skip files whose rendered
855
+ bytes match what's already there (idempotency). Atomic write
856
+ via :func:`brain.vault._atomic.atomic_write_text`.
857
+ 5. **Write the index page.** Same skip-if-unchanged logic.
858
+
859
+ Args:
860
+ conn: live DB connection. Reads only — no writes.
861
+ vault_path: the vault root. ``<vault_path>/people/`` is
862
+ managed; nothing outside it is touched.
863
+ owner_keys: forwarded to :func:`aggregate_people` as-is.
864
+ min_docs: forwarded to :func:`aggregate_people` as-is.
865
+ sender_denylist: forwarded to :func:`aggregate_people` as-is
866
+ (``BRAIN_GRAPH_SENDER_DENYLIST`` extras for the automated-sender
867
+ filter). Default empty.
868
+
869
+ Returns:
870
+ :class:`EmitReport` with counters for the CLI summary line.
871
+ ``deleted_paths`` carries the actual unlinked paths so callers
872
+ (and tests) can inspect what cleanup did.
873
+
874
+ The function never raises on individual page write failures —
875
+ failures are logged at WARNING and the run continues so one bad
876
+ file doesn't drop the whole hub. Aggregation errors propagate
877
+ (a SQL failure means we can't safely produce a target set, and
878
+ deleting pages against a partial target would lose data).
879
+ """
880
+ records = aggregate_people(
881
+ conn,
882
+ owner_keys=owner_keys,
883
+ min_docs=min_docs,
884
+ sender_denylist=sender_denylist,
885
+ )
886
+
887
+ people_dir = _people_dir(vault_path)
888
+ target_paths: set[Path] = {people_dir / f"{rec.slug}.md" for rec in records}
889
+
890
+ report = EmitReport()
891
+
892
+ # Cleanup pass first — deletes are independent of writes.
893
+ for stale in sorted(_existing_person_pages(people_dir) - target_paths):
894
+ try:
895
+ stale.unlink()
896
+ except OSError as exc:
897
+ logger.warning(
898
+ "people emit: could not delete stale page %s: %s — skipping",
899
+ stale, exc,
900
+ )
901
+ continue
902
+ report.pages_deleted += 1
903
+ report.deleted_paths.append(stale)
904
+
905
+ # Per-person pages.
906
+ for rec in records:
907
+ target = people_dir / f"{rec.slug}.md"
908
+ rendered = render_person_md(rec)
909
+ try:
910
+ changed = _write_if_changed(target, rendered)
911
+ except OSError as exc:
912
+ logger.warning(
913
+ "people emit: could not write %s: %s — skipping",
914
+ target, exc,
915
+ )
916
+ continue
917
+ if changed:
918
+ report.pages_written += 1
919
+ else:
920
+ report.skipped_unchanged += 1
921
+
922
+ # Index page.
923
+ index_target = people_dir / "index.md"
924
+ index_rendered = render_index_md(records)
925
+ try:
926
+ report.index_written = _write_if_changed(index_target, index_rendered)
927
+ except OSError as exc:
928
+ logger.warning(
929
+ "people emit: could not write index %s: %s",
930
+ index_target, exc,
931
+ )
932
+ report.index_written = False
933
+
934
+ return report