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
brain/queries.py ADDED
@@ -0,0 +1,1188 @@
1
+ """Shared read-side SQL helpers used by both the CLI and the MCP server.
2
+
3
+ This module exists to avoid duplicating identical SELECTs and prefix-resolution
4
+ logic across two callers. The helpers raise plain :mod:`brain.errors`
5
+ exceptions so each caller can map them to its own framework's error type.
6
+ """
7
+ import re
8
+ from collections.abc import Iterator
9
+ from dataclasses import dataclass
10
+ from datetime import datetime
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ import psycopg
15
+ import yaml
16
+ from psycopg import sql
17
+
18
+ from .embedding_targets import (
19
+ embedding_index_name,
20
+ validate_embedding_target,
21
+ )
22
+ from .errors import (
23
+ IdPrefixAmbiguous,
24
+ IdPrefixNotFound,
25
+ IdPrefixNotHex,
26
+ IdPrefixTooShort,
27
+ PersonAmbiguous,
28
+ PersonNotFound,
29
+ )
30
+ from .ingest import Embedder
31
+
32
+ # libyaml's C loader parses the flat scalar frontmatter mappings ~11x faster
33
+ # than the pure-Python SafeLoader while returning identical values; fall back to
34
+ # the pure-Python loader when the C extension is unavailable so mirror-drift
35
+ # behavior is unchanged on those builds.
36
+ try:
37
+ from yaml import CSafeLoader as _FrontmatterLoader
38
+ except ImportError: # pragma: no cover - libyaml ships in supported envs
39
+ from yaml import SafeLoader as _FrontmatterLoader # type: ignore[assignment]
40
+
41
+ # pgvector 0.8.x caps HNSW (and IVFFlat) at 2000 dims for ``vector`` and 4000
42
+ # for ``halfvec``. Backends with native dims at or below this limit get an
43
+ # HNSW cosine index at finalize time; higher-dim backends (Qwen3 at 4096)
44
+ # use sequential scan, acceptable at personal-corpus scale.
45
+ _PGVECTOR_HNSW_DIM_CAP = 2000
46
+
47
+ # UUID prefixes consist solely of hex digits and hyphens; anything else is
48
+ # rejected before reaching SQL so user-supplied `_` / `%` cannot act as LIKE
49
+ # wildcards.
50
+ _UUID_PREFIX_RE = re.compile(r"[0-9a-f-]+")
51
+ _MIN_PREFIX_LEN = 6
52
+
53
+
54
+ @dataclass
55
+ class DocumentRow:
56
+ """A document row joined with its source kind, used by show + list views."""
57
+
58
+ id: str
59
+ title: str
60
+ content_type: str
61
+ tags: list[str]
62
+ source_kind: str | None
63
+ ingested_at: datetime | None
64
+ # Show-only fields. Populated by :func:`fetch_document`; ``None`` after
65
+ # :func:`list_documents` (which returns the lighter projection).
66
+ content: str | None = None
67
+ source_path: str | None = None
68
+ # Wave Q1-D — per-document auto-summary. Populated by
69
+ # :func:`fetch_document` (so ``brain show`` + MCP ``brain_show`` can
70
+ # surface it); :func:`list_documents` leaves them at ``None`` to keep
71
+ # the listing projection cheap.
72
+ summary: str | None = None
73
+ summary_model: str | None = None
74
+ summary_at: datetime | None = None
75
+
76
+
77
+ def resolve_document_prefix(conn: psycopg.Connection[Any], prefix: str) -> str:
78
+ """Resolve a UUID prefix (min 6 chars) to a full document id.
79
+
80
+ Raises :class:`IdPrefixTooShort`, :class:`IdPrefixNotHex`,
81
+ :class:`IdPrefixNotFound`, or :class:`IdPrefixAmbiguous` so the caller can
82
+ map to its framework's user-facing error type.
83
+ """
84
+ if len(prefix) < _MIN_PREFIX_LEN:
85
+ raise IdPrefixTooShort("id prefix must be at least 6 characters")
86
+ if not _UUID_PREFIX_RE.fullmatch(prefix):
87
+ raise IdPrefixNotHex(
88
+ "id prefix must contain only hex digits and hyphens"
89
+ )
90
+ rows = conn.execute(
91
+ "SELECT id::text FROM documents WHERE id::text LIKE %s",
92
+ (prefix + "%",),
93
+ ).fetchall()
94
+ if not rows:
95
+ raise IdPrefixNotFound(prefix)
96
+ if len(rows) > 1:
97
+ raise IdPrefixAmbiguous(prefix)
98
+ return str(rows[0][0])
99
+
100
+
101
+ @dataclass(frozen=True)
102
+ class PersonMatch:
103
+ """Resolved person → participant-key list for SQL array-overlap filtering.
104
+
105
+ ``keys`` is the lower-cased list of every identifier (display name +
106
+ emails + their ``"Display <email>"`` combination forms) that should
107
+ overlap with a doc's ``documents.participants`` array to count as a
108
+ match. Q1-C uses this to power ``--person`` on ``brain search`` /
109
+ ``brain explain`` / MCP ``brain_search``.
110
+ """
111
+
112
+ display_name: str
113
+ keys: list[str]
114
+
115
+
116
+ def _canonicalize_display_name(name: str) -> str:
117
+ """Reduce a display name to a comparison-only canonical form.
118
+
119
+ Treats common identity-equivalent variants as the same person:
120
+ ``"person-x person-j"`` and ``"person-x.person-j"`` and ``"person-j"`` and
121
+ ``"person-x person-j"`` all canonicalize to ``"person-j person-j"``. Used
122
+ inside :func:`resolve_person_to_keys` to dedupe step-3 substring hits
123
+ that point at one logical person stored under several formattings —
124
+ common when directory entries come from both Gmail header names and
125
+ Krisp speaker labels for the same individual.
126
+
127
+ The canonical form is NOT used for storage or display — only for
128
+ grouping aggregate_people records inside the resolver. Mirror writes
129
+ keep the original casing.
130
+ """
131
+ folded = name.strip().casefold()
132
+ # Treat dots / underscores / hyphens as word separators so
133
+ # "person-x.person-j" canonicalizes to "person-j person-j".
134
+ for sep in (".", "_", "-"):
135
+ folded = folded.replace(sep, " ")
136
+ # Collapse runs of whitespace (incl. NBSP) to a single space.
137
+ return " ".join(folded.split())
138
+
139
+
140
+ def _expand_person_keys(display_name: str, emails: list[str]) -> list[str]:
141
+ """Build the SQL-overlap key list for one person.
142
+
143
+ ``documents.participants`` is a free-form ``TEXT[]`` written by ingest
144
+ extractors. Gmail emits a mix of bare emails (``alice@x.com``) and
145
+ ``"Display <email>"`` combination strings; Krisp emits display names
146
+ only. To match all three forms with a single GIN-friendly ``&&``
147
+ overlap predicate, we expand each person's identity into every form
148
+ they might have been recorded under and let the array operator do
149
+ the union.
150
+
151
+ Returns the deduplicated list, sorted for determinism so tests +
152
+ explain payloads stay byte-stable across runs.
153
+ """
154
+ keys: set[str] = set()
155
+ name = (display_name or "").strip().lower()
156
+ if name:
157
+ keys.add(name)
158
+ lowered_emails: list[str] = []
159
+ for raw in emails:
160
+ if not raw:
161
+ continue
162
+ normalized = raw.strip().lower()
163
+ if not normalized:
164
+ continue
165
+ keys.add(normalized)
166
+ lowered_emails.append(normalized)
167
+ # ``Display <email>`` combination form — Gmail emits this for any
168
+ # header where the sender / recipient had a display-name component.
169
+ if name:
170
+ for email in lowered_emails:
171
+ keys.add(f"{name} <{email}>")
172
+ return sorted(keys)
173
+
174
+
175
+ def _expand_keys_with_directory_variants(
176
+ conn: psycopg.Connection[Any], *, display_name: str, emails: list[str]
177
+ ) -> list[str]:
178
+ """Expand person keys across every RAW directory display-name variant.
179
+
180
+ :func:`brain.wiki.build_people.aggregate_people` collapses separator /
181
+ ordering variants of one person (``jane.doe`` vs ``Jane Doe``) into a
182
+ single canonical record, so the resolver only ever sees the canonical
183
+ display name. Docs, however, store ``participants`` under whichever RAW
184
+ variant their source emitted — Gmail dot-form headers vs Krisp space-form
185
+ speaker labels. A variant whose combo key is never emitted makes its docs
186
+ silently invisible to ``--person`` (Wave A.2, Codex Q1 HIGH). Re-fetch the
187
+ raw variants (linked by shared email or identical canonical key) and union
188
+ the expansion over all of them.
189
+ """
190
+ from .wiki._person_name import normalize_person_name
191
+
192
+ keys: set[str] = set(_expand_person_keys(display_name, emails))
193
+ base = normalize_person_name(display_name)
194
+ canonical_key = base.canonical_key if base is not None else None
195
+ email_set = {e.strip().lower() for e in emails if e and e.strip()}
196
+ rows = conn.execute(
197
+ "SELECT DISTINCT display_name, email FROM directory_entries "
198
+ "WHERE display_name <> ''"
199
+ ).fetchall()
200
+ for raw_name, raw_email in rows:
201
+ linked = (raw_email or "").strip().lower() in email_set
202
+ if not linked and canonical_key is not None:
203
+ normalized = normalize_person_name(raw_name)
204
+ linked = normalized is not None and normalized.canonical_key == canonical_key
205
+ if linked:
206
+ keys.update(_expand_person_keys(raw_name, emails))
207
+ return sorted(keys)
208
+
209
+
210
+ def resolve_person_to_keys(
211
+ conn: psycopg.Connection[Any], name_or_email: str
212
+ ) -> PersonMatch:
213
+ """Resolve a ``--person`` argument to a participant-key set.
214
+
215
+ Resolution order (mirrors ``brain people <name>``):
216
+
217
+ 1. Exact email match (case-insensitive) against any
218
+ ``directory_entries.email``.
219
+ 2. Exact display-name match against ``directory_entries.display_name``
220
+ (case-folded).
221
+ 3. Case-insensitive substring on ``display_name``; alpha-first
222
+ tiebreak when multiple records match.
223
+
224
+ Per plan §3.b D16, the resolver calls
225
+ :func:`brain.wiki.build_people.aggregate_people` with
226
+ ``min_docs=0`` and ``owner_keys=frozenset()`` so query-time
227
+ ``--person`` filters see every known person — including the corpus
228
+ owner and curated-but-low-doc-count entries that the People Hub UI
229
+ threshold would filter out. The UI's display rules should not gate
230
+ a query filter.
231
+
232
+ Raises:
233
+ PersonAmbiguous: Multiple persons matched at step (2) or (3).
234
+ The ``candidates`` attribute carries the top-5 display names
235
+ for caller-side disambiguation messages.
236
+ PersonNotFound: No match at any step.
237
+ """
238
+ # Late import: avoid a top-level dependency on the wiki package so
239
+ # ``brain.queries`` stays import-cheap for non-search code paths.
240
+ from .wiki.build_people import aggregate_people, humanize_display_name
241
+
242
+ needle = name_or_email.strip().casefold()
243
+ if not needle:
244
+ raise PersonNotFound(name_or_email)
245
+
246
+ records = aggregate_people(
247
+ conn, owner_keys=frozenset(), min_docs=0
248
+ )
249
+
250
+ # Step 1 — exact email match.
251
+ for rec in records:
252
+ for email in rec.all_emails:
253
+ if email.casefold() == needle:
254
+ return PersonMatch(
255
+ display_name=humanize_display_name(rec.display_name),
256
+ keys=_expand_keys_with_directory_variants(
257
+ conn, display_name=rec.display_name, emails=rec.all_emails
258
+ ),
259
+ )
260
+
261
+ def _merge_or_ambiguous(hits: list[Any]) -> PersonMatch | None:
262
+ """Group hits by canonical display name. Single canonical group →
263
+ merge all hit records' keys into one PersonMatch. Multiple canonical
264
+ groups → raise PersonAmbiguous with the deduplicated candidate set.
265
+ Returns None when ``hits`` is empty so callers fall through.
266
+
267
+ This is what distinguishes "person-x person-j" + "person-x.person-j" (same
268
+ canonical, one logical person, MERGE) from "John Smith" + "John
269
+ Smith Jr" (different canonicals, two people, AMBIGUOUS).
270
+ """
271
+ if not hits:
272
+ return None
273
+ grouped: dict[str, list[Any]] = {}
274
+ for rec in hits:
275
+ grouped.setdefault(_canonicalize_display_name(rec.display_name), []).append(rec)
276
+ if len(grouped) == 1:
277
+ members = next(iter(grouped.values()))
278
+ merged_emails: list[str] = []
279
+ seen: set[str] = set()
280
+ for rec in members:
281
+ for email in rec.all_emails:
282
+ if email and email.lower() not in seen:
283
+ seen.add(email.lower())
284
+ merged_emails.append(email)
285
+ # Prefer the humanized display name from the alphabetically-first
286
+ # record so output stays deterministic across runs.
287
+ canonical_rec = sorted(members, key=lambda r: r.display_name)[0]
288
+ return PersonMatch(
289
+ display_name=humanize_display_name(canonical_rec.display_name),
290
+ keys=_expand_keys_with_directory_variants(
291
+ conn,
292
+ display_name=canonical_rec.display_name,
293
+ emails=merged_emails,
294
+ ),
295
+ )
296
+ raise PersonAmbiguous(
297
+ name_or_email,
298
+ sorted(humanize_display_name(members[0].display_name) for members in grouped.values()),
299
+ )
300
+
301
+ # Step 2 — canonical-name exact match. Compares the canonical form of
302
+ # each record against the canonical form of the needle so equivalents
303
+ # like ``"person-x person-j"`` / ``"person-x.person-j"`` / ``"person-j"``
304
+ # all match the query ``"person-j person-j"`` at this strict-identity tier.
305
+ canonical_needle = _canonicalize_display_name(name_or_email)
306
+ exact_name_hits = [
307
+ rec
308
+ for rec in records
309
+ if _canonicalize_display_name(rec.display_name) == canonical_needle
310
+ ]
311
+ if (match := _merge_or_ambiguous(exact_name_hits)) is not None:
312
+ return match
313
+
314
+ # Step 3 — canonical-name substring match. Same canonicalization rules
315
+ # so a query like ``"person-j"`` catches both ``"person-x person-j"`` and
316
+ # ``"person-x.person-j"`` (both canonicalize to ``"person-j person-j"``, both
317
+ # contain ``"person-j"``). The merge pass then collapses them into one
318
+ # PersonMatch.
319
+ substring_hits = [
320
+ rec
321
+ for rec in records
322
+ if canonical_needle in _canonicalize_display_name(rec.display_name)
323
+ ]
324
+ if (match := _merge_or_ambiguous(substring_hits)) is not None:
325
+ return match
326
+
327
+ raise PersonNotFound(name_or_email)
328
+
329
+
330
+ def fetch_document(conn: psycopg.Connection[Any], document_id: str) -> DocumentRow | None:
331
+ """Return the full document row for ``document_id`` (or ``None`` if missing).
332
+
333
+ Includes the document body, ``source_path``, and the Q1-D summary
334
+ triple; pair with :func:`resolve_document_prefix` when the caller has
335
+ a prefix instead.
336
+ """
337
+ row = conn.execute(
338
+ """
339
+ SELECT d.id::text, d.title, d.content, d.content_type, d.tags,
340
+ d.source_path, d.ingested_at, s.kind,
341
+ d.summary, d.summary_model, d.summary_at
342
+ FROM documents d
343
+ LEFT JOIN sources s ON s.id = d.source_id
344
+ WHERE d.id = %s
345
+ """,
346
+ (document_id,),
347
+ ).fetchone()
348
+ if row is None:
349
+ return None
350
+ return DocumentRow(
351
+ id=row[0],
352
+ title=row[1],
353
+ content=row[2],
354
+ content_type=row[3],
355
+ tags=list(row[4] or []),
356
+ source_path=row[5],
357
+ ingested_at=row[6],
358
+ source_kind=row[7],
359
+ summary=row[8],
360
+ summary_model=row[9],
361
+ summary_at=row[10],
362
+ )
363
+
364
+
365
+ def fetch_document_summary(
366
+ conn: psycopg.Connection[Any], document_id: str
367
+ ) -> tuple[str, str | None] | None:
368
+ """Return ``(title, summary)`` for ``document_id`` (or ``None`` if missing).
369
+
370
+ The summary-only projection used by the Plan 04 audio bundle path. It
371
+ deliberately selects ``title`` + ``summary`` ONLY — never ``content`` — so a
372
+ full document body can never be loaded into the LLM script prompt (the audio
373
+ privacy contract). :func:`fetch_document` always selects ``d.content`` and so
374
+ is never called from the audio bundle path.
375
+
376
+ ``summary`` is ``None`` for a not-yet-enriched document; the caller falls
377
+ back to the title alone in that case.
378
+ """
379
+ row = conn.execute(
380
+ "SELECT title, summary FROM documents WHERE id = %s",
381
+ (document_id,),
382
+ ).fetchone()
383
+ if row is None:
384
+ return None
385
+ return (row[0], row[1])
386
+
387
+
388
+ @dataclass(frozen=True)
389
+ class UnenrichedDocument:
390
+ """One row from :func:`iter_unenriched_documents` — the backfill driver.
391
+
392
+ Carries just enough state for :class:`brain.enrichment.OllamaEnricher`
393
+ to summarize the doc and for the caller to write the result back. The
394
+ backfill loop reads this in batches via keyset pagination so memory
395
+ stays bounded on a large corpus.
396
+ """
397
+
398
+ id: str
399
+ title: str
400
+ content: str
401
+
402
+
403
+ def iter_unenriched_documents(
404
+ conn: psycopg.Connection[Any],
405
+ *,
406
+ batch_size: int = 32,
407
+ current_model: str | None = None,
408
+ ) -> Iterator[list[UnenrichedDocument]]:
409
+ """Yield batches of documents that need enrichment.
410
+
411
+ Uses keyset pagination over ``documents.id`` (same shape as
412
+ :func:`iter_chunks_missing_embedding`) so the in-memory footprint stays
413
+ bounded regardless of corpus size.
414
+
415
+ Eligibility:
416
+
417
+ - ``current_model is None`` (default) → rows with ``summary IS NULL``
418
+ only. Backed by the partial index ``idx_documents_summary_null``.
419
+ - ``current_model is not None`` → rows with ``summary IS NULL`` OR
420
+ ``summary_model IS DISTINCT FROM current_model``. Lets the backfill
421
+ loop re-enrich after a ``BRAIN_ENRICH_MODEL`` upgrade: an existing
422
+ summary generated by the OLD model is considered stale relative to
423
+ the NEW model, so the new model's improvements propagate to the
424
+ whole corpus. The ``IS DISTINCT FROM`` (vs ``<>``) is critical —
425
+ it matches NULL-summary rows whose ``summary_model`` is also NULL
426
+ without an explicit NULL check.
427
+
428
+ The iterator does NOT do any LLM work itself — the backfill loop in
429
+ :func:`brain.cli.enrich` is what calls the enricher and writes
430
+ ``summary`` / ``summary_model`` / ``summary_at`` back. That keeps this
431
+ helper pure / test-friendly.
432
+ """
433
+ if current_model is None:
434
+ base_where = "summary IS NULL"
435
+ params_base: tuple[Any, ...] = ()
436
+ else:
437
+ base_where = (
438
+ "(summary IS NULL OR summary_model IS DISTINCT FROM %s)"
439
+ )
440
+ params_base = (current_model,)
441
+
442
+ last_id: str | None = None
443
+ while True:
444
+ if last_id is None:
445
+ rows = conn.execute(
446
+ f"SELECT id::text, title, content FROM documents "
447
+ f"WHERE {base_where} "
448
+ f"ORDER BY id LIMIT %s",
449
+ (*params_base, batch_size),
450
+ ).fetchall()
451
+ else:
452
+ rows = conn.execute(
453
+ f"SELECT id::text, title, content FROM documents "
454
+ f"WHERE {base_where} AND id > %s::uuid "
455
+ f"ORDER BY id LIMIT %s",
456
+ (*params_base, last_id, batch_size),
457
+ ).fetchall()
458
+ if not rows:
459
+ return
460
+ last_id = str(rows[-1][0])
461
+ yield [
462
+ UnenrichedDocument(id=str(r[0]), title=str(r[1]), content=str(r[2]))
463
+ for r in rows
464
+ ]
465
+
466
+
467
+ def count_unenriched_documents(
468
+ conn: psycopg.Connection[Any], *, current_model: str | None = None
469
+ ) -> int:
470
+ """Return the number of documents that need enrichment.
471
+
472
+ Mirrors the eligibility rules of :func:`iter_unenriched_documents`:
473
+ ``current_model is None`` counts ``summary IS NULL`` rows only;
474
+ ``current_model is not None`` also counts rows whose ``summary_model``
475
+ differs from the supplied model (model-upgrade staleness).
476
+ """
477
+ if current_model is None:
478
+ row = conn.execute(
479
+ "SELECT count(*) FROM documents WHERE summary IS NULL"
480
+ ).fetchone()
481
+ else:
482
+ row = conn.execute(
483
+ "SELECT count(*) FROM documents "
484
+ "WHERE summary IS NULL "
485
+ "OR summary_model IS DISTINCT FROM %s",
486
+ (current_model,),
487
+ ).fetchone()
488
+ assert row is not None
489
+ return int(row[0])
490
+
491
+
492
+ def list_existing_tags(
493
+ conn: psycopg.Connection[Any], *, min_doc_count: int = 1
494
+ ) -> list[str]:
495
+ """Return every tag currently on documents, normalized + alpha-sorted.
496
+
497
+ Used by ``brain tag --auto`` to feed the LLM the existing tag vocabulary
498
+ so it preferentially proposes from it. Drafts are NOT excluded — they
499
+ can still contribute to vocabulary. Stable alpha sort makes test
500
+ fixtures byte-stable.
501
+
502
+ ``min_doc_count`` is the inclusive lower bound on how many docs must
503
+ already carry a tag for it to count. Q1-D ships with the default
504
+ ``min_doc_count=1`` (every tag counts); a future caller can require an
505
+ established tag (``min_doc_count > 1``) without a schema change.
506
+ """
507
+ rows = conn.execute(
508
+ """
509
+ SELECT t, COUNT(*) AS n
510
+ FROM documents, unnest(tags) AS t
511
+ GROUP BY t
512
+ HAVING COUNT(*) >= %s
513
+ ORDER BY t
514
+ """,
515
+ (min_doc_count,),
516
+ ).fetchall()
517
+ return [str(r[0]) for r in rows]
518
+
519
+
520
+ def list_documents(
521
+ conn: psycopg.Connection[Any],
522
+ *,
523
+ source: str | None = None,
524
+ tag: str | None = None,
525
+ limit: int = 20,
526
+ ) -> list[DocumentRow]:
527
+ """Return up to ``limit`` documents (most-recently-ingested first).
528
+
529
+ Optional ``source`` and ``tag`` filters mirror ``brain list``. The returned
530
+ rows omit the document body (``content``) and ``source_path`` to keep the
531
+ projection cheap.
532
+ """
533
+ where = ["TRUE"]
534
+ params: list[Any] = []
535
+ if source:
536
+ where.append("s.kind = %s")
537
+ params.append(source)
538
+ if tag:
539
+ where.append("%s = ANY(d.tags)")
540
+ params.append(tag)
541
+ sql = f"""
542
+ SELECT d.id::text, d.title, d.content_type, d.tags, s.kind, d.ingested_at
543
+ FROM documents d
544
+ LEFT JOIN sources s ON s.id = d.source_id
545
+ WHERE {" AND ".join(where)}
546
+ ORDER BY d.ingested_at DESC
547
+ LIMIT %s
548
+ """
549
+ params.append(limit)
550
+ rows = conn.execute(sql, params).fetchall()
551
+ return [
552
+ DocumentRow(
553
+ id=r[0],
554
+ title=r[1],
555
+ content_type=r[2],
556
+ tags=list(r[3] or []),
557
+ source_kind=r[4],
558
+ ingested_at=r[5],
559
+ )
560
+ for r in rows
561
+ ]
562
+
563
+
564
+ @dataclass
565
+ class StatusCounts:
566
+ """Aggregate counts for ``brain status`` / ``brain_status``.
567
+
568
+ Each caller (CLI vs MCP) formats this into its own response shape.
569
+ """
570
+
571
+ documents: int
572
+ chunks: int
573
+ sources: int
574
+ last_ingest: datetime | None
575
+ by_kind: list[tuple[str, int]]
576
+
577
+
578
+ def summary_counts(conn: psycopg.Connection[Any]) -> StatusCounts:
579
+ """Aggregate counts for ``status`` / ``brain_status``. Single round-trip via CTE.
580
+
581
+ Replaces five sequential SELECTs with one CTE query so ``brain status``
582
+ and the MCP ``brain_status`` tool each pay one network round-trip instead
583
+ of five. The return type (:class:`StatusCounts`) is unchanged.
584
+ """
585
+ row = conn.execute(
586
+ """
587
+ WITH
588
+ doc_stats AS (
589
+ SELECT count(*) AS doc_count,
590
+ max(ingested_at) AS last_ingest
591
+ FROM documents
592
+ ),
593
+ chunk_count AS (
594
+ SELECT count(*) AS n FROM chunks
595
+ ),
596
+ source_count AS (
597
+ SELECT count(*) AS n FROM sources
598
+ ),
599
+ by_kind AS (
600
+ SELECT coalesce(s.kind, 'manual') AS kind,
601
+ count(*) AS n
602
+ FROM documents d
603
+ LEFT JOIN sources s ON s.id = d.source_id
604
+ GROUP BY 1
605
+ )
606
+ SELECT
607
+ (SELECT doc_count FROM doc_stats),
608
+ (SELECT last_ingest FROM doc_stats),
609
+ (SELECT n FROM chunk_count),
610
+ (SELECT n FROM source_count),
611
+ (SELECT json_agg(json_build_array(kind, n) ORDER BY n DESC)
612
+ FROM by_kind)
613
+ """
614
+ ).fetchone()
615
+ assert row is not None # constant-expression SELECT always yields one row
616
+ doc_count, last_ingest, chunk_count, source_count, by_kind_json = row
617
+ by_kind: list[tuple[str, int]] = (
618
+ [(str(item[0]), int(item[1])) for item in by_kind_json]
619
+ if by_kind_json is not None
620
+ else []
621
+ )
622
+ return StatusCounts(
623
+ documents=int(doc_count),
624
+ chunks=int(chunk_count),
625
+ sources=int(source_count),
626
+ last_ingest=last_ingest,
627
+ by_kind=by_kind,
628
+ )
629
+
630
+
631
+ @dataclass
632
+ class NullEmbeddingChunk:
633
+ """A chunk whose embedding is NULL and needs backfill."""
634
+
635
+ id: str
636
+ content: str
637
+
638
+
639
+ def iter_chunks_missing_embedding(
640
+ conn: psycopg.Connection[Any],
641
+ *,
642
+ batch_size: int = 32,
643
+ include_embedded: bool = False,
644
+ ) -> Iterator[list[NullEmbeddingChunk]]:
645
+ """Yield batches of chunks whose embedding is NULL.
646
+
647
+ Uses keyset pagination over ``chunks.id`` (UUID, ordered) so the
648
+ in-memory footprint stays bounded even on a brain with hundreds of
649
+ thousands of chunks — only one batch's content is materialized at a
650
+ time. Keyset (rather than a server-side ``DECLARE CURSOR``) sidesteps
651
+ the autocommit/transaction subtleties that would arise from issuing
652
+ UPDATEs on the same connection while a named cursor is open.
653
+
654
+ The iterator advances on ``id`` rather than re-running ``LIMIT N``
655
+ against the NULL-set so that callers which inspect rows *without*
656
+ backfilling them (e.g. tests) still see every NULL row exactly once.
657
+
658
+ When ``include_embedded=True``, the ``embedding IS NULL`` filter is
659
+ dropped — the iterator yields every chunk in the table. Used by
660
+ ``brain reembed --all`` to re-embed an entire corpus after switching
661
+ embedder backends.
662
+ """
663
+ null_clause = "" if include_embedded else "WHERE embedding IS NULL"
664
+ null_clause_and = "" if include_embedded else "WHERE embedding IS NULL AND"
665
+ last_id: str | None = None
666
+ while True:
667
+ if last_id is None:
668
+ rows = conn.execute(
669
+ f"SELECT id::text, content FROM chunks "
670
+ f"{null_clause} "
671
+ f"ORDER BY id LIMIT %s",
672
+ (batch_size,),
673
+ ).fetchall()
674
+ else:
675
+ cursor_clause = (
676
+ "WHERE id > %s::uuid" if include_embedded else f"{null_clause_and} id > %s::uuid"
677
+ )
678
+ rows = conn.execute(
679
+ f"SELECT id::text, content FROM chunks "
680
+ f"{cursor_clause} "
681
+ f"ORDER BY id LIMIT %s",
682
+ (last_id, batch_size),
683
+ ).fetchall()
684
+ if not rows:
685
+ return
686
+ last_id = str(rows[-1][0])
687
+ yield [NullEmbeddingChunk(id=str(r[0]), content=str(r[1])) for r in rows]
688
+
689
+
690
+ def count_chunks_missing_embedding(
691
+ conn: psycopg.Connection[Any], *, include_embedded: bool = False
692
+ ) -> int:
693
+ """Return the number of chunks whose embedding is NULL.
694
+
695
+ When ``include_embedded=True``, returns the total chunk count instead.
696
+ """
697
+ sql = (
698
+ "SELECT count(*) FROM chunks"
699
+ if include_embedded
700
+ else "SELECT count(*) FROM chunks WHERE embedding IS NULL"
701
+ )
702
+ row = conn.execute(sql).fetchone()
703
+ assert row is not None # count(*) always yields one row
704
+ return int(row[0])
705
+
706
+
707
+ def iter_all_document_ids(
708
+ conn: psycopg.Connection[Any], *, batch_size: int = 256
709
+ ) -> Iterator[list[str]]:
710
+ """Yield batches of every document id in ascending ``id`` order.
711
+
712
+ Drives ``brain graphrag build --backfill`` — the batch equivalent of the
713
+ per-document graph reconcile hook. Uses keyset pagination over
714
+ ``documents.id`` (UUID, ordered) so the in-memory footprint stays bounded on
715
+ a large corpus, mirroring :func:`iter_chunks_missing_embedding` /
716
+ :func:`iter_unenriched_documents`.
717
+
718
+ The deterministic ascending-id order is what makes a build resumable: a
719
+ re-run after an interruption revisits the ids in the same order, and the
720
+ reconcile watermark skips the already-indexed prefix cheaply.
721
+ """
722
+ last_id: str | None = None
723
+ while True:
724
+ if last_id is None:
725
+ rows = conn.execute(
726
+ "SELECT id::text FROM documents ORDER BY id LIMIT %s",
727
+ (batch_size,),
728
+ ).fetchall()
729
+ else:
730
+ rows = conn.execute(
731
+ "SELECT id::text FROM documents WHERE id > %s::uuid "
732
+ "ORDER BY id LIMIT %s",
733
+ (last_id, batch_size),
734
+ ).fetchall()
735
+ if not rows:
736
+ return
737
+ last_id = str(rows[-1][0])
738
+ yield [str(r[0]) for r in rows]
739
+
740
+
741
+ def count_documents(conn: psycopg.Connection[Any]) -> int:
742
+ """Return the total number of documents (drives build progress reporting)."""
743
+ row = conn.execute("SELECT count(*) FROM documents").fetchone()
744
+ assert row is not None # count(*) always yields one row
745
+ return int(row[0])
746
+
747
+
748
+ def count_documents_with_tag(conn: psycopg.Connection[Any], tag: str) -> int:
749
+ """Return the number of documents carrying ``tag`` in their ``tags`` array.
750
+
751
+ Parameterized membership test (``%s = ANY(tags)``) over ``documents.tags``.
752
+ Backs the ``brain doctor`` inbox-size WARN and the ``brain capture`` inbox
753
+ count (Plan 09 quick-capture). ``tag`` is matched verbatim — callers
754
+ normalize it (e.g. via :func:`brain.tags.normalize_tags`) before counting
755
+ when the input might not already be canonical.
756
+ """
757
+ row = conn.execute(
758
+ "SELECT count(*) FROM documents WHERE %s = ANY(tags)", (tag,)
759
+ ).fetchone()
760
+ assert row is not None # count(*) always yields one row
761
+ return int(row[0])
762
+
763
+
764
+ def _count_null_embedding(
765
+ conn: psycopg.Connection[Any], table: str, column: str
766
+ ) -> int:
767
+ """Count rows in ``<table>`` whose vector ``<column>`` is NULL.
768
+
769
+ Identifiers are quoted via :class:`psycopg.sql.Identifier`; callers
770
+ validate ``(table, column)`` against the allowlist before reaching here.
771
+ """
772
+ count_sql = sql.SQL(
773
+ "SELECT count(*) FROM {table} WHERE {column} IS NULL"
774
+ ).format(table=sql.Identifier(table), column=sql.Identifier(column))
775
+ row = conn.execute(count_sql).fetchone()
776
+ assert row is not None # count(*) always yields one row
777
+ return int(row[0])
778
+
779
+
780
+ def finalize_embedding_index(
781
+ conn: psycopg.Connection[Any],
782
+ embedder: Embedder,
783
+ table: str = "chunks",
784
+ column: str = "embedding",
785
+ *,
786
+ create_hnsw: bool = True,
787
+ ) -> None:
788
+ """Finalize a pgvector embedding column once its backfill is complete.
789
+
790
+ Generalized over ``(table, column)`` (default ``chunks.embedding``) so the
791
+ GraphRAG tables can reuse it; ``(table, column)`` is checked against the
792
+ hard-coded allowlist in :mod:`brain.embedding_targets` and every identifier
793
+ is quoted via :class:`psycopg.sql.Identifier` — never string-formatted.
794
+
795
+ Two regimes, selected by ``create_hnsw``:
796
+
797
+ - ``create_hnsw=True`` (default — the ``chunks`` semantics, **unchanged**):
798
+ apply ``NOT NULL`` on the column, and for embedders with
799
+ ``dim <= 2000`` (arctic, voyage) additionally create an HNSW cosine
800
+ index. pgvector 0.8.x caps HNSW/IVFFlat at 2000 dims for ``vector``, so
801
+ higher-dim embedders (Qwen3 at 4096) get ``NOT NULL`` but skip the index
802
+ — sequential cosine scan is acceptable at personal-corpus scale.
803
+ - ``create_hnsw=False`` (the GraphRAG semantics, e.g.
804
+ ``graph_entities.embedding``): the column stays **NULLABLE** with **no**
805
+ HNSW index. Small row counts make sequential scan fine (spec §5) and
806
+ global ranking guards on ``IS NOT NULL`` rather than a column
807
+ constraint, so there is nothing to finalize yet — the call is a
808
+ validated no-op.
809
+
810
+ Idempotent — ``ALTER COLUMN ... SET NOT NULL`` is a no-op if the column
811
+ is already non-nullable, and ``CREATE INDEX IF NOT EXISTS`` is a no-op
812
+ if the index already exists.
813
+
814
+ Raises :class:`ValueError` (only on the ``create_hnsw=True`` path) if any
815
+ row still has a NULL embedding — that's a caller bug (the CLI should only
816
+ call this after asserting the NULL count is zero).
817
+ """
818
+ validate_embedding_target(table, column)
819
+ if not create_hnsw:
820
+ # GraphRAG deferred mode: column stays NULLABLE, no HNSW index, no
821
+ # NULL-completeness requirement. Nothing to finalize.
822
+ return
823
+
824
+ remaining = _count_null_embedding(conn, table, column)
825
+ if remaining > 0:
826
+ raise ValueError(
827
+ f"cannot finalize: {remaining} {table}.{column} value(s) "
828
+ f"still NULL"
829
+ )
830
+ index_name = embedding_index_name(table, column)
831
+ with conn.transaction():
832
+ conn.execute(
833
+ sql.SQL("ALTER TABLE {table} ALTER COLUMN {column} SET NOT NULL").format(
834
+ table=sql.Identifier(table), column=sql.Identifier(column)
835
+ )
836
+ )
837
+ if embedder.dim <= _PGVECTOR_HNSW_DIM_CAP:
838
+ conn.execute(
839
+ sql.SQL(
840
+ "CREATE INDEX IF NOT EXISTS {index} ON {table} "
841
+ "USING hnsw ({column} vector_cosine_ops)"
842
+ ).format(
843
+ index=sql.Identifier(index_name),
844
+ table=sql.Identifier(table),
845
+ column=sql.Identifier(column),
846
+ )
847
+ )
848
+
849
+
850
+ def sync_chunk_search_metadata(
851
+ conn: psycopg.Connection[Any], document_id: str
852
+ ) -> int:
853
+ """Refresh ``chunks.title_text`` and ``chunks.tags_text`` from documents.
854
+
855
+ Migration 009 denormalizes ``documents.title`` and ``documents.tags`` onto
856
+ every chunk so the weighted FTS tsvector (title at weight A, tags at
857
+ weight B) stays a single GIN-indexable expression. Anything that mutates
858
+ those two source columns without re-inserting chunks must call this so
859
+ the chunk-side denormalization stays consistent — every title/tag UPDATE
860
+ site in the codebase pairs with a call to this helper.
861
+
862
+ The ``IS DISTINCT FROM`` guards on both columns make repeated calls a
863
+ no-op (returns 0). Scoped to one document so the mutation is bounded
864
+ even on a brain with hundreds of thousands of chunks. Returns the
865
+ number of chunk rows actually updated — useful for tests and
866
+ observability.
867
+ """
868
+ cur = conn.execute(
869
+ "UPDATE chunks "
870
+ "SET title_text = d.title, "
871
+ " tags_text = array_to_string(d.tags, ' ') "
872
+ "FROM documents d "
873
+ "WHERE chunks.document_id = d.id "
874
+ " AND chunks.document_id = %s "
875
+ " AND (chunks.title_text IS DISTINCT FROM d.title "
876
+ " OR chunks.tags_text IS DISTINCT FROM array_to_string(d.tags, ' '))",
877
+ (document_id,),
878
+ )
879
+ return cur.rowcount or 0
880
+
881
+
882
+ @dataclass
883
+ class EmbeddingColumnState:
884
+ """Snapshot of the ``chunks.embedding`` column for ``brain doctor``.
885
+
886
+ ``has_index`` reports whether the HNSW cosine index exists. For low-dim
887
+ backends (arctic, voyage) finalize creates it; for Qwen3 (4096 dims) it
888
+ stays absent because pgvector's HNSW cap is 2000.
889
+ """
890
+
891
+ column_type: str
892
+ not_null: bool
893
+ has_index: bool
894
+
895
+
896
+ def embedding_column_state(conn: psycopg.Connection[Any]) -> EmbeddingColumnState:
897
+ """Return the current state of the ``chunks.embedding`` column.
898
+
899
+ Used by ``brain doctor`` to surface the post-migration / pre-finalize
900
+ state to the user without gating exit code on it.
901
+ """
902
+ col_row = conn.execute(
903
+ "SELECT format_type(atttypid, atttypmod), attnotnull "
904
+ "FROM pg_attribute "
905
+ "WHERE attrelid = 'chunks'::regclass AND attname = 'embedding'"
906
+ ).fetchone()
907
+ assert col_row is not None # chunks.embedding always exists post-migration
908
+ idx_row = conn.execute(
909
+ "SELECT 1 FROM pg_indexes WHERE indexname = 'chunks_embedding_idx'"
910
+ ).fetchone()
911
+ return EmbeddingColumnState(
912
+ column_type=str(col_row[0]),
913
+ not_null=bool(col_row[1]),
914
+ has_index=idx_row is not None,
915
+ )
916
+
917
+
918
+ # ---------------------------------------------------------------------------
919
+ # Mirror drift helpers — used by ``brain doctor`` and
920
+ # ``brain vault prune-orphans`` to surface DB-vs-disk drift in the
921
+ # ``_ingested/`` mirror tier.
922
+ # ---------------------------------------------------------------------------
923
+
924
+ # Top-level vault subdirectory that holds mirror files for stdin/file-ingested
925
+ # documents. Mirrors :mod:`brain.vault.sync._INGESTED_DIR_NAME` — duplicating
926
+ # the constant here keeps ``queries`` from importing the heavier
927
+ # ``vault.sync`` module just to read a string.
928
+ _INGESTED_DIR_NAME = "_ingested"
929
+
930
+ # Frontmatter fence delimiter. Mirrors :data:`brain.vault.frontmatter._FENCE`;
931
+ # duplicated here so the mirror-drift scan can slice a file's frontmatter block
932
+ # without importing the heavier ``vault.frontmatter`` module.
933
+ _FRONTMATTER_FENCE = "---"
934
+
935
+
936
+ def _read_mirror_frontmatter_id(path: Path) -> str | None:
937
+ """Return the frontmatter ``id`` of a mirror file, or ``None`` to skip it.
938
+
939
+ Semantically identical to ``parse_frontmatter(path.read_text())[0].get("id")``
940
+ for the drift scan's needs — same fence rules, same "no fence / no closing
941
+ fence / malformed YAML / non-mapping / no string id ⇒ skip" behavior — but
942
+ reads only the frontmatter block (never the possibly-megabyte body) and
943
+ loads that block with libyaml when available. Returns the ``id`` string only
944
+ when the file opens with a ``---`` fence, has a closing ``---`` fence, whose
945
+ enclosed YAML parses to a mapping carrying a non-empty string ``id``.
946
+
947
+ ``OSError`` (and, matching the previous ``read_text``-based code, a
948
+ ``UnicodeDecodeError`` on a non-UTF-8 file) propagates to the caller rather
949
+ than being swallowed; every real mirror file is UTF-8, so this never fires.
950
+ """
951
+ try:
952
+ with path.open(encoding="utf-8") as handle:
953
+ first = handle.readline()
954
+ if first.rstrip("\r\n") != _FRONTMATTER_FENCE:
955
+ return None
956
+ fm_lines: list[str] = []
957
+ for line in handle:
958
+ if line.rstrip("\r\n") == _FRONTMATTER_FENCE:
959
+ break
960
+ fm_lines.append(line)
961
+ else:
962
+ # No closing fence: the whole file is body, no frontmatter.
963
+ return None
964
+ except OSError:
965
+ return None
966
+ yaml_text = "".join(fm_lines)
967
+ if not yaml_text.strip():
968
+ return None
969
+ try:
970
+ parsed = yaml.load(yaml_text, Loader=_FrontmatterLoader)
971
+ except yaml.YAMLError:
972
+ return None
973
+ if not isinstance(parsed, dict):
974
+ return None
975
+ doc_id = parsed.get("id")
976
+ if not isinstance(doc_id, str) or not doc_id:
977
+ return None
978
+ return doc_id
979
+
980
+
981
+ def _iter_ingested_mirror_ids(vault_path: Path) -> Iterator[tuple[Path, str]]:
982
+ """Yield ``(path, id)`` for every ``.md`` mirror under ``<vault>/_ingested/``.
983
+
984
+ Walks the mirror tree once in :py:meth:`Path.rglob` sort order (deterministic
985
+ output) and yields only files that carry a resolvable string frontmatter
986
+ ``id`` (see :func:`_read_mirror_frontmatter_id`). Files without parseable
987
+ frontmatter or without a string ``id`` are silently skipped — they can't be
988
+ classified by id. Shared by :func:`iter_orphan_mirror_files`,
989
+ :func:`iter_stale_mirror_files`, and :func:`mirror_drift_summary` so the
990
+ expensive read+parse happens exactly once per call site.
991
+ """
992
+ ingested_dir = vault_path / _INGESTED_DIR_NAME
993
+ if not ingested_dir.is_dir():
994
+ return
995
+ for path in sorted(ingested_dir.rglob("*.md")):
996
+ if not path.is_file():
997
+ continue
998
+ doc_id = _read_mirror_frontmatter_id(path)
999
+ if doc_id is not None:
1000
+ yield path, doc_id
1001
+
1002
+
1003
+ @dataclass(frozen=True)
1004
+ class MirrorDriftSummary:
1005
+ """Counts of DB↔disk drift in the ``_ingested/`` mirror tier.
1006
+
1007
+ All four counters are independent; a healthy vault has zeros across the
1008
+ board. Used by ``brain doctor`` to surface drift the user can act on:
1009
+
1010
+ - ``rows_with_null_vault_path`` → ``brain vault export --force`` to
1011
+ re-materialize mirror files for ingest rows missing one.
1012
+ - ``orphan_files`` → ``brain vault prune-orphans`` to delete mirror files
1013
+ whose document row was removed.
1014
+ - ``ghost_rows`` → manual ``brain rm <id>`` for each, or
1015
+ ``brain vault export --force`` to re-create the missing files.
1016
+ """
1017
+
1018
+ total_ingested_rows: int
1019
+ rows_with_null_vault_path: int
1020
+ ghost_rows: int # vault_path set, file missing on disk
1021
+ orphan_files: int # file on disk under _ingested/, no DB row matches its id
1022
+
1023
+
1024
+ def iter_orphan_mirror_files(
1025
+ conn: psycopg.Connection[Any], *, vault_path: Path
1026
+ ) -> Iterator[Path]:
1027
+ """Yield absolute paths under ``<vault>/_ingested/`` that have no DB row.
1028
+
1029
+ A file is "orphan" iff:
1030
+
1031
+ - It lives under ``<vault>/_ingested/`` (recursive walk).
1032
+ - It is a regular ``.md`` file.
1033
+ - Its YAML frontmatter parses cleanly and contains a string ``id`` key.
1034
+ - That ``id`` value matches no row in ``documents.id``.
1035
+
1036
+ Files without parseable frontmatter (e.g. ``_ingested/README.md`` written
1037
+ by ``brain vault init``, or any markdown file the user dropped in
1038
+ manually) are skipped — "no frontmatter" is intentional, not orphan.
1039
+ Files whose frontmatter has no ``id`` are likewise skipped: pruning would
1040
+ silently delete user-authored content.
1041
+
1042
+ Returns a generator so the caller (``brain doctor``) can show a count
1043
+ without holding the full path list. Iteration order follows
1044
+ :py:meth:`Path.rglob` sort for deterministic output. The corpus is
1045
+ bounded (≤10K rows in the personal-brain use case), so we materialize
1046
+ every ``documents.id`` into a single in-memory ``set`` to avoid issuing
1047
+ one SELECT per file.
1048
+ """
1049
+ ingested_dir = vault_path / _INGESTED_DIR_NAME
1050
+ if not ingested_dir.is_dir():
1051
+ return
1052
+ rows = conn.execute("SELECT id::text FROM documents").fetchall()
1053
+ known_ids = {str(r[0]) for r in rows}
1054
+ for path, doc_id in _iter_ingested_mirror_ids(vault_path):
1055
+ if doc_id in known_ids:
1056
+ continue
1057
+ yield path
1058
+
1059
+
1060
+ def iter_stale_mirror_files(
1061
+ conn: psycopg.Connection[Any], *, vault_path: Path
1062
+ ) -> Iterator[Path]:
1063
+ """Yield mirror files whose id resolves but whose path is not the row's ``vault_path``.
1064
+
1065
+ A file is "stale" iff:
1066
+
1067
+ - It lives under ``<vault>/_ingested/`` (recursive walk).
1068
+ - It is a regular ``.md`` file.
1069
+ - Its YAML frontmatter parses and contains a string ``id``.
1070
+ - That ``id`` matches a row in ``documents``.
1071
+ - That row's ``documents.vault_path`` is set to a path **other** than this file.
1072
+
1073
+ These are leftovers from a slug-shape change (e.g. P1.5 reshaped Gmail
1074
+ mirrors from ``Mon,-27-Ap-19dd20fb-no-subject.md`` to
1075
+ ``2026-04-28-03015b-no-subject.md``). The DB row's ``vault_path`` was
1076
+ updated but the old file was not unlinked, so it lingers on disk while
1077
+ the new file is the canonical mirror. Stale files don't get deleted by
1078
+ :func:`iter_orphan_mirror_files` because their id still resolves; this
1079
+ helper catches them.
1080
+
1081
+ Files whose frontmatter has no ``id``, or whose ``id`` doesn't resolve
1082
+ to any row, are NOT yielded — those are handled by
1083
+ :func:`iter_orphan_mirror_files`. Rows with ``vault_path IS NULL`` are
1084
+ treated as not-yet-mirrored and yield no stales (the file at this path
1085
+ might be the row's new canonical mirror once the next export runs).
1086
+ """
1087
+ ingested_dir = vault_path / _INGESTED_DIR_NAME
1088
+ if not ingested_dir.is_dir():
1089
+ return
1090
+ rows = conn.execute(
1091
+ "SELECT id::text, vault_path FROM documents WHERE vault_path IS NOT NULL"
1092
+ ).fetchall()
1093
+ canonical: dict[str, str] = {str(r[0]): str(r[1]) for r in rows}
1094
+ for path, doc_id in _iter_ingested_mirror_ids(vault_path):
1095
+ canonical_path = canonical.get(doc_id)
1096
+ if canonical_path is None:
1097
+ continue
1098
+ relative = path.relative_to(vault_path).as_posix()
1099
+ if relative == canonical_path:
1100
+ continue
1101
+ yield path
1102
+
1103
+
1104
+ def mirror_drift_summary(
1105
+ conn: psycopg.Connection[Any], *, vault_path: Path
1106
+ ) -> MirrorDriftSummary:
1107
+ """Compute the four-counter drift snapshot for ``<vault>/_ingested/``.
1108
+
1109
+ One SQL round-trip (``id``, ``kind``, ``vault_path`` for every row) feeds
1110
+ all three DB-derived signals — the ingested/NULL counts and the known-id
1111
+ set — plus one filesystem walk via :func:`_iter_ingested_mirror_ids`. Safe
1112
+ to call from ``brain doctor`` on every invocation: at personal-corpus scale
1113
+ the whole pass is dominated by walking the mirror tree, and that walk reads
1114
+ only each file's frontmatter block.
1115
+
1116
+ Ghost rows are counted purely against the filesystem: a row whose
1117
+ ``vault_path`` points at a file that doesn't exist on disk under
1118
+ ``vault_path`` contributes one ghost. We don't dedupe pairs (a single
1119
+ DB row with a missing file is one ghost regardless of whether an
1120
+ orphan file with the same id happens to also exist — these are
1121
+ independent symptoms with different fixes).
1122
+ """
1123
+ rows = conn.execute("SELECT id::text, kind, vault_path FROM documents").fetchall()
1124
+ known_ids: set[str] = set()
1125
+ total_ingested = 0
1126
+ null_vault_path = 0
1127
+ ingested_vault_paths: list[str] = []
1128
+ for doc_id, kind, vault_path_value in rows:
1129
+ known_ids.add(str(doc_id))
1130
+ if kind != "ingested":
1131
+ continue
1132
+ total_ingested += 1
1133
+ if vault_path_value is None:
1134
+ null_vault_path += 1
1135
+ else:
1136
+ ingested_vault_paths.append(str(vault_path_value))
1137
+
1138
+ ghost_count = 0
1139
+ for relative in ingested_vault_paths:
1140
+ if not (vault_path / relative).is_file():
1141
+ ghost_count += 1
1142
+
1143
+ orphan_count = 0
1144
+ for _path, doc_id in _iter_ingested_mirror_ids(vault_path):
1145
+ if doc_id not in known_ids:
1146
+ orphan_count += 1
1147
+
1148
+ return MirrorDriftSummary(
1149
+ total_ingested_rows=total_ingested,
1150
+ rows_with_null_vault_path=null_vault_path,
1151
+ ghost_rows=ghost_count,
1152
+ orphan_files=orphan_count,
1153
+ )
1154
+
1155
+
1156
+ def list_public_tables(conn: psycopg.Connection[Any]) -> list[str]:
1157
+ """Return the sorted names of base tables in the ``public`` schema.
1158
+
1159
+ Used by ``brain analyze`` to validate a user-supplied table name before it
1160
+ is interpolated into an ``ANALYZE`` statement, giving a friendly error on a
1161
+ typo and a defense-in-depth check on top of identifier quoting.
1162
+ """
1163
+ rows = conn.execute(
1164
+ """
1165
+ SELECT table_name
1166
+ FROM information_schema.tables
1167
+ WHERE table_schema = 'public' AND table_type = 'BASE TABLE'
1168
+ ORDER BY table_name
1169
+ """
1170
+ ).fetchall()
1171
+ return [str(name) for (name,) in rows]
1172
+
1173
+
1174
+ def analyze_tables(conn: psycopg.Connection[Any], tables: list[str]) -> None:
1175
+ """Run ``ANALYZE`` on each given table to refresh planner statistics.
1176
+
1177
+ Each identifier is quoted via :class:`psycopg.sql.Identifier`; callers
1178
+ validate ``tables`` against :func:`list_public_tables` before reaching here
1179
+ (same contract as :func:`_count_null_embedding`). ``ANALYZE`` is run one
1180
+ table at a time so a bad name fails loudly on that name rather than
1181
+ silently analyzing a prefix of the list.
1182
+
1183
+ The connection should be in ``autocommit`` mode (or committed by the
1184
+ caller): ``ANALYZE`` writes to ``pg_statistic``/``pg_class``, which is
1185
+ transactional and would otherwise be rolled back on connection close.
1186
+ """
1187
+ for table in tables:
1188
+ conn.execute(sql.SQL("ANALYZE {table}").format(table=sql.Identifier(table)))