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.
- brain/__init__.py +0 -0
- brain/__main__.py +18 -0
- brain/_capture_command.py +445 -0
- brain/_compose.py +52 -0
- brain/activity.py +206 -0
- brain/ask.py +631 -0
- brain/audio.py +591 -0
- brain/backfill/__init__.py +12 -0
- brain/backfill/search_extras.py +141 -0
- brain/backfill/source_rows.py +101 -0
- brain/bin/__init__.py +1 -0
- brain/bin/_launcher.py +107 -0
- brain/bin/down.py +8 -0
- brain/bin/launchd.py +268 -0
- brain/bin/monitor.py +570 -0
- brain/bin/rebuild.py +8 -0
- brain/bin/status.py +8 -0
- brain/bin/up.py +8 -0
- brain/brief.py +272 -0
- brain/capture.py +49 -0
- brain/chat.py +293 -0
- brain/cli.py +9760 -0
- brain/cli_claude.py +81 -0
- brain/cli_connect.py +285 -0
- brain/cli_demo.py +266 -0
- brain/config.py +1949 -0
- brain/connect.py +925 -0
- brain/db.py +540 -0
- brain/demo/__init__.py +452 -0
- brain/demo/corpus/manifest.json +403 -0
- brain/demo/embedder.py +74 -0
- brain/durations.py +84 -0
- brain/edit_session.py +156 -0
- brain/editor.py +67 -0
- brain/elicit/__init__.py +16 -0
- brain/elicit/detectors.py +250 -0
- brain/elicit/drafter.py +70 -0
- brain/elicit/queue.py +220 -0
- brain/elicit/schema.py +48 -0
- brain/elicit/session.py +445 -0
- brain/embedding_targets.py +54 -0
- brain/embeddings.py +424 -0
- brain/enrichment.py +808 -0
- brain/errors.py +357 -0
- brain/eval/__init__.py +129 -0
- brain/eval/answer_eval.py +281 -0
- brain/eval/baseline.py +265 -0
- brain/eval/concept_extraction.py +378 -0
- brain/eval/corpus.py +152 -0
- brain/eval/errors.py +19 -0
- brain/eval/graph_baseline.py +226 -0
- brain/eval/graph_retrieval.py +202 -0
- brain/eval/graph_runner.py +319 -0
- brain/eval/metrics.py +101 -0
- brain/eval/runner.py +223 -0
- brain/format.py +783 -0
- brain/gaps.py +390 -0
- brain/graph_rag/__init__.py +94 -0
- brain/graph_rag/_retrieval_common.py +113 -0
- brain/graph_rag/aggregates.py +303 -0
- brain/graph_rag/aliases/__init__.py +583 -0
- brain/graph_rag/backends/__init__.py +10 -0
- brain/graph_rag/backends/_age_helpers.py +473 -0
- brain/graph_rag/backends/age.py +782 -0
- brain/graph_rag/backends/base.py +272 -0
- brain/graph_rag/build.py +344 -0
- brain/graph_rag/communities.py +644 -0
- brain/graph_rag/communities_summary.py +437 -0
- brain/graph_rag/concepts.py +202 -0
- brain/graph_rag/cooccur.py +193 -0
- brain/graph_rag/cross_type.py +312 -0
- brain/graph_rag/extract.py +885 -0
- brain/graph_rag/fuse.py +371 -0
- brain/graph_rag/global_.py +412 -0
- brain/graph_rag/grouping.py +372 -0
- brain/graph_rag/person_resolver.py +167 -0
- brain/graph_rag/reconcile.py +792 -0
- brain/graph_rag/relational.py +353 -0
- brain/graph_rag/retrieve.py +526 -0
- brain/graph_rag/router.py +288 -0
- brain/graph_rag/schema.py +320 -0
- brain/graph_rag/sync.py +237 -0
- brain/graph_rag/tenancy.py +43 -0
- brain/graph_rag/themes.py +501 -0
- brain/graph_rag/weighting.py +202 -0
- brain/ingest/__init__.py +1926 -0
- brain/ingest/chunker.py +249 -0
- brain/ingest/docx.py +40 -0
- brain/ingest/gmail.py +621 -0
- brain/ingest/markdown.py +37 -0
- brain/ingest/pdf.py +61 -0
- brain/ingest/stdin.py +22 -0
- brain/ingest/sub_tokens.py +91 -0
- brain/ingest/text.py +16 -0
- brain/interactions.py +205 -0
- brain/maintenance.py +355 -0
- brain/mcp_server.py +3405 -0
- brain/migrations/001_init.sql +43 -0
- brain/migrations/002_qwen3_embedding.sql +17 -0
- brain/migrations/003_vault_model.sql +41 -0
- brain/migrations/004_relax_content_hash_uniqueness.sql +18 -0
- brain/migrations/005_derived_links.sql +67 -0
- brain/migrations/006_dedup_file_by_source_path.sql +25 -0
- brain/migrations/007_email_thread_and_draft.sql +15 -0
- brain/migrations/008_gmail_thread_unique.sql +11 -0
- brain/migrations/009_chunks_weighted_tsv.sql +28 -0
- brain/migrations/010_interactions.sql +30 -0
- brain/migrations/011_documents_summary.sql +23 -0
- brain/migrations/012_graphrag.sql +171 -0
- brain/migrations/013_graphrag_communities.sql +125 -0
- brain/migrations/014_graphrag_community_summary_hash.sql +33 -0
- brain/migrations/015_interactions_graph_targets.sql +89 -0
- brain/migrations/016_index_hygiene.sql +61 -0
- brain/migrations/017_elicit.sql +30 -0
- brain/migrations/018_review_gap_signal_kinds.sql +40 -0
- brain/migrations/019_search_queries.sql +35 -0
- brain/migrations/020_link_suggestions.sql +40 -0
- brain/migrations/021_timeline_doc_date.sql +34 -0
- brain/migrations/022_link_suggestions_undirected.sql +84 -0
- brain/migrations/023_search_queries_fts_count.sql +28 -0
- brain/quartz_overrides/__init__.py +8 -0
- brain/quartz_overrides/quartz/bootstrap-cli.mjs +65 -0
- brain/quartz_overrides/quartz/build.ts +568 -0
- brain/quartz_overrides/quartz/cli/args.js +152 -0
- brain/quartz_overrides/quartz/cli/build_partial_handler.js +544 -0
- brain/quartz_overrides/quartz/cli/handlers.js +636 -0
- brain/quartz_overrides/quartz/components/CommandPalette.tsx +172 -0
- brain/quartz_overrides/quartz/components/Explorer.tsx +198 -0
- brain/quartz_overrides/quartz/components/Footer.tsx +27 -0
- brain/quartz_overrides/quartz/components/Graph.tsx +468 -0
- brain/quartz_overrides/quartz/components/PageTitle.tsx +72 -0
- brain/quartz_overrides/quartz/components/RelatedDocs.tsx +38 -0
- brain/quartz_overrides/quartz/components/Search.tsx +161 -0
- brain/quartz_overrides/quartz/components/SummaryLede.tsx +72 -0
- brain/quartz_overrides/quartz/components/index.ts +92 -0
- brain/quartz_overrides/quartz/components/pages/TagContent.tsx +272 -0
- brain/quartz_overrides/quartz/components/scripts/commandPalette.inline.ts +665 -0
- brain/quartz_overrides/quartz/components/scripts/explorer.inline.ts +768 -0
- brain/quartz_overrides/quartz/components/scripts/graph.inline.ts +2302 -0
- brain/quartz_overrides/quartz/components/scripts/relatedDocs.inline.ts +163 -0
- brain/quartz_overrides/quartz/components/scripts/search.inline.ts +1011 -0
- brain/quartz_overrides/quartz/plugins/emitters/contentIndex.ts +546 -0
- brain/quartz_overrides/quartz/plugins/transformers/codeCopy.ts +94 -0
- brain/quartz_overrides/quartz/plugins/transformers/derivedFenceMark.ts +302 -0
- brain/quartz_overrides/quartz/plugins/transformers/emailThread.ts +148 -0
- brain/quartz_overrides/quartz/plugins/transformers/emptyDoorFilter.ts +213 -0
- brain/quartz_overrides/quartz/plugins/transformers/index.ts +114 -0
- brain/quartz_overrides/quartz/plugins/transformers/linkKindMark.ts +205 -0
- brain/quartz_overrides/quartz/plugins/transformers/linkSourceTag.ts +104 -0
- brain/quartz_overrides/quartz/plugins/transformers/relativeDate.ts +100 -0
- brain/quartz_overrides/quartz/plugins/transformers/reloadSignal.ts +131 -0
- brain/quartz_overrides/quartz/processors/parse.ts +371 -0
- brain/quartz_overrides/quartz/processors/parser_cache.ts +78 -0
- brain/quartz_overrides/quartz/static/brain-logo-dark.png +0 -0
- brain/quartz_overrides/quartz/static/brain-logo-light.png +0 -0
- brain/quartz_overrides/quartz/static/codeCopy.js +196 -0
- brain/quartz_overrides/quartz/static/emailThread.js +334 -0
- brain/quartz_overrides/quartz/static/favicon.ico +0 -0
- brain/quartz_overrides/quartz/static/icon.png +0 -0
- brain/quartz_overrides/quartz/static/linkSourceTag.js +104 -0
- brain/quartz_overrides/quartz/static/relativeDate.js +142 -0
- brain/quartz_overrides/quartz/static/reload.js +168 -0
- brain/quartz_overrides/quartz/styles/brain/_article.scss +252 -0
- brain/quartz_overrides/quartz/styles/brain/_atmosphere.scss +113 -0
- brain/quartz_overrides/quartz/styles/brain/_callouts.scss +180 -0
- brain/quartz_overrides/quartz/styles/brain/_cmdk.scss +7 -0
- brain/quartz_overrides/quartz/styles/brain/_code.scss +208 -0
- brain/quartz_overrides/quartz/styles/brain/_command_palette.scss +369 -0
- brain/quartz_overrides/quartz/styles/brain/_email_thread.scss +228 -0
- brain/quartz_overrides/quartz/styles/brain/_explorer.scss +142 -0
- brain/quartz_overrides/quartz/styles/brain/_home.scss +182 -0
- brain/quartz_overrides/quartz/styles/brain/_links.scss +322 -0
- brain/quartz_overrides/quartz/styles/brain/_marginalia.scss +117 -0
- brain/quartz_overrides/quartz/styles/brain/_motion.scss +175 -0
- brain/quartz_overrides/quartz/styles/brain/_people_hub.scss +100 -0
- brain/quartz_overrides/quartz/styles/brain/_related_docs.scss +137 -0
- brain/quartz_overrides/quartz/styles/brain/_search.scss +252 -0
- brain/quartz_overrides/quartz/styles/brain/_sidebar.scss +468 -0
- brain/quartz_overrides/quartz/styles/brain/_summary_lede.scss +56 -0
- brain/quartz_overrides/quartz/styles/brain/_surface.scss +43 -0
- brain/quartz_overrides/quartz/styles/brain/_tag_content.scss +118 -0
- brain/quartz_overrides/quartz/styles/brain/_tokens.scss +197 -0
- brain/quartz_overrides/quartz/styles/brain/_typography.scss +92 -0
- brain/quartz_overrides/quartz/styles/custom.scss +89 -0
- brain/quartz_overrides/quartz/styles/graph.scss +505 -0
- brain/quartz_overrides/quartz/util/ctx.ts +92 -0
- brain/quartz_overrides/quartz/util/fastpath_manifest.ts +608 -0
- brain/quartz_overrides/quartz/util/path.ts +358 -0
- brain/quartz_overrides/quartz/util/sourceIcons.ts +55 -0
- brain/quartz_overrides/quartz.config.ts +270 -0
- brain/quartz_overrides/quartz.layout.ts +314 -0
- brain/queries.py +1188 -0
- brain/rank_fusion.py +8 -0
- brain/resurface.py +210 -0
- brain/review/__init__.py +26 -0
- brain/review/emit.py +27 -0
- brain/review/queries.py +436 -0
- brain/review/render.py +196 -0
- brain/review/scans.py +355 -0
- brain/review/weekly.py +413 -0
- brain/search.py +704 -0
- brain/set_similarity.py +15 -0
- brain/setup.py +1205 -0
- brain/tags.py +56 -0
- brain/templates/Caddyfile.j2 +9 -0
- brain/templates/__init__.py +1 -0
- brain/templates/bin/__init__.py +1 -0
- brain/templates/bin/_brain-brief-fg.sh +25 -0
- brain/templates/bin/_brain-build-fg.sh +53 -0
- brain/templates/bin/_brain-watcher-fg.sh +65 -0
- brain/templates/bin/brain-down.sh +89 -0
- brain/templates/bin/brain-status.sh +83 -0
- brain/templates/bin/brain-up.sh +221 -0
- brain/templates/docker/age/Dockerfile +79 -0
- brain/templates/docker-compose.stock.yml.j2 +26 -0
- brain/templates/docker-compose.yml.j2 +34 -0
- brain/templates/env.example +190 -0
- brain/templates/launchd/__init__.py +1 -0
- brain/templates/launchd/com.brain.brief.plist.j2 +45 -0
- brain/templates/launchd/com.brain.build.plist.j2 +46 -0
- brain/templates/launchd/com.brain.watcher.plist.j2 +46 -0
- brain/templates/skill/SKILL.md +63 -0
- brain/templates/skill/__init__.py +1 -0
- brain/timeline.py +834 -0
- brain/todo.py +124 -0
- brain/uninstall.py +185 -0
- brain/vault/__init__.py +115 -0
- brain/vault/_atomic.py +25 -0
- brain/vault/daily_index.py +228 -0
- brain/vault/derived_links/__init__.py +50 -0
- brain/vault/derived_links/directory.py +683 -0
- brain/vault/derived_links/fence.py +408 -0
- brain/vault/derived_links/gws.py +64 -0
- brain/vault/derived_links/participants.py +143 -0
- brain/vault/derived_links/pass_runner.py +362 -0
- brain/vault/derived_links/rules.py +137 -0
- brain/vault/export.py +683 -0
- brain/vault/frontmatter.py +165 -0
- brain/vault/graph.py +620 -0
- brain/vault/graph_format.py +388 -0
- brain/vault/link_rewrite.py +235 -0
- brain/vault/links.py +260 -0
- brain/vault/note_builder.py +211 -0
- brain/vault/paths.py +55 -0
- brain/vault/quartz_overlay.py +236 -0
- brain/vault/rename.py +591 -0
- brain/vault/resolver.py +304 -0
- brain/vault/slug.py +127 -0
- brain/vault/sync.py +1513 -0
- brain/vault/sync_summaries.py +264 -0
- brain/vault/templates.py +145 -0
- brain/vault/watch.py +1052 -0
- brain/wiki/__init__.py +6 -0
- brain/wiki/_github_slugger.py +76 -0
- brain/wiki/_person_name.py +314 -0
- brain/wiki/build_homepage.py +541 -0
- brain/wiki/build_partial.py +273 -0
- brain/wiki/build_people.py +934 -0
- brain/wiki/build_related.py +758 -0
- brain/wiki/build_swap.py +585 -0
- brain/wiki/build_watcher.py +975 -0
- brain/wiki/edit_classifier.py +215 -0
- brain/wiki/errors.py +10 -0
- brain/wiki/fastpath_manifest.py +475 -0
- brain/wiki/fastpath_state.py +174 -0
- brain/wiki/install.py +296 -0
- brain/wiki/slug.py +111 -0
- secondbrain_py-0.2.1.dist-info/METADATA +195 -0
- secondbrain_py-0.2.1.dist-info/RECORD +273 -0
- secondbrain_py-0.2.1.dist-info/WHEEL +5 -0
- secondbrain_py-0.2.1.dist-info/entry_points.txt +11 -0
- secondbrain_py-0.2.1.dist-info/licenses/LICENSE +21 -0
- 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()
|