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,683 @@
1
+ """Persistent name↔email directory + refresh logic for the metadata linker."""
2
+ import json
3
+ import logging
4
+ from collections.abc import Iterable
5
+ from datetime import datetime
6
+ from pathlib import Path
7
+ from typing import Any, Protocol
8
+
9
+ import psycopg
10
+ import yaml
11
+
12
+ from brain.errors import DirectoryRefreshError
13
+ from brain.vault.derived_links.participants import (
14
+ extract_gmail_addresses,
15
+ is_email_like,
16
+ normalize_participant,
17
+ )
18
+
19
+ _logger = logging.getLogger(__name__)
20
+
21
+ # Sources accepted by ``directory_entries.source`` (mirrors the CHECK
22
+ # constraint in migration 005). Kept in sync with the migration.
23
+ _VALID_SOURCES: frozenset[str] = frozenset(
24
+ {"gmail", "calendar", "contacts", "people_yml"}
25
+ )
26
+
27
+
28
+ def _score_directory_rows(
29
+ rows: Iterable[tuple[str, int, bool]],
30
+ *,
31
+ skip_ambiguous: bool = False,
32
+ ) -> str | None:
33
+ """Pick a canonical winner among candidate ``(target, total, is_people_yml)`` rows.
34
+
35
+ Shared scoring core for the directory layer — used by both
36
+ :meth:`DirectoryStore.resolve_name_to_email` (name → email) and the
37
+ per-person index built by :func:`brain.wiki.build_people.aggregate_people`
38
+ (name → primary email *and* email → canonical name). One helper keeps
39
+ the precedence rules from drifting between surfaces.
40
+
41
+ Precedence (identical across both call sites, deterministic regardless
42
+ of input row order):
43
+
44
+ 1. ``people_yml`` rows win unconditionally. When multiple rows carry
45
+ ``is_people_yml=True`` (caller-side bug — ``_people.yml`` should
46
+ map one name to one email), the alphabetically-first ``target``
47
+ wins so the same input always produces the same answer.
48
+ 2. Otherwise: highest summed ``total`` count. The tail behaves per
49
+ ``skip_ambiguous``:
50
+
51
+ - ``skip_ambiguous=False`` (default — used by the People Hub
52
+ aggregator where every person needs a primary email): ties
53
+ broken alphabetically by ``target``.
54
+ - ``skip_ambiguous=True`` (used by
55
+ :meth:`DirectoryStore.resolve_name_to_email`, which would
56
+ rather refuse than guess): a tie at the top total returns
57
+ ``None``.
58
+
59
+ Args:
60
+ rows: Iterable of ``(target, total, is_people_yml)`` triples.
61
+ ``target`` is the candidate string (an email when scoring
62
+ "name → email", a display_name when scoring "email → name").
63
+ ``total`` is the summed ``occurrence_count`` across sources.
64
+ ``is_people_yml`` is True iff at least one underlying row
65
+ for this candidate has ``source='people_yml'``.
66
+ skip_ambiguous: See above. Default ``False``.
67
+
68
+ Returns:
69
+ The winning ``target`` string, or ``None`` when the input is
70
+ empty or — only with ``skip_ambiguous=True`` — when no clear
71
+ winner exists at the top total.
72
+ """
73
+ materialized = list(rows)
74
+ if not materialized:
75
+ return None
76
+
77
+ people_yml_targets = sorted(
78
+ target for target, _total, is_yml in materialized if is_yml
79
+ )
80
+ if people_yml_targets:
81
+ return people_yml_targets[0]
82
+
83
+ ranked = sorted(materialized, key=lambda row: (-row[1], row[0]))
84
+ if (
85
+ skip_ambiguous
86
+ and len(ranked) > 1
87
+ and ranked[0][1] == ranked[1][1]
88
+ ):
89
+ return None
90
+ return ranked[0][0]
91
+
92
+
93
+ class GwsRunner(Protocol):
94
+ """Subprocess shell-out for the `gws` CLI used to mine Calendar / Contacts.
95
+
96
+ Production runner shells out to `gws` (must be on PATH); tests pass a
97
+ fake. See `src/brain/ingest/gmail.py:179` for the parallel pattern,
98
+ but note: that pattern raises ``GmailError`` — the refresh helpers
99
+ here only catch ``(OSError, DirectoryRefreshError, RuntimeError)``.
100
+
101
+ **Implementations must raise ``DirectoryRefreshError``, ``OSError``,
102
+ or ``RuntimeError`` on failure.** ``subprocess.CalledProcessError``
103
+ must be translated by the runner — it is NOT caught by the refresh
104
+ helpers and will propagate.
105
+
106
+ Implementations should return the raw stdout of the gws command
107
+ (typically JSON). The refresh helpers invoke the real ``gws`` CLI as:
108
+
109
+ - ``gws calendar events list --params <JSON> --format json --page-all``
110
+ where ``<JSON>`` carries Google Calendar API params
111
+ (``calendarId``, ``timeMin``, ``timeMax``, ``singleEvents``,
112
+ ``maxResults``). Each NDJSON page is shaped
113
+ ``{"items": [{"summary": ..., "attendees": [{"email": ...,
114
+ "displayName": ...}], ...}, ...], "nextPageToken": ...}``.
115
+ - ``gws people otherContacts list --params <JSON> --format json --page-all``
116
+ where ``<JSON>`` carries People API params (``readMask``,
117
+ ``pageSize``). Each NDJSON page is shaped
118
+ ``{"otherContacts": [{"names": [{"displayName": ...}],
119
+ "emailAddresses": [{"value": ...}, ...]}, ...], "nextPageToken": ...}``.
120
+
121
+ The refresh helpers parse three accepted shapes from a runner's
122
+ stdout (see ``_parse_gws_pages``): NDJSON pages, a single JSON
123
+ response with the page key, and — for backwards compatibility with
124
+ tests — a top-level JSON list of records.
125
+ """
126
+
127
+ def __call__(self, args: list[str]) -> str: ...
128
+
129
+
130
+ class DirectoryStore:
131
+ """Read/write interface over `directory_entries` + `directory_refresh_state`.
132
+
133
+ Bridges Krisp's name-only speaker labels with Gmail's email-only headers
134
+ by remembering `(display_name, email)` co-occurrences mined from Gmail
135
+ metadata, Calendar invites, Contacts, and `_people.yml`.
136
+ """
137
+
138
+ def __init__(self, conn: psycopg.Connection[Any]) -> None:
139
+ self._conn = conn
140
+
141
+ def upsert_pair(
142
+ self,
143
+ *,
144
+ display_name: str | None,
145
+ email: str,
146
+ source: str,
147
+ ) -> None:
148
+ """Insert or bump occurrence_count for (display_name, email, source).
149
+
150
+ `display_name` may be None (bare-email Gmail headers); the row is
151
+ still recorded with display_name='' for indexability — resolution
152
+ helpers ignore empty-name rows.
153
+ """
154
+ if source not in _VALID_SOURCES:
155
+ raise ValueError(f"invalid source: {source!r}")
156
+
157
+ normalized_name: str | None = (
158
+ None if display_name is None else normalize_participant(display_name)
159
+ )
160
+ # Empty-name rows are still indexable but will be skipped by name
161
+ # resolution; canonical "no display name" stored as ''.
162
+ name_value = normalized_name if normalized_name else ""
163
+
164
+ normalized_email = email.strip().lower()
165
+ if not normalized_email:
166
+ raise ValueError("email cannot be empty")
167
+
168
+ self._conn.execute(
169
+ """
170
+ INSERT INTO directory_entries (display_name, email, source)
171
+ VALUES (%s, %s, %s)
172
+ ON CONFLICT (display_name, email, source) DO UPDATE SET
173
+ occurrence_count = directory_entries.occurrence_count + 1,
174
+ last_seen_at = NOW()
175
+ """,
176
+ (name_value, normalized_email, source),
177
+ )
178
+
179
+ def resolve_name_to_email(self, name: str) -> str | None:
180
+ """Return canonical email for `name`, or None if zero / multiple matches.
181
+
182
+ `_people.yml` overrides win; otherwise prefer entries where exactly
183
+ one (display_name, email) pair has highest occurrence_count across
184
+ sources. Returns None on ambiguity.
185
+
186
+ Precedence rules are evaluated by :func:`_score_directory_rows`
187
+ with ``skip_ambiguous=True`` so a tied top total refuses to guess
188
+ — same helper backs the People Hub aggregator's primary-email
189
+ picker, keeping the two surfaces in lockstep.
190
+ """
191
+ normalized = normalize_participant(name)
192
+ if not normalized:
193
+ return None
194
+
195
+ rows = self._conn.execute(
196
+ """
197
+ SELECT email,
198
+ SUM(occurrence_count) AS total,
199
+ BOOL_OR(source = 'people_yml') AS has_people_yml
200
+ FROM directory_entries
201
+ WHERE display_name = %s AND display_name <> ''
202
+ GROUP BY email
203
+ """,
204
+ (normalized,),
205
+ ).fetchall()
206
+
207
+ return _score_directory_rows(
208
+ (
209
+ (str(email), int(total), bool(has_people_yml))
210
+ for email, total, has_people_yml in rows
211
+ ),
212
+ skip_ambiguous=True,
213
+ )
214
+
215
+ def all_emails(self) -> set[str]:
216
+ """Return the set of every email seen in any directory row.
217
+
218
+ Used by the linker pass for fast membership checks.
219
+ """
220
+ rows = self._conn.execute(
221
+ "SELECT DISTINCT email FROM directory_entries"
222
+ ).fetchall()
223
+ return {str(r[0]) for r in rows}
224
+
225
+
226
+ def load_people_yml(vault_path: Path) -> dict[str, str]:
227
+ """Parse `<vault_path>/_people.yml` if present; return {} otherwise.
228
+
229
+ Schema: `Display Name: canonical@example.com` per line. Returns a mapping
230
+ from normalized lowercase name to lowercase email. Missing file is not
231
+ an error.
232
+ """
233
+ yml_path = vault_path / "_people.yml"
234
+ if not yml_path.exists():
235
+ return {}
236
+
237
+ try:
238
+ with yml_path.open("r", encoding="utf-8") as fh:
239
+ data = yaml.safe_load(fh)
240
+ except yaml.YAMLError as exc:
241
+ _logger.warning("malformed _people.yml at %s: %s", yml_path, exc)
242
+ return {}
243
+ except OSError as exc:
244
+ _logger.warning("could not read _people.yml at %s: %s", yml_path, exc)
245
+ return {}
246
+
247
+ if data is None:
248
+ # Empty file — yaml.safe_load returns None. Treat as empty mapping.
249
+ return {}
250
+
251
+ if not isinstance(data, dict):
252
+ _logger.warning(
253
+ "_people.yml at %s: expected top-level mapping, got %s",
254
+ yml_path,
255
+ type(data).__name__,
256
+ )
257
+ return {}
258
+
259
+ result: dict[str, str] = {}
260
+ for raw_name, raw_email in data.items():
261
+ if not isinstance(raw_name, str) or not isinstance(raw_email, str):
262
+ _logger.warning(
263
+ "_people.yml: skipping non-string entry %r -> %r",
264
+ raw_name,
265
+ raw_email,
266
+ )
267
+ continue
268
+ normalized_name = normalize_participant(raw_name)
269
+ if not normalized_name:
270
+ _logger.warning("_people.yml: skipping unnormalizable name %r", raw_name)
271
+ continue
272
+ normalized_email = raw_email.strip().lower()
273
+ if not is_email_like(normalized_email):
274
+ _logger.warning(
275
+ "_people.yml: skipping invalid email for %r: %r",
276
+ raw_name,
277
+ raw_email,
278
+ )
279
+ continue
280
+ result[normalized_name] = normalized_email
281
+ return result
282
+
283
+
284
+ def refresh_people_yml(
285
+ conn: psycopg.Connection[Any], vault_path: Path
286
+ ) -> int:
287
+ """Replace all ``source='people_yml'`` rows from ``<vault_path>/_people.yml``.
288
+
289
+ Authoritative semantics: the YAML file is the source of truth for the
290
+ ``people_yml`` source. Every refresh drops the existing rows and
291
+ re-inserts the file's current contents — so removing an entry from
292
+ ``_people.yml`` removes it from the directory on the next refresh.
293
+
294
+ Missing file → 0 rows kept (any prior rows are wiped). Malformed file
295
+ is treated identically: ``load_people_yml`` already logs a warning,
296
+ and the file's effective content is empty, so the directory follows.
297
+
298
+ No ``directory_refresh_state`` row is written — ``people_yml`` is
299
+ excluded from ``_REFRESH_STATE_SOURCES`` (it has no refresh cadence;
300
+ it's loaded fresh every call).
301
+
302
+ Returns the number of pairs loaded.
303
+ """
304
+ pairs = load_people_yml(vault_path)
305
+ conn.execute(
306
+ "DELETE FROM directory_entries WHERE source = %s", ("people_yml",)
307
+ )
308
+ if not pairs:
309
+ return 0
310
+ store = DirectoryStore(conn)
311
+ for name, email in pairs.items():
312
+ store.upsert_pair(
313
+ display_name=name, email=email, source="people_yml"
314
+ )
315
+ return len(pairs)
316
+
317
+
318
+ _REFRESH_STATE_SOURCES: frozenset[str] = frozenset({"gmail", "calendar", "contacts"})
319
+
320
+
321
+ def _parse_gws_pages(raw: str, *, key: str) -> list[Any] | None:
322
+ """Normalize ``gws`` stdout into a flat list of records under ``key``.
323
+
324
+ Production ``gws`` emits one Google API JSON response per page; with
325
+ ``--page-all`` those pages are concatenated as NDJSON (one object
326
+ per line). Without ``--page-all`` a single JSON object is returned.
327
+ Each page wraps its records under a service-specific key
328
+ (``items`` for Calendar's ``events.list``, ``otherContacts`` for
329
+ People's ``otherContacts.list``).
330
+
331
+ Returns:
332
+ - ``[]`` for an empty / whitespace-only response.
333
+ - The flattened list of records on success.
334
+ - ``None`` when the response can't be parsed at all (malformed
335
+ JSON) or doesn't carry any recognized shape — caller logs
336
+ a warning and aborts the refresh.
337
+
338
+ Backwards compatibility: a top-level JSON list is also accepted and
339
+ returned as-is. This was the documented shape in the original Task
340
+ B.1 spec (before real ``gws`` subcommands were known) and remains
341
+ convenient for fake-runner tests that don't want to wrap fixtures
342
+ in a ``{"items": …}`` envelope.
343
+ """
344
+ stripped = raw.strip()
345
+ if not stripped:
346
+ return []
347
+
348
+ # Single-document JSON path (covers no-pagination output and the
349
+ # legacy top-level-list shape used by tests).
350
+ try:
351
+ parsed = json.loads(stripped)
352
+ except json.JSONDecodeError:
353
+ # Fall through to the NDJSON path below.
354
+ parsed = None
355
+
356
+ if isinstance(parsed, list):
357
+ return parsed
358
+ if isinstance(parsed, dict):
359
+ records = parsed.get(key)
360
+ if isinstance(records, list):
361
+ return records
362
+ # Recognized as JSON but missing the expected page key — treat
363
+ # as an unrecoverable shape so the caller warns.
364
+ return None
365
+
366
+ # NDJSON fallback: one JSON page per line.
367
+ accumulated: list[Any] = []
368
+ saw_any_page = False
369
+ for line in stripped.splitlines():
370
+ chunk = line.strip()
371
+ if not chunk:
372
+ continue
373
+ try:
374
+ page = json.loads(chunk)
375
+ except json.JSONDecodeError:
376
+ return None
377
+ saw_any_page = True
378
+ if not isinstance(page, dict):
379
+ return None
380
+ records = page.get(key)
381
+ if isinstance(records, list):
382
+ accumulated.extend(records)
383
+ # Pages that lack the key entirely (e.g. an error envelope) are
384
+ # treated as zero records but don't fail the whole refresh — gws
385
+ # itself raises on errors via the runner's CalledProcessError
386
+ # translation.
387
+ if not saw_any_page:
388
+ # ``parsed is None`` and no NDJSON lines parsed — truly garbage.
389
+ return None
390
+ return accumulated
391
+
392
+
393
+ def _update_refresh_state(
394
+ conn: psycopg.Connection[Any], *, source: str, records_seen: int
395
+ ) -> None:
396
+ """Insert / update the high-water mark for ``source``.
397
+
398
+ On conflict the running ``records_seen`` counter is incremented so each
399
+ refresh contributes to the lifetime tally; ``last_refreshed_at`` is
400
+ always set to ``NOW()``.
401
+
402
+ Mirrors the CHECK constraint on ``directory_refresh_state.source`` from
403
+ migration 005:55 — ``people_yml`` is rejected here (it has no refresh
404
+ cadence). Raising early keeps the failure local instead of surfacing
405
+ as a Postgres ``IntegrityError`` from the INSERT.
406
+ """
407
+ if source not in _REFRESH_STATE_SOURCES:
408
+ raise ValueError(
409
+ f"invalid refresh-state source {source!r}; "
410
+ "see migration 005's CHECK constraint"
411
+ )
412
+ conn.execute(
413
+ """
414
+ INSERT INTO directory_refresh_state (source, last_refreshed_at, records_seen)
415
+ VALUES (%s, NOW(), %s)
416
+ ON CONFLICT (source) DO UPDATE SET
417
+ last_refreshed_at = NOW(),
418
+ records_seen = directory_refresh_state.records_seen + EXCLUDED.records_seen
419
+ """,
420
+ (source, records_seen),
421
+ )
422
+
423
+
424
+ def refresh_calendar(
425
+ conn: psycopg.Connection[Any],
426
+ *,
427
+ since: datetime,
428
+ until: datetime,
429
+ runner: GwsRunner,
430
+ ) -> int:
431
+ """Mine name↔email pairs from Google Calendar via the `gws` CLI.
432
+
433
+ On any subprocess / JSON failure the error is logged at WARNING level
434
+ and the function returns 0 — the linker keeps running on a stale
435
+ directory rather than failing the whole sync. Successful empty
436
+ refreshes still bump ``last_refreshed_at`` so the high-water mark
437
+ advances even when no events match.
438
+
439
+ Production calls ``gws calendar events list --params <JSON> --format
440
+ json --page-all``: the params JSON carries ``calendarId``,
441
+ ``timeMin``, ``timeMax``, ``singleEvents`` and ``maxResults`` as
442
+ Google Calendar API expects. ``--page-all`` produces NDJSON, one
443
+ page per line; :func:`_parse_gws_pages` flattens that into a single
444
+ list of event records.
445
+ """
446
+ store = DirectoryStore(conn)
447
+ params = json.dumps(
448
+ {
449
+ "calendarId": "primary",
450
+ "timeMin": since.isoformat(),
451
+ "timeMax": until.isoformat(),
452
+ "singleEvents": True,
453
+ "maxResults": 250,
454
+ }
455
+ )
456
+ try:
457
+ raw = runner(
458
+ [
459
+ "gws",
460
+ "calendar",
461
+ "events",
462
+ "list",
463
+ "--params",
464
+ params,
465
+ "--format",
466
+ "json",
467
+ "--page-all",
468
+ ]
469
+ )
470
+ except (OSError, DirectoryRefreshError, RuntimeError) as exc:
471
+ _logger.warning(
472
+ "gws calendar refresh failed [%s..%s]: %s",
473
+ since.isoformat(),
474
+ until.isoformat(),
475
+ exc,
476
+ )
477
+ return 0
478
+
479
+ events = _parse_gws_pages(raw, key="items")
480
+ if events is None:
481
+ _logger.warning(
482
+ "gws calendar JSON parse failed [%s..%s]: "
483
+ "could not normalize gws stdout into events",
484
+ since.isoformat(),
485
+ until.isoformat(),
486
+ )
487
+ return 0
488
+
489
+ events_seen = 0
490
+ for event in events:
491
+ if not isinstance(event, dict):
492
+ continue
493
+ events_seen += 1
494
+ attendees = event.get("attendees") or []
495
+ if not isinstance(attendees, list):
496
+ continue
497
+ for attendee in attendees:
498
+ if not isinstance(attendee, dict):
499
+ continue
500
+ email = attendee.get("email")
501
+ if not isinstance(email, str) or not email.strip():
502
+ # Without an email there's nothing to upsert.
503
+ continue
504
+ display = attendee.get("displayName")
505
+ display_name = display if isinstance(display, str) else None
506
+ try:
507
+ store.upsert_pair(
508
+ display_name=display_name,
509
+ email=email,
510
+ source="calendar",
511
+ )
512
+ except ValueError:
513
+ # Bad email shape — skip, keep going. (Don't let one bad
514
+ # row poison the whole refresh.) Log the rejected email at
515
+ # DEBUG so operators can correlate; emails are IDs (not
516
+ # payload bodies) so this respects the CLAUDE.md log rule.
517
+ _logger.debug(
518
+ "calendar refresh: skipping attendee with invalid email: %s",
519
+ email or "<missing>",
520
+ )
521
+ continue
522
+
523
+ _update_refresh_state(conn, source="calendar", records_seen=events_seen)
524
+ return events_seen
525
+
526
+
527
+ _CONTACTS_CMD = "gws people otherContacts list --format json --page-all"
528
+
529
+
530
+ def refresh_contacts(
531
+ conn: psycopg.Connection[Any],
532
+ *,
533
+ runner: GwsRunner,
534
+ ) -> int:
535
+ """Mine name↔email pairs from Google Contacts via the `gws` CLI.
536
+
537
+ Same error-handling contract as :func:`refresh_calendar`: any failure
538
+ logs a warning and returns 0 without updating the high-water mark.
539
+
540
+ Production calls ``gws people otherContacts list --params <JSON>
541
+ --format json --page-all``. ``otherContacts`` is preferred over
542
+ ``people connections`` because it auto-collects every email-address
543
+ correspondent the user has interacted with — exactly the directory
544
+ we want to mine for the linker — without requiring the user to add
545
+ them as named contacts. Each NDJSON page is shaped
546
+ ``{"otherContacts": [{"names": [{"displayName": ...}],
547
+ "emailAddresses": [{"value": ...}]}, ...]}``.
548
+ """
549
+ store = DirectoryStore(conn)
550
+ params = json.dumps(
551
+ {
552
+ "readMask": "names,emailAddresses",
553
+ "pageSize": 1000,
554
+ }
555
+ )
556
+ try:
557
+ raw = runner(
558
+ [
559
+ "gws",
560
+ "people",
561
+ "otherContacts",
562
+ "list",
563
+ "--params",
564
+ params,
565
+ "--format",
566
+ "json",
567
+ "--page-all",
568
+ ]
569
+ )
570
+ except (OSError, DirectoryRefreshError, RuntimeError) as exc:
571
+ _logger.warning(
572
+ "gws people refresh failed (cmd=%s): %s",
573
+ _CONTACTS_CMD,
574
+ exc,
575
+ )
576
+ return 0
577
+
578
+ contacts = _parse_gws_pages(raw, key="otherContacts")
579
+ if contacts is None:
580
+ _logger.warning(
581
+ "gws people JSON parse failed (cmd=%s): "
582
+ "could not normalize gws stdout into contacts",
583
+ _CONTACTS_CMD,
584
+ )
585
+ return 0
586
+
587
+ contacts_seen = 0
588
+ for contact in contacts:
589
+ if not isinstance(contact, dict):
590
+ continue
591
+ names = contact.get("names") or []
592
+ emails = contact.get("emailAddresses") or []
593
+ if not isinstance(names, list) or not isinstance(emails, list):
594
+ continue
595
+
596
+ # Pick the first display name; Google People returns names ordered
597
+ # by primacy.
598
+ display: str | None = None
599
+ for name_obj in names:
600
+ if isinstance(name_obj, dict):
601
+ candidate = name_obj.get("displayName")
602
+ if isinstance(candidate, str) and candidate.strip():
603
+ display = candidate
604
+ break
605
+
606
+ # A contact with no usable email contributes nothing.
607
+ upserted = False
608
+ for email_obj in emails:
609
+ if not isinstance(email_obj, dict):
610
+ continue
611
+ value = email_obj.get("value")
612
+ if not isinstance(value, str) or not value.strip():
613
+ continue
614
+ try:
615
+ store.upsert_pair(
616
+ display_name=display,
617
+ email=value,
618
+ source="contacts",
619
+ )
620
+ except ValueError:
621
+ # Same belt-and-suspenders catch as refresh_calendar — see
622
+ # the comment there for rationale. DEBUG-log the rejected
623
+ # email value (it's an ID, not a payload body).
624
+ _logger.debug(
625
+ "contacts refresh: skipping email with invalid shape: %s",
626
+ value or "<missing>",
627
+ )
628
+ continue
629
+ upserted = True
630
+
631
+ if upserted:
632
+ contacts_seen += 1
633
+
634
+ _update_refresh_state(conn, source="contacts", records_seen=contacts_seen)
635
+ return contacts_seen
636
+
637
+
638
+ def rescan_gmail_directory(conn: psycopg.Connection[Any]) -> tuple[int, int]:
639
+ """Re-walk every Gmail document and upsert (display, email) pairs.
640
+
641
+ Returns ``(docs_seen, pairs_upserted)``. Each (display, email) pair from
642
+ a single document's ``from``/``to`` headers contributes ``+1`` to the
643
+ pair count even when the upsert is a count bump (no row inserted) — the
644
+ metric matches the user-facing "pairs from N docs" line in the summary.
645
+
646
+ The whole rescan runs in one transaction. ``directory_entries`` rows
647
+ upserted earlier in this command (e.g. by a prior partial run) only have
648
+ their ``occurrence_count`` incremented, so re-running this is safe.
649
+
650
+ Shared between ``brain vault relink-derived`` (full corpus rebuild) and
651
+ ``brain vault directory refresh`` (directory-only rebuild) — the linker
652
+ pass is intentionally NOT invoked from this helper, leaving rebuild
653
+ orchestration to the caller.
654
+ """
655
+ rows = conn.execute(
656
+ """
657
+ SELECT d.id::text, d.metadata
658
+ FROM documents d
659
+ JOIN sources s ON s.id = d.source_id
660
+ WHERE s.kind = 'gmail'
661
+ """
662
+ ).fetchall()
663
+
664
+ store = DirectoryStore(conn)
665
+ pairs = 0
666
+ with conn.transaction():
667
+ for _doc_id, metadata in rows:
668
+ for display_name, email in extract_gmail_addresses(
669
+ dict(metadata or {})
670
+ ):
671
+ try:
672
+ store.upsert_pair(
673
+ display_name=display_name,
674
+ email=email,
675
+ source="gmail",
676
+ )
677
+ except ValueError:
678
+ # Defensive — extract_gmail_addresses already filters
679
+ # malformed shapes; a residual bad email would otherwise
680
+ # poison the whole rescan.
681
+ continue
682
+ pairs += 1
683
+ return len(rows), pairs