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/mcp_server.py ADDED
@@ -0,0 +1,3405 @@
1
+ """MCP server exposing the second brain's tools over stdio."""
2
+ import logging
3
+ import os
4
+ import re
5
+ import sys
6
+ import time
7
+ import uuid
8
+ from collections.abc import Iterator
9
+ from contextlib import contextmanager
10
+ from dataclasses import dataclass, replace
11
+ from datetime import date as date_cls
12
+ from datetime import datetime
13
+ from pathlib import Path
14
+ from typing import TYPE_CHECKING, Any
15
+
16
+ import psycopg
17
+ import yaml
18
+ from mcp import McpError
19
+ from mcp.server.fastmcp import FastMCP
20
+ from mcp.types import INTERNAL_ERROR, INVALID_PARAMS, ErrorData
21
+
22
+ from . import capture as capture_mod
23
+ from . import connect as connect_mod
24
+ from .config import Config
25
+ from .db import PersistentConnection, age_extension_available, connect, connect_age
26
+ from .embeddings import make_embedder
27
+ from .enrichment import ContradictionVerdict, OllamaEnricher, make_enricher
28
+ from .errors import (
29
+ BrainError,
30
+ ConnectError,
31
+ EmbedError,
32
+ EnrichmentError,
33
+ GraphBackendError,
34
+ GraphReconcileError,
35
+ GraphTenantError,
36
+ IdPrefixAmbiguous,
37
+ IdPrefixNotFound,
38
+ IdPrefixNotHex,
39
+ IdPrefixTooShort,
40
+ InteractionError,
41
+ OllamaUnavailable,
42
+ PersonAmbiguous,
43
+ PersonNotFound,
44
+ ReviewError,
45
+ VaultNoteSyncError,
46
+ )
47
+ from .format import (
48
+ alias_result_json,
49
+ community_record_json,
50
+ entity_summaries_json,
51
+ graph_context_json,
52
+ graph_stats_json,
53
+ timeline_context_json,
54
+ )
55
+ from .gaps import (
56
+ SearchFailureDetector,
57
+ record_search_query,
58
+ search_queries_schema_hint,
59
+ top_search_failures,
60
+ )
61
+ from .ingest import (
62
+ Embedder,
63
+ apply_tags,
64
+ ingest_document,
65
+ update_document,
66
+ )
67
+ from .ingest.stdin import make_doc as _stdin_make_doc
68
+ from .interactions import (
69
+ InteractionAction,
70
+ InteractionTargetType,
71
+ record_interaction,
72
+ )
73
+ from .queries import (
74
+ fetch_document,
75
+ iter_all_document_ids,
76
+ list_documents,
77
+ resolve_document_prefix,
78
+ resolve_person_to_keys,
79
+ summary_counts,
80
+ )
81
+ from .resurface import resurface_docs
82
+ from .search import SearchDiagnostics, hybrid_search
83
+ from .tags import normalize_tags
84
+
85
+ if TYPE_CHECKING:
86
+ from .ask import AskResult
87
+ from .graph_rag.reconcile import ReconcileConfig
88
+ from .graph_rag.schema import GraphContext
89
+ from .graph_rag.sync import GraphSyncer
90
+ from .vault.frontmatter import (
91
+ dump_frontmatter,
92
+ parse_frontmatter,
93
+ rewrite_tags,
94
+ )
95
+ from .vault.graph import backlinks_for, orphans, outgoing_links_for
96
+ from .vault.note_builder import create_vault_note
97
+ from .vault.slug import slugify
98
+ from .vault.sync import sync_one_file
99
+ from .vault.templates import render_template
100
+
101
+ logger = logging.getLogger("brain.mcp")
102
+
103
+ _VALID_LOG_LEVELS = {"DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"}
104
+
105
+ # Tag automatically added to every document ingested via ``brain_ingest_stdin``
106
+ # so MCP-saved snippets are distinguishable from CLI-ingested ones.
107
+ _MCP_AUTO_TAG = "source-mcp"
108
+
109
+ # Cap on the body argument to ``brain_note_new`` — protects the disk + DB
110
+ # from a runaway LLM that submits a multi-megabyte body. 256 KB is well
111
+ # above any human-authored note size and safely under FastMCP's transport
112
+ # limits. Documented in the tool docstring + spec's Risks section.
113
+ _MAX_NOTE_BODY_BYTES = 256 * 1024
114
+
115
+ # Hex-only id-prefix detector for ``brain_link_proposal`` dst resolution.
116
+ # Matches the resolver's threshold + character set so behavior across the
117
+ # CLI, sync, and MCP is consistent.
118
+ _ID_PREFIX_MIN_LEN = 6
119
+ _HEX_ONLY_RE = re.compile(r"^[0-9a-f-]+$")
120
+
121
+ # Pause between the initial warmup embed and its single bounded retry. Covers
122
+ # the cold-boot race where launchd has started the Ollama daemon but the
123
+ # embedding model hasn't finished loading yet (observed 2026-05-13 41 s after
124
+ # system boot). Kept small so a sustained Ollama outage still falls through to
125
+ # the warning path quickly.
126
+ _WARMUP_RETRY_DELAY_SECONDS = 1.0
127
+
128
+
129
+ @dataclass
130
+ class _State:
131
+ """Shared server state initialized once in :func:`main`.
132
+
133
+ Tests build this directly and assign to ``_state`` (via
134
+ ``monkeypatch.setattr``) instead of calling :func:`main`.
135
+
136
+ Wave Q2-SUMMARY-WIKI (2026-05-11): added ``enricher`` so the
137
+ ``brain_edit`` MCP tool can refresh ``documents.summary`` on
138
+ body-changing edits — without it the post-ingest hook hits the
139
+ "no enricher supplied" skip and the Q2 wiki lede shows the
140
+ pre-edit summary above the new body. ``None`` is an allowed
141
+ value so tests can opt out of the LLM round-trip; production
142
+ :func:`main` always populates it via :func:`make_enricher`.
143
+
144
+ DB-08 (perf/waves-0-2 T6): ``db_conn`` holds the persistent connection
145
+ opened once in :func:`main` and reused across all tool calls. Tests
146
+ that build ``_State`` directly may leave it ``None``; :func:`_mcp_conn`
147
+ then falls back to the per-call :func:`connect` path so all existing
148
+ test-doubles remain functional.
149
+ """
150
+
151
+ cfg: Config
152
+ embedder: Embedder
153
+ enricher: OllamaEnricher | None = None
154
+ # Wave G1-c: people-aspect graph syncer wired through ``brain_ingest`` /
155
+ # ``brain_edit`` so MCP-driven writes keep the graph in lock-step. ``None``
156
+ # (tests that build ``_State`` directly) skips graph sync; production
157
+ # :func:`main` always populates it via :func:`make_graph_syncer`.
158
+ # String-quoted forward ref: ``GraphSyncer`` is TYPE_CHECKING-only so the
159
+ # networkx-pulling graph_rag chain stays off MCP-server startup.
160
+ graph_syncer: "GraphSyncer | None" = None
161
+ # DB-08: persistent connection opened at server startup, reused per call.
162
+ # ``None`` preserves per-call ``connect()`` semantics for existing tests.
163
+ db_conn: PersistentConnection | None = None
164
+
165
+
166
+ _state: _State | None = None
167
+
168
+
169
+ def _get_state() -> _State:
170
+ """Return the initialized server state.
171
+
172
+ Raises ``AssertionError`` if called before :func:`main` has run (or before
173
+ a test has explicitly populated ``_state``)."""
174
+ assert _state is not None, "mcp_server not initialized — call main() first"
175
+ return _state
176
+
177
+
178
+ def _mcp_error(code: int, message: str) -> McpError:
179
+ """Construct an :class:`McpError` with ``code`` and ``message``."""
180
+ return McpError(ErrorData(code=code, message=message))
181
+
182
+
183
+ def _wrap_db_error(e: psycopg.Error) -> McpError:
184
+ """Wrap a Postgres failure as an MCP error.
185
+
186
+ The user-facing message intentionally omits ``str(e)`` (which can include
187
+ SQL fragments + connection details) and exposes only the exception class
188
+ name. The full exception is logged to stderr so we can still debug.
189
+
190
+ DB-08: on :class:`psycopg.OperationalError` (broken pipe, server restart),
191
+ also triggers a best-effort :meth:`PersistentConnection.reconnect` so the
192
+ *next* tool call can succeed with a fresh connection. The current call
193
+ still surfaces ``INTERNAL_ERROR`` — reconnect is for the benefit of
194
+ subsequent calls, not this one. A reconnect failure is logged at ERROR and
195
+ swallowed; it will surface again as ``OperationalError`` on the next call.
196
+ """
197
+ logger.error("database error", exc_info=e)
198
+ if isinstance(e, psycopg.OperationalError) and _state is not None:
199
+ db_conn = _state.db_conn
200
+ if db_conn is not None:
201
+ try:
202
+ db_conn.reconnect()
203
+ logger.info(
204
+ "persistent connection reconnected after OperationalError"
205
+ )
206
+ except psycopg.OperationalError as reconnect_err:
207
+ logger.error(
208
+ "persistent connection reconnect failed: %s",
209
+ type(reconnect_err).__name__,
210
+ )
211
+ return _mcp_error(INTERNAL_ERROR, f"database error: {type(e).__name__}")
212
+
213
+
214
+ def _wrap_embed_error(e: EmbedError) -> McpError:
215
+ """Wrap any embedding-backend failure as an MCP error.
216
+
217
+ Accepts the shared :class:`~brain.errors.EmbedError` base, so it maps a
218
+ failure from any backend — :class:`~brain.embeddings.OllamaEmbedError`
219
+ (arctic / qwen3) or :class:`~brain.embeddings.VoyageEmbedError` — uniformly.
220
+ Mirrors :func:`_wrap_db_error`: the user-facing message exposes only the
221
+ exception class name; the full exception is logged to stderr.
222
+ """
223
+ logger.error("embedding failed", exc_info=e)
224
+ return _mcp_error(INTERNAL_ERROR, f"embedding failed: {type(e).__name__}")
225
+
226
+
227
+ @contextmanager
228
+ def _mcp_conn(state: _State) -> Iterator[psycopg.Connection[Any]]:
229
+ """Yield a DB connection for an MCP tool call.
230
+
231
+ DB-08: in production (``state.db_conn`` set by :func:`main`) yields the
232
+ persistent connection (always ``autocommit=True``) without closing it on
233
+ exit — it stays alive for the next call.
234
+
235
+ Tests that build ``_State`` without ``db_conn`` fall through to a per-call
236
+ :func:`connect` so existing test-doubles
237
+ (``monkeypatch.setattr(mcp_server, "connect", ...)``) stay functional and
238
+ the entire pre-T6 test suite continues to pass unchanged.
239
+ """
240
+ if state.db_conn is not None:
241
+ yield state.db_conn.get()
242
+ else:
243
+ with connect(state.cfg.database_url) as conn:
244
+ yield conn
245
+
246
+
247
+ def _resolve_id(conn: psycopg.Connection[Any], prefix: str) -> str:
248
+ """Resolve a UUID prefix (min 6 chars) to a full document id.
249
+
250
+ Thin wrapper around :func:`brain.queries.resolve_document_prefix` that
251
+ maps its plain exceptions to ``McpError`` so the MCP runtime can surface
252
+ the failure to the caller.
253
+ """
254
+ try:
255
+ return resolve_document_prefix(conn, prefix)
256
+ except (
257
+ IdPrefixTooShort,
258
+ IdPrefixNotHex,
259
+ IdPrefixNotFound,
260
+ IdPrefixAmbiguous,
261
+ ) as e:
262
+ raise _mcp_error(INVALID_PARAMS, str(e)) from e
263
+
264
+
265
+ def _assert_within_vault(target: Path, vault_path: Path, *, label: str) -> None:
266
+ """Reject ``target`` if it resolves outside ``vault_path``.
267
+
268
+ Mirror of the CLI's :func:`brain.cli._assert_within_vault` — copied
269
+ here rather than imported so the MCP server has no dependency on a
270
+ Typer-based module. The check is the same: resolve both sides through
271
+ symlinks, then assert the target sits under the vault root. Anything
272
+ that escapes the vault (``--folder ../../etc``, ``daily/../../...``)
273
+ raises an :class:`McpError` with ``INVALID_PARAMS``.
274
+ """
275
+ try:
276
+ target.resolve().relative_to(vault_path.resolve())
277
+ except ValueError as e:
278
+ raise _mcp_error(
279
+ INVALID_PARAMS,
280
+ f"{label} must stay within the vault; "
281
+ f"got a path that resolves outside {vault_path}",
282
+ ) from e
283
+
284
+
285
+ def _ensure_template_path(vault_path: Path, name: str) -> Path:
286
+ """Resolve ``<vault>/_templates/<name>.md`` or raise ``McpError``.
287
+
288
+ Mirror of the CLI's :func:`brain.cli._ensure_template` — same recovery
289
+ semantics (point the user at ``brain vault init`` when the directory
290
+ is missing) but raises an MCP-flavoured error so the runtime can
291
+ surface it cleanly instead of as a Typer crash.
292
+ """
293
+ templates_dir = vault_path / "_templates"
294
+ if not templates_dir.is_dir():
295
+ raise _mcp_error(
296
+ INVALID_PARAMS,
297
+ f"vault has no _templates/ directory at {templates_dir} — "
298
+ "run `brain vault init` first",
299
+ )
300
+ target = templates_dir / f"{name}.md"
301
+ if not target.is_file():
302
+ raise _mcp_error(
303
+ INVALID_PARAMS, f"template {name!r} not found at {target}"
304
+ )
305
+ return target
306
+
307
+
308
+ mcp_app: FastMCP = FastMCP(name="brain")
309
+
310
+
311
+ def _parse_iso_datetime(value: str, *, field: str) -> datetime:
312
+ """Parse an ISO date/datetime string from an MCP arg or raise INVALID_PARAMS.
313
+
314
+ Accepts ``YYYY-MM-DD`` and ``YYYY-MM-DDTHH:MM:SS`` (the CLI takes the
315
+ same two formats — kept in sync per plan D5 / D8 CLI↔MCP parity).
316
+ A bad string surfaces to the MCP caller as ``INVALID_PARAMS`` with
317
+ the underlying error appended for debuggability.
318
+ """
319
+ try:
320
+ return datetime.fromisoformat(value)
321
+ except ValueError as e:
322
+ raise _mcp_error(
323
+ INVALID_PARAMS,
324
+ f"{field} must be an ISO date (YYYY-MM-DD or "
325
+ f"YYYY-MM-DDTHH:MM:SS); got {value!r}: {e}",
326
+ ) from e
327
+
328
+
329
+ @mcp_app.tool()
330
+ def brain_search(
331
+ query: str,
332
+ limit: int = 5,
333
+ source: str | None = None,
334
+ tag: str | None = None,
335
+ since_days: int | None = None,
336
+ fts_only: bool = False,
337
+ # — Q1-C metadata filters — names mirror the CLI flags 1:1.
338
+ person: str | None = None,
339
+ after: str | None = None,
340
+ before: str | None = None,
341
+ kind: str | None = None,
342
+ thread: str | None = None,
343
+ draft: bool | None = None,
344
+ has_tag: str | None = None,
345
+ without_tag: str | None = None,
346
+ ) -> dict[str, Any]:
347
+ """Hybrid search across the second brain.
348
+
349
+ Returns a dict with two keys:
350
+
351
+ - ``session_id``: a fresh ``uuid.uuid4()`` minted on every call. Pass
352
+ it back via ``brain_show(..., session_id=...)`` to record the open
353
+ as part of the same search session (powers Q1-C interaction logging).
354
+ - ``results``: up to ``limit`` matching documents ranked by RRF over
355
+ FTS + vector cosine similarity. Each entry is shaped
356
+ ``{id, title, source_kind, snippet, score, content_type, tags}``.
357
+
358
+ **Breaking shape change in Q1-C:** prior versions returned the
359
+ results list directly; the new top-level dict carries ``session_id``
360
+ alongside ``results``. Update callers that assume a top-level list.
361
+
362
+ Filters (all optional, default = no filter):
363
+
364
+ - ``source``: source kind (``manual``, ``gmail``, ``krisp``, ...).
365
+ - ``tag`` / ``has_tag``: ``has_tag`` is a strict alias of ``tag``;
366
+ conflicting values raise ``INVALID_PARAMS``.
367
+ - ``without_tag``: exclude docs carrying this tag.
368
+ - ``since_days``: relative-window recency (N days lookback).
369
+ - ``person``: match docs where this person participated. Resolved
370
+ via the directory (same logic as ``brain people``).
371
+ - ``after`` / ``before``: ISO date strings (``YYYY-MM-DD`` or
372
+ ``YYYY-MM-DDTHH:MM:SS``). ``after`` is inclusive; ``before`` is
373
+ exclusive.
374
+ - ``kind``: filter by ``documents.content_type`` (``email``,
375
+ ``email_thread``, ``note``, ``transcript``, ...).
376
+ - ``thread``: filter by Gmail thread id.
377
+ - ``draft``: ``True`` → drafts only; ``False`` → published only;
378
+ ``None`` (default) → both.
379
+ - ``fts_only``: skip the local Ollama embed call (FTS-only mode).
380
+ """
381
+ state = _get_state()
382
+ logger.debug("brain_search: query=%r limit=%d", query, limit)
383
+
384
+ # Fail fast on a non-positive limit (mirrors brain_ask) — otherwise the
385
+ # ``results[:limit]`` slice silently drops the tail and returns wrong data.
386
+ if limit < 1:
387
+ raise _mcp_error(INVALID_PARAMS, "limit must be >= 1")
388
+
389
+ if tag is not None and has_tag is not None and tag != has_tag:
390
+ raise _mcp_error(
391
+ INVALID_PARAMS,
392
+ "tag and has_tag both given with different values",
393
+ )
394
+ effective_tag = tag if tag is not None else has_tag
395
+
396
+ after_dt = _parse_iso_datetime(after, field="after") if after else None
397
+ before_dt = _parse_iso_datetime(before, field="before") if before else None
398
+
399
+ session_uuid = uuid.uuid4()
400
+ session_id = str(session_uuid)
401
+
402
+ try:
403
+ with _mcp_conn(state) as conn:
404
+ person_match = None
405
+ if person is not None:
406
+ try:
407
+ person_match = resolve_person_to_keys(conn, person)
408
+ except (PersonNotFound, PersonAmbiguous) as e:
409
+ raise _mcp_error(INVALID_PARAMS, str(e)) from e
410
+ # The diagnostics holder captures the FTS-leg hit count from work
411
+ # the search already does (no extra query) — the lexical-miss
412
+ # signal `brain gaps` keys off (the vector leg always returns
413
+ # filler).
414
+ diagnostics = SearchDiagnostics()
415
+ results = hybrid_search(
416
+ conn,
417
+ embedder=state.embedder,
418
+ query=query,
419
+ limit=limit,
420
+ source_kind=source,
421
+ tag=effective_tag,
422
+ since_days=since_days,
423
+ fts_only=fts_only,
424
+ vector_sim_floor=state.cfg.vector_sim_floor,
425
+ recency_halflife_days=state.cfg.recency_halflife_days,
426
+ snippet_context_tokens=state.cfg.snippet_context_tokens,
427
+ diagnostics=diagnostics,
428
+ person_keys=person_match.keys if person_match else None,
429
+ person_display_name=(
430
+ person_match.display_name if person_match else None
431
+ ),
432
+ after=after_dt,
433
+ before=before_dt,
434
+ content_type=kind,
435
+ thread_id=thread,
436
+ draft=draft,
437
+ without_tag=without_tag,
438
+ )
439
+ # Plan 08 — best-effort search-failure logging. The minted
440
+ # ``session_uuid`` lets no-click detection join this search against a
441
+ # later ``brain_show`` open in the same session. A transient
442
+ # ``OperationalError``, a missing-table ``UndefinedTable``
443
+ # (migration 019 not applied) AND a missing ``fts_count`` column
444
+ # ``UndefinedColumn`` (migration 023 not applied) are all swallowed
445
+ # inside ``record_search_query`` — search results must still be
446
+ # returned on a pre-019/pre-023 DB; the server log carries the
447
+ # `brain init` hint.
448
+ record_search_query(
449
+ conn,
450
+ query=query,
451
+ result_count=len(results),
452
+ fts_count=diagnostics.fts_count,
453
+ session_id=session_uuid,
454
+ source="mcp",
455
+ tenant_id=state.cfg.graph_tenant_id,
456
+ )
457
+ except psycopg.Error as e:
458
+ raise _wrap_db_error(e) from e
459
+ except EmbedError as e:
460
+ raise _wrap_embed_error(e) from e
461
+ return {
462
+ "session_id": session_id,
463
+ "results": [
464
+ {
465
+ "id": r.document_id,
466
+ "title": r.title,
467
+ "source_kind": r.source_kind,
468
+ "snippet": r.snippet,
469
+ "score": r.score,
470
+ "content_type": r.content_type,
471
+ "tags": r.tags,
472
+ }
473
+ for r in results
474
+ ],
475
+ }
476
+
477
+
478
+ @mcp_app.tool()
479
+ def brain_gaps(
480
+ since_days: int = 30,
481
+ limit: int = 20,
482
+ push: bool = False,
483
+ ) -> dict[str, Any]:
484
+ """Surface knowledge gaps from repeated search failures (Plan 08).
485
+
486
+ Mines the ``search_queries`` log for queries the brain failed to answer —
487
+ ``zero_results`` (nothing matched) and ``no_click`` (results returned but
488
+ never opened in the search session) — over the last ``since_days`` days.
489
+
490
+ Returns ``{"gaps": [{"query": str, "count": int, "kind": str}]}`` where
491
+ ``kind`` is ``"zero_results"`` or ``"no_click"``. ``query`` is the derived
492
+ NORMALIZED canonical label (sorted, de-duplicated tokens) — raw stored
493
+ query strings never leave the server (privacy boundary).
494
+
495
+ When ``push=True`` the full :class:`SearchFailureDetector` runs and upserts
496
+ the resulting ``search_failure`` gaps into the elicitation queue
497
+ (``elicitation_gaps``) — the same effect as ``brain gaps push`` — before the
498
+ read view is returned.
499
+ """
500
+ state = _get_state()
501
+ try:
502
+ with _mcp_conn(state) as conn:
503
+ if push:
504
+ from .elicit.queue import build_queue
505
+
506
+ detector = SearchFailureDetector(
507
+ lookback_days=since_days,
508
+ min_cluster_size=state.cfg.gaps_min_cluster_size,
509
+ )
510
+ build_queue(
511
+ conn,
512
+ cfg=state.cfg,
513
+ tenant_id=state.cfg.graph_tenant_id,
514
+ detectors=[detector],
515
+ limit=state.cfg.elicit_queue_limit,
516
+ signal_kinds=["search_failure"],
517
+ )
518
+ failures = top_search_failures(
519
+ conn,
520
+ tenant_id=state.cfg.graph_tenant_id,
521
+ since_days=since_days,
522
+ limit=limit,
523
+ normalize=True,
524
+ )
525
+ except (psycopg.errors.UndefinedTable, psycopg.errors.UndefinedColumn) as e:
526
+ # Pre-019 (no table) / pre-023 (no fts_count column): surface the
527
+ # actionable `brain init` hint instead of a generic DB error. Any other
528
+ # undefined-column (a real bug) falls through to _wrap_db_error.
529
+ hint = search_queries_schema_hint(e)
530
+ if hint is None:
531
+ raise _wrap_db_error(e) from e
532
+ raise _mcp_error(INVALID_PARAMS, hint) from e
533
+ except psycopg.Error as e:
534
+ raise _wrap_db_error(e) from e
535
+ return {
536
+ "gaps": [
537
+ {"query": f.query, "count": f.count, "kind": f.kind}
538
+ for f in failures
539
+ ],
540
+ }
541
+
542
+
543
+ @mcp_app.tool()
544
+ def brain_show(
545
+ id_prefix: str,
546
+ originating_query: str | None = None,
547
+ session_id: str | None = None,
548
+ graph_retrieved: bool = False,
549
+ ) -> dict[str, Any]:
550
+ """Return the full body and metadata of a single document by id prefix.
551
+
552
+ The prefix must be at least 6 hex characters and must uniquely identify
553
+ a document. Raises :class:`McpError` (``INVALID_PARAMS``) if the prefix
554
+ is too short, non-hex, unknown, or ambiguous.
555
+
556
+ Q1-C interaction logging: when ``originating_query`` is provided, also
557
+ records an ``opened`` row in the ``interactions`` table (source='mcp',
558
+ query=originating_query, session_id=parsed UUID or NULL). Supplying
559
+ ``session_id`` without ``originating_query`` is rejected — a session
560
+ id alone carries no useful signal. ``session_id`` must be a valid UUID
561
+ string (the one returned from a prior ``brain_search`` *or*
562
+ ``brain_graphrag_*`` call); a malformed value raises ``INVALID_PARAMS``.
563
+
564
+ G4-b graph provenance (spec §17d Q2): pass ``graph_retrieved=true`` when
565
+ this open came from a graph surface (``brain_graphrag_search`` /
566
+ ``…_themes`` / ``…_entity``). It stamps ``graph_retrieved=TRUE`` on the
567
+ logged ``opened`` row — a provenance flag only; the row is still a
568
+ document row (``document_id`` set). Default ``false`` preserves the
569
+ pre-G4 behavior. The graph retrieval tools never log at retrieval time;
570
+ only this user-action open does. Interaction logging is best-effort — a
571
+ logging failure is warned and swallowed so the document still returns
572
+ (this supersedes the Q1-C D13 "propagate logging errors" note for the
573
+ locked never-raise discipline). The return shape is unchanged.
574
+ """
575
+ state = _get_state()
576
+ logger.debug(
577
+ "brain_show: id_prefix=%s originating_query?=%s session_id?=%s",
578
+ id_prefix,
579
+ originating_query is not None,
580
+ session_id is not None,
581
+ )
582
+
583
+ # D15: session_id without originating_query is rejected outright —
584
+ # there is no useful signal to log.
585
+ if session_id is not None and originating_query is None:
586
+ raise _mcp_error(
587
+ INVALID_PARAMS,
588
+ "session_id requires originating_query (the query that "
589
+ "produced this session). Pass originating_query alongside, "
590
+ "or omit session_id.",
591
+ )
592
+
593
+ # Parse session_id eagerly so a bad UUID surfaces as INVALID_PARAMS
594
+ # before we hit the DB. Done outside the connect() block so we don't
595
+ # mask a parse error behind a connection cost.
596
+ session_uuid: uuid.UUID | None = None
597
+ if session_id is not None:
598
+ try:
599
+ session_uuid = uuid.UUID(session_id)
600
+ except ValueError as e:
601
+ raise _mcp_error(
602
+ INVALID_PARAMS,
603
+ f"session_id is not a valid UUID: {session_id!r} ({e})",
604
+ ) from e
605
+
606
+ try:
607
+ with _mcp_conn(state) as conn:
608
+ conn.autocommit = True
609
+ doc_id = _resolve_id(conn, id_prefix)
610
+ doc = fetch_document(conn, doc_id)
611
+ # Log the open AFTER the fetch succeeded, gated on
612
+ # originating_query. ``graph_retrieved`` is provenance on the
613
+ # document row (G4-b). Best-effort (never-raise): a logging
614
+ # failure is warned + swallowed so the document still returns —
615
+ # this supersedes the Q1-C D13 propagate-the-error note.
616
+ if originating_query is not None:
617
+ try:
618
+ record_interaction(
619
+ conn,
620
+ document_id=doc_id,
621
+ action="opened",
622
+ source="mcp",
623
+ query=originating_query,
624
+ session_id=session_uuid,
625
+ graph_retrieved=graph_retrieved,
626
+ )
627
+ except (psycopg.Error, InteractionError) as log_exc:
628
+ logger.warning(
629
+ "brain_show: interaction logging failed: %s",
630
+ type(log_exc).__name__,
631
+ )
632
+ except psycopg.Error as e:
633
+ raise _wrap_db_error(e) from e
634
+ assert doc is not None # _resolve_id confirmed the doc exists
635
+ payload: dict[str, Any] = {
636
+ "id": doc.id,
637
+ "title": doc.title,
638
+ "content": doc.content,
639
+ "content_type": doc.content_type,
640
+ "tags": doc.tags,
641
+ "source_path": doc.source_path,
642
+ "ingested_at": doc.ingested_at.isoformat() if doc.ingested_at else None,
643
+ "source_kind": doc.source_kind,
644
+ }
645
+ # Wave Q1-D — additive ``summary`` key (D16). Omitted when NULL so
646
+ # existing consumers that don't expect the key still parse cleanly.
647
+ # Reachable only via brain_show, never brain_search — per the wave plan
648
+ # we explicitly do NOT change the brain_search return shape this wave.
649
+ if doc.summary is not None:
650
+ payload["summary"] = doc.summary
651
+ return payload
652
+
653
+
654
+ @mcp_app.tool()
655
+ def brain_list(
656
+ source: str | None = None,
657
+ tag: str | None = None,
658
+ limit: int = 20,
659
+ ) -> list[dict[str, Any]]:
660
+ """List documents in the brain, optionally filtered by source kind and/or tag.
661
+
662
+ Returns up to ``limit`` rows ordered most-recently-ingested first. Mirrors
663
+ the JSON output of ``brain list --json``.
664
+ """
665
+ state = _get_state()
666
+ logger.debug("brain_list: source=%s tag=%s limit=%d", source, tag, limit)
667
+ # Fail fast on a non-positive limit (mirrors brain_ask) — otherwise
668
+ # ``LIMIT -N`` reaches Postgres and surfaces as an opaque INTERNAL_ERROR.
669
+ if limit < 1:
670
+ raise _mcp_error(INVALID_PARAMS, "limit must be >= 1")
671
+ try:
672
+ with _mcp_conn(state) as conn:
673
+ rows = list_documents(conn, source=source, tag=tag, limit=limit)
674
+ except psycopg.Error as e:
675
+ raise _wrap_db_error(e) from e
676
+ return [
677
+ {
678
+ "id": r.id,
679
+ "title": r.title,
680
+ "content_type": r.content_type,
681
+ "tags": r.tags,
682
+ "source_kind": r.source_kind,
683
+ "ingested_at": r.ingested_at.isoformat() if r.ingested_at else None,
684
+ }
685
+ for r in rows
686
+ ]
687
+
688
+
689
+ @mcp_app.tool()
690
+ def brain_resurface(
691
+ limit: int | None = None,
692
+ min_age_days: int | None = None,
693
+ source_kind: str | None = None,
694
+ ) -> dict[str, Any]:
695
+ """Return a spaced-repetition review queue of older, unrevisited docs.
696
+
697
+ Proactive, query-free: scores every non-draft, non-action-item document by
698
+ age, last-access staleness, and importance (tags + summary), then returns
699
+ the top ``limit`` by descending score. ``min_age_days`` excludes
700
+ recently-ingested docs; ``source_kind`` optionally filters by source.
701
+
702
+ ``limit`` (default 7 via ``BRAIN_RESURFACE_LIMIT``) and ``min_age_days``
703
+ (default 14 via ``BRAIN_RESURFACE_MIN_AGE_DAYS``) fall back to the server's
704
+ configured values when omitted (``None``). ``limit`` must be >= 1 and
705
+ ``min_age_days`` >= 0 — out-of-range values surface as ``INVALID_PARAMS``.
706
+
707
+ ``last_access_days`` is ``None`` for never-accessed docs. ``snippet`` is the
708
+ first 200 characters of the body (never the full content).
709
+ """
710
+ state = _get_state()
711
+ logger.debug(
712
+ "brain_resurface: limit=%s min_age_days=%s source_kind=%s",
713
+ limit,
714
+ min_age_days,
715
+ source_kind,
716
+ )
717
+ # Fail fast on a non-positive limit (mirrors brain_ask) — before opening a
718
+ # connection. ``limit`` is optional here (None → cfg default), so only a
719
+ # supplied out-of-range value is rejected.
720
+ if limit is not None and limit < 1:
721
+ raise _mcp_error(INVALID_PARAMS, "limit must be >= 1")
722
+ try:
723
+ with _mcp_conn(state) as conn:
724
+ items = resurface_docs(
725
+ conn,
726
+ cfg=state.cfg,
727
+ limit=limit,
728
+ min_age_days=min_age_days,
729
+ source_kind=source_kind,
730
+ )
731
+ except ValueError as e:
732
+ raise _mcp_error(INVALID_PARAMS, str(e)) from e
733
+ except psycopg.Error as e:
734
+ raise _wrap_db_error(e) from e
735
+ return {
736
+ "items": [
737
+ {
738
+ "id": it.id,
739
+ "title": it.title,
740
+ "source_kind": it.source_kind,
741
+ "content_type": it.content_type,
742
+ "tags": it.tags,
743
+ "age_days": round(it.age_days, 2),
744
+ "last_access_days": (
745
+ round(it.last_access_days, 2)
746
+ if it.last_access_days is not None
747
+ else None
748
+ ),
749
+ "score": round(it.score, 4),
750
+ "snippet": it.snippet,
751
+ }
752
+ for it in items
753
+ ]
754
+ }
755
+
756
+
757
+ @mcp_app.tool()
758
+ def brain_status() -> dict[str, Any]:
759
+ """Return summary counts for the brain.
760
+
761
+ ``documents`` / ``chunks`` / ``sources`` are total row counts;
762
+ ``last_ingest`` is the most recent ``documents.ingested_at`` (ISO 8601, or
763
+ ``None`` if the brain is empty); ``by_kind`` is a list of
764
+ ``{kind, count}`` pairs.
765
+ """
766
+ state = _get_state()
767
+ logger.debug("brain_status: called")
768
+ try:
769
+ with _mcp_conn(state) as conn:
770
+ counts = summary_counts(conn)
771
+ except psycopg.Error as e:
772
+ raise _wrap_db_error(e) from e
773
+ return {
774
+ "documents": counts.documents,
775
+ "chunks": counts.chunks,
776
+ "sources": counts.sources,
777
+ "last_ingest": (
778
+ counts.last_ingest.isoformat()
779
+ if counts.last_ingest is not None
780
+ else None
781
+ ),
782
+ "by_kind": [{"kind": k, "count": c} for k, c in counts.by_kind],
783
+ }
784
+
785
+
786
+ @mcp_app.tool()
787
+ def brain_ingest_stdin(
788
+ content: str,
789
+ source: str,
790
+ external_id: str,
791
+ title: str,
792
+ content_type: str = "note",
793
+ tags: list[str] | None = None,
794
+ metadata: dict[str, Any] | None = None,
795
+ date: str | None = None,
796
+ ) -> dict[str, Any]:
797
+ """Save a snippet from the conversation into the brain.
798
+
799
+ Same code path as ``brain ingest-stdin`` on the CLI: chunk → embed → store
800
+ with ``content_hash`` dedup and ``(source, external_id)`` source dedup.
801
+ The ``source-mcp`` tag is auto-added to every document ingested through
802
+ this tool so MCP-saved snippets are distinguishable from CLI ingests.
803
+ """
804
+ if not content.strip():
805
+ raise _mcp_error(INVALID_PARAMS, "content is empty")
806
+ state = _get_state()
807
+ user_tags = list(tags or [])
808
+ # Set-union with the auto tag, then back to a list for storage. Sorted so
809
+ # the resulting tag list is deterministic for tests / users.
810
+ merged_tags = sorted({*user_tags, _MCP_AUTO_TAG})
811
+ meta: dict[str, Any] = dict(metadata or {})
812
+ if date:
813
+ meta.setdefault("date", date)
814
+ doc = _stdin_make_doc(
815
+ content=content,
816
+ title=title,
817
+ content_type=content_type,
818
+ metadata=meta,
819
+ )
820
+ logger.debug(
821
+ "brain_ingest_stdin: source=%s external_id=%s title=%s",
822
+ source,
823
+ external_id,
824
+ title,
825
+ )
826
+ try:
827
+ with _mcp_conn(state) as conn:
828
+ conn.autocommit = True
829
+ result = ingest_document(
830
+ conn,
831
+ embedder=state.embedder,
832
+ doc=doc,
833
+ source_kind=source,
834
+ source_external_id=external_id,
835
+ source_metadata=meta,
836
+ tags=merged_tags,
837
+ vault_root=state.cfg.vault_path,
838
+ graph_syncer=state.graph_syncer,
839
+ )
840
+ except psycopg.Error as e:
841
+ raise _wrap_db_error(e) from e
842
+ except EmbedError as e:
843
+ raise _wrap_embed_error(e) from e
844
+ return {
845
+ "document_id": result.document_id,
846
+ "created": result.created,
847
+ }
848
+
849
+
850
+ @mcp_app.tool()
851
+ def brain_capture(
852
+ title: str | None = None,
853
+ content: str = "",
854
+ tags: list[str] | None = None,
855
+ ) -> dict[str, Any]:
856
+ """Quick-capture a thought into the brain's inbox (tagged ``inbox``).
857
+
858
+ Authors a vault-tier note under ``capture/`` (visible in the Quartz UI
859
+ without the "Show ingested" toggle). Every captured document carries the
860
+ always-on ``inbox`` tag (plus any caller extras), normalized at the write
861
+ boundary, until it is reviewed out via ``brain capture review``. When
862
+ ``title`` is omitted/blank it defaults to a deterministic, date-stamped
863
+ auto-title derived from the content. No inline graph sync is performed —
864
+ captures are picked up by the normal ``brain-rebuild`` cycle.
865
+
866
+ No inline enrichment is performed — ``documents.summary`` stays ``NULL``
867
+ until ``brain enrich --backfill`` (or a full ``brain-rebuild``) runs, so
868
+ ``brain capture review --auto`` will defer freshly-captured items until
869
+ a summary is present.
870
+
871
+ Returns ``{"document_id": ..., "status": "ingested"}``.
872
+ """
873
+ if not content.strip():
874
+ raise _mcp_error(INVALID_PARAMS, "content is empty")
875
+ state = _get_state()
876
+ cfg = state.cfg
877
+ if cfg.vault_path is None:
878
+ raise _mcp_error(
879
+ INVALID_PARAMS,
880
+ "vault path is not configured — set BRAIN_VAULT_PATH",
881
+ )
882
+ # Normalize at the capture boundary so the vault frontmatter tags are
883
+ # queryable by exact tag filters. "inbox" is already canonical, so
884
+ # prepending it never collides.
885
+ resolved_tags = normalize_tags(["inbox", *(tags or [])])
886
+ resolved_title = (
887
+ title.strip()
888
+ if title and title.strip()
889
+ else capture_mod.make_capture_title(
890
+ content, today=date_cls.today(), max_words=cfg.capture_title_words
891
+ )
892
+ )
893
+ # NOTE: never log ``resolved_title`` — for auto-titles it is derived from the
894
+ # first words of the capture content, so logging it would leak document body
895
+ # text. Log only the non-sensitive tag set.
896
+ logger.debug("brain_capture: tags=%s", resolved_tags)
897
+ try:
898
+ with _mcp_conn(state) as conn:
899
+ conn.autocommit = True
900
+ doc_id = create_vault_note(
901
+ conn,
902
+ cfg=cfg,
903
+ vault_path=cfg.vault_path,
904
+ title=resolved_title,
905
+ body=content,
906
+ tags=resolved_tags,
907
+ folder="capture",
908
+ embedder=state.embedder,
909
+ )
910
+ except psycopg.Error as e:
911
+ raise _wrap_db_error(e) from e
912
+ except EmbedError as e:
913
+ raise _wrap_embed_error(e) from e
914
+ except VaultNoteSyncError as e:
915
+ raise _mcp_error(INVALID_PARAMS, str(e)) from e
916
+ return {
917
+ "document_id": doc_id,
918
+ "status": "ingested",
919
+ }
920
+
921
+
922
+ @mcp_app.tool()
923
+ def brain_tag(
924
+ id_prefix: str,
925
+ add: list[str] | None = None,
926
+ remove: list[str] | None = None,
927
+ ) -> dict[str, Any]:
928
+ """Add and/or remove tags on an existing document.
929
+
930
+ At least one of ``add`` / ``remove`` must be non-empty. Returns the
931
+ document's full tag list after the mutation. Re-adding an existing tag is
932
+ a no-op.
933
+
934
+ File-writeback parity with ``brain tag`` on the CLI: when the document
935
+ has a populated ``vault_path`` and the on-disk mirror exists, the file's
936
+ frontmatter ``tags:`` field is rewritten via :func:`rewrite_tags` so the
937
+ next ``brain vault sync`` does not re-read stale ``tags: []`` from disk
938
+ and overwrite the DB. A populated ``vault_path`` whose mirror is missing
939
+ on disk emits a WARNING (the MCP caller — Claude — can decide whether
940
+ to re-ingest or invoke a future regenerate tool). A NULL ``vault_path``
941
+ is silently DB-only. The MCP tool does not expose a
942
+ ``--regenerate-file`` equivalent — recovery there is reserved for the
943
+ CLI command.
944
+ """
945
+ add = add or []
946
+ remove = remove or []
947
+ if not add and not remove:
948
+ raise _mcp_error(INVALID_PARAMS, "expected add or remove tags")
949
+ state = _get_state()
950
+ logger.debug(
951
+ "brain_tag: id_prefix=%s add=%s remove=%s", id_prefix, add, remove
952
+ )
953
+ try:
954
+ with _mcp_conn(state) as conn:
955
+ conn.autocommit = True
956
+ doc_id = _resolve_id(conn, id_prefix)
957
+ row = conn.execute(
958
+ "SELECT vault_path FROM documents WHERE id = %s", (doc_id,)
959
+ ).fetchone()
960
+ if row is None:
961
+ # _resolve_id just confirmed the row exists; a None here means
962
+ # someone deleted it between the two queries. Surface explicitly
963
+ # so the failure mode survives `python -O`.
964
+ raise BrainError(f"document vanished mid-transaction: {doc_id}")
965
+ vault_path_rel: str | None = row[0]
966
+ tags = apply_tags(conn, doc_id, add=add, remove=remove)
967
+ except psycopg.Error as e:
968
+ raise _wrap_db_error(e) from e
969
+ if vault_path_rel is not None:
970
+ abs_path = state.cfg.vault_path / vault_path_rel
971
+ if abs_path.exists():
972
+ rewrite_tags(abs_path, tags)
973
+ logger.debug("brain_tag: rewrote tags in %s", abs_path)
974
+ else:
975
+ logger.warning(
976
+ "brain_tag: mirror missing for %s "
977
+ "(expected %s); db updated only",
978
+ doc_id,
979
+ abs_path,
980
+ )
981
+ return {"document_id": doc_id, "tags": tags}
982
+
983
+
984
+ @mcp_app.tool()
985
+ def brain_edit(
986
+ id_prefix: str,
987
+ title: str | None = None,
988
+ content_type: str | None = None,
989
+ content: str | None = None,
990
+ metadata: dict[str, Any] | None = None,
991
+ replace_metadata: bool = False,
992
+ ) -> dict[str, Any]:
993
+ """Update one of: title, content_type, content, or metadata of a document.
994
+
995
+ Body changes (``content``) re-chunk + re-embed; field-only changes are a
996
+ single SQL UPDATE. Metadata defaults to a shallow merge — set
997
+ ``replace_metadata=True`` (with a non-None ``metadata``) to swap the JSONB
998
+ blob entirely. Tag mutations live in :func:`brain_tag` for clean
999
+ separation.
1000
+ """
1001
+ has_field = any(
1002
+ v is not None for v in (title, content_type, content, metadata)
1003
+ ) or replace_metadata
1004
+ if not has_field:
1005
+ raise _mcp_error(INVALID_PARAMS, "no edit fields specified")
1006
+ if replace_metadata and metadata is None:
1007
+ raise _mcp_error(
1008
+ INVALID_PARAMS, "replace_metadata requires metadata"
1009
+ )
1010
+ state = _get_state()
1011
+ logger.debug(
1012
+ "brain_edit: id_prefix=%s title?=%s ct?=%s content?=%s meta?=%s replace=%s",
1013
+ id_prefix,
1014
+ title is not None,
1015
+ content_type is not None,
1016
+ content is not None,
1017
+ metadata is not None,
1018
+ replace_metadata,
1019
+ )
1020
+ embedder = state.embedder if content is not None else None
1021
+ # Wave Q2-SUMMARY-WIKI smoke gap (Codex finding 1 follow-up,
1022
+ # 2026-05-11): wire ``state.enricher`` on body-changing edits so the
1023
+ # auto-summary refreshes alongside the new body. Without this the
1024
+ # Q2 wiki lede shows the pre-edit summary above a freshly-edited
1025
+ # body when the user (or Claude via MCP) edits via ``brain_edit``.
1026
+ # Reuses the long-lived enricher built in :func:`main`; no new
1027
+ # Ollama probe per request. Ollama failures inside the hook
1028
+ # degrade soft — the row keeps its prior summary and
1029
+ # ``brain enrich --backfill`` recovers it later.
1030
+ enricher = state.enricher if content is not None else None
1031
+ try:
1032
+ with _mcp_conn(state) as conn:
1033
+ conn.autocommit = True
1034
+ doc_id = _resolve_id(conn, id_prefix)
1035
+ try:
1036
+ result = update_document(
1037
+ conn,
1038
+ document_id=doc_id,
1039
+ embedder=embedder,
1040
+ new_title=title,
1041
+ new_content_type=content_type,
1042
+ new_content=content,
1043
+ metadata_patch=metadata,
1044
+ replace_metadata=replace_metadata,
1045
+ vault_root=state.cfg.vault_path,
1046
+ enricher=enricher,
1047
+ graph_syncer=state.graph_syncer,
1048
+ )
1049
+ except ValueError as e:
1050
+ raise _mcp_error(INVALID_PARAMS, str(e)) from e
1051
+ except psycopg.Error as e:
1052
+ raise _wrap_db_error(e) from e
1053
+ except EmbedError as e:
1054
+ raise _wrap_embed_error(e) from e
1055
+ return {
1056
+ "document_id": result.document_id,
1057
+ "fields_changed": result.fields_changed,
1058
+ "rechunked": result.rechunked,
1059
+ }
1060
+
1061
+
1062
+ @mcp_app.tool()
1063
+ def brain_backlinks(id_prefix: str) -> list[dict[str, Any]]:
1064
+ """List documents that link TO ``id_prefix`` (a vault or ingested doc).
1065
+
1066
+ Returns one entry per inbound link with the source document's id, title,
1067
+ kind, and the literal ``[[link-text]]`` that carried the reference. An
1068
+ empty list means the document has no backlinks yet — not an error.
1069
+ """
1070
+ state = _get_state()
1071
+ logger.debug("brain_backlinks: id_prefix=%s", id_prefix)
1072
+ try:
1073
+ with _mcp_conn(state) as conn:
1074
+ doc_id = _resolve_id(conn, id_prefix)
1075
+ rows = backlinks_for(conn, doc_id)
1076
+ except psycopg.Error as e:
1077
+ raise _wrap_db_error(e) from e
1078
+ return [
1079
+ {
1080
+ "src_document_id": r.src_document_id,
1081
+ "src_title": r.src_title,
1082
+ "src_kind": r.src_kind,
1083
+ "link_text": r.link_text,
1084
+ "link_kind": r.link_kind,
1085
+ }
1086
+ for r in rows
1087
+ ]
1088
+
1089
+
1090
+ @mcp_app.tool()
1091
+ def brain_links(
1092
+ id_prefix: str,
1093
+ include_unresolved: bool = False,
1094
+ ) -> list[dict[str, Any]]:
1095
+ """List documents that ``id_prefix`` links TO.
1096
+
1097
+ With ``include_unresolved=True``, also returns dangling ``[[refs]]``
1098
+ from ``unresolved_links`` — each unresolved entry has
1099
+ ``dst_document_id=null`` / ``dst_title=null`` / ``dst_kind=null`` and
1100
+ ``resolved=false``. Resolved rows always come first.
1101
+ """
1102
+ state = _get_state()
1103
+ logger.debug(
1104
+ "brain_links: id_prefix=%s include_unresolved=%s",
1105
+ id_prefix,
1106
+ include_unresolved,
1107
+ )
1108
+ try:
1109
+ with _mcp_conn(state) as conn:
1110
+ doc_id = _resolve_id(conn, id_prefix)
1111
+ rows = outgoing_links_for(
1112
+ conn, doc_id, include_unresolved=include_unresolved
1113
+ )
1114
+ except psycopg.Error as e:
1115
+ raise _wrap_db_error(e) from e
1116
+ return [
1117
+ {
1118
+ "dst_document_id": r.dst_document_id,
1119
+ "dst_title": r.dst_title,
1120
+ "dst_kind": r.dst_kind,
1121
+ "link_text": r.link_text,
1122
+ "link_kind": r.link_kind,
1123
+ "resolved": r.resolved,
1124
+ }
1125
+ for r in rows
1126
+ ]
1127
+
1128
+
1129
+ @mcp_app.tool()
1130
+ def brain_orphans(vault_only: bool = True) -> list[dict[str, Any]]:
1131
+ """List documents with no incoming and no outgoing links.
1132
+
1133
+ Defaults to vault-tier only (``vault_only=True``); pass
1134
+ ``vault_only=False`` to include ingested-tier docs (Krisp / Slack /
1135
+ Gmail mirrors), which are usually noise — most ingested artifacts
1136
+ carry no ``[[refs]]`` yet.
1137
+ """
1138
+ state = _get_state()
1139
+ logger.debug("brain_orphans: vault_only=%s", vault_only)
1140
+ try:
1141
+ with _mcp_conn(state) as conn:
1142
+ rows = orphans(conn, vault_only=vault_only)
1143
+ except psycopg.Error as e:
1144
+ raise _wrap_db_error(e) from e
1145
+ return [
1146
+ {"document_id": n.document_id, "title": n.title, "kind": n.kind}
1147
+ for n in rows
1148
+ ]
1149
+
1150
+
1151
+ @mcp_app.tool()
1152
+ def brain_note_new(
1153
+ title: str,
1154
+ body: str,
1155
+ folder: str | None = None,
1156
+ tags: list[str] | None = None,
1157
+ template: str | None = None,
1158
+ ) -> dict[str, Any]:
1159
+ """Create a new vault note (no ``$EDITOR`` — Claude can't drive one).
1160
+
1161
+ ``title`` becomes the frontmatter ``title`` and the slug-based
1162
+ filename. ``body`` is the markdown body (no frontmatter — brain
1163
+ assembles the frontmatter automatically). ``folder`` is a
1164
+ vault-relative subfolder (cannot escape the vault). ``tags``
1165
+ populate the frontmatter. ``template`` (default ``"note"``) names a
1166
+ file under ``_templates/`` whose body is appended after the
1167
+ user-supplied body — pass ``""`` (empty string) to skip the template
1168
+ entirely.
1169
+
1170
+ The note is auto-tagged with ``source-mcp`` so MCP-created notes
1171
+ are distinguishable from CLI-created ones (mirrors the
1172
+ ``brain_ingest_stdin`` contract). User-supplied ``source-mcp`` is
1173
+ deduped via set-union.
1174
+
1175
+ Refuses to overwrite an existing path. Bodies larger than 256 KB are
1176
+ rejected with ``INVALID_PARAMS``.
1177
+
1178
+ Returns ``{document_id, vault_path, source_path}`` — the synced doc
1179
+ id, the relative path inside the vault, and the absolute path on
1180
+ disk for callers that need to reference the file later.
1181
+ """
1182
+ if not title.strip():
1183
+ raise _mcp_error(INVALID_PARAMS, "title is empty")
1184
+ body_bytes = body.encode("utf-8")
1185
+ if len(body_bytes) > _MAX_NOTE_BODY_BYTES:
1186
+ raise _mcp_error(
1187
+ INVALID_PARAMS,
1188
+ f"body is {len(body_bytes)} bytes (max {_MAX_NOTE_BODY_BYTES})",
1189
+ )
1190
+
1191
+ state = _get_state()
1192
+ vault_path = state.cfg.vault_path
1193
+ if not vault_path.is_dir():
1194
+ raise _mcp_error(
1195
+ INVALID_PARAMS,
1196
+ f"vault path does not exist: {vault_path} — run `brain vault init`",
1197
+ )
1198
+
1199
+ template_name = "note" if template is None else template
1200
+ template_text = ""
1201
+ if template_name:
1202
+ template_path = _ensure_template_path(vault_path, template_name)
1203
+ template_text = template_path.read_text(encoding="utf-8")
1204
+
1205
+ slug = slugify(title)
1206
+ folder_part = (folder or "").strip()
1207
+ target_relative = (
1208
+ Path(folder_part) / f"{slug}.md" if folder_part else Path(f"{slug}.md")
1209
+ )
1210
+ target = vault_path / target_relative
1211
+ _assert_within_vault(target, vault_path, label="folder")
1212
+ if target.exists():
1213
+ raise _mcp_error(
1214
+ INVALID_PARAMS,
1215
+ f"note already exists at {target_relative.as_posix()}",
1216
+ )
1217
+
1218
+ user_tags = list(tags or [])
1219
+ merged_tags = sorted({*user_tags, _MCP_AUTO_TAG})
1220
+ now = datetime.now()
1221
+ iso_now = now.isoformat(timespec="seconds")
1222
+ document_id = str(uuid.uuid4())
1223
+
1224
+ file_text = _build_note_file_text(
1225
+ body=body,
1226
+ template_text=template_text,
1227
+ title=title,
1228
+ document_id=document_id,
1229
+ tags=merged_tags,
1230
+ iso_now=iso_now,
1231
+ date_iso=now.date().isoformat(),
1232
+ slug=slug,
1233
+ )
1234
+
1235
+ target.parent.mkdir(parents=True, exist_ok=True)
1236
+ target.write_text(file_text, encoding="utf-8")
1237
+
1238
+ logger.debug(
1239
+ "brain_note_new: title=%r path=%s id=%s",
1240
+ title,
1241
+ target_relative.as_posix(),
1242
+ document_id[:8],
1243
+ )
1244
+ try:
1245
+ with _mcp_conn(state) as conn:
1246
+ conn.autocommit = True
1247
+ report = sync_one_file(
1248
+ conn,
1249
+ embedder=state.embedder,
1250
+ vault_path=vault_path,
1251
+ file_path=target,
1252
+ )
1253
+ except psycopg.Error as e:
1254
+ raise _wrap_db_error(e) from e
1255
+ except EmbedError as e:
1256
+ raise _wrap_embed_error(e) from e
1257
+ if report.errors:
1258
+ # The on-disk file is intact; surface the first error so the
1259
+ # caller knows the index didn't update. Subsequent
1260
+ # ``brain vault sync`` will pick it up once the user fixes the
1261
+ # underlying issue.
1262
+ _path, reason = report.errors[0]
1263
+ raise _mcp_error(INTERNAL_ERROR, f"sync failed: {reason}")
1264
+
1265
+ return {
1266
+ "document_id": document_id,
1267
+ "vault_path": target_relative.as_posix(),
1268
+ "source_path": str(target),
1269
+ }
1270
+
1271
+
1272
+ @mcp_app.tool()
1273
+ def brain_daily(date: str | None = None) -> dict[str, Any]:
1274
+ """Resolve or create the daily note for ``date`` (default: today).
1275
+
1276
+ ``date`` accepts ISO 8601 (``YYYY-MM-DD``); ``None`` falls back to
1277
+ today's local date. The path is
1278
+ ``<vault>/daily/<YYYY>/<YYYY-MM-DD>.md``. Idempotent: if the file
1279
+ already exists, returns the existing ``document_id`` with
1280
+ ``created=false``; otherwise renders ``_templates/daily.md``, writes
1281
+ the file (auto-tagged with ``source-mcp``), syncs, and returns
1282
+ ``created=true``.
1283
+ """
1284
+ if date is not None:
1285
+ try:
1286
+ target_date = date_cls.fromisoformat(date)
1287
+ except ValueError as e:
1288
+ raise _mcp_error(
1289
+ INVALID_PARAMS, f"date must be YYYY-MM-DD ({e})"
1290
+ ) from e
1291
+ else:
1292
+ target_date = date_cls.today()
1293
+
1294
+ state = _get_state()
1295
+ vault_path = state.cfg.vault_path
1296
+ if not vault_path.is_dir():
1297
+ raise _mcp_error(
1298
+ INVALID_PARAMS,
1299
+ f"vault path does not exist: {vault_path} — run `brain vault init`",
1300
+ )
1301
+
1302
+ iso_date = target_date.isoformat()
1303
+ year_folder = f"{target_date.year:04d}"
1304
+ target_relative = Path("daily") / year_folder / f"{iso_date}.md"
1305
+ target = vault_path / target_relative
1306
+ _assert_within_vault(target, vault_path, label="date")
1307
+
1308
+ if target.is_file():
1309
+ # Recover the existing document_id from the file's frontmatter so
1310
+ # callers can chain ``brain_show`` / ``brain_link_proposal``
1311
+ # without a second round-trip.
1312
+ try:
1313
+ existing_text = target.read_text(encoding="utf-8")
1314
+ except OSError as e:
1315
+ raise _mcp_error(
1316
+ INTERNAL_ERROR, f"could not read daily note: {e}"
1317
+ ) from e
1318
+ try:
1319
+ fields, _ = parse_frontmatter(existing_text)
1320
+ except (ValueError, yaml.YAMLError) as e:
1321
+ raise _mcp_error(
1322
+ INTERNAL_ERROR, f"daily note has malformed frontmatter: {e}"
1323
+ ) from e
1324
+ existing_id = fields.get("id")
1325
+ if not isinstance(existing_id, str) or not existing_id:
1326
+ # File on disk but no id — extremely unusual; surface clearly.
1327
+ raise _mcp_error(
1328
+ INTERNAL_ERROR,
1329
+ "daily note exists but has no id in frontmatter — "
1330
+ "run `brain vault sync` to repair",
1331
+ )
1332
+ return {
1333
+ "document_id": existing_id,
1334
+ "vault_path": target_relative.as_posix(),
1335
+ "source_path": str(target),
1336
+ "created": False,
1337
+ }
1338
+
1339
+ template_path = _ensure_template_path(vault_path, "daily")
1340
+ template_text = template_path.read_text(encoding="utf-8")
1341
+
1342
+ now = datetime.now()
1343
+ iso_now = now.isoformat(timespec="seconds")
1344
+ document_id = str(uuid.uuid4())
1345
+ file_text = _build_note_file_text(
1346
+ body="",
1347
+ template_text=template_text,
1348
+ title=iso_date,
1349
+ document_id=document_id,
1350
+ tags=[_MCP_AUTO_TAG],
1351
+ iso_now=iso_now,
1352
+ date_iso=iso_date,
1353
+ slug=iso_date,
1354
+ )
1355
+ target.parent.mkdir(parents=True, exist_ok=True)
1356
+ target.write_text(file_text, encoding="utf-8")
1357
+
1358
+ logger.debug(
1359
+ "brain_daily: date=%s path=%s id=%s",
1360
+ iso_date,
1361
+ target_relative.as_posix(),
1362
+ document_id[:8],
1363
+ )
1364
+ try:
1365
+ with _mcp_conn(state) as conn:
1366
+ conn.autocommit = True
1367
+ report = sync_one_file(
1368
+ conn,
1369
+ embedder=state.embedder,
1370
+ vault_path=vault_path,
1371
+ file_path=target,
1372
+ )
1373
+ except psycopg.Error as e:
1374
+ raise _wrap_db_error(e) from e
1375
+ except EmbedError as e:
1376
+ raise _wrap_embed_error(e) from e
1377
+ if report.errors:
1378
+ _path, reason = report.errors[0]
1379
+ raise _mcp_error(INTERNAL_ERROR, f"sync failed: {reason}")
1380
+
1381
+ return {
1382
+ "document_id": document_id,
1383
+ "vault_path": target_relative.as_posix(),
1384
+ "source_path": str(target),
1385
+ "created": True,
1386
+ }
1387
+
1388
+
1389
+ @mcp_app.tool()
1390
+ def brain_review_weekly(
1391
+ week: str | None = None,
1392
+ no_graph: bool = False,
1393
+ emit: bool = True,
1394
+ ) -> dict[str, Any]:
1395
+ """Synthesize the prior week's activity into a weekly review.
1396
+
1397
+ ``week`` is an ISO week (``YYYY-Www``, e.g. ``2026-W23``); ``None`` uses the
1398
+ current week. ``no_graph`` forces tag-cluster themes instead of graph
1399
+ communities. When ``emit`` is true (default) the dated page is written to
1400
+ ``<vault>/reviews/<week>.md``. Returns the machine-readable sections plus
1401
+ the relative ``vault_path`` slug (mirrors ``brain review weekly --json``).
1402
+ """
1403
+ from .activity import current_iso_week
1404
+ from .review import build_weekly_report, emit_weekly_page, render_weekly_json
1405
+
1406
+ state = _get_state()
1407
+ target_week = week or current_iso_week()
1408
+ # Theme synthesis matters only on the graph path; reuse the long-lived
1409
+ # enricher built in main(). summarize_group never raises if Ollama is down.
1410
+ enricher = state.enricher if not no_graph else None
1411
+ try:
1412
+ with _mcp_conn(state) as conn:
1413
+ report = build_weekly_report(
1414
+ conn,
1415
+ state.cfg,
1416
+ week=target_week,
1417
+ generated_on=date_cls.today(),
1418
+ no_graph=no_graph,
1419
+ enricher=enricher,
1420
+ )
1421
+ except ValueError as e:
1422
+ raise _mcp_error(
1423
+ INVALID_PARAMS, f"week must be YYYY-Www (e.g. 2026-W23): {e}"
1424
+ ) from e
1425
+ except psycopg.Error as e:
1426
+ raise _wrap_db_error(e) from e
1427
+
1428
+ if emit:
1429
+ try:
1430
+ emit_weekly_page(state.cfg.vault_path, report)
1431
+ except OSError as e:
1432
+ raise _mcp_error(
1433
+ INTERNAL_ERROR, f"could not write review page: {e}"
1434
+ ) from e
1435
+
1436
+ return render_weekly_json(report)
1437
+
1438
+
1439
+ class _CountingAssessor:
1440
+ """Wrap an enricher to count ``assess_contradiction`` calls (MCP ``llm_calls``).
1441
+
1442
+ Satisfies :class:`brain.review.scans.ContradictionAssessor` structurally so
1443
+ it drops into ``run_conflict_scan`` without the scan layer importing the
1444
+ concrete enricher.
1445
+ """
1446
+
1447
+ def __init__(self, inner: OllamaEnricher) -> None:
1448
+ self._inner = inner
1449
+ self.calls = 0
1450
+
1451
+ def assess_contradiction(
1452
+ self, *, subject: str, summaries: list[str]
1453
+ ) -> ContradictionVerdict:
1454
+ self.calls += 1
1455
+ return self._inner.assess_contradiction(subject=subject, summaries=summaries)
1456
+
1457
+
1458
+ _REVIEW_SCAN_TYPES = ("conflicts", "stale", "all")
1459
+ _REVIEW_LIST_KINDS: dict[str, tuple[str, ...]] = {
1460
+ "all": ("contradiction", "stale"),
1461
+ "conflicts": ("contradiction",),
1462
+ "stale": ("stale",),
1463
+ }
1464
+
1465
+
1466
+ def _review_finding_to_dict(finding: Any) -> dict[str, Any]:
1467
+ """Serialize a scan :class:`ReviewFinding` to the MCP finding shape."""
1468
+ return {
1469
+ "kind": finding.kind,
1470
+ "target_type": finding.target_type,
1471
+ "target_id": finding.target_id,
1472
+ "score": finding.score,
1473
+ "rationale": finding.rationale,
1474
+ "evidence_ids": finding.evidence_ids,
1475
+ }
1476
+
1477
+
1478
+ @mcp_app.tool()
1479
+ def brain_review_scan(
1480
+ scan_type: str = "all",
1481
+ dry_run: bool = False,
1482
+ limit: int = 20,
1483
+ ) -> dict[str, Any]:
1484
+ """Run a contradiction / staleness scan, surfacing findings into the queue.
1485
+
1486
+ ``scan_type`` is ``"conflicts"`` | ``"stale"`` | ``"all"``. The conflict leg
1487
+ is gated on ``BRAIN_ELICIT_CONTRADICTION_ENABLED`` + a wired Ollama enricher;
1488
+ when unavailable it is skipped silently (no error). ``dry_run`` computes
1489
+ findings without writing. ``limit`` caps conflict entity candidates. Returns
1490
+ ``{findings, scanned, llm_calls}`` — ``scanned`` is the number of candidates
1491
+ examined by the prefilters.
1492
+ """
1493
+ from dataclasses import replace
1494
+
1495
+ from .review import queries as review_queries
1496
+ from .review import run_conflict_scan, run_staleness_scan
1497
+
1498
+ if scan_type not in _REVIEW_SCAN_TYPES:
1499
+ raise _mcp_error(
1500
+ INVALID_PARAMS, "scan_type must be one of conflicts|stale|all"
1501
+ )
1502
+ if limit < 1:
1503
+ raise _mcp_error(INVALID_PARAMS, "limit must be >= 1")
1504
+
1505
+ state = _get_state()
1506
+ cfg = replace(state.cfg, review_conflict_limit=limit)
1507
+ tenant = cfg.graph_tenant_id
1508
+ do_conflicts = scan_type in ("conflicts", "all")
1509
+ do_stale = scan_type in ("stale", "all")
1510
+ findings: list[Any] = []
1511
+ scanned = 0
1512
+ counter = (
1513
+ _CountingAssessor(state.enricher) if state.enricher is not None else None
1514
+ )
1515
+ try:
1516
+ with _mcp_conn(state) as conn:
1517
+ if do_conflicts:
1518
+ scanned += len(
1519
+ review_queries.iter_entities_for_conflict_scan(
1520
+ conn,
1521
+ tenant_id=tenant,
1522
+ min_docs=cfg.elicit_contradiction_min_docs,
1523
+ limit=cfg.review_conflict_limit,
1524
+ )
1525
+ )
1526
+ if cfg.elicit_contradiction_enabled and counter is not None:
1527
+ try:
1528
+ findings.extend(
1529
+ run_conflict_scan(
1530
+ conn,
1531
+ counter,
1532
+ state.embedder,
1533
+ cfg,
1534
+ tenant_id=tenant,
1535
+ dry_run=dry_run,
1536
+ )
1537
+ )
1538
+ except ReviewError as exc:
1539
+ findings.extend(exc.findings)
1540
+ if do_stale:
1541
+ scanned += len(
1542
+ review_queries.iter_docs_for_staleness_scan(
1543
+ conn,
1544
+ tenant_id=tenant,
1545
+ stale_age_days=cfg.review_stale_age_days,
1546
+ limit=cfg.review_stale_limit,
1547
+ )
1548
+ )
1549
+ findings.extend(
1550
+ run_staleness_scan(
1551
+ conn, state.embedder, cfg, tenant_id=tenant, dry_run=dry_run
1552
+ )
1553
+ )
1554
+ except psycopg.Error as e:
1555
+ raise _wrap_db_error(e) from e
1556
+
1557
+ return {
1558
+ "findings": [_review_finding_to_dict(f) for f in findings],
1559
+ "scanned": scanned,
1560
+ "llm_calls": counter.calls if counter is not None else 0,
1561
+ }
1562
+
1563
+
1564
+ @mcp_app.tool()
1565
+ def brain_review_findings_list(
1566
+ kind: str = "all",
1567
+ limit: int = 20,
1568
+ ) -> dict[str, Any]:
1569
+ """Read the current review queue without scanning.
1570
+
1571
+ ``kind`` is ``"all"`` | ``"conflicts"`` | ``"stale"``. Returns
1572
+ ``{findings: [...]}`` where each finding is
1573
+ ``{kind, id, target_type, target_id, score, rationale, evidence_ids, status}``.
1574
+ Named to avoid ambiguity with ``brain_review_weekly``.
1575
+ """
1576
+ from .review.queries import list_review_queue
1577
+
1578
+ if kind not in _REVIEW_LIST_KINDS:
1579
+ raise _mcp_error(INVALID_PARAMS, "kind must be one of all|conflicts|stale")
1580
+ if limit < 1:
1581
+ raise _mcp_error(INVALID_PARAMS, "limit must be >= 1")
1582
+
1583
+ state = _get_state()
1584
+ try:
1585
+ with _mcp_conn(state) as conn:
1586
+ rows = list_review_queue(
1587
+ conn,
1588
+ tenant_id=state.cfg.graph_tenant_id,
1589
+ signal_kinds=_REVIEW_LIST_KINDS[kind],
1590
+ limit=limit,
1591
+ )
1592
+ except psycopg.Error as e:
1593
+ raise _wrap_db_error(e) from e
1594
+
1595
+ return {
1596
+ "findings": [
1597
+ {
1598
+ "kind": r.signal_kind,
1599
+ "id": r.id,
1600
+ "target_type": r.target_type,
1601
+ "target_id": r.target_id,
1602
+ "score": r.score,
1603
+ "rationale": r.rationale,
1604
+ "evidence_ids": r.evidence_ids,
1605
+ "status": r.status,
1606
+ }
1607
+ for r in rows
1608
+ ]
1609
+ }
1610
+
1611
+
1612
+ @mcp_app.tool()
1613
+ def brain_brief(
1614
+ since_hours: int | None = None,
1615
+ todo_since_days: int | None = None,
1616
+ no_enrich: bool = False,
1617
+ ) -> dict[str, Any]:
1618
+ """Proactive daily digest: recent captures, open todos, pins, next steps.
1619
+
1620
+ ``since_hours`` bounds the recent-captures window; ``todo_since_days`` bounds
1621
+ the open-action-item window. Both default to ``None``, resolving to
1622
+ ``cfg.brief_since_hours`` / ``cfg.brief_todo_since_days`` (themselves 24 / 7
1623
+ unless overridden) so the tool honors config exactly like ``brain brief
1624
+ --json``. Best-effort LLM next-step suggestions are included unless
1625
+ ``no_enrich`` is true or Ollama is unavailable (then ``suggestions`` is
1626
+ empty). Surfaces titles + todo texts only — never document bodies.
1627
+ """
1628
+ from dataclasses import replace
1629
+
1630
+ from .brief import assemble_brief, suggest_next_steps
1631
+
1632
+ state = _get_state()
1633
+ resolved_since_hours = (
1634
+ since_hours if since_hours is not None else state.cfg.brief_since_hours
1635
+ )
1636
+ resolved_todo_since_days = (
1637
+ todo_since_days
1638
+ if todo_since_days is not None
1639
+ else state.cfg.brief_todo_since_days
1640
+ )
1641
+ try:
1642
+ with _mcp_conn(state) as conn:
1643
+ data = assemble_brief(
1644
+ conn,
1645
+ state.cfg,
1646
+ since_hours=resolved_since_hours,
1647
+ todo_since_days=resolved_todo_since_days,
1648
+ on_date=date_cls.today(),
1649
+ )
1650
+ except psycopg.Error as e:
1651
+ raise _wrap_db_error(e) from e
1652
+ if not no_enrich:
1653
+ suggestions = suggest_next_steps(data, state.cfg)
1654
+ if suggestions:
1655
+ data = replace(data, suggestions=suggestions)
1656
+ return data.to_dict()
1657
+
1658
+
1659
+ def _log_ask_interactions_mcp(
1660
+ conn: psycopg.Connection[Any], result: "AskResult"
1661
+ ) -> None:
1662
+ """Best-effort: one ``opened`` interaction per emitted citation (source=mcp).
1663
+
1664
+ Document rows only (``document_id`` set, ``target_type`` unset). A logging
1665
+ failure is warned + swallowed so the answer still returns (mirrors
1666
+ ``brain_show``'s never-raise interaction discipline)."""
1667
+ try:
1668
+ session_uuid: uuid.UUID | None = uuid.UUID(result.session_id)
1669
+ except ValueError:
1670
+ session_uuid = None
1671
+ for citation in result.citations:
1672
+ try:
1673
+ record_interaction(
1674
+ conn,
1675
+ document_id=citation.document_id,
1676
+ action="opened",
1677
+ source="mcp",
1678
+ session_id=session_uuid,
1679
+ )
1680
+ except (psycopg.Error, InteractionError) as log_exc:
1681
+ logger.warning(
1682
+ "brain_ask: interaction logging failed: %s",
1683
+ type(log_exc).__name__,
1684
+ )
1685
+
1686
+
1687
+ @mcp_app.tool()
1688
+ def brain_ask(
1689
+ question: str,
1690
+ mode: str = "hybrid",
1691
+ no_loop: bool = False,
1692
+ limit: int | None = None,
1693
+ max_iterations: int | None = None,
1694
+ ) -> dict[str, Any]:
1695
+ """Agentic multi-hop cited answer synthesis over the second brain.
1696
+
1697
+ Unlike ``brain_search`` (which returns a ranked list you must read and
1698
+ synthesize yourself), this plans sub-queries, retrieves across iterations,
1699
+ optionally reflects to fill coverage gaps, and returns a single direct
1700
+ ANSWER with inline ``[N]`` citations into your documents. Reach for it on
1701
+ multi-hop / synthesis questions like "what did I learn negotiating across my
1702
+ job searches?" or "what did we decide about the data pipeline?".
1703
+
1704
+ ``mode``: ``hybrid`` (vector/FTS only, default) | ``auto`` (graph router) |
1705
+ ``fuse`` (RRF of graph + hybrid) | ``local`` (graph entity-centric). The
1706
+ three graph modes require the Apache AGE image. ``no_loop`` skips
1707
+ plan/reflect for a single fast retrieve+synthesize pass. ``max_iterations``
1708
+ defaults to ``cfg.ask_max_iterations``.
1709
+
1710
+ Returns ``{answer, citations[], iterations_used, sub_queries[],
1711
+ fallback_used, session_id}``. Requires a local Ollama for the LLM steps; an
1712
+ unavailable Ollama surfaces as ``INTERNAL_ERROR`` (start it and retry). A
1713
+ bad ``mode`` is ``INVALID_PARAMS``. Document snippets only reach the LLM —
1714
+ never full bodies.
1715
+ """
1716
+ from .ask import ASK_MODES, HYBRID_MODE
1717
+ from .ask import ask as run_ask
1718
+ from .chat import chat_json
1719
+
1720
+ state = _get_state()
1721
+ if mode not in ASK_MODES:
1722
+ raise _mcp_error(
1723
+ INVALID_PARAMS,
1724
+ f"mode must be one of: {', '.join(sorted(ASK_MODES))}",
1725
+ )
1726
+ if max_iterations is not None and max_iterations < 1:
1727
+ raise _mcp_error(
1728
+ INVALID_PARAMS, "max_iterations must be >= 1"
1729
+ )
1730
+ if limit is not None and limit < 1:
1731
+ raise _mcp_error(INVALID_PARAMS, "limit must be >= 1")
1732
+ resolved_max_iter = (
1733
+ max_iterations
1734
+ if max_iterations is not None
1735
+ else state.cfg.ask_max_iterations
1736
+ )
1737
+ resolved_limit = limit if limit is not None else state.cfg.ask_docs_per_iter
1738
+
1739
+ try:
1740
+ if mode == HYBRID_MODE:
1741
+ with _mcp_conn(state) as conn:
1742
+ conn.autocommit = True
1743
+ result = run_ask(
1744
+ conn,
1745
+ state.cfg,
1746
+ embedder=state.embedder,
1747
+ chat=chat_json,
1748
+ question=question,
1749
+ mode=mode,
1750
+ no_loop=no_loop,
1751
+ limit=resolved_limit,
1752
+ max_iterations=resolved_max_iter,
1753
+ )
1754
+ _log_ask_interactions_mcp(conn, result)
1755
+ else:
1756
+ from .graph_rag.backends import AgeBackend
1757
+
1758
+ with connect_age(state.cfg.database_url) as conn:
1759
+ conn.autocommit = True
1760
+ _require_age_or_mcp_error(conn)
1761
+ backend = AgeBackend()
1762
+ backend.bootstrap(conn)
1763
+ result = run_ask(
1764
+ conn,
1765
+ state.cfg,
1766
+ embedder=state.embedder,
1767
+ chat=chat_json,
1768
+ question=question,
1769
+ mode=mode,
1770
+ no_loop=no_loop,
1771
+ limit=resolved_limit,
1772
+ max_iterations=resolved_max_iter,
1773
+ backend=backend,
1774
+ )
1775
+ _log_ask_interactions_mcp(conn, result)
1776
+ except OllamaUnavailable as exc:
1777
+ raise _mcp_error(
1778
+ INTERNAL_ERROR,
1779
+ "Ollama is not running — start it (e.g. `brew services start "
1780
+ f"ollama`) and retry. ({type(exc).__name__})",
1781
+ ) from exc
1782
+ except EnrichmentError as exc:
1783
+ raise _mcp_error(
1784
+ INTERNAL_ERROR, f"answer synthesis failed: {type(exc).__name__}"
1785
+ ) from exc
1786
+ except (GraphTenantError, GraphBackendError) as exc:
1787
+ raise _mcp_error(
1788
+ INTERNAL_ERROR, f"graph retrieval failed: {type(exc).__name__}"
1789
+ ) from exc
1790
+ except ValueError as exc:
1791
+ raise _mcp_error(INVALID_PARAMS, str(exc)) from exc
1792
+ except psycopg.Error as exc:
1793
+ raise _wrap_db_error(exc) from exc
1794
+
1795
+ return result.to_dict()
1796
+
1797
+
1798
+ @mcp_app.tool()
1799
+ def brain_link_proposal(
1800
+ src_id_prefix: str,
1801
+ dst_id_or_title: str,
1802
+ ) -> dict[str, Any]:
1803
+ """Propose adding a ``[[link]]`` from ``src`` to ``dst``. Writes nothing.
1804
+
1805
+ Resolves both endpoints, builds the line that would append the link
1806
+ to the source's body, and returns the diff data — the source's vault
1807
+ path, the line number where the link would land, the proposed text,
1808
+ and the link surface text. The on-disk file is not modified.
1809
+
1810
+ ``src`` must be a vault-tier document (proposals only make sense for
1811
+ user-authored notes). ``dst`` is resolved as an id prefix when it
1812
+ looks like one (6+ hex chars), otherwise via case-insensitive title
1813
+ match. Ambiguous title lookups return ``INVALID_PARAMS`` with the
1814
+ candidate ids so the caller (or user) can disambiguate.
1815
+ """
1816
+ if not src_id_prefix.strip():
1817
+ raise _mcp_error(INVALID_PARAMS, "src_id_prefix is empty")
1818
+ if not dst_id_or_title.strip():
1819
+ raise _mcp_error(INVALID_PARAMS, "dst_id_or_title is empty")
1820
+
1821
+ state = _get_state()
1822
+ logger.debug(
1823
+ "brain_link_proposal: src=%s dst=%r",
1824
+ src_id_prefix,
1825
+ dst_id_or_title,
1826
+ )
1827
+ try:
1828
+ with _mcp_conn(state) as conn:
1829
+ src_id = _resolve_id(conn, src_id_prefix)
1830
+ src_row = conn.execute(
1831
+ "SELECT kind, title, vault_path FROM documents WHERE id = %s",
1832
+ (src_id,),
1833
+ ).fetchone()
1834
+ assert src_row is not None # _resolve_id confirmed existence
1835
+ src_kind, _src_title, src_vault_path = src_row
1836
+ if src_kind != "vault":
1837
+ raise _mcp_error(
1838
+ INVALID_PARAMS,
1839
+ f"src must be a vault-tier document (got kind={src_kind!r})",
1840
+ )
1841
+ if not isinstance(src_vault_path, str) or not src_vault_path:
1842
+ raise _mcp_error(
1843
+ INVALID_PARAMS,
1844
+ "src has no vault_path on disk — cannot propose a link",
1845
+ )
1846
+
1847
+ dst_id, dst_title, link_text = _resolve_proposal_dst(
1848
+ conn, dst_id_or_title
1849
+ )
1850
+ except psycopg.Error as e:
1851
+ raise _wrap_db_error(e) from e
1852
+
1853
+ abs_src = state.cfg.vault_path / src_vault_path
1854
+ if not abs_src.is_file():
1855
+ raise _mcp_error(
1856
+ INTERNAL_ERROR,
1857
+ f"src vault file is missing on disk: {src_vault_path}",
1858
+ )
1859
+ try:
1860
+ text = abs_src.read_text(encoding="utf-8")
1861
+ except OSError as e:
1862
+ raise _mcp_error(
1863
+ INTERNAL_ERROR, f"could not read src vault file: {e}"
1864
+ ) from e
1865
+ try:
1866
+ _, body = parse_frontmatter(text)
1867
+ except (ValueError, yaml.YAMLError) as e:
1868
+ raise _mcp_error(
1869
+ INTERNAL_ERROR, f"src has malformed frontmatter: {e}"
1870
+ ) from e
1871
+
1872
+ # The proposed snippet appends a "See also" line at the end of the
1873
+ # body — the simplest correct place to land a new link without
1874
+ # disturbing existing text. Documented in the tool's docstring.
1875
+ needs_blank_line = bool(body) and not body.endswith("\n\n")
1876
+ separator = "\n" if not body.endswith("\n") else ""
1877
+ if needs_blank_line:
1878
+ separator += "\n"
1879
+ proposed_text = f"{separator}See also [[{link_text}]].\n"
1880
+ # Line number of the appended snippet's first content line: the
1881
+ # number of body lines before the append, plus the separator's
1882
+ # blank-line count + 1. We count lines in the existing body
1883
+ # (treating an empty body as 0 lines) and add the count of newlines
1884
+ # introduced before the new "See also" line.
1885
+ body_line_count = body.count("\n") + (
1886
+ 1 if body and not body.endswith("\n") else 0
1887
+ )
1888
+ blank_lines_inserted = separator.count("\n")
1889
+ line_no = body_line_count + blank_lines_inserted + 1
1890
+
1891
+ return {
1892
+ "src_vault_path": src_vault_path,
1893
+ "src_document_id": src_id,
1894
+ "dst_document_id": dst_id,
1895
+ "dst_title": dst_title,
1896
+ "line_no": line_no,
1897
+ "proposed_text": proposed_text,
1898
+ "link_text": link_text,
1899
+ }
1900
+
1901
+
1902
+ # ---------------------------------------------------------------------------
1903
+ # Wave G2-i — GraphRAG retrieval surfaces (MCP parity with the G2-h CLI).
1904
+ # Full CLI↔MCP parity (spec §9): every `brain graphrag …` capability/param/
1905
+ # semantic is reachable here and behaves identically — same `graph_rag_search`
1906
+ # core, same router, same degradation signalling on the returned JSON, same
1907
+ # tenant scoping. Structured params only; the backend injects tenant_id + caps;
1908
+ # NEVER raw Cypher (spec §4 D9). The wire shape REUSES
1909
+ # :func:`brain.format.graph_context_json` (the exact `--json` CLI shape, which
1910
+ # already carries ``session_id`` like ``brain_search``).
1911
+ # ---------------------------------------------------------------------------
1912
+
1913
+
1914
+ def _wrap_graph_backend_error(e: GraphBackendError) -> McpError:
1915
+ """Wrap an Apache AGE backend failure as an MCP error.
1916
+
1917
+ Mirrors :func:`_wrap_db_error`: the user-facing message exposes only the
1918
+ exception class name (a :class:`GraphBackendError` can wrap a generated
1919
+ Cypher / catalog statement, which must NEVER reach the wire — spec §4 D9
1920
+ no-raw-Cypher), and the full exception is logged to stderr for debugging.
1921
+ """
1922
+ logger.error("graph backend error", exc_info=e)
1923
+ return _mcp_error(INTERNAL_ERROR, f"graph backend error: {type(e).__name__}")
1924
+
1925
+
1926
+ def _require_age_or_mcp_error(conn: psycopg.Connection[Any]) -> None:
1927
+ """Raise ``McpError`` when this DB image lacks Apache AGE.
1928
+
1929
+ The MCP analogue of the CLI's :func:`brain.cli._require_age_or_exit`: the
1930
+ graphrag tools exist solely to query / maintain the AGE graph, so an
1931
+ AGE-absent image is an unrecoverable server-side condition (the caller
1932
+ cannot fix the DB image), surfaced as ``INTERNAL_ERROR`` — matching how the
1933
+ other tools surface unavailable subsystems (DB / embedder failures).
1934
+ """
1935
+ if not age_extension_available(conn):
1936
+ raise _mcp_error(
1937
+ INTERNAL_ERROR,
1938
+ "graphrag: Apache AGE is not available in this database image — "
1939
+ "cut over to the AGE image and run `brain init` first",
1940
+ )
1941
+
1942
+
1943
+ def _graphrag_reconcile_config(
1944
+ cfg: Config, tenant: str | None
1945
+ ) -> "ReconcileConfig":
1946
+ """Resolve the shared :class:`ReconcileConfig`, applying a ``tenant`` override.
1947
+
1948
+ The MCP twin of the CLI's :func:`brain.cli._graphrag_config`: starts from the
1949
+ single :func:`brain.graph_rag.sync.build_reconcile_config` (so a build uses
1950
+ the SAME co-occurrence window / per-doc cap / generic ratio / owner keys as
1951
+ the incremental sync hook) and overrides only the tenant id when a non-blank
1952
+ ``tenant`` is supplied — leaving every weighting knob identical so an MCP
1953
+ build cannot diverge from the CLI / incremental path.
1954
+ """
1955
+ from .graph_rag.sync import build_reconcile_config
1956
+
1957
+ base = build_reconcile_config(cfg)
1958
+ if tenant is not None and tenant.strip():
1959
+ return replace(base, tenant_id=tenant.strip())
1960
+ return base
1961
+
1962
+
1963
+ def _graphrag_search_or_mcp_error(
1964
+ cfg: Config,
1965
+ query: str,
1966
+ *,
1967
+ mode: str,
1968
+ tenant: str | None,
1969
+ person: str | None,
1970
+ depth: int | None,
1971
+ limit: int | None,
1972
+ synthesize: bool,
1973
+ enricher: OllamaEnricher | None,
1974
+ embedder: Embedder | None = None,
1975
+ ) -> "GraphContext":
1976
+ """Open an AGE connection, run :func:`graph_rag_search`, map core errors.
1977
+
1978
+ The single construction + error-mapping seam shared by the graphrag
1979
+ retrieval tools (the MCP twin of the CLI's
1980
+ :func:`brain.cli._graphrag_search_or_exit`): opens an AGE-capable autocommit
1981
+ connection, bootstraps the backend, and runs the SAME ``graph_rag_search``
1982
+ core the CLI calls — so identical inputs yield an identical
1983
+ :class:`GraphContext` (the parity guarantee). Local seed resolution + the
1984
+ snippet path are FTS-only, so an embedder is needed ONLY for the ``global``
1985
+ (community) path's vector leg AND the ``fuse`` hybrid leg (spec §17c Q9;
1986
+ perf-T4 G5): the caller passes the long-lived ``state.embedder`` instance
1987
+ directly (no per-call construction); local / themes / entity ignore it.
1988
+ The enricher is the opt-in ``synthesize`` group-summary seam.
1989
+
1990
+ Error → ``McpError`` mapping (spec §17b decision 4 + repo error contract;
1991
+ mirrors the CLI's exit-code mapping; the G3-e flip means explicit
1992
+ ``mode='global'`` now EXECUTES so the former ``GraphModeUnavailable`` reject
1993
+ is gone — §17c Q6):
1994
+
1995
+ * :class:`PersonNotFound` / :class:`PersonAmbiguous` (themes resolver) →
1996
+ ``INVALID_PARAMS`` (caller-fixable — pick / disambiguate a real person).
1997
+ * ``ValueError`` (themes mode with no resolvable person, or an unknown mode
1998
+ surfaced by the router) → ``INVALID_PARAMS`` (usage error).
1999
+ * :class:`GraphTenantError` / :class:`GraphBackendError` → ``INTERNAL_ERROR``
2000
+ (the code the other tools use for unavailable subsystems; the backend
2001
+ error's message is class-name-only so no Cypher reaches the wire).
2002
+ * :class:`psycopg.Error` → ``INTERNAL_ERROR`` (class-name only, via
2003
+ :func:`_wrap_db_error`).
2004
+
2005
+ AGE-absent is handled before retrieval by :func:`_require_age_or_mcp_error`.
2006
+ """
2007
+ from .graph_rag import graph_rag_search
2008
+ from .graph_rag.backends import AgeBackend
2009
+
2010
+ try:
2011
+ with connect_age(cfg.database_url) as conn:
2012
+ conn.autocommit = True
2013
+ _require_age_or_mcp_error(conn)
2014
+ backend = AgeBackend()
2015
+ backend.bootstrap(conn)
2016
+ return graph_rag_search(
2017
+ conn,
2018
+ cfg,
2019
+ query,
2020
+ backend=backend,
2021
+ tenant=tenant,
2022
+ depth=depth,
2023
+ limit=limit,
2024
+ mode=mode,
2025
+ person=person,
2026
+ synthesize=synthesize,
2027
+ enricher=enricher,
2028
+ embedder=embedder,
2029
+ )
2030
+ except (PersonNotFound, PersonAmbiguous) as exc:
2031
+ raise _mcp_error(INVALID_PARAMS, str(exc)) from exc
2032
+ except GraphTenantError as exc:
2033
+ # Empty effective tenant — a degenerate config bug, not caller-fixable
2034
+ # via params; surface as INTERNAL_ERROR (no Cypher in the message).
2035
+ raise _mcp_error(INTERNAL_ERROR, str(exc)) from exc
2036
+ except GraphBackendError as exc:
2037
+ raise _wrap_graph_backend_error(exc) from exc
2038
+ except ValueError as exc:
2039
+ # Router caller-bug surface: themes mode with no resolvable person, or an
2040
+ # unrecognized mode value. Both are usage errors → INVALID_PARAMS.
2041
+ raise _mcp_error(INVALID_PARAMS, str(exc)) from exc
2042
+ except psycopg.Error as exc:
2043
+ raise _wrap_db_error(exc) from exc
2044
+
2045
+
2046
+ @mcp_app.tool()
2047
+ def brain_graphrag_search(
2048
+ query: str,
2049
+ mode: str = "auto",
2050
+ person: str | None = None,
2051
+ depth: int | None = None,
2052
+ limit: int | None = None,
2053
+ tenant: str | None = None,
2054
+ synthesize: bool = False,
2055
+ ) -> dict[str, Any]:
2056
+ """Graph retrieval — THEMES, PATTERNS, and CONNECTIONS across interactions.
2057
+
2058
+ WHEN TO USE (graph vs. plain ``brain_search``): reach for this when the
2059
+ question is about how things RELATE — themes that keep coming up, patterns
2060
+ across conversations, what connects two people/topics, how thinking on a
2061
+ subject evolved, the bigger picture, "map out / cluster …". Use plain
2062
+ ``brain_search`` instead for a flat "find docs about X", a quote, or a
2063
+ single fact from one document. Rule of thumb: *flat answer about content →*
2064
+ ``brain_search``; *relationships / themes / clustering →* this tool. When a
2065
+ person is named and the ask is thematic, ``mode='themes'`` (or
2066
+ ``brain_graphrag_themes``) is the headline move.
2067
+
2068
+ Pick a ``mode`` (the five retrieval strategies):
2069
+
2070
+ - ``auto`` *(default router)* — heuristic: a thematic query WITH a
2071
+ resolvable person → themes; thematic WITHOUT a person → global; otherwise
2072
+ → local. Let it choose when intent is fuzzy.
2073
+ - ``local`` — entity-centric: resolve the seed entity, traverse its bounded
2074
+ ``CO_OCCURS`` neighbourhood, return the seed + reached entities and their
2075
+ docs. "What connects to X." Same core as ``brain_graphrag_entity``.
2076
+ - ``themes`` — *the headline* — "themes in my conversations with X".
2077
+ Requires ``person``. Groups the person's co-occurrence subgraph into
2078
+ ranked theme groups. Same core as ``brain_graphrag_themes``.
2079
+ - ``global`` — community-level RRF over the detected clusters (FTS over
2080
+ community summaries ⊕ vector over summary embeddings). Best for "overall
2081
+ themes in my brain"; build the communities first via
2082
+ ``brain_graphrag_communities_build``.
2083
+ - ``fuse`` — RRF-merge the local-graph doc leg with the vector/FTS hybrid
2084
+ leg into one ranked doc list. Explicit-only (``auto`` never routes here);
2085
+ default tenant only.
2086
+
2087
+ Full parity with ``brain graphrag search``. Returns the
2088
+ :func:`brain.format.graph_context_json` wire shape (identical to the CLI's
2089
+ ``--json`` output), which carries a fresh ``session_id`` (like
2090
+ ``brain_search``) plus ``mode`` / ``query`` / ``tenant_id`` / ``person``,
2091
+ the degradation signals (``requested_mode`` / ``degraded_from`` /
2092
+ ``degradation_reason``), the ranked ``themes`` (themes mode) and
2093
+ ``entities`` (local mode), the document hits (``docs``), and the ranking
2094
+ ``explanation``. In ``global`` mode the JSON carries a top-level
2095
+ ``communities`` key; in ``fuse`` mode per-doc leg provenance rides in
2096
+ ``explanation.matched_filters.fuse_doc_provenance``. Raw Cypher is never
2097
+ accepted or returned — the backend injects the tenant + caps automatically.
2098
+
2099
+ Params (all but ``query`` optional; mirror the CLI flags 1:1):
2100
+
2101
+ - ``mode``: one of the five above (default ``auto``). Under ``auto`` a
2102
+ thematic query with no resolvable person routes to ``global`` (the G3-e
2103
+ flip, spec §17c Q6 — no longer a global→local degradation); an explicit
2104
+ ``global`` EXECUTES (spec §6c). ``fuse`` is explicit-only (wave G4-c,
2105
+ spec §17d Q1).
2106
+ - ``person``: scope themes to this person (resolved via the directory). An
2107
+ unknown / ambiguous person raises ``INVALID_PARAMS``.
2108
+ - ``depth``: traversal depth (default ``BRAIN_GRAPH_DEPTH``).
2109
+ - ``limit``: max documents returned (default 10).
2110
+ - ``tenant``: tenant to query (default ``BRAIN_GRAPH_TENANT``); the backend
2111
+ scopes every query to it — no cross-tenant leak.
2112
+ - ``synthesize``: attach a best-effort local-Ollama summary to each theme
2113
+ group (opt-in; never required for retrieval — a missing/failed Ollama
2114
+ yields ``summary=None``).
2115
+ """
2116
+ state = _get_state()
2117
+ logger.debug(
2118
+ "brain_graphrag_search: query=%r mode=%s person?=%s",
2119
+ query,
2120
+ mode,
2121
+ person is not None,
2122
+ )
2123
+ ctx = _graphrag_search_or_mcp_error(
2124
+ state.cfg,
2125
+ query,
2126
+ mode=mode,
2127
+ tenant=tenant,
2128
+ person=person,
2129
+ depth=depth,
2130
+ limit=limit,
2131
+ synthesize=synthesize,
2132
+ enricher=state.enricher if synthesize else None,
2133
+ # The global (community) path's vector leg embeds the query via this
2134
+ # embedder (spec §17c Q9 — local/themes never use it). Passes the
2135
+ # long-lived server embedder built in :func:`main` directly — no
2136
+ # per-call construction (perf-T4 G5).
2137
+ embedder=state.embedder,
2138
+ )
2139
+ return graph_context_json(ctx)
2140
+
2141
+
2142
+ @mcp_app.tool()
2143
+ def brain_graphrag_themes(
2144
+ person: str,
2145
+ depth: int | None = None,
2146
+ limit: int | None = None,
2147
+ tenant: str | None = None,
2148
+ synthesize: bool = False,
2149
+ ) -> dict[str, Any]:
2150
+ """THE HEADLINE — "themes in my conversations with X" (spec §6b).
2151
+
2152
+ WHEN TO USE: the go-to graph tool whenever the ask is thematic AND names a
2153
+ person — "what themes keep coming up with X", "what do X and I talk about",
2154
+ "patterns in my conversations with X". This is the most common graph route;
2155
+ prefer it over ``brain_graphrag_search`` when a person is explicit. For a
2156
+ thematic ask with NO person, use ``brain_graphrag_search(mode='global')``;
2157
+ for one entity's neighbourhood, ``brain_graphrag_entity``; for a flat doc
2158
+ lookup, plain ``brain_search``.
2159
+
2160
+ Full parity with ``brain graphrag themes`` — a convenience wrapper for
2161
+ ``brain_graphrag_search(mode='themes', person=X)``: scopes to ``person``,
2162
+ groups their co-occurrence subgraph, and returns ranked theme groups (key
2163
+ entities + representative documents) in the
2164
+ :func:`brain.format.graph_context_json` wire shape. ``person`` is REQUIRED;
2165
+ an empty / whitespace-only value raises ``INVALID_PARAMS`` (an unknown or
2166
+ ambiguous person likewise). ``synthesize`` attaches a best-effort per-group
2167
+ local-Ollama summary (opt-in; a missing/failed Ollama yields
2168
+ ``summary=None``).
2169
+ """
2170
+ if not person.strip():
2171
+ raise _mcp_error(
2172
+ INVALID_PARAMS,
2173
+ "person is required for themes mode (the X to scope to)",
2174
+ )
2175
+ state = _get_state()
2176
+ logger.debug("brain_graphrag_themes: person=%r", person)
2177
+ ctx = _graphrag_search_or_mcp_error(
2178
+ state.cfg,
2179
+ "",
2180
+ mode="themes",
2181
+ tenant=tenant,
2182
+ person=person,
2183
+ depth=depth,
2184
+ limit=limit,
2185
+ synthesize=synthesize,
2186
+ enricher=state.enricher if synthesize else None,
2187
+ )
2188
+ return graph_context_json(ctx)
2189
+
2190
+
2191
+ @mcp_app.tool()
2192
+ def brain_graphrag_entity(
2193
+ name: str,
2194
+ depth: int | None = None,
2195
+ limit: int | None = None,
2196
+ tenant: str | None = None,
2197
+ ) -> dict[str, Any]:
2198
+ """One entity's neighbourhood — "what connects to X" (spec §9).
2199
+
2200
+ WHEN TO USE: the ask centres on a SINGLE named entity (a person, project,
2201
+ org, tool, or topic) and you want what it links to — "who/what is related
2202
+ to X", "show me everything around X", "X's neighbourhood". For thematic
2203
+ asks scoped to a person use ``brain_graphrag_themes``; for brain-wide
2204
+ clusters use ``brain_graphrag_search(mode='global')``; to merely ENUMERATE
2205
+ entities (not traverse one) use ``brain_graphrag_entities``.
2206
+
2207
+ Same local (entity-centric) retrieval as ``brain graphrag entity``: a thin
2208
+ wrapper that reuses the SAME path as ``brain_graphrag_search(mode='local')``,
2209
+ resolving the entity seeded on ``name``, traversing its bounded
2210
+ ``CO_OCCURS`` neighbourhood, and returning the seed + reached entities and
2211
+ their documents in the :func:`brain.format.graph_context_json` wire shape.
2212
+ ``name`` is REQUIRED; an empty / whitespace-only value raises
2213
+ ``INVALID_PARAMS``.
2214
+
2215
+ NOTE — ``limit`` semantics differ from the CLI. The CLI's ``-n/--limit`` is a
2216
+ neighbour-render cap (default 30; ``-n 0`` = all) that only trims the human
2217
+ table, leaving documents at the graph default. Here ``limit`` is the
2218
+ max-documents bound passed straight through to local retrieval, and the JSON
2219
+ payload is never neighbour-capped.
2220
+ """
2221
+ if not name.strip():
2222
+ raise _mcp_error(INVALID_PARAMS, "name is required")
2223
+ state = _get_state()
2224
+ logger.debug("brain_graphrag_entity: name=%r", name)
2225
+ ctx = _graphrag_search_or_mcp_error(
2226
+ state.cfg,
2227
+ name,
2228
+ mode="local",
2229
+ tenant=tenant,
2230
+ person=None,
2231
+ depth=depth,
2232
+ limit=limit,
2233
+ synthesize=False,
2234
+ enricher=None,
2235
+ )
2236
+ return graph_context_json(ctx)
2237
+
2238
+
2239
+ @mcp_app.tool()
2240
+ def brain_timeline(
2241
+ query: str,
2242
+ person: str | None = None,
2243
+ granularity: str = "auto",
2244
+ since: str | None = None,
2245
+ until: str | None = None,
2246
+ limit: int = 20,
2247
+ synthesize: bool = False,
2248
+ tenant: str | None = None,
2249
+ ) -> dict[str, Any]:
2250
+ """How a theme or entity evolved over TIME — temporal bucketing (Plan 05).
2251
+
2252
+ WHEN TO USE: the ask is about evolution / chronology — "how has my thinking
2253
+ on X evolved", "when did X come up most", "X over time", "the history of X".
2254
+ For themes/connections at a single point in time use
2255
+ ``brain_graphrag_themes`` / ``brain_graphrag_search``; for a flat doc lookup
2256
+ use plain ``brain_search``.
2257
+
2258
+ Buckets the documents that mention ``query`` by document date
2259
+ (``COALESCE(sent_at, ingested_at)``) and returns the
2260
+ :func:`brain.format.timeline_context_json` wire shape (identical to
2261
+ ``brain timeline --json``): the resolved ``entity_names`` / ``tenant_id`` /
2262
+ ``granularity``, the optional scope ``person``, ``buckets_omitted`` (trimmed
2263
+ by ``limit``), and the ascending ``buckets`` series — each bucket carrying
2264
+ ``doc_count`` / ``mention_count`` / ``doc_ids`` / ``doc_titles`` /
2265
+ ``cotopics`` / ``synthesis``.
2266
+
2267
+ Params (only ``query`` required; mirror the CLI flags 1:1):
2268
+
2269
+ - ``person``: scope to documents where this person co-appears as a
2270
+ participant (resolved via the directory). Unknown / ambiguous →
2271
+ ``INVALID_PARAMS``.
2272
+ - ``granularity``: ``auto`` (default) | ``month`` | ``quarter`` | ``year``.
2273
+ ``auto`` picks the coarsest width yielding >=3 non-empty buckets for the
2274
+ matched docs' date span (else month); an explicit value forces it. The
2275
+ resolved concrete width is returned in ``granularity`` with a
2276
+ ``granularity_auto`` flag.
2277
+ - ``since`` / ``until``: ISO month (``YYYY-MM``) cutoffs; ``since`` inclusive,
2278
+ ``until`` inclusive of the named month.
2279
+ - ``limit``: max buckets returned (default 20); the sparse tail is trimmed
2280
+ per ``BRAIN_TIMELINE_TRIM``.
2281
+ - ``synthesize``: attach a best-effort local-Ollama summary to the densest
2282
+ buckets (opt-in; a missing/failed Ollama yields ``synthesis=None``).
2283
+ - ``tenant``: tenant to query (default ``BRAIN_GRAPH_TENANT``).
2284
+
2285
+ Requires the graph layer (``BRAIN_GRAPH_ENABLED`` + ``brain graphrag
2286
+ build``); when disabled, raises ``INVALID_PARAMS``. An unknown entity returns
2287
+ an empty timeline (not an error). No raw SQL — the query is ILIKE-resolved to
2288
+ graph entities and every clause is parameterized + tenant-scoped.
2289
+ """
2290
+ if not query.strip():
2291
+ raise _mcp_error(INVALID_PARAMS, "query is required")
2292
+ state = _get_state()
2293
+ cfg = state.cfg
2294
+ if not cfg.graph_enabled:
2295
+ raise _mcp_error(
2296
+ INVALID_PARAMS,
2297
+ "timeline requires the graph — set BRAIN_GRAPH_ENABLED=true and run "
2298
+ "`brain graphrag build`",
2299
+ )
2300
+ from .timeline import build_timeline
2301
+
2302
+ logger.debug("brain_timeline: query=%r granularity=%s", query, granularity)
2303
+ enricher = state.enricher if synthesize else None
2304
+ try:
2305
+ with _mcp_conn(state) as conn:
2306
+ ctx = build_timeline(
2307
+ conn,
2308
+ cfg,
2309
+ query,
2310
+ granularity=granularity,
2311
+ since=since,
2312
+ until=until,
2313
+ limit=limit,
2314
+ person=person,
2315
+ synthesize=synthesize,
2316
+ enricher=enricher,
2317
+ tenant=tenant,
2318
+ )
2319
+ except (PersonNotFound, PersonAmbiguous, GraphTenantError) as exc:
2320
+ raise _mcp_error(INVALID_PARAMS, f"timeline: {exc}") from exc
2321
+ except ValueError as exc:
2322
+ raise _mcp_error(INVALID_PARAMS, f"timeline: {exc}") from exc
2323
+ except psycopg.Error as exc:
2324
+ raise _wrap_db_error(exc) from exc
2325
+ return timeline_context_json(ctx)
2326
+
2327
+
2328
+ @mcp_app.tool()
2329
+ def brain_graphrag_build(
2330
+ tenant: str | None = None,
2331
+ concepts: bool = False,
2332
+ backfill: bool = False,
2333
+ force: bool = False,
2334
+ limit: int | None = None,
2335
+ ) -> dict[str, Any]:
2336
+ """ADMIN/SETUP — bulk-build the entity graph from all documents (spec §9).
2337
+
2338
+ WHEN TO USE: a slow, write-side maintenance op — NOT everyday querying.
2339
+ Reach for it only to bootstrap the graph the first time, or to rebuild
2340
+ after ``brain doctor`` reports drift / a missing graph. Once built, query
2341
+ with ``brain_graphrag_search`` / ``_themes`` / ``_entity``. After a
2342
+ corpus-wide weighting change that needs no re-resolve, prefer the lighter
2343
+ ``brain_graphrag_refresh``; to (re)detect clusters for ``mode='global'``
2344
+ use ``brain_graphrag_communities_build``.
2345
+
2346
+ Full parity with ``brain graphrag build``. Walks every document in id order
2347
+ and reconciles its people aspect (and, when ``concepts`` or
2348
+ ``BRAIN_GRAPH_CONCEPTS`` is on, its concept aspect) into the Apache AGE
2349
+ graph, sharing the SAME :class:`ReconcileConfig` as the incremental sync
2350
+ path. Idempotent + resumable via the per-aspect watermark.
2351
+
2352
+ Params (mirror the CLI flags):
2353
+
2354
+ - ``backfill``: reconcile the people aspect of EVERY existing document.
2355
+ Required (pass ``backfill`` or ``force``) — calling with neither raises
2356
+ ``INVALID_PARAMS`` (the MCP equivalent of the CLI's "pass --backfill"
2357
+ hint).
2358
+ - ``force``: authoritative full rebuild — bypass the watermark and
2359
+ re-reconcile every document from the relational source-of-truth (recovery
2360
+ for a dropped / corrupted AGE mirror). Implies ``backfill`` and is
2361
+ incompatible with ``limit`` (rejected with ``INVALID_PARAMS``).
2362
+ - ``concepts``: also (re)build the concept aspect via the local Ollama
2363
+ extractor (explicit per-run opt-in, independent of ``BRAIN_GRAPH_CONCEPTS``).
2364
+ - ``tenant``: tenant to build (default ``BRAIN_GRAPH_TENANT``).
2365
+ - ``limit``: max documents to reconcile (default all).
2366
+
2367
+ Returns the :class:`~brain.graph_rag.build.BuildResult` tally as a dict:
2368
+ ``{processed, reconciled, skipped, orphans_removed, tenant_id, concepts}``.
2369
+ """
2370
+ if force and limit is not None:
2371
+ raise _mcp_error(
2372
+ INVALID_PARAMS,
2373
+ "force rebuilds the full corpus and cannot be combined with limit",
2374
+ )
2375
+ if not (backfill or force):
2376
+ raise _mcp_error(
2377
+ INVALID_PARAMS,
2378
+ "pass backfill=true to reconcile all existing documents into the "
2379
+ "graph (or force=true for an authoritative full rebuild that ignores "
2380
+ "the watermark). Add concepts=true to also build the concept aspect.",
2381
+ )
2382
+ state = _get_state()
2383
+ cfg = state.cfg
2384
+ logger.debug(
2385
+ "brain_graphrag_build: backfill=%s force=%s concepts=%s limit=%s",
2386
+ backfill,
2387
+ force,
2388
+ concepts,
2389
+ limit,
2390
+ )
2391
+ # Concepts run when the param is passed OR the env gate is on (mirrors the
2392
+ # CLI): the param is an explicit per-run opt-in independent of
2393
+ # BRAIN_GRAPH_CONCEPTS, so a caller can build the concept graph on demand
2394
+ # without flipping the ingest-time gate.
2395
+ include_concepts = concepts or cfg.graph_concepts
2396
+ config = _graphrag_reconcile_config(cfg, tenant)
2397
+
2398
+ from .graph_rag.backends import AgeBackend
2399
+ from .graph_rag.build import build_graph
2400
+ from .graph_rag.extract import EntityExtractor, make_extractor
2401
+
2402
+ extractor: EntityExtractor | None = None
2403
+ if include_concepts:
2404
+ config = replace(config, concepts_enabled=True)
2405
+ extractor = make_extractor(cfg)
2406
+
2407
+ try:
2408
+ with connect_age(cfg.database_url) as conn:
2409
+ conn.autocommit = True
2410
+ _require_age_or_mcp_error(conn)
2411
+ backend = AgeBackend()
2412
+ backend.bootstrap(conn)
2413
+ document_ids = (
2414
+ doc_id for batch in iter_all_document_ids(conn) for doc_id in batch
2415
+ )
2416
+ result = build_graph(
2417
+ conn,
2418
+ document_ids,
2419
+ backend=backend,
2420
+ config=config,
2421
+ limit=limit,
2422
+ extractor=extractor,
2423
+ force=force,
2424
+ )
2425
+ except GraphBackendError as exc:
2426
+ raise _wrap_graph_backend_error(exc) from exc
2427
+ except GraphReconcileError as exc:
2428
+ raise _mcp_error(INTERNAL_ERROR, str(exc)) from exc
2429
+ except psycopg.Error as exc:
2430
+ raise _wrap_db_error(exc) from exc
2431
+ return {
2432
+ "processed": result.processed,
2433
+ "reconciled": result.reconciled,
2434
+ "skipped": result.skipped,
2435
+ "orphans_removed": result.orphans_removed,
2436
+ "tenant_id": config.tenant_id,
2437
+ "concepts": include_concepts,
2438
+ }
2439
+
2440
+
2441
+ @mcp_app.tool()
2442
+ def brain_graphrag_refresh(
2443
+ tenant: str | None = None,
2444
+ ) -> dict[str, Any]:
2445
+ """ADMIN — recompute a tenant's aggregate edges (no re-resolve; spec §7/§9).
2446
+
2447
+ WHEN TO USE: a corpus-wide weight/edge recompute that does NOT re-resolve
2448
+ any document's persons — the response to a weighting / suppression knob
2449
+ change (e.g. a new ``BRAIN_GRAPH_GENERIC_DF``) that must propagate to every
2450
+ edge at once. Lighter than a full ``brain_graphrag_build``; NOT everyday
2451
+ querying. It assumes the tenant's entity vertices already exist — run
2452
+ ``brain_graphrag_build(backfill=true)`` first. For a dropped / corrupted AGE
2453
+ mirror (vertices missing) use ``brain_graphrag_build(force=true)`` instead.
2454
+
2455
+ Full parity with ``brain graphrag refresh``: rebuilds every
2456
+ ``graph_relationships`` edge from ``graph_edge_contributions`` (normalized
2457
+ lift + generic suppression), GCs now-orphaned catalog rows, and
2458
+ rematerializes the AGE ``CO_OCCURS`` edges. Idempotent — a second run with
2459
+ the same config converges to the identical graph. Shares the SAME
2460
+ :class:`ReconcileConfig` as the incremental sync + the build path.
2461
+
2462
+ Params:
2463
+
2464
+ - ``tenant``: tenant to refresh (default ``BRAIN_GRAPH_TENANT``).
2465
+
2466
+ Returns ``{tenant_id, relationship_count, orphans_removed}`` — the number of
2467
+ aggregate edges written and now-zero-mention catalog rows GC'd. No raw
2468
+ Cypher is ever returned.
2469
+ """
2470
+ state = _get_state()
2471
+ cfg = state.cfg
2472
+ logger.debug("brain_graphrag_refresh: tenant=%s", tenant)
2473
+ config = _graphrag_reconcile_config(cfg, tenant)
2474
+
2475
+ from .graph_rag.aggregates import refresh_aggregates
2476
+ from .graph_rag.backends import AgeBackend
2477
+
2478
+ try:
2479
+ with connect_age(cfg.database_url) as conn:
2480
+ conn.autocommit = True
2481
+ _require_age_or_mcp_error(conn)
2482
+ backend = AgeBackend()
2483
+ backend.bootstrap(conn)
2484
+ result = refresh_aggregates(conn, backend=backend, config=config)
2485
+ except GraphBackendError as exc:
2486
+ raise _wrap_graph_backend_error(exc) from exc
2487
+ except psycopg.Error as exc:
2488
+ raise _wrap_db_error(exc) from exc
2489
+ return {
2490
+ "tenant_id": config.tenant_id,
2491
+ "relationship_count": result.relationship_count,
2492
+ "orphans_removed": result.orphans_removed,
2493
+ }
2494
+
2495
+
2496
+ # ---------------------------------------------------------------------------
2497
+ # Wave G3-f — GraphRAG communities admin (MCP parity with the CLI
2498
+ # `brain graphrag communities` group). Communities are RELATIONAL-only (spec
2499
+ # §17c Q2/Q3); build/refresh run Louvain + persist + eagerly summarize, list
2500
+ # reads the stored rows. Structured params only; tenant-scoped; NEVER raw Cypher.
2501
+ # ---------------------------------------------------------------------------
2502
+
2503
+
2504
+ def _graphrag_communities_build_or_mcp_error(
2505
+ cfg: Config,
2506
+ *,
2507
+ tenant: str | None,
2508
+ limit: int | None,
2509
+ force: bool,
2510
+ enricher: OllamaEnricher | None,
2511
+ embedder: Embedder | None,
2512
+ ) -> dict[str, Any]:
2513
+ """Build (``force=False``) / refresh (``force=True``) + summarize communities.
2514
+
2515
+ The MCP twin of the CLI's :func:`brain.cli._run_communities_build`: resolves
2516
+ the tenant, runs :func:`build_communities` (dirty-gated unless ``force``),
2517
+ then the best-effort :func:`summarize_communities` (a ``None`` enricher or an
2518
+ unreachable Ollama leaves summaries NULL; a ``None`` embedder still writes
2519
+ summaries and only skips the embeddings — the build always succeeds; spec
2520
+ §17c Q10). Returns ``{tenant_id, build:{…}, summary:{…}}``.
2521
+
2522
+ Error mapping mirrors :func:`_graphrag_search_or_mcp_error`:
2523
+ :class:`GraphTenantError` → ``INTERNAL_ERROR``,
2524
+ :class:`GraphBackendError` → wrapped (class-name only — no Cypher on the
2525
+ wire), :class:`psycopg.Error` → ``INTERNAL_ERROR``.
2526
+ """
2527
+ from .graph_rag.communities import build_communities
2528
+ from .graph_rag.communities_summary import summarize_communities
2529
+ from .graph_rag.tenancy import resolve_tenant
2530
+
2531
+ try:
2532
+ tenant_id = resolve_tenant(cfg, tenant)
2533
+ with connect_age(cfg.database_url) as conn:
2534
+ conn.autocommit = True
2535
+ _require_age_or_mcp_error(conn)
2536
+ build_result = build_communities(conn, cfg, tenant=tenant_id, force=force)
2537
+ summary_result = summarize_communities(
2538
+ conn,
2539
+ cfg,
2540
+ tenant=tenant_id,
2541
+ enricher=enricher,
2542
+ embedder=embedder,
2543
+ limit=limit,
2544
+ )
2545
+ except GraphTenantError as exc:
2546
+ raise _mcp_error(INTERNAL_ERROR, str(exc)) from exc
2547
+ except GraphBackendError as exc:
2548
+ raise _wrap_graph_backend_error(exc) from exc
2549
+ except psycopg.Error as exc:
2550
+ raise _wrap_db_error(exc) from exc
2551
+ return {
2552
+ "tenant_id": tenant_id,
2553
+ "build": {
2554
+ "communities_total": build_result.communities_total,
2555
+ "created": build_result.created,
2556
+ "reused": build_result.reused,
2557
+ "deleted": build_result.deleted,
2558
+ "dirty": build_result.dirty,
2559
+ "skipped": build_result.skipped,
2560
+ },
2561
+ "summary": {
2562
+ "candidates": summary_result.candidates,
2563
+ "summarized": summary_result.summarized,
2564
+ "summary_failures": summary_result.summary_failures,
2565
+ "embedded": summary_result.embedded,
2566
+ "embed_failures": summary_result.embed_failures,
2567
+ "skipped": summary_result.skipped,
2568
+ },
2569
+ }
2570
+
2571
+
2572
+ @mcp_app.tool()
2573
+ def brain_graphrag_communities_build(
2574
+ tenant: str | None = None,
2575
+ limit: int | None = None,
2576
+ force: bool = False,
2577
+ ) -> dict[str, Any]:
2578
+ """ADMIN/SETUP — detect + summarize the tenant's clusters (spec §17c Q3).
2579
+
2580
+ WHEN TO USE: a slow, write-side prerequisite for ``mode='global'`` (and for
2581
+ ``brain_graphrag_communities`` to have rows to list) — NOT everyday
2582
+ querying. Run it once after a ``brain_graphrag_build``, then re-run only
2583
+ when ``brain doctor`` flags stale communities. To force a rebuild past the
2584
+ dirty gate after a corpus-wide weighting change, use ``force=True`` here or
2585
+ the sibling ``brain_graphrag_communities_refresh``.
2586
+
2587
+ Full parity with ``brain graphrag communities build`` / ``… refresh``: runs
2588
+ Louvain over the tenant's relational ``graph_relationships`` edges, persists
2589
+ the partition to ``graph_communities`` / ``graph_community_members``, then
2590
+ EAGERLY (best-effort) summarizes + embeds each community via the server's
2591
+ enricher + embedder. A missing/unreachable Ollama leaves summaries NULL and
2592
+ the build still succeeds (the global path then ranks FTS-only).
2593
+
2594
+ Params (mirror the CLI flags):
2595
+
2596
+ - ``force``: bypass the ``(build_version, source_graph_hash)`` dirty gate and
2597
+ rebuild even when the graph is unchanged (the ``communities refresh``
2598
+ equivalent). Default ``False`` skips an unchanged graph.
2599
+ - ``tenant``: tenant to build (default ``BRAIN_GRAPH_TENANT``).
2600
+ - ``limit``: max stale/new communities to (re)summarize this run (does NOT
2601
+ cap detection — Louvain always partitions the full edge set).
2602
+
2603
+ Returns ``{tenant_id, build:{communities_total, created, reused, deleted,
2604
+ dirty, skipped}, summary:{candidates, summarized, summary_failures, embedded,
2605
+ embed_failures, skipped}}``. No raw Cypher is ever returned.
2606
+ """
2607
+ state = _get_state()
2608
+ logger.debug(
2609
+ "brain_graphrag_communities_build: force=%s tenant=%s limit=%s",
2610
+ force,
2611
+ tenant,
2612
+ limit,
2613
+ )
2614
+ return _graphrag_communities_build_or_mcp_error(
2615
+ state.cfg,
2616
+ tenant=tenant,
2617
+ limit=limit,
2618
+ force=force,
2619
+ enricher=state.enricher,
2620
+ embedder=state.embedder,
2621
+ )
2622
+
2623
+
2624
+ @mcp_app.tool()
2625
+ def brain_graphrag_communities_refresh(
2626
+ tenant: str | None = None,
2627
+ limit: int | None = None,
2628
+ ) -> dict[str, Any]:
2629
+ """ADMIN — force a community rebuild past the dirty gate (spec §17c Q3).
2630
+
2631
+ WHEN TO USE: identical to ``brain_graphrag_communities_build`` except it
2632
+ BYPASSES the ``(build_version, source_graph_hash)`` dirty gate — Louvain +
2633
+ the relational replace always run, then the eager (best-effort)
2634
+ summary/embedding pass. Reach for it after a corpus-wide weighting /
2635
+ suppression change (or a knob change that should re-partition an otherwise
2636
+ unchanged graph); for a routine "build if stale" pass, use the plain
2637
+ ``brain_graphrag_communities_build`` (which skips an unchanged graph). NOT
2638
+ everyday querying.
2639
+
2640
+ Full parity with ``brain graphrag communities refresh``: the MCP twin of
2641
+ ``brain_graphrag_communities_build(force=true)`` — same Louvain detection,
2642
+ relational persistence, and eager summary/embedding pass; a
2643
+ missing/unreachable Ollama leaves summaries NULL and the rebuild still
2644
+ succeeds (the global path then ranks FTS-only).
2645
+
2646
+ Params (mirror the CLI flags):
2647
+
2648
+ - ``tenant``: tenant to refresh (default ``BRAIN_GRAPH_TENANT``).
2649
+ - ``limit``: max stale/new communities to (re)summarize this run (does NOT
2650
+ cap detection — Louvain always partitions the full edge set).
2651
+
2652
+ Returns the same ``{tenant_id, build:{…}, summary:{…}}`` shape as
2653
+ ``brain_graphrag_communities_build``. No raw Cypher is ever returned.
2654
+ """
2655
+ state = _get_state()
2656
+ logger.debug(
2657
+ "brain_graphrag_communities_refresh: tenant=%s limit=%s", tenant, limit
2658
+ )
2659
+ return _graphrag_communities_build_or_mcp_error(
2660
+ state.cfg,
2661
+ tenant=tenant,
2662
+ limit=limit,
2663
+ force=True,
2664
+ enricher=state.enricher,
2665
+ embedder=state.embedder,
2666
+ )
2667
+
2668
+
2669
+ @mcp_app.tool()
2670
+ def brain_graphrag_communities(
2671
+ tenant: str | None = None,
2672
+ limit: int | None = None,
2673
+ ) -> dict[str, Any]:
2674
+ """List the tenant's materialized community clusters (spec §17c Q3).
2675
+
2676
+ WHEN TO USE: an admin/overview read of the brain's top-level clusters —
2677
+ "what are the big clusters / themes in my brain" at a glance, or to confirm
2678
+ communities exist before a ``mode='global'`` search. Read-only and fast (no
2679
+ detection). For the actual ranked global retrieval use
2680
+ ``brain_graphrag_search(mode='global')``; to (re)build the clusters first
2681
+ use ``brain_graphrag_communities_build``.
2682
+
2683
+ Full parity with ``brain graphrag communities list``: reads the stored
2684
+ ``graph_communities`` rows (largest-first, ``limit`` capped) and returns
2685
+ ``{tenant_id, count, communities:[{community_key, level, build_version,
2686
+ member_count, edge_count, total_weight, summary, summary_model,
2687
+ summary_at}]}``. Read-only; the raw ``summary_embedding`` vector is omitted
2688
+ and no raw Cypher is ever returned.
2689
+
2690
+ Params:
2691
+
2692
+ - ``tenant``: tenant to list (default ``BRAIN_GRAPH_TENANT``).
2693
+ - ``limit``: max communities to return (default all).
2694
+ """
2695
+ from .graph_rag.communities import list_communities
2696
+ from .graph_rag.tenancy import resolve_tenant
2697
+
2698
+ state = _get_state()
2699
+ logger.debug("brain_graphrag_communities: tenant=%s limit=%s", tenant, limit)
2700
+ try:
2701
+ tenant_id = resolve_tenant(state.cfg, tenant)
2702
+ with connect_age(state.cfg.database_url) as conn:
2703
+ conn.autocommit = True
2704
+ _require_age_or_mcp_error(conn)
2705
+ records = list_communities(conn, tenant_id, limit=limit)
2706
+ except GraphTenantError as exc:
2707
+ raise _mcp_error(INTERNAL_ERROR, str(exc)) from exc
2708
+ except psycopg.Error as exc:
2709
+ raise _wrap_db_error(exc) from exc
2710
+ return {
2711
+ "tenant_id": tenant_id,
2712
+ "count": len(records),
2713
+ "communities": [community_record_json(r) for r in records],
2714
+ }
2715
+
2716
+
2717
+ # ---------------------------------------------------------------------------
2718
+ # Wave C4 — brain_graphrag_aliases_apply (MCP twin of `brain graphrag aliases
2719
+ # apply`). Single admin tool that mirrors the CLI: load rules from the
2720
+ # server-configured BRAIN_GRAPH_ALIASES_PATH, run merge_aliases atomically,
2721
+ # return the structured tally. Structured params only; tenant-scoped; NEVER
2722
+ # raw Cypher; PII-safe — only counts + tenant id reach the wire.
2723
+ # ---------------------------------------------------------------------------
2724
+
2725
+
2726
+ @mcp_app.tool()
2727
+ def brain_graphrag_aliases_apply(
2728
+ dry_run: bool = False,
2729
+ tenant: str | None = None,
2730
+ ) -> dict[str, Any]:
2731
+ """ADMIN — apply curated entity alias/merge rules to the graph (spec §9).
2732
+
2733
+ WHEN TO USE: a one-shot administrative cleanup that folds known variant
2734
+ entities (acronym variants, abbreviation duplicates, a topic that is
2735
+ really a known person) into their canonical entity. NOT everyday querying.
2736
+ Reach for it after curating ``BRAIN_GRAPH_ALIASES_PATH`` and before / after
2737
+ a ``brain_graphrag_build`` when the build's auto-apply path was skipped.
2738
+
2739
+ Full parity with ``brain graphrag aliases apply``: re-points every source
2740
+ entity's mentions + contributions onto its target, provisions any
2741
+ newly-created target AGE vertices, then refreshes aggregates so the
2742
+ GC + AGE-detach + ``CO_OCCURS`` rebuild reflects the merges. Atomic per
2743
+ :func:`brain.graph_rag.aliases.merge_aliases` — a failure rolls the whole
2744
+ alias work back without disturbing other tenant data. ``dry_run=True``
2745
+ rolls the transaction back and reports what WOULD have moved (no writes).
2746
+
2747
+ A missing / empty rules file is a clean no-op: the returned payload has
2748
+ ``rules_total=0`` and ``rules_applied=0`` and
2749
+ ``communities_refresh_recommended`` is ``False``. After a non-dry, non-
2750
+ empty apply ``communities_refresh_recommended`` is ``True`` — the caller
2751
+ SHOULD run ``brain_graphrag_communities_refresh`` (community membership
2752
+ can drift across merges; spec §17c Q3).
2753
+
2754
+ Params (mirror the CLI flags):
2755
+
2756
+ - ``dry_run``: report would-be moves without writing (default ``False``).
2757
+ - ``tenant``: tenant to apply rules to (default ``BRAIN_GRAPH_TENANT``).
2758
+
2759
+ Returns ``{tenant_id, rules_total, rules_applied, mentions_repointed,
2760
+ contributions_repointed, sources_orphaned, dry_run,
2761
+ communities_refresh_recommended}`` — all 7 :class:`AliasResult` fields
2762
+ plus the staleness hint. No raw Cypher / SQL / real entity name is ever
2763
+ returned (error/log messages only redact-prefix rule keys; F5).
2764
+ """
2765
+ state = _get_state()
2766
+ cfg = state.cfg
2767
+ logger.debug("brain_graphrag_aliases_apply: dry_run=%s tenant=%s", dry_run, tenant)
2768
+ config = _graphrag_reconcile_config(cfg, tenant)
2769
+
2770
+ from .graph_rag.aliases import load_alias_rules, merge_aliases
2771
+ from .graph_rag.backends import AgeBackend
2772
+
2773
+ try:
2774
+ alias_rules = load_alias_rules(cfg.graph_aliases_path)
2775
+ except GraphReconcileError as exc:
2776
+ # Caller-fixable: malformed alias YAML — chained, but message only
2777
+ # carries the validator's redacted key + rule type (F5), never the
2778
+ # full real mapping.
2779
+ raise _mcp_error(INVALID_PARAMS, str(exc)) from exc
2780
+
2781
+ if not alias_rules:
2782
+ # Empty/missing file is the opt-out: same shape as a real apply with
2783
+ # zero rules, plus the staleness hint cleared.
2784
+ payload = {
2785
+ "tenant_id": config.tenant_id,
2786
+ "rules_total": 0,
2787
+ "rules_applied": 0,
2788
+ "mentions_repointed": 0,
2789
+ "contributions_repointed": 0,
2790
+ "sources_orphaned": 0,
2791
+ "dry_run": dry_run,
2792
+ "communities_refresh_recommended": False,
2793
+ }
2794
+ return payload
2795
+
2796
+ try:
2797
+ with connect_age(cfg.database_url) as conn:
2798
+ conn.autocommit = True
2799
+ _require_age_or_mcp_error(conn)
2800
+ backend = AgeBackend()
2801
+ backend.bootstrap(conn)
2802
+ res = merge_aliases(
2803
+ conn,
2804
+ config.tenant_id,
2805
+ alias_rules,
2806
+ backend,
2807
+ dry_run=dry_run,
2808
+ config=config,
2809
+ )
2810
+ except GraphBackendError as exc:
2811
+ raise _wrap_graph_backend_error(exc) from exc
2812
+ except GraphReconcileError as exc:
2813
+ raise _mcp_error(INTERNAL_ERROR, str(exc)) from exc
2814
+ except psycopg.Error as exc:
2815
+ raise _wrap_db_error(exc) from exc
2816
+
2817
+ payload = alias_result_json(res)
2818
+ payload["communities_refresh_recommended"] = (
2819
+ not res.dry_run and res.rules_applied > 0
2820
+ )
2821
+ return payload
2822
+
2823
+
2824
+ @mcp_app.tool()
2825
+ def brain_graphrag_entities(
2826
+ entity_type: str | None = None,
2827
+ sort: str = "docs",
2828
+ limit: int = 50,
2829
+ tenant: str | None = None,
2830
+ ) -> dict[str, Any]:
2831
+ """ENUMERATE the entities in the graph — "what's in my brain" (admin view).
2832
+
2833
+ WHEN TO USE: the ask is to LIST / inventory entities — "what orgs (or
2834
+ people / projects / topics / tools) are in my brain", "list all projects",
2835
+ "what topics do I have". Distinct from ``brain_graphrag_entity`` (singular),
2836
+ which traverses ONE entity's neighbourhood: this one just enumerates and
2837
+ filters by type. For a one-line size overview use ``brain_graphrag_stats``.
2838
+
2839
+ Full parity with ``brain graphrag entities``: reads ``graph_entities``
2840
+ rows (filtered, sorted, ``limit``-capped) and returns
2841
+ ``{tenant_id, count, entities:[{entity_type, name, canonical_key,
2842
+ doc_count, description}]}``. Read-only; no raw Cypher.
2843
+
2844
+ Params:
2845
+
2846
+ - ``entity_type``: filter to one type — ``org`` / ``project`` / ``tool`` /
2847
+ ``topic`` / ``person`` (default all).
2848
+ - ``sort``: ``"docs"`` (doc_count DESC, default) or ``"name"`` (name ASC).
2849
+ - ``limit``: max entities to return (default 50; 0 = all).
2850
+ - ``tenant``: tenant to list (default ``BRAIN_GRAPH_TENANT``).
2851
+ """
2852
+ from .graph_rag.relational import list_entities
2853
+ from .graph_rag.tenancy import resolve_tenant
2854
+
2855
+ state = _get_state()
2856
+ logger.debug(
2857
+ "brain_graphrag_entities: entity_type=%s sort=%s limit=%s tenant=%s",
2858
+ entity_type,
2859
+ sort,
2860
+ limit,
2861
+ tenant,
2862
+ )
2863
+ try:
2864
+ tenant_id = resolve_tenant(state.cfg, tenant)
2865
+ with connect_age(state.cfg.database_url) as conn:
2866
+ conn.autocommit = True
2867
+ _require_age_or_mcp_error(conn)
2868
+ rows = list_entities(
2869
+ conn, tenant_id, entity_type=entity_type, sort=sort, limit=limit
2870
+ )
2871
+ except GraphTenantError as exc:
2872
+ raise _mcp_error(INTERNAL_ERROR, str(exc)) from exc
2873
+ # Safe to surface str(exc): list_entities raises GraphBackendError only from
2874
+ # its own input-validation guards on a pure-relational path (no AGE/Cypher to
2875
+ # leak per spec §4 D9). If this path ever raises GraphBackendError from an
2876
+ # AGE/DB failure, switch to _wrap_db_error() instead.
2877
+ except GraphBackendError as exc:
2878
+ raise _mcp_error(INTERNAL_ERROR, str(exc)) from exc
2879
+ except psycopg.Error as exc:
2880
+ raise _wrap_db_error(exc) from exc
2881
+ return {
2882
+ "tenant_id": tenant_id,
2883
+ "count": len(rows),
2884
+ "entities": [entity_summaries_json(r) for r in rows],
2885
+ }
2886
+
2887
+
2888
+ @mcp_app.tool()
2889
+ def brain_graphrag_stats(
2890
+ tenant: str | None = None,
2891
+ ) -> dict[str, Any]:
2892
+ """OVERVIEW — how big is the graph, at a glance (the graph's "status").
2893
+
2894
+ WHEN TO USE: the ask is about the graph's SIZE / shape — "how big is my
2895
+ graph", "how many entities/relationships/communities do I have", "is the
2896
+ graph built / worth querying". One fast read-only roll-up. To then list the
2897
+ entities themselves use ``brain_graphrag_entities``; to list clusters use
2898
+ ``brain_graphrag_communities``.
2899
+
2900
+ Full parity with ``brain graphrag stats``: reads entity counts by type,
2901
+ total relationships, total communities, and the top-10 entities by
2902
+ doc_count from the relational tables. Returns ``{tenant_id,
2903
+ counts_by_type, total_entities, total_relationships, total_communities,
2904
+ top_entities:[…]}``. Read-only; no raw Cypher.
2905
+
2906
+ Params:
2907
+
2908
+ - ``tenant``: tenant to summarize (default ``BRAIN_GRAPH_TENANT``).
2909
+ """
2910
+ from .graph_rag.relational import graph_stats
2911
+ from .graph_rag.tenancy import resolve_tenant
2912
+
2913
+ state = _get_state()
2914
+ logger.debug("brain_graphrag_stats: tenant=%s", tenant)
2915
+ try:
2916
+ tenant_id = resolve_tenant(state.cfg, tenant)
2917
+ with connect_age(state.cfg.database_url) as conn:
2918
+ conn.autocommit = True
2919
+ _require_age_or_mcp_error(conn)
2920
+ stats = graph_stats(conn, tenant_id)
2921
+ except GraphTenantError as exc:
2922
+ raise _mcp_error(INTERNAL_ERROR, str(exc)) from exc
2923
+ except psycopg.Error as exc:
2924
+ raise _wrap_db_error(exc) from exc
2925
+ return {"tenant_id": tenant_id, **graph_stats_json(stats)}
2926
+
2927
+
2928
+ # ---------------------------------------------------------------------------
2929
+ # Feedback (G4-b parity, spec §17d Q2) — the MCP counterpart of the CLI's
2930
+ # `brain rate`. Closes the parity gap noted in the T3 rate audit: before this
2931
+ # tool the graph feedback path (entity/community/theme ratings) was reachable
2932
+ # ONLY from the CLI. Mirrors the CLI's local tuple rather than importing the
2933
+ # writer's private `brain.interactions._VALID_TARGET_TYPES` set.
2934
+ # ---------------------------------------------------------------------------
2935
+ _RATE_TARGET_TYPES = ("entity", "community", "theme")
2936
+
2937
+
2938
+ @mcp_app.tool()
2939
+ def brain_rate(
2940
+ id: str,
2941
+ verdict: str,
2942
+ target_type: str | None = None,
2943
+ graph_retrieved: bool = False,
2944
+ ) -> dict[str, Any]:
2945
+ """Record a thumbs-up / thumbs-down on a document OR a graph target.
2946
+
2947
+ WHEN TO USE: the user reacts to a specific result — "that one was useful" /
2948
+ "that's irrelevant". Works for BOTH a document (the default) and a graph
2949
+ target (an entity / community / theme), so graph retrieval feedback is
2950
+ reachable here, not just from the CLI. Graph retrieval tools
2951
+ (``brain_graphrag_*``) deliberately do NOT auto-log feedback at retrieval
2952
+ time — only this explicit user action does. (Document *opens* are logged
2953
+ separately by ``brain_show(originating_query=…)``.)
2954
+
2955
+ Full parity with ``brain rate``. Persists one append-only row to the
2956
+ ``interactions`` table (``action='rated_useful'`` or ``'rated_irrelevant'``,
2957
+ ``source='mcp'``, ``session_id=NULL``). Ratings APPEND every call — re-rating
2958
+ the same target adds a new row with a fresh timestamp; the full history is
2959
+ preserved.
2960
+
2961
+ Two mutually-exclusive target shapes (the XOR is enforced by
2962
+ ``record_interaction`` + the SQL CHECK):
2963
+
2964
+ - Document (default — leave ``target_type`` unset): ``id`` is a document id
2965
+ prefix (6+ hex chars), resolved to a document. A too-short / non-hex /
2966
+ unknown / ambiguous prefix raises ``INVALID_PARAMS``.
2967
+ - Graph target (``target_type`` set): ``id`` is the durable graph-target id
2968
+ (entity UUID / community key / theme key) and is NOT resolved as a
2969
+ document; ``document_id`` stays NULL.
2970
+
2971
+ Params:
2972
+
2973
+ - ``id``: the document id prefix, or — when ``target_type`` is set — the
2974
+ graph target's id.
2975
+ - ``verdict``: ``'useful'`` or ``'irrelevant'`` (anything else →
2976
+ ``INVALID_PARAMS``).
2977
+ - ``target_type``: ``None`` (document, default) or one of
2978
+ ``'entity'`` / ``'community'`` / ``'theme'`` (anything else →
2979
+ ``INVALID_PARAMS``).
2980
+ - ``graph_retrieved``: provenance flag — set ``true`` when this rating came
2981
+ from a graph surface. Orthogonal to the target shape: a document rated via
2982
+ a graph path is still a document row with ``graph_retrieved=true``.
2983
+
2984
+ Returns ``{interaction_id, action, graph_retrieved}`` plus ``document_id``
2985
+ (document shape) or ``target_type`` + ``target_id`` (graph shape). Unlike
2986
+ ``brain_show``'s best-effort open logging, a persistence failure here is
2987
+ surfaced as an MCP error — recording the rating IS this tool's job.
2988
+ """
2989
+ if verdict not in {"useful", "irrelevant"}:
2990
+ raise _mcp_error(
2991
+ INVALID_PARAMS, "verdict must be 'useful' or 'irrelevant'"
2992
+ )
2993
+ action: InteractionAction = (
2994
+ "rated_useful" if verdict == "useful" else "rated_irrelevant"
2995
+ )
2996
+ if target_type is not None and target_type not in _RATE_TARGET_TYPES:
2997
+ raise _mcp_error(
2998
+ INVALID_PARAMS,
2999
+ "target_type must be one of: " + ", ".join(_RATE_TARGET_TYPES),
3000
+ )
3001
+ state = _get_state()
3002
+ logger.debug(
3003
+ "brain_rate: target_type=%s graph_retrieved=%s", target_type, graph_retrieved
3004
+ )
3005
+ try:
3006
+ with _mcp_conn(state) as conn:
3007
+ conn.autocommit = True
3008
+ if target_type is not None:
3009
+ # Graph-target rating: ``id`` is the durable target id, NOT a
3010
+ # document prefix — skip _resolve_id; document_id stays NULL.
3011
+ # Validated against _RATE_TARGET_TYPES above → safe to narrow to
3012
+ # the writer's Literal for the static checker.
3013
+ narrowed: InteractionTargetType = target_type # type: ignore[assignment]
3014
+ new_id = record_interaction(
3015
+ conn,
3016
+ action=action,
3017
+ source="mcp",
3018
+ target_type=narrowed,
3019
+ target_id=id,
3020
+ graph_retrieved=graph_retrieved,
3021
+ )
3022
+ return {
3023
+ "interaction_id": new_id,
3024
+ "action": action,
3025
+ "target_type": target_type,
3026
+ "target_id": id,
3027
+ "graph_retrieved": graph_retrieved,
3028
+ }
3029
+ # Document rating (the default shape).
3030
+ doc_id = _resolve_id(conn, id)
3031
+ new_id = record_interaction(
3032
+ conn,
3033
+ document_id=doc_id,
3034
+ action=action,
3035
+ source="mcp",
3036
+ graph_retrieved=graph_retrieved,
3037
+ )
3038
+ except InteractionError as exc:
3039
+ # Shape / enum guard tripped (caller-fixable) → INVALID_PARAMS.
3040
+ raise _mcp_error(INVALID_PARAMS, str(exc)) from exc
3041
+ except psycopg.Error as exc:
3042
+ raise _wrap_db_error(exc) from exc
3043
+ return {
3044
+ "interaction_id": new_id,
3045
+ "action": action,
3046
+ "document_id": doc_id,
3047
+ "graph_retrieved": graph_retrieved,
3048
+ }
3049
+
3050
+
3051
+ def _build_note_file_text(
3052
+ *,
3053
+ body: str,
3054
+ template_text: str,
3055
+ title: str,
3056
+ document_id: str,
3057
+ tags: list[str],
3058
+ iso_now: str,
3059
+ date_iso: str,
3060
+ slug: str,
3061
+ ) -> str:
3062
+ """Assemble the on-disk file text for ``brain_note_new`` / ``brain_daily``.
3063
+
3064
+ Mirrors :func:`brain.vault.note_builder._build_note_text`'s contract: brain-managed
3065
+ frontmatter fields (``id`` / ``title`` / ``created`` / ``updated`` /
3066
+ ``kind`` / ``tags``) always win over whatever the template author
3067
+ wrote; the template's body is preserved (and substituted with
3068
+ ``{{title}}`` / ``{{date}}`` / ``{{datetime}}`` / ``{{slug}}``).
3069
+
3070
+ The user-supplied ``body`` (from ``brain_note_new``) is prepended
3071
+ above the template's body so MCP-authored notes get the user's
3072
+ content first, then any boilerplate from the template (``daily.md``
3073
+ sections, etc.). Pass ``body=""`` to use only the template.
3074
+ """
3075
+ if template_text:
3076
+ rendered = render_template(
3077
+ template_text,
3078
+ {
3079
+ "title": title,
3080
+ "date": date_iso,
3081
+ "datetime": iso_now,
3082
+ "slug": slug,
3083
+ },
3084
+ )
3085
+ try:
3086
+ existing_fields, template_body = parse_frontmatter(rendered)
3087
+ except (ValueError, yaml.YAMLError):
3088
+ existing_fields = {}
3089
+ template_body = rendered
3090
+ else:
3091
+ existing_fields = {}
3092
+ template_body = ""
3093
+
3094
+ if body and template_body:
3095
+ # User content first, then a blank line, then the template body.
3096
+ combined_body = f"{body.rstrip()}\n\n{template_body.lstrip()}"
3097
+ else:
3098
+ combined_body = body or template_body
3099
+
3100
+ fields: dict[str, Any] = dict(existing_fields)
3101
+ fields["id"] = document_id
3102
+ fields["title"] = title
3103
+ fields["created"] = iso_now
3104
+ fields["updated"] = iso_now
3105
+ fields["kind"] = "vault"
3106
+ if tags:
3107
+ fields["tags"] = list(tags)
3108
+ elif "tags" not in fields:
3109
+ fields["tags"] = []
3110
+
3111
+ return dump_frontmatter(fields, combined_body)
3112
+
3113
+
3114
+ def _resolve_proposal_dst(
3115
+ conn: psycopg.Connection[Any], value: str
3116
+ ) -> tuple[str, str, str]:
3117
+ """Resolve ``value`` to ``(document_id, title, link_text)`` for proposals.
3118
+
3119
+ Resolution order:
3120
+
3121
+ - 6+ chars and all hex (digits + ``a-f`` + hyphens) → id-prefix lookup
3122
+ against ``documents.id``. On match, ``link_text`` falls back to
3123
+ ``brain:<short-id>`` so the resulting ``[[brain:...]]`` is
3124
+ unambiguous.
3125
+ - Otherwise → case-insensitive exact match on ``documents.title``.
3126
+ ``link_text`` is the dst's title verbatim (preserving the user's
3127
+ capitalization).
3128
+
3129
+ Ambiguity (multiple title matches) raises ``INVALID_PARAMS`` with
3130
+ the candidate ids included in the error message.
3131
+ """
3132
+ cleaned = value.strip()
3133
+ if (
3134
+ len(cleaned) >= _ID_PREFIX_MIN_LEN
3135
+ and _HEX_ONLY_RE.match(cleaned.lower()) is not None
3136
+ ):
3137
+ rows = conn.execute(
3138
+ "SELECT id::text, title FROM documents "
3139
+ "WHERE id::text LIKE %s LIMIT 2",
3140
+ (cleaned.lower() + "%",),
3141
+ ).fetchall()
3142
+ if len(rows) == 1:
3143
+ dst_id = str(rows[0][0])
3144
+ dst_title = str(rows[0][1])
3145
+ return dst_id, dst_title, f"brain:{dst_id[:8]}"
3146
+ if len(rows) > 1:
3147
+ raise _mcp_error(
3148
+ INVALID_PARAMS,
3149
+ f"id-prefix {cleaned!r} is ambiguous; provide more characters",
3150
+ )
3151
+ # Zero hex matches → fall through to title resolution; users may
3152
+ # legitimately have a title that happens to look like hex.
3153
+ rows = conn.execute(
3154
+ "SELECT id::text, title FROM documents "
3155
+ "WHERE LOWER(title) = LOWER(%s) LIMIT 5",
3156
+ (cleaned,),
3157
+ ).fetchall()
3158
+ if not rows:
3159
+ raise _mcp_error(
3160
+ INVALID_PARAMS, f"no document found matching {cleaned!r}"
3161
+ )
3162
+ if len(rows) > 1:
3163
+ candidates = ", ".join(str(r[0])[:8] for r in rows)
3164
+ raise _mcp_error(
3165
+ INVALID_PARAMS,
3166
+ f"title {cleaned!r} is ambiguous (candidates: {candidates}); "
3167
+ "use a 6+ hex id prefix to disambiguate",
3168
+ )
3169
+ dst_id = str(rows[0][0])
3170
+ dst_title = str(rows[0][1])
3171
+ return dst_id, dst_title, dst_title
3172
+
3173
+
3174
+ def _warmup_embed(embedder: Embedder) -> None:
3175
+ """Fire a single embed call so the model is in VRAM before the first query.
3176
+
3177
+ Retries once after :data:`_WARMUP_RETRY_DELAY_SECONDS` to cover the
3178
+ cold-boot race where the Ollama daemon is up but the model is still
3179
+ loading. Persistent failure is logged at WARNING and swallowed — the
3180
+ server stays up and real tool calls surface errors through the normal
3181
+ ``_wrap_embed_error`` path. Only the shared
3182
+ :class:`~brain.errors.EmbedError` base is caught (so any backend's typed
3183
+ embed failure is tolerated) while import / programming errors still surface
3184
+ here.
3185
+ """
3186
+ try:
3187
+ embedder.embed(["hello"], input_type="document")
3188
+ logger.info("warmup embed completed")
3189
+ except EmbedError:
3190
+ time.sleep(_WARMUP_RETRY_DELAY_SECONDS)
3191
+ try:
3192
+ embedder.embed(["hello"], input_type="document")
3193
+ logger.info("warmup embed completed (after retry)")
3194
+ except EmbedError as e:
3195
+ logger.warning(
3196
+ "warmup embed failed (continuing without): %s", type(e).__name__
3197
+ )
3198
+
3199
+
3200
+ def _resolve_suggestion(conn: psycopg.Connection[Any], prefix: str) -> str:
3201
+ """Resolve a suggestion-id prefix to a full id, mapping errors to McpError."""
3202
+ try:
3203
+ return connect_mod.resolve_suggestion_prefix(conn, prefix)
3204
+ except ConnectError as e:
3205
+ raise _mcp_error(INVALID_PARAMS, str(e)) from e
3206
+
3207
+
3208
+ @mcp_app.tool()
3209
+ def brain_connect_list(
3210
+ limit: int = 20,
3211
+ status: str = "pending",
3212
+ ) -> list[dict[str, Any]]:
3213
+ """List proactive auto-link suggestions (Plan 07 `brain connect`).
3214
+
3215
+ WHEN TO USE: surface note pairs that share entities / semantics but aren't
3216
+ linked yet, so the user (or you) can accept or reject them. Mirrors
3217
+ ``brain connect list --json``.
3218
+
3219
+ ``status`` filters to ``'pending'`` (default), ``'accepted'``, or
3220
+ ``'rejected'``; pass ``'all'`` for every status. Results are ordered by
3221
+ blended score descending. ``graph_score`` / ``embed_score`` are the raw
3222
+ per-leg signals (``null`` when the pair came from only one leg); ``score``
3223
+ is the RRF blend.
3224
+ """
3225
+ state = _get_state()
3226
+ logger.debug("brain_connect_list: status=%s limit=%d", status, limit)
3227
+ if status not in ("pending", "accepted", "rejected", "all"):
3228
+ # Reject typos loudly — a silent empty list reads as "queue is empty"
3229
+ # rather than "you passed a bad status" (Codex R1 #3).
3230
+ raise _mcp_error(
3231
+ INVALID_PARAMS,
3232
+ "status must be one of: pending, accepted, rejected, all "
3233
+ f"(got {status!r})",
3234
+ )
3235
+ effective_status = None if status == "all" else status
3236
+ try:
3237
+ with _mcp_conn(state) as conn:
3238
+ rows = connect_mod.iter_suggestions(
3239
+ conn, status=effective_status, limit=limit
3240
+ )
3241
+ except psycopg.Error as e:
3242
+ raise _wrap_db_error(e) from e
3243
+ return [
3244
+ {
3245
+ "id": r.id,
3246
+ "source_title": r.source_title,
3247
+ "target_title": r.target_title,
3248
+ "score": round(r.score, 6),
3249
+ "graph_score": None if r.graph_score is None else round(r.graph_score, 6),
3250
+ "embed_score": None if r.embed_score is None else round(r.embed_score, 6),
3251
+ }
3252
+ for r in rows
3253
+ ]
3254
+
3255
+
3256
+ @mcp_app.tool()
3257
+ def brain_connect_accept(id: str, write: bool = False) -> dict[str, Any]:
3258
+ """Accept an auto-link suggestion; with ``write=True`` insert the wikilink.
3259
+
3260
+ WHEN TO USE: the user agrees two notes should be linked. Flips the
3261
+ suggestion to ``accepted`` (frozen — never re-proposed on refresh). With
3262
+ ``write=True`` the path-form wikilink is appended (idempotently) under a
3263
+ ``## See Also`` section in the source doc's vault file.
3264
+
3265
+ ``id`` is a suggestion-id prefix (6+ hex chars). Returns
3266
+ ``{status: "accepted", wikilink_written: bool}`` — ``wikilink_written`` is
3267
+ ``False`` when ``write`` is omitted or the link was already present. A
3268
+ too-short / unknown / ambiguous id, or a ``write`` request where the
3269
+ source/target vault path is missing, raises ``INVALID_PARAMS``.
3270
+ """
3271
+ state = _get_state()
3272
+ logger.debug("brain_connect_accept: write=%s", write)
3273
+ try:
3274
+ with _mcp_conn(state) as conn:
3275
+ conn.autocommit = True
3276
+ resolved = _resolve_suggestion(conn, id)
3277
+ try:
3278
+ ctx = connect_mod.load_action_context(conn, resolved)
3279
+ except ConnectError as e:
3280
+ raise _mcp_error(INVALID_PARAMS, str(e)) from e
3281
+ # Write the wikilink FIRST so a write failure (missing path / IO
3282
+ # error) leaves the suggestion pending, not frozen accepted-without
3283
+ # -link (Codex R1 #1).
3284
+ wikilink_written = (
3285
+ _connect_write_wikilink(state.cfg, ctx) if write else False
3286
+ )
3287
+ try:
3288
+ connect_mod.set_suggestion_status(conn, resolved, "accepted")
3289
+ except ConnectError as e:
3290
+ raise _mcp_error(INVALID_PARAMS, str(e)) from e
3291
+ except psycopg.Error as e:
3292
+ raise _wrap_db_error(e) from e
3293
+ return {"status": "accepted", "wikilink_written": wikilink_written}
3294
+
3295
+
3296
+ def _connect_write_wikilink(cfg: Config, action: connect_mod.ActionResult) -> bool:
3297
+ """Append the accepted suggestion's wikilink to the source vault file.
3298
+
3299
+ Returns ``True`` when newly written, ``False`` when already present. Raises
3300
+ ``INVALID_PARAMS`` when the source/target vault path (or the on-disk file)
3301
+ is missing, so the link cannot be located.
3302
+ """
3303
+ if action.source_vault_path is None:
3304
+ raise _mcp_error(
3305
+ INVALID_PARAMS, "cannot write wikilink: source doc has no vault file"
3306
+ )
3307
+ if action.target_vault_path is None:
3308
+ raise _mcp_error(
3309
+ INVALID_PARAMS, "cannot write wikilink: target doc has no vault path"
3310
+ )
3311
+ source_file = Path(cfg.vault_path) / action.source_vault_path
3312
+ if not source_file.is_file():
3313
+ raise _mcp_error(
3314
+ INVALID_PARAMS,
3315
+ "cannot write wikilink: source vault file not found",
3316
+ )
3317
+ wikilink = connect_mod.build_see_also_wikilink(
3318
+ action.target_vault_path, action.target_title
3319
+ )
3320
+ return connect_mod.append_see_also_link(source_file, wikilink)
3321
+
3322
+
3323
+ @mcp_app.tool()
3324
+ def brain_connect_reject(id: str) -> dict[str, Any]:
3325
+ """Reject an auto-link suggestion; it is frozen and not re-proposed.
3326
+
3327
+ WHEN TO USE: the user disagrees two notes should be linked. ``id`` is a
3328
+ suggestion-id prefix (6+ hex chars). Returns ``{status: "rejected"}``. A
3329
+ too-short / unknown / ambiguous id raises ``INVALID_PARAMS``.
3330
+ """
3331
+ state = _get_state()
3332
+ logger.debug("brain_connect_reject: called")
3333
+ try:
3334
+ with _mcp_conn(state) as conn:
3335
+ conn.autocommit = True
3336
+ resolved = _resolve_suggestion(conn, id)
3337
+ try:
3338
+ connect_mod.set_suggestion_status(conn, resolved, "rejected")
3339
+ except ConnectError as e:
3340
+ raise _mcp_error(INVALID_PARAMS, str(e)) from e
3341
+ except psycopg.Error as e:
3342
+ raise _wrap_db_error(e) from e
3343
+ return {"status": "rejected"}
3344
+
3345
+
3346
+ def _configure_logging() -> None:
3347
+ """Configure stderr logging from the ``BRAIN_MCP_LOG_LEVEL`` env var.
3348
+
3349
+ Defaults to ``INFO``. Unknown values fall back to ``INFO`` and emit a
3350
+ warning. Logging always goes to stderr — stdout belongs to the MCP
3351
+ JSON-RPC channel.
3352
+ """
3353
+ raw_level = os.environ.get("BRAIN_MCP_LOG_LEVEL", "INFO").upper()
3354
+ if raw_level in _VALID_LOG_LEVELS:
3355
+ level = getattr(logging, raw_level)
3356
+ logging.basicConfig(stream=sys.stderr, level=level)
3357
+ logger.setLevel(level)
3358
+ else:
3359
+ logging.basicConfig(stream=sys.stderr, level=logging.INFO)
3360
+ logger.setLevel(logging.INFO)
3361
+ logger.warning(
3362
+ "BRAIN_MCP_LOG_LEVEL=%r is not a valid level; falling back to INFO",
3363
+ raw_level,
3364
+ )
3365
+
3366
+
3367
+ def main() -> None:
3368
+ """Initialize shared state and run the brain-mcp server over stdio."""
3369
+ global _state
3370
+ _configure_logging()
3371
+ cfg = Config.load()
3372
+ embedder = make_embedder(cfg)
3373
+ # Wave Q2-SUMMARY-WIKI: build the long-lived enricher so the
3374
+ # ``brain_edit`` MCP tool can refresh ``documents.summary`` on
3375
+ # body-changing edits. Construction is cheap (no Ollama probe — the
3376
+ # hook handles unavailability inline). Per CLAUDE.md, every external
3377
+ # service has explicit timeouts; the enricher reads
3378
+ # ``Config.enrich_timeout_seconds`` from env.
3379
+ enricher = make_enricher(cfg)
3380
+ # Wave G1-c: build the per-process people-aspect graph syncer (shares one
3381
+ # ReconcileConfig). Cheap + self-gating on BRAIN_GRAPH_ENABLED + AGE
3382
+ # availability, so it's a no-op on a stock pgvector DB. Imported lazily so
3383
+ # the networkx graph_rag chain stays off the hot import path.
3384
+ from .graph_rag.sync import make_graph_syncer
3385
+
3386
+ graph_syncer = make_graph_syncer(cfg)
3387
+ # DB-08: open the persistent connection once at server startup; every tool
3388
+ # call reuses it via _mcp_conn(state), saving the ~10–30 ms TCP handshake
3389
+ # overhead per request. autocommit=True is set inside PersistentConnection.get.
3390
+ db_conn = PersistentConnection(cfg.database_url)
3391
+ _state = _State(
3392
+ cfg=cfg,
3393
+ embedder=embedder,
3394
+ enricher=enricher,
3395
+ graph_syncer=graph_syncer,
3396
+ db_conn=db_conn,
3397
+ )
3398
+ # F7: warm the embedding model into VRAM before the first real query.
3399
+ _warmup_embed(_state.embedder)
3400
+ logger.info("brain-mcp starting (stdio transport)")
3401
+ mcp_app.run(transport="stdio")
3402
+
3403
+
3404
+ if __name__ == "__main__": # pragma: no cover - exercised by integration test
3405
+ main()