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/db.py ADDED
@@ -0,0 +1,540 @@
1
+ """Postgres connection + migration helpers."""
2
+ import importlib.resources
3
+ from collections.abc import Iterator
4
+ from contextlib import contextmanager, suppress
5
+ from pathlib import Path
6
+
7
+ import psycopg
8
+ from pgvector.psycopg import register_vector
9
+ from psycopg import sql
10
+
11
+ from .embedding_targets import (
12
+ embedding_index_name,
13
+ validate_embedding_target,
14
+ )
15
+ from .errors import AgeBootstrapError, BrainError
16
+ from .ingest import Embedder
17
+
18
+ # Canonical Apache AGE graph name created by the ``brain init`` bootstrap and
19
+ # traversed by the graph backend (wave G0). Defined here — not in
20
+ # :mod:`brain.config` — so this low-level layer stays dependency-free; the
21
+ # configurable ``BRAIN_GRAPH_NAME`` override (spec §10) is resolved by higher
22
+ # layers and passed in explicitly as ``graph_name`` when it lands (G0-4+).
23
+ # ``tests/conftest.py``'s ``_reset_age_graph`` drops exactly this graph between
24
+ # tests, so the bootstrap and the reset must agree on the name.
25
+ DEFAULT_GRAPH_NAME = "brain_graph"
26
+
27
+
28
+ def connect_raw(database_url: str) -> psycopg.Connection:
29
+ """Open a connection with pgvector adapter registered.
30
+
31
+ Same semantics as :func:`connect` but without the context-manager
32
+ wrapper. Callers own the connection lifecycle: they MUST call
33
+ ``conn.close()`` themselves. Used by the long-running watcher, which
34
+ holds a connection across many sync calls in a worker thread; the
35
+ `with` block of :func:`connect` would auto-close it after the first
36
+ use.
37
+ """
38
+ conn = psycopg.connect(database_url, connect_timeout=10)
39
+ # The vector extension may not exist yet during the initial `brain init`
40
+ # bootstrap; check pg_type before registering so we don't rely on
41
+ # exception-as-control-flow. The SELECT starts an implicit transaction
42
+ # under psycopg3's default autocommit=False — roll it back so the
43
+ # caller can still flip autocommit on if needed.
44
+ row = conn.execute(
45
+ "SELECT 1 FROM pg_type WHERE typname = 'vector'"
46
+ ).fetchone()
47
+ conn.rollback()
48
+ if row is not None:
49
+ register_vector(conn)
50
+ return conn
51
+
52
+
53
+ @contextmanager
54
+ def connect(database_url: str) -> Iterator[psycopg.Connection]:
55
+ """Open a connection with pgvector adapter registered.
56
+
57
+ Tolerates the bootstrap case where the `vector` extension has not yet been
58
+ installed (e.g. the first `brain init` on a fresh database). In that case
59
+ the adapter registration is skipped; callers that need vector support
60
+ should open a new connection after `run_migrations`.
61
+ """
62
+ conn = connect_raw(database_url)
63
+ try:
64
+ yield conn
65
+ finally:
66
+ conn.close()
67
+
68
+
69
+ def _age_extension_installed(conn: psycopg.Connection) -> bool:
70
+ """True iff the ``age`` extension object exists in this database.
71
+
72
+ ``LOAD 'age'`` only loads the shared library; the openCypher catalog
73
+ functions (``cypher``, ``create_graph``, ...) come from the extension
74
+ object created by ``CREATE EXTENSION age``. On a fresh database — before
75
+ :func:`bootstrap_age` runs during ``brain init`` — that object is absent, so
76
+ the session helpers must no-op rather than fail. Mirrors the
77
+ ``vector``-extension tolerance in :func:`connect_raw`.
78
+ """
79
+ row = conn.execute(
80
+ "SELECT 1 FROM pg_extension WHERE extname = 'age'"
81
+ ).fetchone()
82
+ return row is not None
83
+
84
+
85
+ def load_age(conn: psycopg.Connection) -> bool:
86
+ """Make Apache AGE callable on ``conn`` for the rest of the session.
87
+
88
+ Issues ``LOAD 'age'`` so the openCypher catalog functions become available.
89
+ AGE requires this once per backend session even when the extension object
90
+ already exists — verified against the live AGE image, where a fresh
91
+ connection that skips ``LOAD`` raises
92
+ ``unhandled cypher(cstring) function call``.
93
+
94
+ Deliberately does **not** mutate ``search_path``. The graph backend
95
+ fully-qualifies every call (``ag_catalog.cypher(...) AS (col
96
+ ag_catalog.agtype)``), which the live AGE image accepts without an
97
+ ``ag_catalog``-first search_path. Avoiding the global path keeps the
98
+ contract identical to ``tests/conftest.py``'s ``_reset_age_graph`` (which
99
+ explicitly refuses to leak ``ag_catalog`` onto the session) and guarantees
100
+ zero impact on the unqualified ``public`` queries every other command runs.
101
+
102
+ Tolerates the bootstrap window where the extension is not yet installed
103
+ (returns ``False`` without loading) so :func:`connect_age` is safe on a
104
+ fresh database. ``LOAD`` is a process-level effect that survives a
105
+ transaction rollback, so the implicit transaction opened under psycopg's
106
+ default ``autocommit=False`` is rolled back here — mirroring
107
+ :func:`connect_raw` — leaving the caller free to flip ``autocommit``
108
+ afterwards.
109
+
110
+ Returns ``True`` when AGE was loaded, ``False`` when the extension is
111
+ absent (nothing loaded). Raises :class:`AgeBootstrapError` (never a raw
112
+ ``psycopg.Error``) if the catalog probe or ``LOAD`` fails.
113
+ """
114
+ try:
115
+ installed = _age_extension_installed(conn)
116
+ if installed:
117
+ conn.execute("LOAD 'age'")
118
+ except psycopg.Error as exc:
119
+ # Clear the aborted transaction first so the connection stays usable,
120
+ # then surface a typed bootstrap failure (no raw psycopg.Error escapes).
121
+ if not conn.autocommit:
122
+ conn.rollback()
123
+ raise AgeBootstrapError(f"failed to LOAD Apache AGE: {exc}") from exc
124
+ # Clear the implicit transaction opened by the SELECT (and LOAD) so the
125
+ # caller can still flip autocommit; harmless no-op under autocommit=True.
126
+ if not conn.autocommit:
127
+ conn.rollback()
128
+ return installed
129
+
130
+
131
+ @contextmanager
132
+ def connect_age(database_url: str) -> Iterator[psycopg.Connection]:
133
+ """Open a connection with pgvector registered **and** Apache AGE loaded.
134
+
135
+ Thin wrapper over :func:`connect` that additionally runs :func:`load_age`,
136
+ so graph callers don't repeat the per-session ``LOAD 'age'`` bootstrap. On a
137
+ fresh database where the ``age`` extension isn't installed yet,
138
+ :func:`load_age` no-ops; the connection is still usable for the relational
139
+ source-of-truth tables.
140
+ """
141
+ with connect(database_url) as conn:
142
+ load_age(conn)
143
+ yield conn
144
+
145
+
146
+ def migrations_dir() -> Path:
147
+ """Path to the packaged migrations directory (``brain/migrations``).
148
+
149
+ Resolved via :mod:`importlib.resources` so it works in BOTH editable
150
+ (``pip install -e``) and wheel / pip installs: the SQL files ship inside the
151
+ ``brain`` package itself (see pyproject ``[tool.setuptools.package-data]``),
152
+ not at the repo root. Before 0.2.1 they lived at ``<repo>/migrations`` — a
153
+ sibling of ``src/`` — which setuptools never bundled, so ``brain init``
154
+ globbed an empty directory and applied zero migrations on wheel installs.
155
+
156
+ ``brain`` is a regular, never-zip-imported package, so the ``Traversable``
157
+ returned by ``files()`` is already a concrete filesystem path; the ``Path``
158
+ cast is safe and keeps ``.glob`` working at the call site.
159
+ """
160
+ return Path(str(importlib.resources.files("brain").joinpath("migrations")))
161
+
162
+
163
+ _SCHEMA_MIGRATIONS_DDL = """
164
+ CREATE TABLE IF NOT EXISTS schema_migrations (
165
+ name TEXT PRIMARY KEY,
166
+ applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
167
+ )
168
+ """
169
+
170
+
171
+ def _table_exists(conn: psycopg.Connection, name: str) -> bool:
172
+ row = conn.execute(
173
+ "SELECT 1 FROM information_schema.tables "
174
+ "WHERE table_schema = 'public' AND table_name = %s",
175
+ (name,),
176
+ ).fetchone()
177
+ return row is not None
178
+
179
+
180
+ def _column_exists(conn: psycopg.Connection, table: str, column: str) -> bool:
181
+ row = conn.execute(
182
+ "SELECT 1 FROM information_schema.columns "
183
+ "WHERE table_schema = 'public' AND table_name = %s AND column_name = %s",
184
+ (table, column),
185
+ ).fetchone()
186
+ return row is not None
187
+
188
+
189
+ def _index_exists(conn: psycopg.Connection, name: str) -> bool:
190
+ row = conn.execute(
191
+ "SELECT 1 FROM pg_indexes WHERE indexname = %s",
192
+ (name,),
193
+ ).fetchone()
194
+ return row is not None
195
+
196
+
197
+ def _seed_applied_migrations(conn: psycopg.Connection) -> None:
198
+ """Detect and record migrations already applied to a pre-existing schema.
199
+
200
+ Run on first encounter with a DB that predates ``schema_migrations``. We
201
+ can't run prior migrations against a populated DB without crashing
202
+ (``CREATE TABLE`` collides; ``ALTER TABLE DROP COLUMN`` on 002 would lose
203
+ data), so we infer their applied state from schema artifacts and seed the
204
+ tracking table. Subsequent runs then skip them.
205
+
206
+ Detection is conservative: 002 is treated as applied whenever 001 is,
207
+ since 002 is the documented qwen3 backend swap that's been the only
208
+ supported path for any DB old enough to lack ``schema_migrations``.
209
+ """
210
+ if _table_exists(conn, "sources"):
211
+ conn.execute(
212
+ "INSERT INTO schema_migrations (name) VALUES (%s) "
213
+ "ON CONFLICT (name) DO NOTHING",
214
+ ("001_init.sql",),
215
+ )
216
+ conn.execute(
217
+ "INSERT INTO schema_migrations (name) VALUES (%s) "
218
+ "ON CONFLICT (name) DO NOTHING",
219
+ ("002_qwen3_embedding.sql",),
220
+ )
221
+ if _column_exists(conn, "documents", "kind"):
222
+ conn.execute(
223
+ "INSERT INTO schema_migrations (name) VALUES (%s) "
224
+ "ON CONFLICT (name) DO NOTHING",
225
+ ("003_vault_model.sql",),
226
+ )
227
+ if _index_exists(conn, "documents_content_hash_ingested_idx"):
228
+ conn.execute(
229
+ "INSERT INTO schema_migrations (name) VALUES (%s) "
230
+ "ON CONFLICT (name) DO NOTHING",
231
+ ("004_relax_content_hash_uniqueness.sql",),
232
+ )
233
+
234
+
235
+ def run_migrations(conn: psycopg.Connection) -> list[str]:
236
+ """Apply pending migrations in name order. Returns the list newly applied.
237
+
238
+ Tracks applied migrations in the ``schema_migrations`` table so each .sql
239
+ file runs at most once. On first run against a pre-existing schema (no
240
+ ``schema_migrations`` table yet), seeds the table from schema state via
241
+ :func:`_seed_applied_migrations` so the prior CREATE TABLE / ALTER COLUMN
242
+ statements aren't re-attempted.
243
+ """
244
+ mdir = migrations_dir()
245
+ migration_files = sorted(mdir.glob("*.sql"))
246
+ if not migration_files:
247
+ # Loud-fail guard: an empty migrations dir means the package was installed
248
+ # without its SQL (the pre-0.2.1 wheel bug). Raise instead of silently
249
+ # applying zero migrations and leaving an empty schema.
250
+ raise BrainError(
251
+ f"No migration files found under {mdir} — the package is installed "
252
+ "incorrectly (migrations were not shipped in the wheel). Reinstall "
253
+ "from a build that includes src/brain/migrations/*.sql."
254
+ )
255
+
256
+ conn.execute(_SCHEMA_MIGRATIONS_DDL)
257
+ seeded_row = conn.execute("SELECT count(*) FROM schema_migrations").fetchone()
258
+ assert seeded_row is not None # count(*) always yields one row
259
+ if int(seeded_row[0]) == 0:
260
+ _seed_applied_migrations(conn)
261
+
262
+ rows = conn.execute("SELECT name FROM schema_migrations").fetchall()
263
+ applied_names = {str(r[0]) for r in rows}
264
+
265
+ applied: list[str] = []
266
+ for sql_file in migration_files:
267
+ if sql_file.name in applied_names:
268
+ continue
269
+ conn.execute(sql_file.read_text())
270
+ conn.execute(
271
+ "INSERT INTO schema_migrations (name) VALUES (%s)",
272
+ (sql_file.name,),
273
+ )
274
+ applied.append(sql_file.name)
275
+ return applied
276
+
277
+
278
+ def age_extension_available(conn: psycopg.Connection) -> bool:
279
+ """True iff the ``age`` extension is *installable* in this database.
280
+
281
+ Probes ``pg_available_extensions`` — the catalog of extensions whose control
282
+ files are present on the server (i.e. extensions that *can* be
283
+ ``CREATE EXTENSION``-ed) — as opposed to ``pg_extension``, which lists the
284
+ extensions already *installed* (the latter is what
285
+ :func:`_age_extension_installed` checks). On a stock pgvector image (a prod
286
+ DB before the Apache AGE cut-over) ``age`` is absent here, so ``brain init``
287
+ can SKIP the AGE bootstrap instead of crashing on ``CREATE EXTENSION age``
288
+ after the relational migrations have already committed.
289
+
290
+ Rolls back the implicit read transaction opened by the SELECT under
291
+ psycopg's default ``autocommit=False`` so the caller's connection stays
292
+ clean (a harmless no-op when the caller is already autocommit, as ``init``
293
+ is) — on BOTH the success and failure paths: if the probe statement itself
294
+ raises on a non-autocommit connection it would otherwise leave the
295
+ transaction aborted, poisoning the caller's connection. Mirrors the
296
+ ``vector``-extension tolerance in :func:`connect_raw`.
297
+ """
298
+ try:
299
+ row = conn.execute(
300
+ "SELECT 1 FROM pg_available_extensions WHERE name = 'age'"
301
+ ).fetchone()
302
+ except psycopg.Error:
303
+ # Clear the aborted transaction the failed SELECT may have opened on a
304
+ # non-autocommit connection, then re-raise (the probe makes no claim it
305
+ # can swallow DB failures — the caller decides how to surface them).
306
+ if not conn.autocommit:
307
+ conn.rollback()
308
+ raise
309
+ if not conn.autocommit:
310
+ conn.rollback()
311
+ return row is not None
312
+
313
+
314
+ def bootstrap_age(
315
+ conn: psycopg.Connection,
316
+ graph_name: str = DEFAULT_GRAPH_NAME,
317
+ ) -> bool:
318
+ """Idempotently provision the Apache AGE extension + the canonical graph.
319
+
320
+ Invoked by ``brain init`` after :func:`run_migrations`. All steps are
321
+ idempotent / guarded:
322
+
323
+ 1. ``CREATE EXTENSION IF NOT EXISTS age CASCADE`` — installs the catalog
324
+ functions if absent; no-op otherwise.
325
+ 2. ``LOAD 'age'`` — required before ``create_graph`` is callable.
326
+ 3. Check ``ag_catalog.ag_graph`` for ``graph_name`` and call
327
+ ``ag_catalog.create_graph`` **only when absent**. ``create_graph`` raises
328
+ ``InvalidSchemaName`` ("graph already exists") on a second call, so the
329
+ existence guard is mandatory (verified against the live AGE image); it is
330
+ what makes re-running ``brain init`` a safe no-op.
331
+
332
+ AGE catalog DDL (``CREATE EXTENSION`` / ``create_graph``) does not behave
333
+ well inside an open transaction under psycopg v3, so this **requires
334
+ ``conn.autocommit`` to be True** and raises :class:`BrainError` otherwise.
335
+ ``brain init`` already sets autocommit before running migrations.
336
+
337
+ Every AGE catalog reference is fully-qualified (``ag_catalog.*``) and no
338
+ ``search_path`` is mutated, so the surrounding ``init`` work (migrations,
339
+ :func:`ensure_embedding_column`, search backfill) keeps targeting
340
+ ``public``.
341
+
342
+ Returns ``True`` when the graph was created by this call, ``False`` when it
343
+ already existed (re-run no-op). Raises :class:`AgeBootstrapError` (never a
344
+ raw ``psycopg.Error``) on any AGE catalog DDL failure; the autocommit
345
+ precondition is a separate, plain :class:`BrainError` (caller bug).
346
+
347
+ **Vertex/edge labels and property indexes are intentionally NOT created
348
+ here.** Per the phase split (plan §G0-4 / spec §12), the G0-4
349
+ ``GraphBackend`` owns entity/edge upserts and will create its labels (via
350
+ ``MERGE`` / ``create_vlabel``/``create_elabel``) and the matching per-label
351
+ property indexes (spec §5b: ``tenant_id``, ``entity_uuid``,
352
+ ``canonical_key``, ``CO_OCCURS.weight``) at that point — when G0-3's
353
+ tenantized relational schema and the ``tenant_id`` property contract are in
354
+ place. AGE property indexes target a specific label's backing table, which
355
+ cannot exist before its label is created, so pre-creating empty labels now
356
+ would duplicate G0-4's ownership and risk drift. Deferred deliberately.
357
+ """
358
+ if not conn.autocommit:
359
+ raise BrainError(
360
+ "bootstrap_age requires an autocommit connection — AGE catalog DDL "
361
+ "does not run reliably inside an open transaction under psycopg v3"
362
+ )
363
+ try:
364
+ conn.execute("CREATE EXTENSION IF NOT EXISTS age CASCADE")
365
+ conn.execute("LOAD 'age'")
366
+ existing = conn.execute(
367
+ "SELECT 1 FROM ag_catalog.ag_graph WHERE name = %s",
368
+ (graph_name,),
369
+ ).fetchone()
370
+ if existing is not None:
371
+ return False
372
+ conn.execute("SELECT ag_catalog.create_graph(%s)", (graph_name,))
373
+ except psycopg.Error as exc:
374
+ raise AgeBootstrapError(
375
+ f"failed to bootstrap Apache AGE graph {graph_name!r}: {exc}"
376
+ ) from exc
377
+ return True
378
+
379
+
380
+ def _current_embedding_dim(
381
+ conn: psycopg.Connection, table: str, column: str
382
+ ) -> int:
383
+ """Return the dim declared in ``<table>.<column>``'s ``vector(N)`` type.
384
+
385
+ The table/column are bound as *values* through the ``::regclass`` cast and
386
+ an ``attname`` equality (not interpolated into SQL text), so this query is
387
+ safe for any name. Callers still validate against the allowlist before
388
+ issuing DDL.
389
+
390
+ Raises :class:`BrainError` if the column doesn't exist or the type isn't
391
+ a ``vector(N)``. Both are bugs (migrations should always shape the
392
+ column), not user-facing conditions.
393
+ """
394
+ row = conn.execute(
395
+ "SELECT format_type(atttypid, atttypmod) FROM pg_attribute "
396
+ "WHERE attrelid = %s::regclass AND attname = %s",
397
+ (table, column),
398
+ ).fetchone()
399
+ if row is None:
400
+ raise BrainError(
401
+ f"{table}.{column} column not found — run brain init first"
402
+ )
403
+ formatted = str(row[0])
404
+ # format_type returns e.g. ``vector(1024)``; strip + parse the int.
405
+ if not (formatted.startswith("vector(") and formatted.endswith(")")):
406
+ raise BrainError(
407
+ f"unexpected {table}.{column} column type: {formatted!r}"
408
+ )
409
+ return int(formatted[len("vector(") : -1])
410
+
411
+
412
+ def ensure_embedding_column(
413
+ conn: psycopg.Connection,
414
+ embedder: Embedder,
415
+ table: str = "chunks",
416
+ column: str = "embedding",
417
+ ) -> None:
418
+ """Reconcile a pgvector embedding column's dim with the active embedder.
419
+
420
+ Generalized over ``(table, column)`` (default ``chunks.embedding``) so the
421
+ GraphRAG tables can reuse it; ``(table, column)`` is checked against the
422
+ hard-coded allowlist in :mod:`brain.embedding_targets` and every identifier
423
+ is quoted via :class:`psycopg.sql.Identifier` — never string-formatted into
424
+ SQL.
425
+
426
+ Idempotent. The contract (applies to whichever ``(table, column)`` is
427
+ passed; the re-added column is **nullable**, matching the migration shape —
428
+ NOT NULL is a separate finalize concern handled by
429
+ :func:`brain.queries.finalize_embedding_index`):
430
+
431
+ - Column dim already matches ``embedder.dim`` → no-op.
432
+ - Mismatch with zero non-NULL embeddings → drop + re-add the column at
433
+ ``embedder.dim`` (and drop any leftover HNSW index, which would point at
434
+ a column that's about to disappear). Safe — there are no embeddings to
435
+ lose. Existing rows are preserved; only the (NULL) embedding column is
436
+ rebuilt at the new dim.
437
+ - Mismatch with one or more non-NULL embeddings → raise
438
+ :class:`BrainError` instructing the user to do a destructive reset.
439
+ Switching backends with populated embeddings is intentionally not
440
+ silent; those embeddings would all be invalidated and re-embedding is
441
+ the only correct recovery.
442
+
443
+ Called by ``brain init`` after :func:`run_migrations` so ``chunks.embedding``
444
+ always matches the configured backend before any embeddings are written;
445
+ the GraphRAG reconcile path uses it for ``graph_entities.embedding``.
446
+ """
447
+ validate_embedding_target(table, column)
448
+ current_dim = _current_embedding_dim(conn, table, column)
449
+ if current_dim == embedder.dim:
450
+ return
451
+
452
+ # Count rows that ACTUALLY hold a vector. Rows whose embedding is NULL
453
+ # (e.g. immediately after migration 002 drops + re-adds the column, or
454
+ # after `brain reembed` ingest of new docs that haven't been embedded)
455
+ # contribute no data we'd lose by resizing.
456
+ populated_sql = sql.SQL(
457
+ "SELECT count(*) FROM {table} WHERE {column} IS NOT NULL"
458
+ ).format(table=sql.Identifier(table), column=sql.Identifier(column))
459
+ row = conn.execute(populated_sql).fetchone()
460
+ assert row is not None # count(*) always yields one row
461
+ populated = int(row[0])
462
+ if populated > 0:
463
+ raise BrainError(
464
+ f"Embedding column {table}.{column} is vector({current_dim}) but "
465
+ f"BRAIN_EMBEDDER expects vector({embedder.dim}). Switching backends "
466
+ f"with existing embeddings requires a destructive reset. Run: "
467
+ f"docker compose down && rm -rf data/postgres && "
468
+ f"docker compose up -d && brain init && brain reembed"
469
+ )
470
+
471
+ index_name = embedding_index_name(table, column)
472
+ with conn.transaction():
473
+ conn.execute(
474
+ sql.SQL("DROP INDEX IF EXISTS {index}").format(
475
+ index=sql.Identifier(index_name)
476
+ )
477
+ )
478
+ conn.execute(
479
+ sql.SQL("ALTER TABLE {table} DROP COLUMN {column}").format(
480
+ table=sql.Identifier(table), column=sql.Identifier(column)
481
+ )
482
+ )
483
+ conn.execute(
484
+ sql.SQL(
485
+ "ALTER TABLE {table} ADD COLUMN {column} vector({dim})"
486
+ ).format(
487
+ table=sql.Identifier(table),
488
+ column=sql.Identifier(column),
489
+ dim=sql.Literal(embedder.dim),
490
+ )
491
+ )
492
+
493
+
494
+ class PersistentConnection:
495
+ """A single long-lived psycopg connection for the MCP server.
496
+
497
+ Opened lazily on first :meth:`get` call and reused across all subsequent
498
+ MCP tool calls, eliminating the ~10–30 ms per-call TCP handshake overhead.
499
+ Kept in ``autocommit=True`` mode so write helpers can manage transactions
500
+ explicitly via ``conn.transaction()`` without a wrapping session-level
501
+ transaction blocking them.
502
+
503
+ On :class:`psycopg.OperationalError` during a tool call, callers invoke
504
+ :meth:`reconnect` once before retrying; if the reconnect itself fails the
505
+ ``OperationalError`` propagates as ``INTERNAL_ERROR``.
506
+
507
+ Intended exclusively for the MCP server — CLI invocations use the
508
+ per-call :func:`connect` context-manager so their connection lifetime
509
+ remains bounded.
510
+ """
511
+
512
+ def __init__(self, database_url: str) -> None:
513
+ self._url = database_url
514
+ self._conn: psycopg.Connection | None = None
515
+
516
+ def get(self) -> psycopg.Connection:
517
+ """Return the live connection, opening it lazily if absent or closed."""
518
+ if self._conn is None or self._conn.closed:
519
+ self._conn = connect_raw(self._url)
520
+ self._conn.autocommit = True
521
+ return self._conn
522
+
523
+ def reconnect(self) -> None:
524
+ """Close the current connection (if any) and open a fresh one.
525
+
526
+ Raises :class:`psycopg.OperationalError` when the new connection
527
+ cannot be established — callers surface this as ``INTERNAL_ERROR``.
528
+ """
529
+ if self._conn is not None and not self._conn.closed:
530
+ with suppress(psycopg.Error): # best-effort close; open fresh one below
531
+ self._conn.close()
532
+ self._conn = connect_raw(self._url)
533
+ self._conn.autocommit = True
534
+
535
+ def close(self) -> None:
536
+ """Shut down the connection gracefully (called at server teardown)."""
537
+ if self._conn is not None and not self._conn.closed:
538
+ with suppress(psycopg.Error):
539
+ self._conn.close()
540
+ self._conn = None