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/config.py ADDED
@@ -0,0 +1,1949 @@
1
+ """Configuration loading from environment / .env."""
2
+ import os
3
+ import re
4
+ from dataclasses import dataclass, field
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from dotenv import dotenv_values, find_dotenv
9
+
10
+ DEFAULT_OLLAMA_HOST = "http://localhost:11434"
11
+ DEFAULT_QWEN3_MODEL = "qwen3-embedding:8b"
12
+ DEFAULT_EMBEDDER = "arctic"
13
+ # ``none`` is the FTS-only backend (see :class:`brain.embeddings.NullEmbedder`):
14
+ # a user with no Ollama gets a working brain (ingest + lexical search + passing
15
+ # doctor) instead of crashes. It produces no vectors, so hybrid search degrades
16
+ # to the FTS leg.
17
+ _VALID_EMBEDDERS = {"arctic", "voyage", "qwen3", "none"}
18
+
19
+ # How long Ollama keeps a model loaded in VRAM between requests. Passed as
20
+ # ``keep_alive`` in every outgoing Ollama HTTP payload (``/api/embed``,
21
+ # ``/api/chat``, ``/api/generate``). "30m" keeps the model hot across bursts of
22
+ # ingest/search; raise to "1h" if your workflow has longer idle gaps, or set
23
+ # "-1" to keep the model loaded indefinitely (never unloaded). Set via
24
+ # ``BRAIN_OLLAMA_KEEP_ALIVE``; accepts any format Ollama understands: a positive
25
+ # integer duration string ("30m", "1h", "60s"), a bare positive integer seconds
26
+ # string ("60"), "-1" (keep loaded forever), or "0" (unload immediately after
27
+ # each call). The shipped default stays "30m" so a machine shared with other GPU
28
+ # workloads frees VRAM on idle; on a dedicated box, "-1" eliminates the measured
29
+ # 5,028ms cold-embed latency cliff that hits the first embed once the model has
30
+ # gone idle past the keep_alive window. Other malformed values are rejected.
31
+ DEFAULT_OLLAMA_KEEP_ALIVE = "30m"
32
+
33
+ # Accepts "30m", "1h", "60s", "60" etc. — any POSITIVE integer optionally
34
+ # followed by m/h/s — plus the two Ollama sentinels "-1" (keep loaded
35
+ # indefinitely) and "0" (unload immediately). Empty / whitespace-only strings
36
+ # fall back to the default; every other malformed value ("-2", "-1m", "0m",
37
+ # "abc", "1.5m", …) is rejected at load time.
38
+ _KEEP_ALIVE_RE = re.compile(r"^(-1|0|[1-9]\d*(?:m|h|s)?)$")
39
+
40
+
41
+ def keep_alive_wire_value(keep_alive: str) -> str | int:
42
+ """Coerce the numeric keep_alive sentinels to JSON numbers for Ollama.
43
+
44
+ Ollama's ``/api/embed`` and ``/api/chat`` accept ``keep_alive`` as either
45
+ a duration STRING with a unit (``"30m"``, ``"1h"``) or a bare JSON NUMBER
46
+ of seconds (``-1`` = keep the model loaded indefinitely, ``0`` = unload
47
+ immediately). They reject the unit-less strings ``"-1"`` / ``"0"`` with
48
+ HTTP 400 (``time: missing unit in duration "-1"``). Config validation
49
+ accepts ``"-1"`` / ``"0"`` as the documented sentinels, so EVERY Ollama
50
+ payload site must translate through this one function; unit-bearing
51
+ strings pass through unchanged.
52
+ """
53
+ if keep_alive in ("-1", "0"):
54
+ return int(keep_alive)
55
+ return keep_alive
56
+
57
+ # Cosine-similarity floor for the vector leg of hybrid search. Tuned
58
+ # empirically against the live corpus on 2026-05-06 (see Phase D of
59
+ # `docs/plans/2026-05-06-search-ranking-fix.md` and
60
+ # `tests/test_search_floor_default_excludes_known_bad.py`).
61
+ #
62
+ # Measurement: real Arctic embedding of the query ``person-x``
63
+ # vs. stored chunk embeddings on the live corpus.
64
+ #
65
+ # * Known-bad docs (interview prep / cheatsheet false positives):
66
+ # max cosine <= 0.20.
67
+ # * True positives kept by acceptance criterion #1: Krisp meeting
68
+ # ``3508c63e`` max 0.36; Gmail thread ``bc9f06c9`` max 0.27.
69
+ #
70
+ # The clean gap is (0.20, 0.27). A floor of 0.25 sits ``max_bad +
71
+ # 0.05`` (per plan revision #3) and stays comfortably below the
72
+ # 0.27 true-positive ceiling so neither acceptance-criterion doc is
73
+ # dropped from the vector leg. Override via ``BRAIN_VECTOR_SIM_FLOOR``.
74
+ DEFAULT_VECTOR_SIM_FLOOR = 0.25
75
+
76
+ # Default exponential-decay half-life for the recency boost applied after RRF.
77
+ # At 180 days a document is a year old (365 days) => boost ~0.25x. Tuned as
78
+ # a reasonable default for a personal corpus that spans years; override via
79
+ # ``BRAIN_RECENCY_HALFLIFE_DAYS``. Set to a very large value (e.g. 999999)
80
+ # to effectively disable the boost.
81
+ DEFAULT_RECENCY_HALFLIFE_DAYS = 180.0
82
+
83
+ # Default token budget for snippet-context expansion. After the best-matching
84
+ # chunk is selected, this many tokens of neighboring-chunk context are
85
+ # stitched around it to give Claude / the user richer reading context. Set to
86
+ # 0 to disable expansion. Override via ``BRAIN_SNIPPET_CONTEXT_TOKENS``.
87
+ DEFAULT_SNIPPET_CONTEXT_TOKENS = 200
88
+
89
+ # Default vault location -- clean, no implicit cloud sync. Users who want iCloud
90
+ # can either symlink ``~/brain-vault`` to an iCloud Drive folder or set
91
+ # ``BRAIN_VAULT_PATH`` to an iCloud path.
92
+ DEFAULT_VAULT_PATH = Path.home() / "brain-vault"
93
+
94
+ # Default doc-count threshold for the People Hub (``brain people``,
95
+ # ``<vault>/people/``). Persons with fewer than this many documents whose
96
+ # names are NOT pinned in ``<vault>/_people.yml`` are filtered out. The
97
+ # default of 3 keeps the long-tail of one-off cc'd recipients from each
98
+ # getting their own page; curated ``_people.yml`` entries always render
99
+ # regardless. Override via ``BRAIN_PEOPLE_HUB_MIN_DOCS``.
100
+ DEFAULT_PEOPLE_HUB_MIN_DOCS = 3
101
+
102
+ # Plan 09 -- quick-capture inbox (`brain capture`) defaults.
103
+ #
104
+ # Number of leading words from the body used to build the auto-title slug when
105
+ # ``--title`` is omitted. Override via ``BRAIN_CAPTURE_TITLE_WORDS``.
106
+ DEFAULT_CAPTURE_TITLE_WORDS = 6
107
+ # Inbox size strictly above which `brain doctor` warns the capture queue is
108
+ # backing up (count > threshold; consumed in Plan 09 Phase 3). Override via
109
+ # ``BRAIN_CAPTURE_INBOX_WARN_THRESHOLD``.
110
+ DEFAULT_CAPTURE_INBOX_WARN_THRESHOLD = 20
111
+
112
+ # Plan 10 -- `brain review weekly` periodic-synthesis defaults. All three are
113
+ # positive-integer display caps validated the same way as the capture knobs.
114
+ #
115
+ # Max activity / ingested documents listed per weekly report section.
116
+ DEFAULT_REVIEW_ACTIVITY_LIMIT = 20
117
+ # Max theme clusters (graph communities or tag clusters) per weekly report.
118
+ DEFAULT_REVIEW_THEME_LIMIT = 5
119
+ # Max open-loop (action item) rows listed per weekly report.
120
+ DEFAULT_REVIEW_OPEN_LOOP_LIMIT = 20
121
+
122
+ # Plan 01 -- `brain brief` proactive daily-digest defaults. All four are
123
+ # positive-integer knobs validated the same way as the capture knobs.
124
+ #
125
+ # Default capture window (hours) for the brief's "recent captures" section.
126
+ DEFAULT_BRIEF_SINCE_HOURS = 24
127
+ # Default open-todo window (days) for the brief's "open action items" section.
128
+ DEFAULT_BRIEF_TODO_SINCE_DAYS = 7
129
+ # Max recent-capture rows listed in the brief.
130
+ DEFAULT_BRIEF_CAPTURE_LIMIT = 20
131
+ # Max pinned-doc rows listed in the brief.
132
+ DEFAULT_BRIEF_PIN_LIMIT = 10
133
+
134
+ # Wave Q1-D -- enrichment (auto-summary + auto-tag) defaults.
135
+ #
136
+ # ``DEFAULT_ENRICH_MODEL`` is the Ollama model name passed to ``/api/chat``.
137
+ # llama3.1:8b is the only model authorized by the roadmap intake brief for
138
+ # Q1-D. Users override via ``BRAIN_ENRICH_MODEL`` -- anything pullable via
139
+ # ``ollama pull <name>`` works as long as it supports JSON-mode output.
140
+ DEFAULT_ENRICH_MODEL = "llama3.1:8b"
141
+
142
+ # Min content tokens (tiktoken ``cl100k_base``) below which the post-ingest
143
+ # enrichment hook silently skips. A 50-token doc is roughly two sentences;
144
+ # the title alone is already a fine summary. Conservative default mirrors
145
+ # the planner's recommendation (D3 in the wave plan).
146
+ DEFAULT_ENRICH_MIN_TOKENS = 50
147
+
148
+ # Max content tokens fed to the model. llama3.1:8b has a 128K context window
149
+ # but we never need more than the opening of a doc to summarize it. Capping
150
+ # at 1200 tokens (~900 words, the doc head) keeps the LLM round-trip under
151
+ # ~2.5s on M-series silicon (was ~7s at 4000 tokens) and bounds the prompt
152
+ # cost while still covering the content that matters most for a 2-3 sentence
153
+ # summary. Override via ``BRAIN_ENRICH_MAX_INPUT_TOKENS`` when higher fidelity
154
+ # on very long docs is more important than throughput.
155
+ DEFAULT_ENRICH_MAX_INPUT_TOKENS = 1200
156
+
157
+ # Ollama HTTP timeout for ``/api/chat`` calls. 60 s gives headroom for a
158
+ # cold-model swap-in on first call without spiraling. Override via
159
+ # ``BRAIN_ENRICH_TIMEOUT_SECONDS``.
160
+ DEFAULT_ENRICH_TIMEOUT_SECONDS = 60.0
161
+
162
+ # Plan 02 -- spaced-repetition resurfacing (`brain resurface`) defaults.
163
+ #
164
+ # ``DEFAULT_RESURFACE_LIMIT`` is the number of docs surfaced per run when
165
+ # ``--limit`` is omitted. Override via ``BRAIN_RESURFACE_LIMIT`` (int >= 1).
166
+ DEFAULT_RESURFACE_LIMIT = 7
167
+ # Documents younger than this many days are excluded from the queue before
168
+ # scoring -- brand-new notes don't need resurfacing. Override via
169
+ # ``BRAIN_RESURFACE_MIN_AGE_DAYS`` (int >= 0).
170
+ DEFAULT_RESURFACE_MIN_AGE_DAYS = 14
171
+ # Exponential half-life (days) for the age factor: at this age the age factor
172
+ # is 0.5, saturating toward 1.0 for much older docs. Reuses the ``0.5^(t/hl)``
173
+ # decay shape from search.py, inverted so older = higher priority. Override via
174
+ # ``BRAIN_RESURFACE_AGE_HALFLIFE_DAYS`` (float > 0).
175
+ DEFAULT_RESURFACE_AGE_HALFLIFE_DAYS = 180.0
176
+ # Exponential half-life (days) for the access-staleness factor: a doc last
177
+ # opened this many days ago contributes 0.5. Shorter than the age half-life so
178
+ # a recent open visibly deprioritizes a doc. Override via
179
+ # ``BRAIN_RESURFACE_ACCESS_HALFLIFE_DAYS`` (float > 0).
180
+ DEFAULT_RESURFACE_ACCESS_HALFLIFE_DAYS = 90.0
181
+
182
+ # Wave G1-c -- GraphRAG incremental sync (people aspect) settings.
183
+ #
184
+ # Graph sync is OPT-IN by origin; ``BRAIN_GRAPH_ENABLED`` now defaults to True so
185
+ # new deployments get graph retrieval out of the box. Deployments on stock pgvector
186
+ # (no AGE) are safe: GraphSyncer.reconcile is best-effort + never-raises. When
187
+ # the AGE image is present, a post-write/post-delete hook keeps the graph in sync with
188
+ # ``documents`` (see :mod:`brain.graph_rag.sync`). The remaining knobs map
189
+ # 1:1 onto :class:`brain.graph_rag.reconcile.ReconcileConfig`; defaults
190
+ # mirror the canonical constants in :mod:`brain.graph_rag.cooccur` /
191
+ # :mod:`brain.graph_rag.weighting` -- kept as literals here (not imported) so
192
+ # ``config`` stays import-cheap and free of any cycle with the graph package.
193
+ DEFAULT_GRAPH_ENABLED = True
194
+ DEFAULT_GRAPH_TENANT_ID = "default"
195
+ DEFAULT_GRAPH_COOCCUR_WINDOW = 3 # == brain.graph_rag.cooccur.DEFAULT_COOCCUR_WINDOW
196
+ DEFAULT_GRAPH_MAX_ENTITIES = 40 # == cooccur.DEFAULT_MAX_ENTITIES_PER_DOC
197
+ DEFAULT_GRAPH_GENERIC_DF_RATIO = 0.30 # == weighting.DEFAULT_GENERIC_DF
198
+
199
+ # Accepted spellings for the boolean ``BRAIN_GRAPH_ENABLED`` /
200
+ # ``BRAIN_GRAPH_CONCEPTS`` flags (compared case-insensitively after ``.strip()``).
201
+ _GRAPH_ENABLED_TRUTHY = frozenset({"1", "true", "yes", "on"})
202
+ _GRAPH_ENABLED_FALSY = frozenset({"0", "false", "no", "off"})
203
+
204
+ # Wave G2 -- GraphRAG concept extraction + bounded retrieval (spec §10). Parsed
205
+ # here in G2-a; the concept aspect (G2-b/c) and the local/themes retrieval
206
+ # surfaces (G2-d..i) consume them. Defaults follow spec §10 + Codex ruling Q4
207
+ # (``BRAIN_GRAPH_MAX_DEGREE`` = 50, ``BRAIN_GRAPH_MIN_EDGE_WEIGHT`` = 0.20).
208
+ DEFAULT_GRAPH_CONCEPTS = True
209
+ # Ollama model for the gated concept entity extractor (spec §3 D3: the default
210
+ # ``OllamaExtractor`` wraps ``enrichment.extract_entities()``). Mirrors the
211
+ # enrich-model default convention but kept a separate literal so the concept
212
+ # extractor and the summary enricher stay independently overridable -- ``config``
213
+ # carries no enrich<->graph coupling.
214
+ DEFAULT_GRAPH_EXTRACT_MODEL = "llama3.1:8b"
215
+ # Head-cap (tiktoken ``cl100k_base`` tokens) applied to a document body BEFORE the
216
+ # concept extractor chunks it (perf Fix C, 2026-05-24). The extractor calls the
217
+ # LLM once per ~1500-token chunk, so a long document generates a heavy tail of
218
+ # calls (the perf investigation measured 7.7% of docs driving ~33% of all calls).
219
+ # Capping the input to its first ~5-6 chunks bounds that tail while preserving
220
+ # recall on the bulk; mirrors the summary enricher's input head cap. ``0`` /
221
+ # ``none`` / ``unlimited`` disables the cap (whole document extracted) -- the
222
+ # escape hatch. A generous default of 8000 only trims the extreme tail.
223
+ DEFAULT_GRAPH_EXTRACT_MAX_INPUT_TOKENS = 8000
224
+ DEFAULT_GRAPH_DEPTH = 2 # spec §6 bounded variable-length traversal radius
225
+ DEFAULT_GRAPH_FRONTIER_CAP = 200 # spec §6 LIMIT on entities reached per seed
226
+ DEFAULT_GRAPH_MAX_DEGREE = 50 # Codex ruling Q4 -- per-node expansion fan-out cap
227
+ DEFAULT_GRAPH_MIN_EDGE_WEIGHT = 0.20 # Codex ruling Q4 -- normalized-lift floor
228
+ DEFAULT_GRAPH_THEME_LIMIT = 5 # spec §6b ranked ThemeGroup count
229
+
230
+ # Wave G3 -- global community detection (networkx Louvain) knobs (spec §17c).
231
+ # Parsed here in G3-a; the detection core (G3-b), the lazy/eager summaries
232
+ # (G3-c), and the global retrieval path (G3-d) consume them. §17c pins the
233
+ # migration-013 schema (Q1) and the perf budgets (Q8) but does NOT pin these
234
+ # tuning defaults, so the values below are CHOSEN to be consistent with the
235
+ # existing graph knobs and are documented as such in ``.env.example``.
236
+ DEFAULT_GRAPH_COMMUNITY_RESOLUTION = 1.0 # networkx louvain_communities() default
237
+ DEFAULT_GRAPH_COMMUNITY_SEED = 1234 # deterministic Louvain RNG seed (chosen)
238
+ DEFAULT_GRAPH_COMMUNITY_MIN_SIZE = 3 # min members to materialize a community (chosen)
239
+ DEFAULT_GRAPH_COMMUNITY_JACCARD = 0.5 # stable-identity match threshold (§17c Q3/Q7; chosen)
240
+ DEFAULT_GRAPH_COMMUNITY_LIMIT = 5 # global retrieval community count (== theme limit)
241
+ # Perf/ops safety valve: max communities materialized per tenant per build
242
+ # (bounds the summary + embedding cost behind the §17c Q8 budgets). ``None`` ==
243
+ # unlimited so the default is behavior-neutral; mirrors the int|none idiom of
244
+ # ``BRAIN_GRAPH_MAX_ENTITIES_PER_DOC``.
245
+ DEFAULT_GRAPH_COMMUNITY_MAX: int | None = None
246
+
247
+ # Tacit-knowledge elicitation knobs (feat/tacit-knowledge-elicitation).
248
+ #
249
+ # ``DEFAULT_ELICIT_MIN_EVIDENCE_DOCS`` — minimum number of evidence documents
250
+ # required before a gap is eligible for surfacing. A gap with fewer supporting
251
+ # docs is too thin to act on; conservative default of 3 mirrors the People Hub
252
+ # threshold.
253
+ DEFAULT_ELICIT_MIN_EVIDENCE_DOCS = 3
254
+
255
+ # Minimum gap score (in [0.0, 1.0]) for a gap to enter the queue. Below this
256
+ # threshold the gap is computed but silently discarded. Default 0.3 keeps
257
+ # low-confidence noise out while still surfacing moderate signals.
258
+ DEFAULT_ELICIT_MIN_GAP_SCORE = 0.3
259
+
260
+ # Maximum number of open gaps returned by ``brain elicit list`` in a single
261
+ # query. Keeps the CLI output manageable; override via
262
+ # ``BRAIN_ELICIT_QUEUE_LIMIT``.
263
+ DEFAULT_ELICIT_QUEUE_LIMIT = 20
264
+
265
+ # Gates the contradiction detector. Off by default because contradiction
266
+ # detection requires at least ``elicit_contradiction_min_docs`` documents for
267
+ # a target and can produce false positives on sparse corpora.
268
+ DEFAULT_ELICIT_CONTRADICTION_ENABLED = False
269
+
270
+ # Minimum documents for a target before contradiction detection runs on it.
271
+ # Ignored when ``elicit_contradiction_enabled`` is False.
272
+ DEFAULT_ELICIT_CONTRADICTION_MIN_DOCS = 5
273
+
274
+ # Plan 05 -- `brain timeline` (temporal evolution) knobs.
275
+ #
276
+ # ``DEFAULT_TIMELINE_GRANULARITY`` is the default time-bucket width; one of
277
+ # ``auto`` / ``month`` / ``quarter`` / ``year``. ``auto`` (the default) picks the
278
+ # coarsest of {year, quarter, month} that yields >=3 non-empty buckets for the
279
+ # matched docs' date span — a fixed ``quarter`` collapsed a young (few-month)
280
+ # corpus into a single bucket, hiding all evolution. An explicit value forces
281
+ # that granularity exactly. Override via ``BRAIN_TIMELINE_GRANULARITY``.
282
+ DEFAULT_TIMELINE_GRANULARITY = "auto"
283
+ _VALID_TIMELINE_GRANULARITIES = frozenset({"auto", "month", "quarter", "year"})
284
+
285
+ # Max buckets returned by ``brain timeline`` before the sparse tail is trimmed.
286
+ # Positive int. Override via ``BRAIN_TIMELINE_LIMIT``.
287
+ DEFAULT_TIMELINE_LIMIT = 20
288
+
289
+ # Max number of densest buckets that get an Ollama ``--synthesize`` summary.
290
+ # Non-negative int; ``0`` disables synthesis even when ``--synthesize`` is
291
+ # passed (keeps the LLM round-trips bounded). Override via
292
+ # ``BRAIN_TIMELINE_SYNTH_LIMIT``.
293
+ DEFAULT_TIMELINE_SYNTH_LIMIT = 5
294
+
295
+ # Which end of the timeline is trimmed when ``--limit`` is hit: ``oldest``
296
+ # (drop the earliest buckets, keep the most recent) or ``sparsest`` (drop the
297
+ # buckets with the fewest docs). Override via ``BRAIN_TIMELINE_TRIM``.
298
+ DEFAULT_TIMELINE_TRIM = "oldest"
299
+ _VALID_TIMELINE_TRIMS = frozenset({"oldest", "sparsest"})
300
+
301
+ # Plan 07 -- `brain connect` proactive auto-link suggestion knobs.
302
+ #
303
+ # ``DEFAULT_CONNECT_MIN_SCORE`` is the RRF-blend confidence floor: candidate
304
+ # pairs scoring below this are silently discarded (no DB write). Tuned against
305
+ # a live ~1.3k-doc corpus (2026-06-10): the original 0.30 floor admitted
306
+ # essentially every candidate (6.2k pending — the per-doc cap, not the floor,
307
+ # was binding); 0.60 keeps ~1.5 high-signal suggestions per doc. Lower via
308
+ # ``BRAIN_CONNECT_MIN_SCORE`` for more recall; must be a float in (0.0, 1.0].
309
+ DEFAULT_CONNECT_MIN_SCORE = 0.60
310
+ # Per-source-doc cap on candidate targets pulled from EACH leg (graph +
311
+ # embedding) before the RRF blend. Bounds the per-doc cost. Override via
312
+ # ``BRAIN_CONNECT_CANDIDATE_LIMIT``; must be an integer >= 1.
313
+ DEFAULT_CONNECT_CANDIDATE_LIMIT = 50
314
+ # Max suggestions persisted per source doc after gating + ranking. Keeps the
315
+ # review queue focused. Override via ``BRAIN_CONNECT_MAX_PER_DOC``; integer >= 1.
316
+ DEFAULT_CONNECT_MAX_PER_DOC = 5
317
+ # Plan 03 -- `brain review scan` contradiction + staleness knobs.
318
+ # Conflict scan caps: max entity candidates per run, max doc pairs adjudicated
319
+ # per surviving entity, and the embedding cosine floor below which a pair is too
320
+ # topically distant to possibly contradict. The product of the first two bounds
321
+ # the worst-case LLM call budget (default 30 x 3 = 90).
322
+ DEFAULT_REVIEW_CONFLICT_LIMIT = 30
323
+ DEFAULT_REVIEW_CONFLICT_PAIRS_PER_ENTITY = 3
324
+ DEFAULT_REVIEW_EMBED_SIM_FLOOR = 0.40
325
+ # Staleness scan: a doc older than ``stale_age_days`` is a candidate; a newer
326
+ # doc ingested within ``stale_supersede_window_days`` sharing an entity and with
327
+ # cosine >= ``stale_sim_floor`` supersedes it. ``stale_limit`` caps candidates.
328
+ DEFAULT_REVIEW_STALE_AGE_DAYS = 365
329
+ DEFAULT_REVIEW_STALE_SUPERSEDE_WINDOW_DAYS = 90
330
+ DEFAULT_REVIEW_STALE_SIM_FLOOR = 0.60
331
+ DEFAULT_REVIEW_STALE_LIMIT = 200
332
+ # Plan 06 -- `brain ask` agentic-synthesis knobs.
333
+ #
334
+ # ``DEFAULT_ASK_MAX_ITERATIONS`` is the hard cap on plan/retrieve/reflect loop
335
+ # iterations when ``--no-loop`` is not given. 3 covers the initial plan plus two
336
+ # reflect-driven follow-up rounds -- enough for multi-hop coverage without
337
+ # unbounded Ollama latency. Override via ``BRAIN_ASK_MAX_ITERATIONS`` (int >= 1).
338
+ DEFAULT_ASK_MAX_ITERATIONS = 3
339
+
340
+ # Max documents retrieved per sub-query per iteration. Mirrors the search
341
+ # ``--limit`` default. Override via ``BRAIN_ASK_DOCS_PER_ITER`` (int >= 1).
342
+ DEFAULT_ASK_DOCS_PER_ITER = 5
343
+
344
+ # Ollama HTTP timeout (seconds) for each ask plan/reflect/synthesize chat call.
345
+ # 90 s gives headroom for a cold-model swap-in on the first (plan) call plus the
346
+ # longer synthesize completion without spiraling. Override via
347
+ # ``BRAIN_ASK_TIMEOUT_SECONDS`` (float > 0).
348
+ DEFAULT_ASK_TIMEOUT_SECONDS = 90.0
349
+ # Plan 04 -- `brain audio` (NotebookLM-style two-host overview) knobs.
350
+ #
351
+ # ``DEFAULT_AUDIO_SCRIPT_MODEL`` is the Ollama model name passed to the shared
352
+ # ``chat_json`` helper for dialogue generation. Kept a separate literal from the
353
+ # enrich / extract models so the audio script model stays independently
354
+ # overridable via ``BRAIN_AUDIO_SCRIPT_MODEL``.
355
+ DEFAULT_AUDIO_SCRIPT_MODEL = "llama3.1:8b"
356
+ # Max speaker turns in the generated dialogue. Must be a POSITIVE EVEN integer
357
+ # (pairs of Host/Guest). The generator truncates any surplus turns the model
358
+ # emits beyond this cap. Override via ``BRAIN_AUDIO_MAX_TURNS``.
359
+ DEFAULT_AUDIO_MAX_TURNS = 12
360
+ # Max prompt tokens (tiktoken ``cl100k_base``) fed to the script generator. The
361
+ # bundle (entity names + theme summaries + doc titles/summaries — never raw
362
+ # bodies) is trimmed group-by-group to fit this budget before the LLM call,
363
+ # bounding the round-trip cost. Override via ``BRAIN_AUDIO_MAX_INPUT_TOKENS``.
364
+ DEFAULT_AUDIO_MAX_INPUT_TOKENS = 3000
365
+ # Max theme / community groups pulled from the graph context into the source
366
+ # bundle. Positive int. Override via ``BRAIN_AUDIO_THEME_LIMIT``.
367
+ DEFAULT_AUDIO_THEME_LIMIT = 4
368
+
369
+ # Plan 08 -- `brain gaps` search-failure-driven knowledge-gap detection knobs.
370
+ # ``DEFAULT_GAPS_LOOKBACK_DAYS`` is the mining window for the ``search_queries``
371
+ # log; ``DEFAULT_GAPS_MIN_CLUSTER_SIZE`` is the minimum query-occurrence count
372
+ # before a failed-query cluster surfaces as a gap. Both are positive integers
373
+ # validated like the other int knobs. Override via ``BRAIN_GAPS_LOOKBACK_DAYS``
374
+ # / ``BRAIN_GAPS_MIN_CLUSTER_SIZE``.
375
+ DEFAULT_GAPS_LOOKBACK_DAYS = 30
376
+ DEFAULT_GAPS_MIN_CLUSTER_SIZE = 2
377
+
378
+
379
+ # Boilerplate regex patterns stripped from email bodies during Gmail ingest.
380
+ # Compiled with ``re.MULTILINE | re.IGNORECASE`` in
381
+ # :func:`brain.ingest.gmail.strip_boilerplate`. Default-deny for ``re.DOTALL``;
382
+ # patterns that genuinely need cross-line matching opt in with the inline
383
+ # ``(?s)`` flag and document why. Cross-line matches MUST be bounded by a
384
+ # non-greedy lookahead (``.*?(?=\n\n|\Z)``) so a single notice block can be
385
+ # absorbed without devouring the rest of the message.
386
+ #
387
+ # Single-source-of-truth: tweak the list here, no code change required.
388
+ BOILERPLATE_PATTERNS: tuple[str, ...] = (
389
+ # Common mobile-app footers -- single line, terminated by EOL.
390
+ r"^Sent from my (iPhone|iPad|Android|BlackBerry|Windows Phone)\.?$",
391
+ r"^Get Outlook for (iOS|Android)\s*<https?://[^>]+>\s*$",
392
+ # Confidentiality / corporate-disclaimer footers run multiple lines.
393
+ # ``(?s)`` enables DOTALL on this pattern only so ``.*?`` can cross
394
+ # newlines; the ``(?=\n\n|\Z)`` lookahead caps the match at the next
395
+ # blank line (or EOF) so we don't eat into legitimate downstream content.
396
+ r"(?s)^CONFIDENTIALITY NOTICE:.*?(?=\n\n|\Z)",
397
+ r"(?s)^This email and any attachments are confidential.*?(?=\n\n|\Z)",
398
+ )
399
+
400
+
401
+ def _project_dotenv() -> Path:
402
+ """Path to the .env file at the repo root, relative to this module.
403
+
404
+ config.py lives at <repo>/src/brain/config.py, so the repo root is two
405
+ parents up from this file's directory.
406
+ """
407
+ return Path(__file__).resolve().parent.parent.parent / ".env"
408
+
409
+
410
+ def _brain_home_root(_config_file: Path | None = None) -> Path:
411
+ """Resolve the $BRAIN_HOME directory per T1.1 priority order.
412
+
413
+ Priority:
414
+ 1. $BRAIN_HOME env var (expanduser applied).
415
+ 2. Repo-root walk-up: if <three-levels-up>/pyproject.toml exists, that's
416
+ a dev checkout -- use the repo root (dev backcompat).
417
+ 3. ~/.brain -- NOT created here; ``brain setup`` creates it lazily.
418
+
419
+ The optional ``_config_file`` parameter is a test-only seam: production
420
+ code never passes it. Tests exercise the dev-checkout branch by passing a
421
+ synthetic path whose three-levels-up ancestor contains ``pyproject.toml``.
422
+ """
423
+ env = os.environ.get("BRAIN_HOME")
424
+ if env:
425
+ return Path(env).expanduser()
426
+ config_file = _config_file or Path(__file__).resolve()
427
+ repo_root = config_file.parent.parent.parent
428
+ if (repo_root / "pyproject.toml").is_file():
429
+ return repo_root
430
+ return Path.home() / ".brain"
431
+
432
+
433
+ def _brain_home_dotenv() -> Path:
434
+ """Path to $BRAIN_HOME/.env (resolved via _brain_home_root)."""
435
+ return _brain_home_root() / ".env"
436
+
437
+
438
+ class ConfigError(RuntimeError):
439
+ pass
440
+
441
+
442
+ def _parse_positive_int_env(env_var: str, default: int) -> int:
443
+ """Parse ``env_var`` as an integer ``>= 1``, falling back to ``default``.
444
+
445
+ Unset / blank → ``default``; non-parseable or ``< 1`` → :class:`ConfigError`
446
+ at load time so a typo surfaces at startup, not mid-command. Mirrors the
447
+ eager-validation idiom used by every other int knob in this module, factored
448
+ out for the Plan 01 / Plan 10 positive-int families.
449
+ """
450
+ raw = os.environ.get(env_var)
451
+ if raw is None or raw.strip() == "":
452
+ return default
453
+ try:
454
+ value = int(raw)
455
+ except ValueError as exc:
456
+ raise ConfigError(
457
+ f"{env_var} must be an integer >= 1 (got {raw!r})"
458
+ ) from exc
459
+ if value < 1:
460
+ raise ConfigError(f"{env_var} must be an integer >= 1 (got {raw!r})")
461
+ return value
462
+
463
+
464
+ def _parse_unit_interval_env(env_var: str, default: float) -> float:
465
+ """Parse ``env_var`` as a float in ``[0.0, 1.0]``, falling back to ``default``.
466
+
467
+ Unset / blank → ``default``; non-parseable or out of ``[0.0, 1.0]`` →
468
+ :class:`ConfigError` at load time so a typo surfaces at startup, not
469
+ mid-command. Mirrors the inline ``BRAIN_VECTOR_SIM_FLOOR`` /
470
+ ``BRAIN_ELICIT_MIN_GAP_SCORE`` validation, factored out for the Plan 03
471
+ cosine-floor knobs.
472
+ """
473
+ raw = os.environ.get(env_var)
474
+ if raw is None or raw.strip() == "":
475
+ return default
476
+ try:
477
+ value = float(raw)
478
+ except ValueError as exc:
479
+ raise ConfigError(
480
+ f"{env_var} must be a float in [0.0, 1.0] (got {raw!r})"
481
+ ) from exc
482
+ if not (0.0 <= value <= 1.0):
483
+ raise ConfigError(
484
+ f"{env_var} must be a float in [0.0, 1.0] (got {raw!r})"
485
+ )
486
+ return value
487
+
488
+
489
+ def _default_vault_path() -> Path:
490
+ """Resolve the vault root from ``BRAIN_VAULT_PATH``, else ``DEFAULT_VAULT_PATH``.
491
+
492
+ Single source of truth for vault-root resolution. Used BOTH as the
493
+ :attr:`Config.vault_path` field ``default_factory`` AND inside
494
+ :meth:`Config._load_field_dict`. Because the field default resolves the env
495
+ var at construction time, a ``Config`` built directly — bypassing
496
+ :meth:`Config.load` (as many test fixtures do) — honors ``BRAIN_VAULT_PATH``
497
+ exactly like ``Config.load()`` instead of silently falling back to the real
498
+ ``~/brain-vault``. That parity is what stops the test suite's ingest mirrors
499
+ from leaking into the live vault; an empty / unset value yields the packaged
500
+ default.
501
+ """
502
+ raw = os.environ.get("BRAIN_VAULT_PATH")
503
+ return Path(raw).expanduser() if raw else DEFAULT_VAULT_PATH
504
+
505
+
506
+ @dataclass(frozen=True)
507
+ class Config:
508
+ """Project configuration loaded from environment / .env.
509
+
510
+ ``embedder`` selects the embedding backend at setup time (one of
511
+ ``arctic`` / ``voyage`` / ``qwen3``). ``voyage_api_key`` is only
512
+ consulted when ``embedder == "voyage"``; for the other backends it can
513
+ be ``None``.
514
+
515
+ ``user_email`` (P4.4) is the owner's primary email address, optionally
516
+ set via ``BRAIN_USER_EMAIL``. Consumed by the email-thread reading
517
+ mode in the rendered wiki -- the Quartz transformer reads
518
+ ``process.env.BRAIN_USER_EMAIL`` at build time and bakes it into a
519
+ ``window.BRAIN_USER_EMAIL`` global so the runtime "Show only my
520
+ replies" filter knows whose ``From:`` address counts as the user's.
521
+ Empty string / ``None`` disables the filter (button still renders;
522
+ matching no-ops).
523
+ """
524
+
525
+ database_url: str
526
+ brain_home: Path = field(default_factory=_brain_home_root)
527
+ ollama_host: str = DEFAULT_OLLAMA_HOST
528
+ qwen3_model: str = DEFAULT_QWEN3_MODEL
529
+ embedder: str = DEFAULT_EMBEDDER
530
+ voyage_api_key: str | None = None
531
+ vault_path: Path = field(default_factory=_default_vault_path)
532
+ user_email: str | None = None
533
+ vector_sim_floor: float = DEFAULT_VECTOR_SIM_FLOOR
534
+ # Comma-separated list of identifiers (emails AND/OR display names) that
535
+ # count as the corpus owner. Stripped from ``DocSnapshot.participant_keys``
536
+ # before R2 (``shared_participant``) and R3 (``same_day_participant``)
537
+ # evaluate, so a meeting/email isn't linked to every other doc the owner
538
+ # is on. Loaded from ``BRAIN_OWNER_PARTICIPANTS``; entries are trimmed,
539
+ # lowercased, and de-duplicated at load time so downstream comparisons
540
+ # can be a single ``.lower() in owner_participants`` check. Empty
541
+ # frozenset (default) is a fast-path no-op.
542
+ owner_participants: frozenset[str] = frozenset()
543
+ # Minimum number of associated documents required for a non-curated
544
+ # person to render a ``/people/<slug>`` hub page. Curated entries
545
+ # (anyone in ``<vault>/_people.yml``) always render regardless of
546
+ # this threshold. Loaded from ``BRAIN_PEOPLE_HUB_MIN_DOCS``; default
547
+ # is :data:`DEFAULT_PEOPLE_HUB_MIN_DOCS`. Negative values are
548
+ # rejected at load time via :class:`ConfigError` -- a negative
549
+ # threshold would silently flip the filter (no effective filtering)
550
+ # and is almost certainly a config bug.
551
+ people_hub_min_docs: int = DEFAULT_PEOPLE_HUB_MIN_DOCS
552
+ # Plan 09 -- quick-capture inbox knobs.
553
+ # ``capture_title_words`` is how many leading body words form the auto-title
554
+ # slug (>= 1); ``capture_inbox_warn_threshold`` is the inbox size above
555
+ # which `brain doctor` warns (>= 1; Phase 3 compares strictly greater-than).
556
+ # Both are validated at load time, mirroring the other int env knobs.
557
+ capture_title_words: int = DEFAULT_CAPTURE_TITLE_WORDS
558
+ capture_inbox_warn_threshold: int = DEFAULT_CAPTURE_INBOX_WARN_THRESHOLD
559
+ # Plan 10 -- `brain review weekly` display caps. All three are positive
560
+ # integers (>= 1), validated at load time like the capture knobs.
561
+ review_activity_limit: int = DEFAULT_REVIEW_ACTIVITY_LIMIT
562
+ review_theme_limit: int = DEFAULT_REVIEW_THEME_LIMIT
563
+ review_open_loop_limit: int = DEFAULT_REVIEW_OPEN_LOOP_LIMIT
564
+ # Plan 01 -- `brain brief` daily-digest knobs. All four are positive
565
+ # integers (>= 1), validated at load time like the capture knobs.
566
+ brief_since_hours: int = DEFAULT_BRIEF_SINCE_HOURS
567
+ brief_todo_since_days: int = DEFAULT_BRIEF_TODO_SINCE_DAYS
568
+ brief_capture_limit: int = DEFAULT_BRIEF_CAPTURE_LIMIT
569
+ brief_pin_limit: int = DEFAULT_BRIEF_PIN_LIMIT
570
+ # Exponential-decay half-life (days) for the recency boost applied after
571
+ # RRF. ``boost = 0.5 ** (age_days / recency_halflife_days)`` where
572
+ # ``age_days`` is clamped to [0, +inf) so future-dated rows get boost=1.0.
573
+ # Loaded from ``BRAIN_RECENCY_HALFLIFE_DAYS``; must be a positive float.
574
+ recency_halflife_days: float = DEFAULT_RECENCY_HALFLIFE_DAYS
575
+ # Token budget for per-search snippet-context expansion. After the
576
+ # best-matching chunk is selected, this many tokens of neighboring-chunk
577
+ # context are stitched around it. 0 = disabled. Loaded from
578
+ # ``BRAIN_SNIPPET_CONTEXT_TOKENS``; must be a non-negative integer.
579
+ snippet_context_tokens: int = DEFAULT_SNIPPET_CONTEXT_TOKENS
580
+ # Plan 02 -- spaced-repetition resurfacing knobs (`brain resurface`). All
581
+ # four feed :func:`brain.resurface.resurface_docs`; grouped together and
582
+ # validated the same way as the other int/float env knobs. ``limit`` >= 1,
583
+ # ``min_age_days`` >= 0, both half-lives > 0.
584
+ resurface_limit: int = DEFAULT_RESURFACE_LIMIT
585
+ resurface_min_age_days: int = DEFAULT_RESURFACE_MIN_AGE_DAYS
586
+ resurface_age_halflife_days: float = DEFAULT_RESURFACE_AGE_HALFLIFE_DAYS
587
+ resurface_access_halflife_days: float = DEFAULT_RESURFACE_ACCESS_HALFLIFE_DAYS
588
+ # Wave Q1-D -- per-document auto-summary + auto-tag enrichment.
589
+ # The four fields are tightly coupled (they all feed ``OllamaEnricher``)
590
+ # so they live together at the tail of the dataclass.
591
+ enrich_model: str = DEFAULT_ENRICH_MODEL
592
+ enrich_min_tokens: int = DEFAULT_ENRICH_MIN_TOKENS
593
+ enrich_max_input_tokens: int = DEFAULT_ENRICH_MAX_INPUT_TOKENS
594
+ enrich_timeout_seconds: float = DEFAULT_ENRICH_TIMEOUT_SECONDS
595
+ # Per-request ``keep_alive`` sent in every outgoing Ollama payload (embedder
596
+ # ``/api/embed``, enricher ``/api/chat``, extractor ``/api/generate``). Keeps
597
+ # the model loaded in VRAM for this long after the last request, preventing
598
+ # the cold-load latency spike on the next call. Accepts any format Ollama
599
+ # understands: a positive integer duration string ("30m", "1h", "60s"), a
600
+ # bare positive integer seconds string ("60"), "-1" (keep loaded
601
+ # indefinitely — kills the measured 5,028ms cold-embed cliff), or "0"
602
+ # (unload immediately). Malformed values ("-2", "-1m", "abc", …) are
603
+ # rejected at load time via ConfigError. Override via
604
+ # ``BRAIN_OLLAMA_KEEP_ALIVE``.
605
+ ollama_keep_alive: str = DEFAULT_OLLAMA_KEEP_ALIVE
606
+ # Wave G1-c -- GraphRAG people-aspect incremental sync. ``graph_enabled``
607
+ # gates the post-write/delete reconcile hook; the other four resolve into
608
+ # the single shared :class:`ReconcileConfig`
609
+ # (:func:`brain.graph_rag.sync.build_reconcile_config`). ``owner_participants``
610
+ # (above) is reused as the reconcile ``owner_keys`` -- the corpus owner is
611
+ # stripped from the graph's person roster exactly as from the People Hub, so
612
+ # there is no separate ``BRAIN_GRAPH_OWNER_*`` knob.
613
+ graph_enabled: bool = DEFAULT_GRAPH_ENABLED
614
+ graph_tenant_id: str = DEFAULT_GRAPH_TENANT_ID
615
+ graph_cooccur_window: int = DEFAULT_GRAPH_COOCCUR_WINDOW
616
+ graph_max_entities: int | None = DEFAULT_GRAPH_MAX_ENTITIES
617
+ graph_generic_df_ratio: float = DEFAULT_GRAPH_GENERIC_DF_RATIO
618
+ # Wave G2 -- concept extraction + bounded-retrieval knobs (spec §10). Parsed
619
+ # in G2-a; consumed by the concept aspect (G2-b/c) and the local/themes
620
+ # traversal (G2-d..i). ``graph_concepts`` gates the concept aspect;
621
+ # ``graph_extract_model`` selects the Ollama extractor model; the remaining
622
+ # five are the hard traversal caps + theme-ranking knob.
623
+ graph_concepts: bool = DEFAULT_GRAPH_CONCEPTS
624
+ graph_extract_model: str = DEFAULT_GRAPH_EXTRACT_MODEL
625
+ # Concept-extractor input head cap (perf Fix C). ``None`` == no cap (whole
626
+ # document extracted); a positive int caps the body to its first N
627
+ # ``cl100k_base`` tokens before chunking. Threaded into
628
+ # :func:`brain.graph_rag.extract.make_extractor`.
629
+ graph_extract_max_input_tokens: int | None = DEFAULT_GRAPH_EXTRACT_MAX_INPUT_TOKENS
630
+ graph_depth: int = DEFAULT_GRAPH_DEPTH
631
+ graph_frontier_cap: int = DEFAULT_GRAPH_FRONTIER_CAP
632
+ graph_max_degree: int = DEFAULT_GRAPH_MAX_DEGREE
633
+ graph_min_edge_weight: float = DEFAULT_GRAPH_MIN_EDGE_WEIGHT
634
+ graph_theme_limit: int = DEFAULT_GRAPH_THEME_LIMIT
635
+ # Wave G3 -- global community detection (spec §17c). Parsed in G3-a;
636
+ # consumed by detection (G3-b), summaries (G3-c), and global retrieval
637
+ # (G3-d). ``graph_community_max`` is an ops safety cap (None == unlimited).
638
+ graph_community_resolution: float = DEFAULT_GRAPH_COMMUNITY_RESOLUTION
639
+ graph_community_seed: int = DEFAULT_GRAPH_COMMUNITY_SEED
640
+ graph_community_min_size: int = DEFAULT_GRAPH_COMMUNITY_MIN_SIZE
641
+ graph_community_jaccard: float = DEFAULT_GRAPH_COMMUNITY_JACCARD
642
+ graph_community_limit: int = DEFAULT_GRAPH_COMMUNITY_LIMIT
643
+ graph_community_max: int | None = DEFAULT_GRAPH_COMMUNITY_MAX
644
+ # Phase 1 data-quality remediation (2026-05-23). Extra automated-sender
645
+ # denylist entries (substrings or full addresses) layered on top of the
646
+ # always-on generic heuristic (no-reply / notifications / mailer / …) used by
647
+ # :func:`brain.wiki._person_name.is_automated_sender`. Loaded from
648
+ # ``BRAIN_GRAPH_SENDER_DENYLIST`` (comma-separated); entries are trimmed,
649
+ # lowercased, and de-duplicated at load time. Empty frozenset (default) means
650
+ # only the generic heuristic runs. Threaded into both the People Hub
651
+ # (``emit_people_pages``) and the graph reconcile (``ReconcileConfig``) so a
652
+ # no-reply / org sender becomes a person in neither.
653
+ graph_sender_denylist: frozenset[str] = frozenset()
654
+ # Phase B (2026-05-25) — operator-curated concept extraction stopwords.
655
+ # Entities whose ``canonical_key`` appears in this set are dropped by the
656
+ # extractor's ``_finalize`` chokepoint (after the generic noise filter) even
657
+ # when they pass presence validation. Real terms are employer-specific (rule
658
+ # 15) so the default is **empty** — operators set
659
+ # ``BRAIN_GRAPH_EXTRACT_STOPWORDS`` locally. Parsed as a comma-separated list;
660
+ # entries are trimmed, lowercased, and de-duplicated at load time. Also folded
661
+ # into :func:`brain.graph_rag.concepts.concept_inputs_hash` so a stopword
662
+ # change re-extracts (beyond the one-time ``EXTRACTOR_VERSION`` bump).
663
+ graph_extract_stopwords: frozenset[str] = frozenset()
664
+ # Phase C (2026-05-25) — curated entity alias/merge rules. Real rules contain
665
+ # real entity names (rule 15) so they live in a gitignored local file. This
666
+ # path is resolved from ``BRAIN_GRAPH_ALIASES_PATH``; if unset it defaults to
667
+ # ``$BRAIN_HOME/graph_aliases.yml`` when that file exists, else ``None``
668
+ # (feature is opt-in — absent file → no aliases applied). Eager-validated:
669
+ # when a path IS resolved it must be readable (raises ``ConfigError`` if not).
670
+ graph_aliases_path: Path | None = None
671
+ # Tacit-knowledge elicitation knobs (feat/tacit-knowledge-elicitation).
672
+ # Minimum evidence docs required for a gap to surface. See
673
+ # :data:`DEFAULT_ELICIT_MIN_EVIDENCE_DOCS`. Non-negative integer.
674
+ elicit_min_evidence_docs: int = DEFAULT_ELICIT_MIN_EVIDENCE_DOCS
675
+ # Minimum gap score in [0.0, 1.0] for a gap to enter the queue. See
676
+ # :data:`DEFAULT_ELICIT_MIN_GAP_SCORE`. Out-of-range values are rejected
677
+ # at load time via :class:`ConfigError`.
678
+ elicit_min_gap_score: float = DEFAULT_ELICIT_MIN_GAP_SCORE
679
+ # Maximum rows returned by ``brain elicit list``. Non-negative integer.
680
+ elicit_queue_limit: int = DEFAULT_ELICIT_QUEUE_LIMIT
681
+ # Gates the contradiction detector. Bool, default off.
682
+ elicit_contradiction_enabled: bool = DEFAULT_ELICIT_CONTRADICTION_ENABLED
683
+ # Minimum docs per target before contradiction detection runs. Non-negative int.
684
+ elicit_contradiction_min_docs: int = DEFAULT_ELICIT_CONTRADICTION_MIN_DOCS
685
+ # Plan 05 -- `brain timeline` knobs. ``timeline_granularity`` is the default
686
+ # bucket width (validated ∈ {auto, month, quarter, year}); ``timeline_limit`` the
687
+ # default max buckets (positive int); ``timeline_synth_limit`` the max
688
+ # densest buckets synthesized (non-negative int, 0 disables);
689
+ # ``timeline_trim`` which end is trimmed at the limit (∈ {oldest, sparsest}).
690
+ timeline_granularity: str = DEFAULT_TIMELINE_GRANULARITY
691
+ timeline_limit: int = DEFAULT_TIMELINE_LIMIT
692
+ timeline_synth_limit: int = DEFAULT_TIMELINE_SYNTH_LIMIT
693
+ timeline_trim: str = DEFAULT_TIMELINE_TRIM
694
+ # Plan 07 -- `brain connect` auto-link suggestion knobs. ``connect_min_score``
695
+ # is the RRF-blend confidence floor in (0.0, 1.0]; ``connect_candidate_limit``
696
+ # and ``connect_max_per_doc`` are positive-integer caps. All three are
697
+ # eager-validated at load time, mirroring the other env knobs.
698
+ connect_min_score: float = DEFAULT_CONNECT_MIN_SCORE
699
+ connect_candidate_limit: int = DEFAULT_CONNECT_CANDIDATE_LIMIT
700
+ connect_max_per_doc: int = DEFAULT_CONNECT_MAX_PER_DOC
701
+ # Plan 03 -- `brain review scan` contradiction + staleness knobs. The two
702
+ # ``*_sim_floor`` values are cosine floors in [0.0, 1.0]; the rest are
703
+ # positive ints. All eager-validated at load time via ``ConfigError``.
704
+ review_conflict_limit: int = DEFAULT_REVIEW_CONFLICT_LIMIT
705
+ review_conflict_pairs_per_entity: int = DEFAULT_REVIEW_CONFLICT_PAIRS_PER_ENTITY
706
+ review_embed_sim_floor: float = DEFAULT_REVIEW_EMBED_SIM_FLOOR
707
+ review_stale_age_days: int = DEFAULT_REVIEW_STALE_AGE_DAYS
708
+ review_stale_supersede_window_days: int = DEFAULT_REVIEW_STALE_SUPERSEDE_WINDOW_DAYS
709
+ review_stale_sim_floor: float = DEFAULT_REVIEW_STALE_SIM_FLOOR
710
+ review_stale_limit: int = DEFAULT_REVIEW_STALE_LIMIT
711
+ # Plan 06 -- `brain ask` agentic-synthesis knobs. ``ask_max_iterations`` and
712
+ # ``ask_docs_per_iter`` are positive ints (>= 1); ``ask_timeout_seconds`` is
713
+ # a positive float; ``ask_model`` defaults to ``enrich_model`` (blank env
714
+ # resets to that default), so the ask loop reuses the configured chat model
715
+ # unless explicitly overridden via ``BRAIN_ASK_MODEL``.
716
+ ask_max_iterations: int = DEFAULT_ASK_MAX_ITERATIONS
717
+ ask_docs_per_iter: int = DEFAULT_ASK_DOCS_PER_ITER
718
+ ask_model: str = DEFAULT_ENRICH_MODEL
719
+ ask_timeout_seconds: float = DEFAULT_ASK_TIMEOUT_SECONDS
720
+ # Plan 04 -- `brain audio` knobs. ``audio_script_model`` selects the Ollama
721
+ # dialogue model; ``audio_max_turns`` caps the dialogue (positive even int);
722
+ # ``audio_max_input_tokens`` bounds the bundle prompt (positive int);
723
+ # ``audio_theme_limit`` caps the theme/community groups pulled into the
724
+ # source bundle (positive int).
725
+ audio_script_model: str = DEFAULT_AUDIO_SCRIPT_MODEL
726
+ audio_max_turns: int = DEFAULT_AUDIO_MAX_TURNS
727
+ audio_max_input_tokens: int = DEFAULT_AUDIO_MAX_INPUT_TOKENS
728
+ audio_theme_limit: int = DEFAULT_AUDIO_THEME_LIMIT
729
+ # Plan 08 -- `brain gaps` search-failure knobs. Both positive ints (>= 1),
730
+ # eager-validated at load time via ``ConfigError`` like the review knobs.
731
+ gaps_lookback_days: int = DEFAULT_GAPS_LOOKBACK_DAYS
732
+ gaps_min_cluster_size: int = DEFAULT_GAPS_MIN_CLUSTER_SIZE
733
+
734
+ @classmethod
735
+ def load(cls) -> "Config":
736
+ """Load config from env / .env files. Raises ConfigError if DATABASE_URL is unset."""
737
+ fields = cls._load_field_dict(require_db=True)
738
+ return cls(**fields)
739
+
740
+ @classmethod
741
+ def load_minimal(cls) -> "Config":
742
+ """Same as load() but doesn't require DATABASE_URL.
743
+
744
+ Used by purely-filesystem commands (brain vault render --overlay,
745
+ brain wiki install, brain claude install-skill) that run BEFORE
746
+ brain setup writes .env. The brain_home field still resolves, but
747
+ database_url defaults to an empty string sentinel and any caller
748
+ that tries to actually USE database_url on this config will fail
749
+ at the first DB connection attempt -- which is the right level to
750
+ fail.
751
+ """
752
+ fields = cls._load_field_dict(require_db=False)
753
+ return cls(**fields)
754
+
755
+ @classmethod
756
+ def _load_field_dict(cls, *, require_db: bool) -> dict[str, Any]:
757
+ """Shared parser. Loads the dotenv chain, then parses every field.
758
+
759
+ If require_db=True, raises ConfigError on missing DATABASE_URL;
760
+ if False, sets it to "" (empty string sentinel). This is the single
761
+ source of truth for all field parsing -- load() and load_minimal()
762
+ both delegate here so overlapping fields can never drift.
763
+ """
764
+ # Load .env files using a merged-dict + setdefault algorithm so that:
765
+ # 1. os.environ (process env) is NEVER overwritten -- highest priority.
766
+ # 2. <repo-root>/.env wins over cwd and BRAIN_HOME .env files.
767
+ # 3. <cwd>/.env (via walk-up) wins over BRAIN_HOME .env.
768
+ # 4. $BRAIN_HOME/.env is the lowest-priority file source.
769
+ #
770
+ # Files are layered in REVERSE priority order (lowest first) into a
771
+ # merged dict; higher-priority files overwrite lower-priority ones on
772
+ # key collisions. Process env is applied last via os.environ.setdefault
773
+ # so an existing value is never clobbered -- preserving the precedence
774
+ # contract regardless of who set it (shell, parent process, or
775
+ # monkeypatch.setenv).
776
+ merged: dict[str, str] = {}
777
+ cwd_env_str = find_dotenv(usecwd=True)
778
+ for candidate in (
779
+ _brain_home_dotenv(),
780
+ Path(cwd_env_str) if cwd_env_str else None,
781
+ _project_dotenv(),
782
+ ):
783
+ if candidate is not None and candidate.exists():
784
+ merged.update(
785
+ {k: v for k, v in dotenv_values(candidate).items() if v is not None}
786
+ )
787
+ for key, value in merged.items():
788
+ os.environ.setdefault(key, value)
789
+ database_url = os.environ.get("DATABASE_URL")
790
+ if require_db and not database_url:
791
+ raise ConfigError("DATABASE_URL is not set (see .env.example)")
792
+ database_url = database_url or "" # empty sentinel when require_db=False
793
+ ollama_host = os.environ.get("OLLAMA_HOST", DEFAULT_OLLAMA_HOST)
794
+ qwen3_model = os.environ.get("QWEN3_MODEL", DEFAULT_QWEN3_MODEL)
795
+ embedder = os.environ.get("BRAIN_EMBEDDER", DEFAULT_EMBEDDER).lower()
796
+ if embedder not in _VALID_EMBEDDERS:
797
+ raise ConfigError(
798
+ f"BRAIN_EMBEDDER must be one of: arctic, voyage, qwen3, none "
799
+ f"(got {embedder!r})"
800
+ )
801
+ voyage_api_key = os.environ.get("VOYAGE_API_KEY")
802
+ vault_path = _default_vault_path()
803
+ # P4.4 -- owner identity for the email-thread "Show only my replies"
804
+ # filter. Optional; an unset/empty value renders the button but
805
+ # the runtime filter no-ops (no message ever matches the empty
806
+ # user identity, so toggling the button hides every section --
807
+ # which is the right "I forgot to set this" feedback signal).
808
+ # ``.strip()`` so trailing newlines from a `.env` quirk don't
809
+ # bleed into the JS global.
810
+ user_email_raw = os.environ.get("BRAIN_USER_EMAIL")
811
+ user_email = (user_email_raw or "").strip() or None
812
+ # Vector cosine floor -- see DEFAULT_VECTOR_SIM_FLOOR. Validation:
813
+ # must parse as float in [0.0, 1.0] (cosine similarity range).
814
+ # Negative values would silently re-admit the noise tail; >1
815
+ # would exclude every chunk. Either is a config bug -- surface it
816
+ # eagerly with ``ConfigError`` (per plan revision #6).
817
+ floor_raw = os.environ.get("BRAIN_VECTOR_SIM_FLOOR")
818
+ if floor_raw is None or floor_raw.strip() == "":
819
+ vector_sim_floor = DEFAULT_VECTOR_SIM_FLOOR
820
+ else:
821
+ try:
822
+ vector_sim_floor = float(floor_raw)
823
+ except ValueError as exc:
824
+ raise ConfigError(
825
+ f"BRAIN_VECTOR_SIM_FLOOR must be a float in [0.0, 1.0] "
826
+ f"(got {floor_raw!r})"
827
+ ) from exc
828
+ if not (0.0 <= vector_sim_floor <= 1.0):
829
+ raise ConfigError(
830
+ f"BRAIN_VECTOR_SIM_FLOOR must be a float in [0.0, 1.0] "
831
+ f"(got {vector_sim_floor!r})"
832
+ )
833
+ # Owner participants -- identifiers (emails and/or display names)
834
+ # whose presence in a doc's participant set is treated as
835
+ # ``corpus owner``. Comma-separated; trim + lowercase + drop empty
836
+ # entries at load time so the downstream filter is a fast
837
+ # ``key.lower() in owner_participants`` check. Unset / blank /
838
+ # whitespace-only strings produce an empty frozenset (no
839
+ # behavioral change).
840
+ owner_raw = os.environ.get("BRAIN_OWNER_PARTICIPANTS", "")
841
+ owner_participants = frozenset(
842
+ piece
843
+ for piece in (entry.strip().lower() for entry in owner_raw.split(","))
844
+ if piece
845
+ )
846
+ # People Hub doc-count threshold -- see DEFAULT_PEOPLE_HUB_MIN_DOCS.
847
+ # Validation: must parse as a non-negative integer. A negative
848
+ # threshold is almost certainly a config typo (it would render every
849
+ # person, since ``len(docs) < negative`` is never true) -- surface
850
+ # eagerly via ConfigError so the user fixes the typo rather than
851
+ # silently getting a hub flooded with one-off recipients.
852
+ people_min_raw = os.environ.get("BRAIN_PEOPLE_HUB_MIN_DOCS")
853
+ if people_min_raw is None or people_min_raw.strip() == "":
854
+ people_hub_min_docs = DEFAULT_PEOPLE_HUB_MIN_DOCS
855
+ else:
856
+ try:
857
+ people_hub_min_docs = int(people_min_raw)
858
+ except ValueError as exc:
859
+ raise ConfigError(
860
+ f"BRAIN_PEOPLE_HUB_MIN_DOCS must be a non-negative integer "
861
+ f"(got {people_min_raw!r})"
862
+ ) from exc
863
+ if people_hub_min_docs < 0:
864
+ raise ConfigError(
865
+ f"BRAIN_PEOPLE_HUB_MIN_DOCS must be a non-negative integer "
866
+ f"(got {people_hub_min_docs!r})"
867
+ )
868
+ # Plan 09 -- quick-capture inbox knobs. Same eager-validation idiom as
869
+ # the other int env vars: unset -> default; non-parseable or
870
+ # out-of-range -> ConfigError at startup instead of a mid-capture crash.
871
+ capture_words_raw = os.environ.get("BRAIN_CAPTURE_TITLE_WORDS")
872
+ if capture_words_raw is None or capture_words_raw.strip() == "":
873
+ capture_title_words = DEFAULT_CAPTURE_TITLE_WORDS
874
+ else:
875
+ try:
876
+ capture_title_words = int(capture_words_raw)
877
+ except ValueError as exc:
878
+ raise ConfigError(
879
+ f"BRAIN_CAPTURE_TITLE_WORDS must be an integer >= 1 "
880
+ f"(got {capture_words_raw!r})"
881
+ ) from exc
882
+ if capture_title_words < 1:
883
+ raise ConfigError(
884
+ f"BRAIN_CAPTURE_TITLE_WORDS must be an integer >= 1 "
885
+ f"(got {capture_title_words!r})"
886
+ )
887
+
888
+ capture_warn_raw = os.environ.get("BRAIN_CAPTURE_INBOX_WARN_THRESHOLD")
889
+ if capture_warn_raw is None or capture_warn_raw.strip() == "":
890
+ capture_inbox_warn_threshold = DEFAULT_CAPTURE_INBOX_WARN_THRESHOLD
891
+ else:
892
+ try:
893
+ capture_inbox_warn_threshold = int(capture_warn_raw)
894
+ except ValueError as exc:
895
+ raise ConfigError(
896
+ f"BRAIN_CAPTURE_INBOX_WARN_THRESHOLD must be an integer >= 1 "
897
+ f"(got {capture_warn_raw!r})"
898
+ ) from exc
899
+ if capture_inbox_warn_threshold < 1:
900
+ raise ConfigError(
901
+ f"BRAIN_CAPTURE_INBOX_WARN_THRESHOLD must be an integer >= 1 "
902
+ f"(got {capture_inbox_warn_threshold!r})"
903
+ )
904
+ # Plan 10 -- `brain review weekly` display caps. Positive ints.
905
+ review_activity_limit = _parse_positive_int_env(
906
+ "BRAIN_REVIEW_ACTIVITY_LIMIT", DEFAULT_REVIEW_ACTIVITY_LIMIT
907
+ )
908
+ review_theme_limit = _parse_positive_int_env(
909
+ "BRAIN_REVIEW_THEME_LIMIT", DEFAULT_REVIEW_THEME_LIMIT
910
+ )
911
+ review_open_loop_limit = _parse_positive_int_env(
912
+ "BRAIN_REVIEW_OPEN_LOOP_LIMIT", DEFAULT_REVIEW_OPEN_LOOP_LIMIT
913
+ )
914
+ # Plan 01 -- `brain brief` daily-digest knobs. Positive ints.
915
+ brief_since_hours = _parse_positive_int_env(
916
+ "BRAIN_BRIEF_SINCE_HOURS", DEFAULT_BRIEF_SINCE_HOURS
917
+ )
918
+ brief_todo_since_days = _parse_positive_int_env(
919
+ "BRAIN_BRIEF_TODO_SINCE_DAYS", DEFAULT_BRIEF_TODO_SINCE_DAYS
920
+ )
921
+ brief_capture_limit = _parse_positive_int_env(
922
+ "BRAIN_BRIEF_CAPTURE_LIMIT", DEFAULT_BRIEF_CAPTURE_LIMIT
923
+ )
924
+ brief_pin_limit = _parse_positive_int_env(
925
+ "BRAIN_BRIEF_PIN_LIMIT", DEFAULT_BRIEF_PIN_LIMIT
926
+ )
927
+ # Recency half-life -- see DEFAULT_RECENCY_HALFLIFE_DAYS.
928
+ # Validation: must parse as a positive float. Zero is invalid
929
+ # (produces 0 ** inf = 0 for any finite age, degenerate). Negative
930
+ # is invalid -- flips the decay direction so old docs score higher.
931
+ halflife_raw = os.environ.get("BRAIN_RECENCY_HALFLIFE_DAYS")
932
+ if halflife_raw is None or halflife_raw.strip() == "":
933
+ recency_halflife_days = DEFAULT_RECENCY_HALFLIFE_DAYS
934
+ else:
935
+ try:
936
+ recency_halflife_days = float(halflife_raw)
937
+ except ValueError as exc:
938
+ raise ConfigError(
939
+ f"BRAIN_RECENCY_HALFLIFE_DAYS must be a positive float "
940
+ f"(got {halflife_raw!r})"
941
+ ) from exc
942
+ if recency_halflife_days <= 0:
943
+ raise ConfigError(
944
+ f"BRAIN_RECENCY_HALFLIFE_DAYS must be a positive float "
945
+ f"(got {recency_halflife_days!r})"
946
+ )
947
+ # Snippet context tokens -- see DEFAULT_SNIPPET_CONTEXT_TOKENS.
948
+ # Validation: must parse as a non-negative integer. 0 = disabled.
949
+ # Negative is invalid -- there is no sensible semantic for a negative
950
+ # token budget.
951
+ ctx_raw = os.environ.get("BRAIN_SNIPPET_CONTEXT_TOKENS")
952
+ if ctx_raw is None or ctx_raw.strip() == "":
953
+ snippet_context_tokens = DEFAULT_SNIPPET_CONTEXT_TOKENS
954
+ else:
955
+ try:
956
+ snippet_context_tokens = int(ctx_raw)
957
+ except ValueError as exc:
958
+ raise ConfigError(
959
+ f"BRAIN_SNIPPET_CONTEXT_TOKENS must be a non-negative integer "
960
+ f"(got {ctx_raw!r})"
961
+ ) from exc
962
+ if snippet_context_tokens < 0:
963
+ raise ConfigError(
964
+ f"BRAIN_SNIPPET_CONTEXT_TOKENS must be a non-negative integer "
965
+ f"(got {snippet_context_tokens!r})"
966
+ )
967
+ # Plan 02 -- resurface env vars. Same eager-validation idiom as the
968
+ # snippet-context / recency-halflife knobs above: unset/blank ->
969
+ # default; non-parseable / out-of-range -> ConfigError at startup so a
970
+ # config typo surfaces immediately rather than mid-resurface.
971
+ resurface_limit_raw = os.environ.get("BRAIN_RESURFACE_LIMIT")
972
+ if resurface_limit_raw is None or resurface_limit_raw.strip() == "":
973
+ resurface_limit = DEFAULT_RESURFACE_LIMIT
974
+ else:
975
+ try:
976
+ resurface_limit = int(resurface_limit_raw)
977
+ except ValueError as exc:
978
+ raise ConfigError(
979
+ f"BRAIN_RESURFACE_LIMIT must be an integer >= 1 "
980
+ f"(got {resurface_limit_raw!r})"
981
+ ) from exc
982
+ if resurface_limit < 1:
983
+ raise ConfigError(
984
+ f"BRAIN_RESURFACE_LIMIT must be an integer >= 1 "
985
+ f"(got {resurface_limit!r})"
986
+ )
987
+
988
+ resurface_min_age_raw = os.environ.get("BRAIN_RESURFACE_MIN_AGE_DAYS")
989
+ if resurface_min_age_raw is None or resurface_min_age_raw.strip() == "":
990
+ resurface_min_age_days = DEFAULT_RESURFACE_MIN_AGE_DAYS
991
+ else:
992
+ try:
993
+ resurface_min_age_days = int(resurface_min_age_raw)
994
+ except ValueError as exc:
995
+ raise ConfigError(
996
+ f"BRAIN_RESURFACE_MIN_AGE_DAYS must be a non-negative integer "
997
+ f"(got {resurface_min_age_raw!r})"
998
+ ) from exc
999
+ if resurface_min_age_days < 0:
1000
+ raise ConfigError(
1001
+ f"BRAIN_RESURFACE_MIN_AGE_DAYS must be a non-negative integer "
1002
+ f"(got {resurface_min_age_days!r})"
1003
+ )
1004
+
1005
+ resurface_age_hl_raw = os.environ.get("BRAIN_RESURFACE_AGE_HALFLIFE_DAYS")
1006
+ if resurface_age_hl_raw is None or resurface_age_hl_raw.strip() == "":
1007
+ resurface_age_halflife_days = DEFAULT_RESURFACE_AGE_HALFLIFE_DAYS
1008
+ else:
1009
+ try:
1010
+ resurface_age_halflife_days = float(resurface_age_hl_raw)
1011
+ except ValueError as exc:
1012
+ raise ConfigError(
1013
+ f"BRAIN_RESURFACE_AGE_HALFLIFE_DAYS must be a positive float "
1014
+ f"(got {resurface_age_hl_raw!r})"
1015
+ ) from exc
1016
+ if resurface_age_halflife_days <= 0:
1017
+ raise ConfigError(
1018
+ f"BRAIN_RESURFACE_AGE_HALFLIFE_DAYS must be a positive float "
1019
+ f"(got {resurface_age_halflife_days!r})"
1020
+ )
1021
+
1022
+ resurface_access_hl_raw = os.environ.get(
1023
+ "BRAIN_RESURFACE_ACCESS_HALFLIFE_DAYS"
1024
+ )
1025
+ if resurface_access_hl_raw is None or resurface_access_hl_raw.strip() == "":
1026
+ resurface_access_halflife_days = DEFAULT_RESURFACE_ACCESS_HALFLIFE_DAYS
1027
+ else:
1028
+ try:
1029
+ resurface_access_halflife_days = float(resurface_access_hl_raw)
1030
+ except ValueError as exc:
1031
+ raise ConfigError(
1032
+ f"BRAIN_RESURFACE_ACCESS_HALFLIFE_DAYS must be a positive float "
1033
+ f"(got {resurface_access_hl_raw!r})"
1034
+ ) from exc
1035
+ if resurface_access_halflife_days <= 0:
1036
+ raise ConfigError(
1037
+ f"BRAIN_RESURFACE_ACCESS_HALFLIFE_DAYS must be a positive float "
1038
+ f"(got {resurface_access_halflife_days!r})"
1039
+ )
1040
+
1041
+ # Wave Q1-D -- enrichment env vars. Same validation pattern as the
1042
+ # snippet-context / recency-halflife knobs above: unset/blank ->
1043
+ # default; non-parseable / out-of-range -> ConfigError eagerly so a
1044
+ # config typo surfaces at startup instead of mid-ingest.
1045
+ enrich_model_raw = os.environ.get("BRAIN_ENRICH_MODEL")
1046
+ if enrich_model_raw is None or enrich_model_raw.strip() == "":
1047
+ enrich_model = DEFAULT_ENRICH_MODEL
1048
+ else:
1049
+ enrich_model = enrich_model_raw.strip()
1050
+
1051
+ enrich_min_raw = os.environ.get("BRAIN_ENRICH_MIN_TOKENS")
1052
+ if enrich_min_raw is None or enrich_min_raw.strip() == "":
1053
+ enrich_min_tokens = DEFAULT_ENRICH_MIN_TOKENS
1054
+ else:
1055
+ try:
1056
+ enrich_min_tokens = int(enrich_min_raw)
1057
+ except ValueError as exc:
1058
+ raise ConfigError(
1059
+ f"BRAIN_ENRICH_MIN_TOKENS must be a non-negative integer "
1060
+ f"(got {enrich_min_raw!r})"
1061
+ ) from exc
1062
+ if enrich_min_tokens < 0:
1063
+ raise ConfigError(
1064
+ f"BRAIN_ENRICH_MIN_TOKENS must be a non-negative integer "
1065
+ f"(got {enrich_min_raw!r})"
1066
+ )
1067
+
1068
+ enrich_max_raw = os.environ.get("BRAIN_ENRICH_MAX_INPUT_TOKENS")
1069
+ if enrich_max_raw is None or enrich_max_raw.strip() == "":
1070
+ enrich_max_input_tokens = DEFAULT_ENRICH_MAX_INPUT_TOKENS
1071
+ else:
1072
+ try:
1073
+ enrich_max_input_tokens = int(enrich_max_raw)
1074
+ except ValueError as exc:
1075
+ raise ConfigError(
1076
+ f"BRAIN_ENRICH_MAX_INPUT_TOKENS must be a positive integer "
1077
+ f"(got {enrich_max_raw!r})"
1078
+ ) from exc
1079
+ if enrich_max_input_tokens <= 0:
1080
+ raise ConfigError(
1081
+ f"BRAIN_ENRICH_MAX_INPUT_TOKENS must be a positive integer "
1082
+ f"(got {enrich_max_input_tokens!r})"
1083
+ )
1084
+
1085
+ enrich_timeout_raw = os.environ.get("BRAIN_ENRICH_TIMEOUT_SECONDS")
1086
+ if enrich_timeout_raw is None or enrich_timeout_raw.strip() == "":
1087
+ enrich_timeout_seconds = DEFAULT_ENRICH_TIMEOUT_SECONDS
1088
+ else:
1089
+ try:
1090
+ enrich_timeout_seconds = float(enrich_timeout_raw)
1091
+ except ValueError as exc:
1092
+ raise ConfigError(
1093
+ f"BRAIN_ENRICH_TIMEOUT_SECONDS must be a positive float "
1094
+ f"(got {enrich_timeout_raw!r})"
1095
+ ) from exc
1096
+ if enrich_timeout_seconds <= 0:
1097
+ raise ConfigError(
1098
+ f"BRAIN_ENRICH_TIMEOUT_SECONDS must be a positive float "
1099
+ f"(got {enrich_timeout_raw!r})"
1100
+ )
1101
+
1102
+ # Ollama keep_alive -- see DEFAULT_OLLAMA_KEEP_ALIVE. Accepts a positive
1103
+ # integer optionally followed by m/h/s (e.g. "30m", "1h", "60s", "60")
1104
+ # plus the two Ollama sentinels "-1" (keep loaded indefinitely) and "0"
1105
+ # (unload immediately). Empty / whitespace-only falls back to the
1106
+ # default; every other malformed value ("-2", "-1m", "abc", …) is
1107
+ # rejected via ConfigError so a config typo surfaces at startup, not
1108
+ # during an embed/chat call.
1109
+ keep_alive_raw = os.environ.get("BRAIN_OLLAMA_KEEP_ALIVE")
1110
+ if keep_alive_raw is None or keep_alive_raw.strip() == "":
1111
+ ollama_keep_alive = DEFAULT_OLLAMA_KEEP_ALIVE
1112
+ else:
1113
+ stripped_ka = keep_alive_raw.strip()
1114
+ if not _KEEP_ALIVE_RE.match(stripped_ka):
1115
+ raise ConfigError(
1116
+ "BRAIN_OLLAMA_KEEP_ALIVE must be '-1' (keep loaded), '0' "
1117
+ "(unload immediately), or a positive integer / duration "
1118
+ "string like '30m', '1h', '60s', '60' "
1119
+ f"(got {keep_alive_raw!r})"
1120
+ )
1121
+ ollama_keep_alive = stripped_ka
1122
+
1123
+ # Wave G1-c -- GraphRAG sync env vars. Same eager-validation pattern as
1124
+ # the enrich knobs above: unset/blank -> default; non-parseable /
1125
+ # out-of-range -> ConfigError so a typo surfaces at startup, never
1126
+ # mid-ingest.
1127
+ graph_enabled_raw = os.environ.get("BRAIN_GRAPH_ENABLED")
1128
+ if graph_enabled_raw is None or graph_enabled_raw.strip() == "":
1129
+ graph_enabled = DEFAULT_GRAPH_ENABLED
1130
+ else:
1131
+ token = graph_enabled_raw.strip().lower()
1132
+ if token in _GRAPH_ENABLED_TRUTHY:
1133
+ graph_enabled = True
1134
+ elif token in _GRAPH_ENABLED_FALSY:
1135
+ graph_enabled = False
1136
+ else:
1137
+ raise ConfigError(
1138
+ "BRAIN_GRAPH_ENABLED must be one of "
1139
+ "1/true/yes/on or 0/false/no/off "
1140
+ f"(got {graph_enabled_raw!r})"
1141
+ )
1142
+
1143
+ graph_tenant_raw = os.environ.get("BRAIN_GRAPH_TENANT")
1144
+ if graph_tenant_raw is None or graph_tenant_raw.strip() == "":
1145
+ graph_tenant_id = DEFAULT_GRAPH_TENANT_ID
1146
+ else:
1147
+ graph_tenant_id = graph_tenant_raw.strip()
1148
+
1149
+ graph_window_raw = os.environ.get("BRAIN_GRAPH_COOCCUR_WINDOW")
1150
+ if graph_window_raw is None or graph_window_raw.strip() == "":
1151
+ graph_cooccur_window = DEFAULT_GRAPH_COOCCUR_WINDOW
1152
+ else:
1153
+ try:
1154
+ graph_cooccur_window = int(graph_window_raw)
1155
+ except ValueError as exc:
1156
+ raise ConfigError(
1157
+ f"BRAIN_GRAPH_COOCCUR_WINDOW must be a positive integer "
1158
+ f"(got {graph_window_raw!r})"
1159
+ ) from exc
1160
+ if graph_cooccur_window < 1:
1161
+ raise ConfigError(
1162
+ f"BRAIN_GRAPH_COOCCUR_WINDOW must be a positive integer "
1163
+ f"(got {graph_window_raw!r})"
1164
+ )
1165
+
1166
+ # ``none`` / ``unlimited`` disables the per-doc cap (maps to ``None``,
1167
+ # which :class:`ReconcileConfig` accepts); otherwise a positive int.
1168
+ graph_max_raw = os.environ.get("BRAIN_GRAPH_MAX_ENTITIES_PER_DOC")
1169
+ graph_max_entities: int | None
1170
+ if graph_max_raw is None or graph_max_raw.strip() == "":
1171
+ graph_max_entities = DEFAULT_GRAPH_MAX_ENTITIES
1172
+ elif graph_max_raw.strip().lower() in {"none", "unlimited"}:
1173
+ graph_max_entities = None
1174
+ else:
1175
+ try:
1176
+ graph_max_entities = int(graph_max_raw)
1177
+ except ValueError as exc:
1178
+ raise ConfigError(
1179
+ "BRAIN_GRAPH_MAX_ENTITIES_PER_DOC must be a positive integer "
1180
+ f"or 'none' (got {graph_max_raw!r})"
1181
+ ) from exc
1182
+ if graph_max_entities < 1:
1183
+ raise ConfigError(
1184
+ "BRAIN_GRAPH_MAX_ENTITIES_PER_DOC must be a positive integer "
1185
+ f"or 'none' (got {graph_max_raw!r})"
1186
+ )
1187
+
1188
+ graph_ratio_raw = os.environ.get("BRAIN_GRAPH_GENERIC_DF")
1189
+ if graph_ratio_raw is None or graph_ratio_raw.strip() == "":
1190
+ graph_generic_df_ratio = DEFAULT_GRAPH_GENERIC_DF_RATIO
1191
+ else:
1192
+ try:
1193
+ graph_generic_df_ratio = float(graph_ratio_raw)
1194
+ except ValueError as exc:
1195
+ raise ConfigError(
1196
+ "BRAIN_GRAPH_GENERIC_DF must be a float in (0.0, 1.0] "
1197
+ f"(got {graph_ratio_raw!r})"
1198
+ ) from exc
1199
+ if not (0.0 < graph_generic_df_ratio <= 1.0):
1200
+ raise ConfigError(
1201
+ "BRAIN_GRAPH_GENERIC_DF must be a float in (0.0, 1.0] "
1202
+ f"(got {graph_ratio_raw!r})"
1203
+ )
1204
+
1205
+ # Wave G2 -- concept-extraction + bounded-retrieval env vars. Same
1206
+ # eager-validation idiom as the G1-c graph knobs above: unset/blank ->
1207
+ # default; non-parseable / out-of-range -> ConfigError at startup so a
1208
+ # typo never surfaces mid-retrieval.
1209
+ graph_concepts_raw = os.environ.get("BRAIN_GRAPH_CONCEPTS")
1210
+ if graph_concepts_raw is None or graph_concepts_raw.strip() == "":
1211
+ graph_concepts = DEFAULT_GRAPH_CONCEPTS
1212
+ else:
1213
+ token = graph_concepts_raw.strip().lower()
1214
+ if token in _GRAPH_ENABLED_TRUTHY:
1215
+ graph_concepts = True
1216
+ elif token in _GRAPH_ENABLED_FALSY:
1217
+ graph_concepts = False
1218
+ else:
1219
+ raise ConfigError(
1220
+ "BRAIN_GRAPH_CONCEPTS must be one of "
1221
+ "1/true/yes/on or 0/false/no/off "
1222
+ f"(got {graph_concepts_raw!r})"
1223
+ )
1224
+
1225
+ graph_extract_model_raw = os.environ.get("BRAIN_GRAPH_EXTRACT_MODEL")
1226
+ if (
1227
+ graph_extract_model_raw is None
1228
+ or graph_extract_model_raw.strip() == ""
1229
+ ):
1230
+ graph_extract_model = DEFAULT_GRAPH_EXTRACT_MODEL
1231
+ else:
1232
+ graph_extract_model = graph_extract_model_raw.strip()
1233
+
1234
+ # Concept-extractor input head cap (perf Fix C). ``0`` / ``none`` /
1235
+ # ``unlimited`` disables the cap (maps to ``None``); otherwise a positive
1236
+ # int. Mirrors the ``BRAIN_GRAPH_MAX_ENTITIES_PER_DOC`` sentinel idiom,
1237
+ # additionally accepting ``0`` as the disable token per the perf plan.
1238
+ gem_raw = os.environ.get("BRAIN_GRAPH_EXTRACT_MAX_INPUT_TOKENS")
1239
+ graph_extract_max_input_tokens: int | None
1240
+ if gem_raw is None or gem_raw.strip() == "":
1241
+ graph_extract_max_input_tokens = DEFAULT_GRAPH_EXTRACT_MAX_INPUT_TOKENS
1242
+ elif gem_raw.strip().lower() in {"0", "none", "unlimited"}:
1243
+ graph_extract_max_input_tokens = None
1244
+ else:
1245
+ try:
1246
+ graph_extract_max_input_tokens = int(gem_raw.strip())
1247
+ except ValueError as exc:
1248
+ raise ConfigError(
1249
+ "BRAIN_GRAPH_EXTRACT_MAX_INPUT_TOKENS must be a positive integer "
1250
+ f"or 0/'none'/'unlimited' to disable (got {gem_raw!r})"
1251
+ ) from exc
1252
+ if graph_extract_max_input_tokens < 1:
1253
+ raise ConfigError(
1254
+ "BRAIN_GRAPH_EXTRACT_MAX_INPUT_TOKENS must be a positive integer "
1255
+ f"or 0/'none'/'unlimited' to disable (got {gem_raw!r})"
1256
+ )
1257
+
1258
+ graph_depth_raw = os.environ.get("BRAIN_GRAPH_DEPTH")
1259
+ if graph_depth_raw is None or graph_depth_raw.strip() == "":
1260
+ graph_depth = DEFAULT_GRAPH_DEPTH
1261
+ else:
1262
+ try:
1263
+ graph_depth = int(graph_depth_raw)
1264
+ except ValueError as exc:
1265
+ raise ConfigError(
1266
+ f"BRAIN_GRAPH_DEPTH must be a positive integer "
1267
+ f"(got {graph_depth_raw!r})"
1268
+ ) from exc
1269
+ if graph_depth < 1:
1270
+ raise ConfigError(
1271
+ f"BRAIN_GRAPH_DEPTH must be a positive integer "
1272
+ f"(got {graph_depth_raw!r})"
1273
+ )
1274
+
1275
+ graph_frontier_raw = os.environ.get("BRAIN_GRAPH_FRONTIER_CAP")
1276
+ if graph_frontier_raw is None or graph_frontier_raw.strip() == "":
1277
+ graph_frontier_cap = DEFAULT_GRAPH_FRONTIER_CAP
1278
+ else:
1279
+ try:
1280
+ graph_frontier_cap = int(graph_frontier_raw)
1281
+ except ValueError as exc:
1282
+ raise ConfigError(
1283
+ f"BRAIN_GRAPH_FRONTIER_CAP must be a positive integer "
1284
+ f"(got {graph_frontier_raw!r})"
1285
+ ) from exc
1286
+ if graph_frontier_cap < 1:
1287
+ raise ConfigError(
1288
+ f"BRAIN_GRAPH_FRONTIER_CAP must be a positive integer "
1289
+ f"(got {graph_frontier_raw!r})"
1290
+ )
1291
+
1292
+ graph_max_degree_raw = os.environ.get("BRAIN_GRAPH_MAX_DEGREE")
1293
+ if graph_max_degree_raw is None or graph_max_degree_raw.strip() == "":
1294
+ graph_max_degree = DEFAULT_GRAPH_MAX_DEGREE
1295
+ else:
1296
+ try:
1297
+ graph_max_degree = int(graph_max_degree_raw)
1298
+ except ValueError as exc:
1299
+ raise ConfigError(
1300
+ f"BRAIN_GRAPH_MAX_DEGREE must be a positive integer "
1301
+ f"(got {graph_max_degree_raw!r})"
1302
+ ) from exc
1303
+ if graph_max_degree < 1:
1304
+ raise ConfigError(
1305
+ f"BRAIN_GRAPH_MAX_DEGREE must be a positive integer "
1306
+ f"(got {graph_max_degree_raw!r})"
1307
+ )
1308
+
1309
+ graph_min_edge_raw = os.environ.get("BRAIN_GRAPH_MIN_EDGE_WEIGHT")
1310
+ if graph_min_edge_raw is None or graph_min_edge_raw.strip() == "":
1311
+ graph_min_edge_weight = DEFAULT_GRAPH_MIN_EDGE_WEIGHT
1312
+ else:
1313
+ try:
1314
+ graph_min_edge_weight = float(graph_min_edge_raw)
1315
+ except ValueError as exc:
1316
+ raise ConfigError(
1317
+ "BRAIN_GRAPH_MIN_EDGE_WEIGHT must be a float in [0.0, 1.0] "
1318
+ f"(got {graph_min_edge_raw!r})"
1319
+ ) from exc
1320
+ if not (0.0 <= graph_min_edge_weight <= 1.0):
1321
+ raise ConfigError(
1322
+ "BRAIN_GRAPH_MIN_EDGE_WEIGHT must be a float in [0.0, 1.0] "
1323
+ f"(got {graph_min_edge_raw!r})"
1324
+ )
1325
+
1326
+ graph_theme_limit_raw = os.environ.get("BRAIN_GRAPH_THEME_LIMIT")
1327
+ if graph_theme_limit_raw is None or graph_theme_limit_raw.strip() == "":
1328
+ graph_theme_limit = DEFAULT_GRAPH_THEME_LIMIT
1329
+ else:
1330
+ try:
1331
+ graph_theme_limit = int(graph_theme_limit_raw)
1332
+ except ValueError as exc:
1333
+ raise ConfigError(
1334
+ f"BRAIN_GRAPH_THEME_LIMIT must be a positive integer "
1335
+ f"(got {graph_theme_limit_raw!r})"
1336
+ ) from exc
1337
+ if graph_theme_limit < 1:
1338
+ raise ConfigError(
1339
+ f"BRAIN_GRAPH_THEME_LIMIT must be a positive integer "
1340
+ f"(got {graph_theme_limit_raw!r})"
1341
+ )
1342
+
1343
+ # Wave G3 -- global community-detection env vars (spec §17c). Same
1344
+ # eager-validation idiom as the G2 graph knobs above: unset/blank ->
1345
+ # default; non-parseable / out-of-range -> ConfigError at startup so a
1346
+ # typo never surfaces mid community-build.
1347
+ gc_resolution_raw = os.environ.get("BRAIN_GRAPH_COMMUNITY_RESOLUTION")
1348
+ if gc_resolution_raw is None or gc_resolution_raw.strip() == "":
1349
+ graph_community_resolution = DEFAULT_GRAPH_COMMUNITY_RESOLUTION
1350
+ else:
1351
+ try:
1352
+ graph_community_resolution = float(gc_resolution_raw)
1353
+ except ValueError as exc:
1354
+ raise ConfigError(
1355
+ "BRAIN_GRAPH_COMMUNITY_RESOLUTION must be a positive float "
1356
+ f"(got {gc_resolution_raw!r})"
1357
+ ) from exc
1358
+ if graph_community_resolution <= 0:
1359
+ raise ConfigError(
1360
+ "BRAIN_GRAPH_COMMUNITY_RESOLUTION must be a positive float "
1361
+ f"(got {gc_resolution_raw!r})"
1362
+ )
1363
+
1364
+ gc_seed_raw = os.environ.get("BRAIN_GRAPH_COMMUNITY_SEED")
1365
+ if gc_seed_raw is None or gc_seed_raw.strip() == "":
1366
+ graph_community_seed = DEFAULT_GRAPH_COMMUNITY_SEED
1367
+ else:
1368
+ try:
1369
+ graph_community_seed = int(gc_seed_raw)
1370
+ except ValueError as exc:
1371
+ raise ConfigError(
1372
+ "BRAIN_GRAPH_COMMUNITY_SEED must be a non-negative integer "
1373
+ f"(got {gc_seed_raw!r})"
1374
+ ) from exc
1375
+ if graph_community_seed < 0:
1376
+ raise ConfigError(
1377
+ "BRAIN_GRAPH_COMMUNITY_SEED must be a non-negative integer "
1378
+ f"(got {gc_seed_raw!r})"
1379
+ )
1380
+
1381
+ gc_min_size_raw = os.environ.get("BRAIN_GRAPH_COMMUNITY_MIN_SIZE")
1382
+ if gc_min_size_raw is None or gc_min_size_raw.strip() == "":
1383
+ graph_community_min_size = DEFAULT_GRAPH_COMMUNITY_MIN_SIZE
1384
+ else:
1385
+ try:
1386
+ graph_community_min_size = int(gc_min_size_raw)
1387
+ except ValueError as exc:
1388
+ raise ConfigError(
1389
+ "BRAIN_GRAPH_COMMUNITY_MIN_SIZE must be a positive integer "
1390
+ f"(got {gc_min_size_raw!r})"
1391
+ ) from exc
1392
+ if graph_community_min_size < 1:
1393
+ raise ConfigError(
1394
+ "BRAIN_GRAPH_COMMUNITY_MIN_SIZE must be a positive integer "
1395
+ f"(got {gc_min_size_raw!r})"
1396
+ )
1397
+
1398
+ gc_jaccard_raw = os.environ.get("BRAIN_GRAPH_COMMUNITY_JACCARD")
1399
+ if gc_jaccard_raw is None or gc_jaccard_raw.strip() == "":
1400
+ graph_community_jaccard = DEFAULT_GRAPH_COMMUNITY_JACCARD
1401
+ else:
1402
+ try:
1403
+ graph_community_jaccard = float(gc_jaccard_raw)
1404
+ except ValueError as exc:
1405
+ raise ConfigError(
1406
+ "BRAIN_GRAPH_COMMUNITY_JACCARD must be a float in [0.0, 1.0] "
1407
+ f"(got {gc_jaccard_raw!r})"
1408
+ ) from exc
1409
+ if not (0.0 <= graph_community_jaccard <= 1.0):
1410
+ raise ConfigError(
1411
+ "BRAIN_GRAPH_COMMUNITY_JACCARD must be a float in [0.0, 1.0] "
1412
+ f"(got {gc_jaccard_raw!r})"
1413
+ )
1414
+
1415
+ gc_limit_raw = os.environ.get("BRAIN_GRAPH_COMMUNITY_LIMIT")
1416
+ if gc_limit_raw is None or gc_limit_raw.strip() == "":
1417
+ graph_community_limit = DEFAULT_GRAPH_COMMUNITY_LIMIT
1418
+ else:
1419
+ try:
1420
+ graph_community_limit = int(gc_limit_raw)
1421
+ except ValueError as exc:
1422
+ raise ConfigError(
1423
+ "BRAIN_GRAPH_COMMUNITY_LIMIT must be a positive integer "
1424
+ f"(got {gc_limit_raw!r})"
1425
+ ) from exc
1426
+ if graph_community_limit < 1:
1427
+ raise ConfigError(
1428
+ "BRAIN_GRAPH_COMMUNITY_LIMIT must be a positive integer "
1429
+ f"(got {gc_limit_raw!r})"
1430
+ )
1431
+
1432
+ # ``none`` / ``unlimited`` disables the per-tenant community cap (maps to
1433
+ # ``None``); otherwise a positive int. Mirrors
1434
+ # ``BRAIN_GRAPH_MAX_ENTITIES_PER_DOC``.
1435
+ gc_max_raw = os.environ.get("BRAIN_GRAPH_COMMUNITY_MAX")
1436
+ graph_community_max: int | None
1437
+ if gc_max_raw is None or gc_max_raw.strip() == "":
1438
+ graph_community_max = DEFAULT_GRAPH_COMMUNITY_MAX
1439
+ elif gc_max_raw.strip().lower() in {"none", "unlimited"}:
1440
+ graph_community_max = None
1441
+ else:
1442
+ try:
1443
+ graph_community_max = int(gc_max_raw)
1444
+ except ValueError as exc:
1445
+ raise ConfigError(
1446
+ "BRAIN_GRAPH_COMMUNITY_MAX must be a positive integer "
1447
+ f"or 'none' (got {gc_max_raw!r})"
1448
+ ) from exc
1449
+ if graph_community_max < 1:
1450
+ raise ConfigError(
1451
+ "BRAIN_GRAPH_COMMUNITY_MAX must be a positive integer "
1452
+ f"or 'none' (got {gc_max_raw!r})"
1453
+ )
1454
+
1455
+ # Phase 1 -- extra automated-sender denylist entries. Same comma-split /
1456
+ # trim / lowercase / dedupe shape as ``BRAIN_OWNER_PARTICIPANTS``;
1457
+ # unset/blank yields an empty frozenset (generic heuristic only).
1458
+ sender_denylist_raw = os.environ.get("BRAIN_GRAPH_SENDER_DENYLIST", "")
1459
+ graph_sender_denylist = frozenset(
1460
+ piece
1461
+ for piece in (
1462
+ entry.strip().lower() for entry in sender_denylist_raw.split(",")
1463
+ )
1464
+ if piece
1465
+ )
1466
+
1467
+ # Phase B (2026-05-25) -- operator-curated concept extraction stopwords.
1468
+ # Same comma-split / trim / lowercase / dedupe shape as
1469
+ # ``BRAIN_GRAPH_SENDER_DENYLIST``; default empty (real terms are
1470
+ # employer-specific, rule 15 — operators set this locally).
1471
+ stopwords_raw = os.environ.get("BRAIN_GRAPH_EXTRACT_STOPWORDS", "")
1472
+ graph_extract_stopwords = frozenset(
1473
+ piece
1474
+ for piece in (
1475
+ entry.strip().lower() for entry in stopwords_raw.split(",")
1476
+ )
1477
+ if piece
1478
+ )
1479
+
1480
+ # Phase C (2026-05-25) -- curated entity alias rules path. Opt-in:
1481
+ # 1. ``BRAIN_GRAPH_ALIASES_PATH`` when set → use that path (expand user).
1482
+ # 2. Else ``$BRAIN_HOME/graph_aliases.yml`` when that file exists.
1483
+ # 3. Else ``None`` (feature off; ``load_alias_rules(None)`` returns ``[]``).
1484
+ # When a path is resolved, eager-validate it is readable (ConfigError if not).
1485
+ graph_aliases_path: Path | None
1486
+ aliases_env_raw = os.environ.get("BRAIN_GRAPH_ALIASES_PATH")
1487
+ if aliases_env_raw and aliases_env_raw.strip():
1488
+ graph_aliases_path = Path(aliases_env_raw.strip()).expanduser()
1489
+ try:
1490
+ graph_aliases_path.read_text(encoding="utf-8")
1491
+ except OSError as exc:
1492
+ raise ConfigError(
1493
+ f"BRAIN_GRAPH_ALIASES_PATH is not readable: {graph_aliases_path} "
1494
+ f"({exc})"
1495
+ ) from exc
1496
+ else:
1497
+ _default_aliases = _brain_home_root() / "graph_aliases.yml"
1498
+ graph_aliases_path = _default_aliases if _default_aliases.exists() else None
1499
+
1500
+ # Tacit-knowledge elicitation knobs. Same eager-validation idiom as the
1501
+ # enrich / graph knobs above: unset/blank -> default; non-parseable /
1502
+ # out-of-range -> ConfigError at startup so a typo never surfaces
1503
+ # mid-elicitation.
1504
+ elicit_min_evidence_raw = os.environ.get("BRAIN_ELICIT_MIN_EVIDENCE_DOCS")
1505
+ if elicit_min_evidence_raw is None or elicit_min_evidence_raw.strip() == "":
1506
+ elicit_min_evidence_docs = DEFAULT_ELICIT_MIN_EVIDENCE_DOCS
1507
+ else:
1508
+ try:
1509
+ elicit_min_evidence_docs = int(elicit_min_evidence_raw)
1510
+ except ValueError as exc:
1511
+ raise ConfigError(
1512
+ f"BRAIN_ELICIT_MIN_EVIDENCE_DOCS must be a non-negative integer "
1513
+ f"(got {elicit_min_evidence_raw!r})"
1514
+ ) from exc
1515
+ if elicit_min_evidence_docs < 0:
1516
+ raise ConfigError(
1517
+ f"BRAIN_ELICIT_MIN_EVIDENCE_DOCS must be a non-negative integer "
1518
+ f"(got {elicit_min_evidence_docs!r})"
1519
+ )
1520
+
1521
+ elicit_gap_score_raw = os.environ.get("BRAIN_ELICIT_MIN_GAP_SCORE")
1522
+ if elicit_gap_score_raw is None or elicit_gap_score_raw.strip() == "":
1523
+ elicit_min_gap_score = DEFAULT_ELICIT_MIN_GAP_SCORE
1524
+ else:
1525
+ try:
1526
+ elicit_min_gap_score = float(elicit_gap_score_raw)
1527
+ except ValueError as exc:
1528
+ raise ConfigError(
1529
+ f"BRAIN_ELICIT_MIN_GAP_SCORE must be a float in [0.0, 1.0] "
1530
+ f"(got {elicit_gap_score_raw!r})"
1531
+ ) from exc
1532
+ if not (0.0 <= elicit_min_gap_score <= 1.0):
1533
+ raise ConfigError(
1534
+ f"BRAIN_ELICIT_MIN_GAP_SCORE must be a float in [0.0, 1.0] "
1535
+ f"(got {elicit_min_gap_score!r})"
1536
+ )
1537
+
1538
+ elicit_queue_limit_raw = os.environ.get("BRAIN_ELICIT_QUEUE_LIMIT")
1539
+ if elicit_queue_limit_raw is None or elicit_queue_limit_raw.strip() == "":
1540
+ elicit_queue_limit = DEFAULT_ELICIT_QUEUE_LIMIT
1541
+ else:
1542
+ try:
1543
+ elicit_queue_limit = int(elicit_queue_limit_raw)
1544
+ except ValueError as exc:
1545
+ raise ConfigError(
1546
+ f"BRAIN_ELICIT_QUEUE_LIMIT must be a non-negative integer "
1547
+ f"(got {elicit_queue_limit_raw!r})"
1548
+ ) from exc
1549
+ if elicit_queue_limit < 0:
1550
+ raise ConfigError(
1551
+ f"BRAIN_ELICIT_QUEUE_LIMIT must be a non-negative integer "
1552
+ f"(got {elicit_queue_limit_raw!r})"
1553
+ )
1554
+
1555
+ elicit_contradiction_enabled_raw = os.environ.get(
1556
+ "BRAIN_ELICIT_CONTRADICTION_ENABLED"
1557
+ )
1558
+ if (
1559
+ elicit_contradiction_enabled_raw is None
1560
+ or elicit_contradiction_enabled_raw.strip() == ""
1561
+ ):
1562
+ elicit_contradiction_enabled = DEFAULT_ELICIT_CONTRADICTION_ENABLED
1563
+ else:
1564
+ token = elicit_contradiction_enabled_raw.strip().lower()
1565
+ if token in _GRAPH_ENABLED_TRUTHY:
1566
+ elicit_contradiction_enabled = True
1567
+ elif token in _GRAPH_ENABLED_FALSY:
1568
+ elicit_contradiction_enabled = False
1569
+ else:
1570
+ raise ConfigError(
1571
+ "BRAIN_ELICIT_CONTRADICTION_ENABLED must be one of "
1572
+ "1/true/yes/on or 0/false/no/off "
1573
+ f"(got {elicit_contradiction_enabled_raw!r})"
1574
+ )
1575
+
1576
+ elicit_contradiction_min_docs_raw = os.environ.get(
1577
+ "BRAIN_ELICIT_CONTRADICTION_MIN_DOCS"
1578
+ )
1579
+ if (
1580
+ elicit_contradiction_min_docs_raw is None
1581
+ or elicit_contradiction_min_docs_raw.strip() == ""
1582
+ ):
1583
+ elicit_contradiction_min_docs = DEFAULT_ELICIT_CONTRADICTION_MIN_DOCS
1584
+ else:
1585
+ try:
1586
+ elicit_contradiction_min_docs = int(elicit_contradiction_min_docs_raw)
1587
+ except ValueError as exc:
1588
+ raise ConfigError(
1589
+ f"BRAIN_ELICIT_CONTRADICTION_MIN_DOCS must be a non-negative integer "
1590
+ f"(got {elicit_contradiction_min_docs_raw!r})"
1591
+ ) from exc
1592
+ if elicit_contradiction_min_docs < 0:
1593
+ raise ConfigError(
1594
+ f"BRAIN_ELICIT_CONTRADICTION_MIN_DOCS must be a non-negative integer "
1595
+ f"(got {elicit_contradiction_min_docs!r})"
1596
+ )
1597
+
1598
+ # Plan 05 -- `brain timeline` env vars. Same eager-validation idiom as
1599
+ # the enrich / graph knobs above: unset/blank -> default; invalid value
1600
+ # -> ConfigError at startup so a typo surfaces before any timeline runs.
1601
+ timeline_gran_raw = os.environ.get("BRAIN_TIMELINE_GRANULARITY")
1602
+ if timeline_gran_raw is None or timeline_gran_raw.strip() == "":
1603
+ timeline_granularity = DEFAULT_TIMELINE_GRANULARITY
1604
+ else:
1605
+ timeline_granularity = timeline_gran_raw.strip().lower()
1606
+ if timeline_granularity not in _VALID_TIMELINE_GRANULARITIES:
1607
+ raise ConfigError(
1608
+ "BRAIN_TIMELINE_GRANULARITY must be one of auto/month/quarter/year "
1609
+ f"(got {timeline_gran_raw!r})"
1610
+ )
1611
+
1612
+ timeline_limit_raw = os.environ.get("BRAIN_TIMELINE_LIMIT")
1613
+ if timeline_limit_raw is None or timeline_limit_raw.strip() == "":
1614
+ timeline_limit = DEFAULT_TIMELINE_LIMIT
1615
+ else:
1616
+ try:
1617
+ timeline_limit = int(timeline_limit_raw)
1618
+ except ValueError as exc:
1619
+ raise ConfigError(
1620
+ f"BRAIN_TIMELINE_LIMIT must be a positive integer "
1621
+ f"(got {timeline_limit_raw!r})"
1622
+ ) from exc
1623
+ if timeline_limit < 1:
1624
+ raise ConfigError(
1625
+ f"BRAIN_TIMELINE_LIMIT must be a positive integer "
1626
+ f"(got {timeline_limit_raw!r})"
1627
+ )
1628
+
1629
+ timeline_synth_raw = os.environ.get("BRAIN_TIMELINE_SYNTH_LIMIT")
1630
+ if timeline_synth_raw is None or timeline_synth_raw.strip() == "":
1631
+ timeline_synth_limit = DEFAULT_TIMELINE_SYNTH_LIMIT
1632
+ else:
1633
+ try:
1634
+ timeline_synth_limit = int(timeline_synth_raw)
1635
+ except ValueError as exc:
1636
+ raise ConfigError(
1637
+ f"BRAIN_TIMELINE_SYNTH_LIMIT must be a non-negative integer "
1638
+ f"(got {timeline_synth_raw!r})"
1639
+ ) from exc
1640
+ if timeline_synth_limit < 0:
1641
+ raise ConfigError(
1642
+ f"BRAIN_TIMELINE_SYNTH_LIMIT must be a non-negative integer "
1643
+ f"(got {timeline_synth_raw!r})"
1644
+ )
1645
+
1646
+ timeline_trim_raw = os.environ.get("BRAIN_TIMELINE_TRIM")
1647
+ if timeline_trim_raw is None or timeline_trim_raw.strip() == "":
1648
+ timeline_trim = DEFAULT_TIMELINE_TRIM
1649
+ else:
1650
+ timeline_trim = timeline_trim_raw.strip().lower()
1651
+ if timeline_trim not in _VALID_TIMELINE_TRIMS:
1652
+ raise ConfigError(
1653
+ "BRAIN_TIMELINE_TRIM must be one of oldest/sparsest "
1654
+ f"(got {timeline_trim_raw!r})"
1655
+ )
1656
+
1657
+ # Plan 07 -- `brain connect` knobs. Same eager-validation idiom as the
1658
+ # elicit / graph knobs above: unset/blank -> default; non-parseable /
1659
+ # out-of-range -> ConfigError at startup so a typo never surfaces
1660
+ # mid-refresh.
1661
+ connect_min_score_raw = os.environ.get("BRAIN_CONNECT_MIN_SCORE")
1662
+ if connect_min_score_raw is None or connect_min_score_raw.strip() == "":
1663
+ connect_min_score = DEFAULT_CONNECT_MIN_SCORE
1664
+ else:
1665
+ try:
1666
+ connect_min_score = float(connect_min_score_raw)
1667
+ except ValueError as exc:
1668
+ raise ConfigError(
1669
+ "BRAIN_CONNECT_MIN_SCORE must be a float in (0.0, 1.0] "
1670
+ f"(got {connect_min_score_raw!r})"
1671
+ ) from exc
1672
+ if not (0.0 < connect_min_score <= 1.0):
1673
+ raise ConfigError(
1674
+ "BRAIN_CONNECT_MIN_SCORE must be a float in (0.0, 1.0] "
1675
+ f"(got {connect_min_score_raw!r})"
1676
+ )
1677
+
1678
+ connect_candidate_raw = os.environ.get("BRAIN_CONNECT_CANDIDATE_LIMIT")
1679
+ if connect_candidate_raw is None or connect_candidate_raw.strip() == "":
1680
+ connect_candidate_limit = DEFAULT_CONNECT_CANDIDATE_LIMIT
1681
+ else:
1682
+ try:
1683
+ connect_candidate_limit = int(connect_candidate_raw)
1684
+ except ValueError as exc:
1685
+ raise ConfigError(
1686
+ "BRAIN_CONNECT_CANDIDATE_LIMIT must be an integer >= 1 "
1687
+ f"(got {connect_candidate_raw!r})"
1688
+ ) from exc
1689
+ if connect_candidate_limit < 1:
1690
+ raise ConfigError(
1691
+ "BRAIN_CONNECT_CANDIDATE_LIMIT must be an integer >= 1 "
1692
+ f"(got {connect_candidate_raw!r})"
1693
+ )
1694
+
1695
+ connect_max_per_doc_raw = os.environ.get("BRAIN_CONNECT_MAX_PER_DOC")
1696
+ if connect_max_per_doc_raw is None or connect_max_per_doc_raw.strip() == "":
1697
+ connect_max_per_doc = DEFAULT_CONNECT_MAX_PER_DOC
1698
+ else:
1699
+ try:
1700
+ connect_max_per_doc = int(connect_max_per_doc_raw)
1701
+ except ValueError as exc:
1702
+ raise ConfigError(
1703
+ "BRAIN_CONNECT_MAX_PER_DOC must be an integer >= 1 "
1704
+ f"(got {connect_max_per_doc_raw!r})"
1705
+ ) from exc
1706
+ if connect_max_per_doc < 1:
1707
+ raise ConfigError(
1708
+ "BRAIN_CONNECT_MAX_PER_DOC must be an integer >= 1 "
1709
+ f"(got {connect_max_per_doc_raw!r})"
1710
+ )
1711
+ # Plan 03 -- `brain review scan` knobs (positive ints + cosine floors).
1712
+ review_conflict_limit = _parse_positive_int_env(
1713
+ "BRAIN_REVIEW_CONFLICT_LIMIT", DEFAULT_REVIEW_CONFLICT_LIMIT
1714
+ )
1715
+ review_conflict_pairs_per_entity = _parse_positive_int_env(
1716
+ "BRAIN_REVIEW_CONFLICT_PAIRS_PER_ENTITY",
1717
+ DEFAULT_REVIEW_CONFLICT_PAIRS_PER_ENTITY,
1718
+ )
1719
+ review_embed_sim_floor = _parse_unit_interval_env(
1720
+ "BRAIN_REVIEW_EMBED_SIM_FLOOR", DEFAULT_REVIEW_EMBED_SIM_FLOOR
1721
+ )
1722
+ review_stale_age_days = _parse_positive_int_env(
1723
+ "BRAIN_REVIEW_STALE_AGE_DAYS", DEFAULT_REVIEW_STALE_AGE_DAYS
1724
+ )
1725
+ review_stale_supersede_window_days = _parse_positive_int_env(
1726
+ "BRAIN_REVIEW_STALE_SUPERSEDE_WINDOW_DAYS",
1727
+ DEFAULT_REVIEW_STALE_SUPERSEDE_WINDOW_DAYS,
1728
+ )
1729
+ review_stale_sim_floor = _parse_unit_interval_env(
1730
+ "BRAIN_REVIEW_STALE_SIM_FLOOR", DEFAULT_REVIEW_STALE_SIM_FLOOR
1731
+ )
1732
+ review_stale_limit = _parse_positive_int_env(
1733
+ "BRAIN_REVIEW_STALE_LIMIT", DEFAULT_REVIEW_STALE_LIMIT
1734
+ )
1735
+ # Plan 06 -- `brain ask` knobs. Same eager-validation pattern as the
1736
+ # enrich / timeline knobs above: unset/blank -> default; non-parseable /
1737
+ # out-of-range -> ConfigError so a typo surfaces at startup, not mid-loop.
1738
+ ask_max_iter_raw = os.environ.get("BRAIN_ASK_MAX_ITERATIONS")
1739
+ if ask_max_iter_raw is None or ask_max_iter_raw.strip() == "":
1740
+ ask_max_iterations = DEFAULT_ASK_MAX_ITERATIONS
1741
+ else:
1742
+ try:
1743
+ ask_max_iterations = int(ask_max_iter_raw)
1744
+ except ValueError as exc:
1745
+ raise ConfigError(
1746
+ f"BRAIN_ASK_MAX_ITERATIONS must be a positive integer "
1747
+ f"(got {ask_max_iter_raw!r})"
1748
+ ) from exc
1749
+ if ask_max_iterations < 1:
1750
+ raise ConfigError(
1751
+ f"BRAIN_ASK_MAX_ITERATIONS must be a positive integer "
1752
+ f"(got {ask_max_iter_raw!r})"
1753
+ )
1754
+
1755
+ ask_docs_raw = os.environ.get("BRAIN_ASK_DOCS_PER_ITER")
1756
+ if ask_docs_raw is None or ask_docs_raw.strip() == "":
1757
+ ask_docs_per_iter = DEFAULT_ASK_DOCS_PER_ITER
1758
+ else:
1759
+ try:
1760
+ ask_docs_per_iter = int(ask_docs_raw)
1761
+ except ValueError as exc:
1762
+ raise ConfigError(
1763
+ f"BRAIN_ASK_DOCS_PER_ITER must be a positive integer "
1764
+ f"(got {ask_docs_raw!r})"
1765
+ ) from exc
1766
+ if ask_docs_per_iter < 1:
1767
+ raise ConfigError(
1768
+ f"BRAIN_ASK_DOCS_PER_ITER must be a positive integer "
1769
+ f"(got {ask_docs_raw!r})"
1770
+ )
1771
+
1772
+ # ``ask_model`` inherits ``enrich_model`` when unset/blank (the locked
1773
+ # default per the plan's config table) so the ask loop reuses the same
1774
+ # chat model as enrichment unless explicitly overridden.
1775
+ ask_model_raw = os.environ.get("BRAIN_ASK_MODEL")
1776
+ if ask_model_raw is None or ask_model_raw.strip() == "":
1777
+ ask_model = enrich_model
1778
+ else:
1779
+ ask_model = ask_model_raw.strip()
1780
+
1781
+ ask_timeout_raw = os.environ.get("BRAIN_ASK_TIMEOUT_SECONDS")
1782
+ if ask_timeout_raw is None or ask_timeout_raw.strip() == "":
1783
+ ask_timeout_seconds = DEFAULT_ASK_TIMEOUT_SECONDS
1784
+ else:
1785
+ try:
1786
+ ask_timeout_seconds = float(ask_timeout_raw)
1787
+ except ValueError as exc:
1788
+ raise ConfigError(
1789
+ f"BRAIN_ASK_TIMEOUT_SECONDS must be a positive float "
1790
+ f"(got {ask_timeout_raw!r})"
1791
+ ) from exc
1792
+ if ask_timeout_seconds <= 0:
1793
+ raise ConfigError(
1794
+ f"BRAIN_ASK_TIMEOUT_SECONDS must be a positive float "
1795
+ f"(got {ask_timeout_raw!r})"
1796
+ )
1797
+
1798
+ # Plan 04 -- `brain audio` env vars. Same eager-validation idiom as the
1799
+ # enrich / timeline knobs above: unset/blank -> default; non-parseable /
1800
+ # out-of-range -> ConfigError at startup so a typo never surfaces
1801
+ # mid-generation.
1802
+ audio_model_raw = os.environ.get("BRAIN_AUDIO_SCRIPT_MODEL")
1803
+ if audio_model_raw is None or audio_model_raw.strip() == "":
1804
+ audio_script_model = DEFAULT_AUDIO_SCRIPT_MODEL
1805
+ else:
1806
+ audio_script_model = audio_model_raw.strip()
1807
+
1808
+ audio_max_turns_raw = os.environ.get("BRAIN_AUDIO_MAX_TURNS")
1809
+ if audio_max_turns_raw is None or audio_max_turns_raw.strip() == "":
1810
+ audio_max_turns = DEFAULT_AUDIO_MAX_TURNS
1811
+ else:
1812
+ try:
1813
+ audio_max_turns = int(audio_max_turns_raw)
1814
+ except ValueError as exc:
1815
+ raise ConfigError(
1816
+ "BRAIN_AUDIO_MAX_TURNS must be a positive even integer "
1817
+ f"(got {audio_max_turns_raw!r})"
1818
+ ) from exc
1819
+ if audio_max_turns <= 0 or audio_max_turns % 2 != 0:
1820
+ raise ConfigError(
1821
+ "BRAIN_AUDIO_MAX_TURNS must be a positive even integer "
1822
+ f"(got {audio_max_turns_raw!r})"
1823
+ )
1824
+
1825
+ audio_max_input_raw = os.environ.get("BRAIN_AUDIO_MAX_INPUT_TOKENS")
1826
+ if audio_max_input_raw is None or audio_max_input_raw.strip() == "":
1827
+ audio_max_input_tokens = DEFAULT_AUDIO_MAX_INPUT_TOKENS
1828
+ else:
1829
+ try:
1830
+ audio_max_input_tokens = int(audio_max_input_raw)
1831
+ except ValueError as exc:
1832
+ raise ConfigError(
1833
+ "BRAIN_AUDIO_MAX_INPUT_TOKENS must be a positive integer "
1834
+ f"(got {audio_max_input_raw!r})"
1835
+ ) from exc
1836
+ if audio_max_input_tokens <= 0:
1837
+ raise ConfigError(
1838
+ "BRAIN_AUDIO_MAX_INPUT_TOKENS must be a positive integer "
1839
+ f"(got {audio_max_input_raw!r})"
1840
+ )
1841
+
1842
+ audio_theme_limit_raw = os.environ.get("BRAIN_AUDIO_THEME_LIMIT")
1843
+ if audio_theme_limit_raw is None or audio_theme_limit_raw.strip() == "":
1844
+ audio_theme_limit = DEFAULT_AUDIO_THEME_LIMIT
1845
+ else:
1846
+ try:
1847
+ audio_theme_limit = int(audio_theme_limit_raw)
1848
+ except ValueError as exc:
1849
+ raise ConfigError(
1850
+ "BRAIN_AUDIO_THEME_LIMIT must be a positive integer "
1851
+ f"(got {audio_theme_limit_raw!r})"
1852
+ ) from exc
1853
+ if audio_theme_limit <= 0:
1854
+ raise ConfigError(
1855
+ "BRAIN_AUDIO_THEME_LIMIT must be a positive integer "
1856
+ f"(got {audio_theme_limit_raw!r})"
1857
+ )
1858
+ # Plan 08 -- `brain gaps` knobs (positive ints).
1859
+ gaps_lookback_days = _parse_positive_int_env(
1860
+ "BRAIN_GAPS_LOOKBACK_DAYS", DEFAULT_GAPS_LOOKBACK_DAYS
1861
+ )
1862
+ gaps_min_cluster_size = _parse_positive_int_env(
1863
+ "BRAIN_GAPS_MIN_CLUSTER_SIZE", DEFAULT_GAPS_MIN_CLUSTER_SIZE
1864
+ )
1865
+
1866
+ return {
1867
+ # brain_home resolves via default_factory=_brain_home_root.
1868
+ "database_url": database_url,
1869
+ "ollama_host": ollama_host,
1870
+ "qwen3_model": qwen3_model,
1871
+ "embedder": embedder,
1872
+ "voyage_api_key": voyage_api_key,
1873
+ "vault_path": vault_path,
1874
+ "user_email": user_email,
1875
+ "vector_sim_floor": vector_sim_floor,
1876
+ "owner_participants": owner_participants,
1877
+ "people_hub_min_docs": people_hub_min_docs,
1878
+ "capture_title_words": capture_title_words,
1879
+ "capture_inbox_warn_threshold": capture_inbox_warn_threshold,
1880
+ "review_activity_limit": review_activity_limit,
1881
+ "review_theme_limit": review_theme_limit,
1882
+ "review_open_loop_limit": review_open_loop_limit,
1883
+ "brief_since_hours": brief_since_hours,
1884
+ "brief_todo_since_days": brief_todo_since_days,
1885
+ "brief_capture_limit": brief_capture_limit,
1886
+ "brief_pin_limit": brief_pin_limit,
1887
+ "recency_halflife_days": recency_halflife_days,
1888
+ "snippet_context_tokens": snippet_context_tokens,
1889
+ "resurface_limit": resurface_limit,
1890
+ "resurface_min_age_days": resurface_min_age_days,
1891
+ "resurface_age_halflife_days": resurface_age_halflife_days,
1892
+ "resurface_access_halflife_days": resurface_access_halflife_days,
1893
+ "enrich_model": enrich_model,
1894
+ "enrich_min_tokens": enrich_min_tokens,
1895
+ "enrich_max_input_tokens": enrich_max_input_tokens,
1896
+ "enrich_timeout_seconds": enrich_timeout_seconds,
1897
+ "ollama_keep_alive": ollama_keep_alive,
1898
+ "graph_enabled": graph_enabled,
1899
+ "graph_tenant_id": graph_tenant_id,
1900
+ "graph_cooccur_window": graph_cooccur_window,
1901
+ "graph_max_entities": graph_max_entities,
1902
+ "graph_generic_df_ratio": graph_generic_df_ratio,
1903
+ "graph_concepts": graph_concepts,
1904
+ "graph_extract_model": graph_extract_model,
1905
+ "graph_extract_max_input_tokens": graph_extract_max_input_tokens,
1906
+ "graph_depth": graph_depth,
1907
+ "graph_frontier_cap": graph_frontier_cap,
1908
+ "graph_max_degree": graph_max_degree,
1909
+ "graph_min_edge_weight": graph_min_edge_weight,
1910
+ "graph_theme_limit": graph_theme_limit,
1911
+ "graph_community_resolution": graph_community_resolution,
1912
+ "graph_community_seed": graph_community_seed,
1913
+ "graph_community_min_size": graph_community_min_size,
1914
+ "graph_community_jaccard": graph_community_jaccard,
1915
+ "graph_community_limit": graph_community_limit,
1916
+ "graph_community_max": graph_community_max,
1917
+ "graph_sender_denylist": graph_sender_denylist,
1918
+ "graph_extract_stopwords": graph_extract_stopwords,
1919
+ "graph_aliases_path": graph_aliases_path,
1920
+ "elicit_min_evidence_docs": elicit_min_evidence_docs,
1921
+ "elicit_min_gap_score": elicit_min_gap_score,
1922
+ "elicit_queue_limit": elicit_queue_limit,
1923
+ "elicit_contradiction_enabled": elicit_contradiction_enabled,
1924
+ "elicit_contradiction_min_docs": elicit_contradiction_min_docs,
1925
+ "timeline_granularity": timeline_granularity,
1926
+ "timeline_limit": timeline_limit,
1927
+ "timeline_synth_limit": timeline_synth_limit,
1928
+ "timeline_trim": timeline_trim,
1929
+ "connect_min_score": connect_min_score,
1930
+ "connect_candidate_limit": connect_candidate_limit,
1931
+ "connect_max_per_doc": connect_max_per_doc,
1932
+ "review_conflict_limit": review_conflict_limit,
1933
+ "review_conflict_pairs_per_entity": review_conflict_pairs_per_entity,
1934
+ "review_embed_sim_floor": review_embed_sim_floor,
1935
+ "review_stale_age_days": review_stale_age_days,
1936
+ "review_stale_supersede_window_days": review_stale_supersede_window_days,
1937
+ "review_stale_sim_floor": review_stale_sim_floor,
1938
+ "review_stale_limit": review_stale_limit,
1939
+ "ask_max_iterations": ask_max_iterations,
1940
+ "ask_docs_per_iter": ask_docs_per_iter,
1941
+ "ask_model": ask_model,
1942
+ "ask_timeout_seconds": ask_timeout_seconds,
1943
+ "audio_script_model": audio_script_model,
1944
+ "audio_max_turns": audio_max_turns,
1945
+ "audio_max_input_tokens": audio_max_input_tokens,
1946
+ "audio_theme_limit": audio_theme_limit,
1947
+ "gaps_lookback_days": gaps_lookback_days,
1948
+ "gaps_min_cluster_size": gaps_min_cluster_size,
1949
+ }