secondbrain-py 0.2.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (273) hide show
  1. brain/__init__.py +0 -0
  2. brain/__main__.py +18 -0
  3. brain/_capture_command.py +445 -0
  4. brain/_compose.py +52 -0
  5. brain/activity.py +206 -0
  6. brain/ask.py +631 -0
  7. brain/audio.py +591 -0
  8. brain/backfill/__init__.py +12 -0
  9. brain/backfill/search_extras.py +141 -0
  10. brain/backfill/source_rows.py +101 -0
  11. brain/bin/__init__.py +1 -0
  12. brain/bin/_launcher.py +107 -0
  13. brain/bin/down.py +8 -0
  14. brain/bin/launchd.py +268 -0
  15. brain/bin/monitor.py +570 -0
  16. brain/bin/rebuild.py +8 -0
  17. brain/bin/status.py +8 -0
  18. brain/bin/up.py +8 -0
  19. brain/brief.py +272 -0
  20. brain/capture.py +49 -0
  21. brain/chat.py +293 -0
  22. brain/cli.py +9760 -0
  23. brain/cli_claude.py +81 -0
  24. brain/cli_connect.py +285 -0
  25. brain/cli_demo.py +266 -0
  26. brain/config.py +1949 -0
  27. brain/connect.py +925 -0
  28. brain/db.py +540 -0
  29. brain/demo/__init__.py +452 -0
  30. brain/demo/corpus/manifest.json +403 -0
  31. brain/demo/embedder.py +74 -0
  32. brain/durations.py +84 -0
  33. brain/edit_session.py +156 -0
  34. brain/editor.py +67 -0
  35. brain/elicit/__init__.py +16 -0
  36. brain/elicit/detectors.py +250 -0
  37. brain/elicit/drafter.py +70 -0
  38. brain/elicit/queue.py +220 -0
  39. brain/elicit/schema.py +48 -0
  40. brain/elicit/session.py +445 -0
  41. brain/embedding_targets.py +54 -0
  42. brain/embeddings.py +424 -0
  43. brain/enrichment.py +808 -0
  44. brain/errors.py +357 -0
  45. brain/eval/__init__.py +129 -0
  46. brain/eval/answer_eval.py +281 -0
  47. brain/eval/baseline.py +265 -0
  48. brain/eval/concept_extraction.py +378 -0
  49. brain/eval/corpus.py +152 -0
  50. brain/eval/errors.py +19 -0
  51. brain/eval/graph_baseline.py +226 -0
  52. brain/eval/graph_retrieval.py +202 -0
  53. brain/eval/graph_runner.py +319 -0
  54. brain/eval/metrics.py +101 -0
  55. brain/eval/runner.py +223 -0
  56. brain/format.py +783 -0
  57. brain/gaps.py +390 -0
  58. brain/graph_rag/__init__.py +94 -0
  59. brain/graph_rag/_retrieval_common.py +113 -0
  60. brain/graph_rag/aggregates.py +303 -0
  61. brain/graph_rag/aliases/__init__.py +583 -0
  62. brain/graph_rag/backends/__init__.py +10 -0
  63. brain/graph_rag/backends/_age_helpers.py +473 -0
  64. brain/graph_rag/backends/age.py +782 -0
  65. brain/graph_rag/backends/base.py +272 -0
  66. brain/graph_rag/build.py +344 -0
  67. brain/graph_rag/communities.py +644 -0
  68. brain/graph_rag/communities_summary.py +437 -0
  69. brain/graph_rag/concepts.py +202 -0
  70. brain/graph_rag/cooccur.py +193 -0
  71. brain/graph_rag/cross_type.py +312 -0
  72. brain/graph_rag/extract.py +885 -0
  73. brain/graph_rag/fuse.py +371 -0
  74. brain/graph_rag/global_.py +412 -0
  75. brain/graph_rag/grouping.py +372 -0
  76. brain/graph_rag/person_resolver.py +167 -0
  77. brain/graph_rag/reconcile.py +792 -0
  78. brain/graph_rag/relational.py +353 -0
  79. brain/graph_rag/retrieve.py +526 -0
  80. brain/graph_rag/router.py +288 -0
  81. brain/graph_rag/schema.py +320 -0
  82. brain/graph_rag/sync.py +237 -0
  83. brain/graph_rag/tenancy.py +43 -0
  84. brain/graph_rag/themes.py +501 -0
  85. brain/graph_rag/weighting.py +202 -0
  86. brain/ingest/__init__.py +1926 -0
  87. brain/ingest/chunker.py +249 -0
  88. brain/ingest/docx.py +40 -0
  89. brain/ingest/gmail.py +621 -0
  90. brain/ingest/markdown.py +37 -0
  91. brain/ingest/pdf.py +61 -0
  92. brain/ingest/stdin.py +22 -0
  93. brain/ingest/sub_tokens.py +91 -0
  94. brain/ingest/text.py +16 -0
  95. brain/interactions.py +205 -0
  96. brain/maintenance.py +355 -0
  97. brain/mcp_server.py +3405 -0
  98. brain/migrations/001_init.sql +43 -0
  99. brain/migrations/002_qwen3_embedding.sql +17 -0
  100. brain/migrations/003_vault_model.sql +41 -0
  101. brain/migrations/004_relax_content_hash_uniqueness.sql +18 -0
  102. brain/migrations/005_derived_links.sql +67 -0
  103. brain/migrations/006_dedup_file_by_source_path.sql +25 -0
  104. brain/migrations/007_email_thread_and_draft.sql +15 -0
  105. brain/migrations/008_gmail_thread_unique.sql +11 -0
  106. brain/migrations/009_chunks_weighted_tsv.sql +28 -0
  107. brain/migrations/010_interactions.sql +30 -0
  108. brain/migrations/011_documents_summary.sql +23 -0
  109. brain/migrations/012_graphrag.sql +171 -0
  110. brain/migrations/013_graphrag_communities.sql +125 -0
  111. brain/migrations/014_graphrag_community_summary_hash.sql +33 -0
  112. brain/migrations/015_interactions_graph_targets.sql +89 -0
  113. brain/migrations/016_index_hygiene.sql +61 -0
  114. brain/migrations/017_elicit.sql +30 -0
  115. brain/migrations/018_review_gap_signal_kinds.sql +40 -0
  116. brain/migrations/019_search_queries.sql +35 -0
  117. brain/migrations/020_link_suggestions.sql +40 -0
  118. brain/migrations/021_timeline_doc_date.sql +34 -0
  119. brain/migrations/022_link_suggestions_undirected.sql +84 -0
  120. brain/migrations/023_search_queries_fts_count.sql +28 -0
  121. brain/quartz_overrides/__init__.py +8 -0
  122. brain/quartz_overrides/quartz/bootstrap-cli.mjs +65 -0
  123. brain/quartz_overrides/quartz/build.ts +568 -0
  124. brain/quartz_overrides/quartz/cli/args.js +152 -0
  125. brain/quartz_overrides/quartz/cli/build_partial_handler.js +544 -0
  126. brain/quartz_overrides/quartz/cli/handlers.js +636 -0
  127. brain/quartz_overrides/quartz/components/CommandPalette.tsx +172 -0
  128. brain/quartz_overrides/quartz/components/Explorer.tsx +198 -0
  129. brain/quartz_overrides/quartz/components/Footer.tsx +27 -0
  130. brain/quartz_overrides/quartz/components/Graph.tsx +468 -0
  131. brain/quartz_overrides/quartz/components/PageTitle.tsx +72 -0
  132. brain/quartz_overrides/quartz/components/RelatedDocs.tsx +38 -0
  133. brain/quartz_overrides/quartz/components/Search.tsx +161 -0
  134. brain/quartz_overrides/quartz/components/SummaryLede.tsx +72 -0
  135. brain/quartz_overrides/quartz/components/index.ts +92 -0
  136. brain/quartz_overrides/quartz/components/pages/TagContent.tsx +272 -0
  137. brain/quartz_overrides/quartz/components/scripts/commandPalette.inline.ts +665 -0
  138. brain/quartz_overrides/quartz/components/scripts/explorer.inline.ts +768 -0
  139. brain/quartz_overrides/quartz/components/scripts/graph.inline.ts +2302 -0
  140. brain/quartz_overrides/quartz/components/scripts/relatedDocs.inline.ts +163 -0
  141. brain/quartz_overrides/quartz/components/scripts/search.inline.ts +1011 -0
  142. brain/quartz_overrides/quartz/plugins/emitters/contentIndex.ts +546 -0
  143. brain/quartz_overrides/quartz/plugins/transformers/codeCopy.ts +94 -0
  144. brain/quartz_overrides/quartz/plugins/transformers/derivedFenceMark.ts +302 -0
  145. brain/quartz_overrides/quartz/plugins/transformers/emailThread.ts +148 -0
  146. brain/quartz_overrides/quartz/plugins/transformers/emptyDoorFilter.ts +213 -0
  147. brain/quartz_overrides/quartz/plugins/transformers/index.ts +114 -0
  148. brain/quartz_overrides/quartz/plugins/transformers/linkKindMark.ts +205 -0
  149. brain/quartz_overrides/quartz/plugins/transformers/linkSourceTag.ts +104 -0
  150. brain/quartz_overrides/quartz/plugins/transformers/relativeDate.ts +100 -0
  151. brain/quartz_overrides/quartz/plugins/transformers/reloadSignal.ts +131 -0
  152. brain/quartz_overrides/quartz/processors/parse.ts +371 -0
  153. brain/quartz_overrides/quartz/processors/parser_cache.ts +78 -0
  154. brain/quartz_overrides/quartz/static/brain-logo-dark.png +0 -0
  155. brain/quartz_overrides/quartz/static/brain-logo-light.png +0 -0
  156. brain/quartz_overrides/quartz/static/codeCopy.js +196 -0
  157. brain/quartz_overrides/quartz/static/emailThread.js +334 -0
  158. brain/quartz_overrides/quartz/static/favicon.ico +0 -0
  159. brain/quartz_overrides/quartz/static/icon.png +0 -0
  160. brain/quartz_overrides/quartz/static/linkSourceTag.js +104 -0
  161. brain/quartz_overrides/quartz/static/relativeDate.js +142 -0
  162. brain/quartz_overrides/quartz/static/reload.js +168 -0
  163. brain/quartz_overrides/quartz/styles/brain/_article.scss +252 -0
  164. brain/quartz_overrides/quartz/styles/brain/_atmosphere.scss +113 -0
  165. brain/quartz_overrides/quartz/styles/brain/_callouts.scss +180 -0
  166. brain/quartz_overrides/quartz/styles/brain/_cmdk.scss +7 -0
  167. brain/quartz_overrides/quartz/styles/brain/_code.scss +208 -0
  168. brain/quartz_overrides/quartz/styles/brain/_command_palette.scss +369 -0
  169. brain/quartz_overrides/quartz/styles/brain/_email_thread.scss +228 -0
  170. brain/quartz_overrides/quartz/styles/brain/_explorer.scss +142 -0
  171. brain/quartz_overrides/quartz/styles/brain/_home.scss +182 -0
  172. brain/quartz_overrides/quartz/styles/brain/_links.scss +322 -0
  173. brain/quartz_overrides/quartz/styles/brain/_marginalia.scss +117 -0
  174. brain/quartz_overrides/quartz/styles/brain/_motion.scss +175 -0
  175. brain/quartz_overrides/quartz/styles/brain/_people_hub.scss +100 -0
  176. brain/quartz_overrides/quartz/styles/brain/_related_docs.scss +137 -0
  177. brain/quartz_overrides/quartz/styles/brain/_search.scss +252 -0
  178. brain/quartz_overrides/quartz/styles/brain/_sidebar.scss +468 -0
  179. brain/quartz_overrides/quartz/styles/brain/_summary_lede.scss +56 -0
  180. brain/quartz_overrides/quartz/styles/brain/_surface.scss +43 -0
  181. brain/quartz_overrides/quartz/styles/brain/_tag_content.scss +118 -0
  182. brain/quartz_overrides/quartz/styles/brain/_tokens.scss +197 -0
  183. brain/quartz_overrides/quartz/styles/brain/_typography.scss +92 -0
  184. brain/quartz_overrides/quartz/styles/custom.scss +89 -0
  185. brain/quartz_overrides/quartz/styles/graph.scss +505 -0
  186. brain/quartz_overrides/quartz/util/ctx.ts +92 -0
  187. brain/quartz_overrides/quartz/util/fastpath_manifest.ts +608 -0
  188. brain/quartz_overrides/quartz/util/path.ts +358 -0
  189. brain/quartz_overrides/quartz/util/sourceIcons.ts +55 -0
  190. brain/quartz_overrides/quartz.config.ts +270 -0
  191. brain/quartz_overrides/quartz.layout.ts +314 -0
  192. brain/queries.py +1188 -0
  193. brain/rank_fusion.py +8 -0
  194. brain/resurface.py +210 -0
  195. brain/review/__init__.py +26 -0
  196. brain/review/emit.py +27 -0
  197. brain/review/queries.py +436 -0
  198. brain/review/render.py +196 -0
  199. brain/review/scans.py +355 -0
  200. brain/review/weekly.py +413 -0
  201. brain/search.py +704 -0
  202. brain/set_similarity.py +15 -0
  203. brain/setup.py +1205 -0
  204. brain/tags.py +56 -0
  205. brain/templates/Caddyfile.j2 +9 -0
  206. brain/templates/__init__.py +1 -0
  207. brain/templates/bin/__init__.py +1 -0
  208. brain/templates/bin/_brain-brief-fg.sh +25 -0
  209. brain/templates/bin/_brain-build-fg.sh +53 -0
  210. brain/templates/bin/_brain-watcher-fg.sh +65 -0
  211. brain/templates/bin/brain-down.sh +89 -0
  212. brain/templates/bin/brain-status.sh +83 -0
  213. brain/templates/bin/brain-up.sh +221 -0
  214. brain/templates/docker/age/Dockerfile +79 -0
  215. brain/templates/docker-compose.stock.yml.j2 +26 -0
  216. brain/templates/docker-compose.yml.j2 +34 -0
  217. brain/templates/env.example +190 -0
  218. brain/templates/launchd/__init__.py +1 -0
  219. brain/templates/launchd/com.brain.brief.plist.j2 +45 -0
  220. brain/templates/launchd/com.brain.build.plist.j2 +46 -0
  221. brain/templates/launchd/com.brain.watcher.plist.j2 +46 -0
  222. brain/templates/skill/SKILL.md +63 -0
  223. brain/templates/skill/__init__.py +1 -0
  224. brain/timeline.py +834 -0
  225. brain/todo.py +124 -0
  226. brain/uninstall.py +185 -0
  227. brain/vault/__init__.py +115 -0
  228. brain/vault/_atomic.py +25 -0
  229. brain/vault/daily_index.py +228 -0
  230. brain/vault/derived_links/__init__.py +50 -0
  231. brain/vault/derived_links/directory.py +683 -0
  232. brain/vault/derived_links/fence.py +408 -0
  233. brain/vault/derived_links/gws.py +64 -0
  234. brain/vault/derived_links/participants.py +143 -0
  235. brain/vault/derived_links/pass_runner.py +362 -0
  236. brain/vault/derived_links/rules.py +137 -0
  237. brain/vault/export.py +683 -0
  238. brain/vault/frontmatter.py +165 -0
  239. brain/vault/graph.py +620 -0
  240. brain/vault/graph_format.py +388 -0
  241. brain/vault/link_rewrite.py +235 -0
  242. brain/vault/links.py +260 -0
  243. brain/vault/note_builder.py +211 -0
  244. brain/vault/paths.py +55 -0
  245. brain/vault/quartz_overlay.py +236 -0
  246. brain/vault/rename.py +591 -0
  247. brain/vault/resolver.py +304 -0
  248. brain/vault/slug.py +127 -0
  249. brain/vault/sync.py +1513 -0
  250. brain/vault/sync_summaries.py +264 -0
  251. brain/vault/templates.py +145 -0
  252. brain/vault/watch.py +1052 -0
  253. brain/wiki/__init__.py +6 -0
  254. brain/wiki/_github_slugger.py +76 -0
  255. brain/wiki/_person_name.py +314 -0
  256. brain/wiki/build_homepage.py +541 -0
  257. brain/wiki/build_partial.py +273 -0
  258. brain/wiki/build_people.py +934 -0
  259. brain/wiki/build_related.py +758 -0
  260. brain/wiki/build_swap.py +585 -0
  261. brain/wiki/build_watcher.py +975 -0
  262. brain/wiki/edit_classifier.py +215 -0
  263. brain/wiki/errors.py +10 -0
  264. brain/wiki/fastpath_manifest.py +475 -0
  265. brain/wiki/fastpath_state.py +174 -0
  266. brain/wiki/install.py +296 -0
  267. brain/wiki/slug.py +111 -0
  268. secondbrain_py-0.2.1.dist-info/METADATA +195 -0
  269. secondbrain_py-0.2.1.dist-info/RECORD +273 -0
  270. secondbrain_py-0.2.1.dist-info/WHEEL +5 -0
  271. secondbrain_py-0.2.1.dist-info/entry_points.txt +11 -0
  272. secondbrain_py-0.2.1.dist-info/licenses/LICENSE +21 -0
  273. secondbrain_py-0.2.1.dist-info/top_level.txt +1 -0
brain/embeddings.py ADDED
@@ -0,0 +1,424 @@
1
+ """Embedder backends — Arctic, Voyage, Qwen3 — selected at setup time.
2
+
3
+ Three implementations satisfy the :class:`brain.ingest.Embedder` Protocol:
4
+
5
+ - :class:`ArcticEmbedder` — Snowflake Arctic Embed v2 over local Ollama (default).
6
+ Native 1024-dim, free, indexable under pgvector's HNSW cap.
7
+ - :class:`VoyageEmbedder` — Voyage AI SDK. 1024-dim, paid SaaS.
8
+ - :class:`Qwen3Embedder` — Qwen3-Embedding-8B over local Ollama. 4096-dim,
9
+ free, but exceeds pgvector's HNSW cap so search uses sequential scan.
10
+ - :class:`NullEmbedder` — FTS-only backend (``BRAIN_EMBEDDER=none``). Produces
11
+ no vectors; for users with no Ollama. Ingest + lexical search + doctor work;
12
+ the vector leg of hybrid search is skipped.
13
+
14
+ Token counting is via tiktoken (cl100k_base) — offline and good enough for
15
+ chunker budgeting. Each backend's :meth:`embed` accepts ``input_type`` to
16
+ dispatch query vs document prompt formatting; the formatting is per-backend
17
+ because each model is trained with a different convention (see comments).
18
+ """
19
+ from typing import Any, NoReturn
20
+
21
+ import httpx
22
+ import tiktoken
23
+
24
+ from .config import Config, ConfigError, keep_alive_wire_value
25
+ from .errors import EmbedError
26
+ from .ingest import Embedder
27
+
28
+ # Shared Ollama transport defaults — both Ollama-backed embedders use these.
29
+ _DEFAULT_OLLAMA_BATCH = 32
30
+ _DEFAULT_OLLAMA_TIMEOUT_S = 60.0
31
+
32
+ # Module-level keep_alive fallback — used when an embedder is constructed
33
+ # without an explicit ``keep_alive`` kwarg (e.g. in tests or legacy call
34
+ # sites). Production always threads the value from ``Config.ollama_keep_alive``
35
+ # (set at construction time, not re-read per request).
36
+ _DEFAULT_OLLAMA_KEEP_ALIVE = "30m"
37
+
38
+
39
+ # Wire-boundary sentinel coercion now lives in ``brain.config`` (shared with
40
+ # the chat path in ``brain.chat``); kept under the historical private name so
41
+ # existing call sites and tests stay valid.
42
+ _keep_alive_payload = keep_alive_wire_value
43
+
44
+ # Arctic Embed v2 query prefix per Snowflake's HF model card guidance:
45
+ # https://huggingface.co/Snowflake/snowflake-arctic-embed-l-v2.0
46
+ # "Use the query prefix below (just on the query)" → 'query: '. Documents
47
+ # get no prefix.
48
+ _ARCTIC_QUERY_PREFIX = "query: "
49
+ _ARCTIC_DEFAULT_MODEL = "snowflake-arctic-embed2"
50
+
51
+ # Qwen3-Embedding query mode prepends an Instruct prompt that primes the model
52
+ # for retrieval over a domain-specific corpus. Documents skip the prefix.
53
+ _QWEN3_QUERY_TASK = (
54
+ "Given a search query, retrieve relevant passages from a personal knowledge "
55
+ "base of career documents, transcripts, and emails"
56
+ )
57
+ _QWEN3_DEFAULT_MODEL = "qwen3-embedding:8b"
58
+
59
+ # Voyage SDK model. Per the plan we pin the named-current production model
60
+ # (voyage-3.5). voyage-4 also works against the same SDK signature; bump here
61
+ # if the user wants the newer generation.
62
+ _VOYAGE_MODEL = "voyage-3.5"
63
+ _VOYAGE_DEFAULT_BATCH = 128
64
+
65
+
66
+ # The FTS-only backend surfaces this exact message everywhere it must explain
67
+ # that semantic search is off. Kept as a module constant so the exception class
68
+ # and any future reference stay in lockstep (DRY).
69
+ _EMBED_DISABLED_MESSAGE = (
70
+ "semantic search is disabled (BRAIN_EMBEDDER=none) — install Ollama, set "
71
+ "BRAIN_EMBEDDER=arctic, then run 'brain init' and 'brain reembed' to enable it"
72
+ )
73
+
74
+
75
+ class EmbedDisabledError(EmbedError):
76
+ """Raised when an embed is attempted under the FTS-only ``none`` backend.
77
+
78
+ A sibling of :class:`OllamaEmbedError` / :class:`VoyageEmbedError` — it is an
79
+ :class:`~brain.errors.EmbedError`, so every ``except EmbedError`` handler
80
+ (the MCP server's ``_wrap_embed_error``, ``brain eval``'s per-query
81
+ tolerance) catches it uniformly. Distinct from the transport-failure
82
+ siblings because nothing *failed*: the ``NullEmbedder`` never produces
83
+ vectors by design, so any code path that reaches an actual embed call under
84
+ the ``none`` backend (e.g. ``brain ask`` / ``graphrag --mode fuse``) gets a
85
+ clear "install Ollama to enable it" message rather than a crash. The
86
+ ingest / search / doctor paths never reach it — they degrade earlier via the
87
+ duck-typed ``produces_embeddings`` flag.
88
+ """
89
+
90
+
91
+ class OllamaEmbedError(EmbedError):
92
+ """Raised when an Ollama-backed embed call fails (network / 4xx / 5xx / shape)."""
93
+
94
+
95
+ class VoyageEmbedError(EmbedError):
96
+ """Raised when a Voyage SDK embed call fails (transport / API / rate-limit / shape).
97
+
98
+ The Voyage sibling of :class:`OllamaEmbedError`: :meth:`VoyageEmbedder.embed`
99
+ wraps any ``voyageai.error.VoyageError`` (its rate-limit / connection /
100
+ timeout / API subclasses) in this so callers get the same typed embed error
101
+ the Ollama backends raise instead of a leaked SDK exception. The originating
102
+ SDK error is preserved as ``__cause__`` (``raise ... from e``).
103
+ """
104
+
105
+
106
+ class _OllamaEmbedderBase:
107
+ """Shared HTTP transport, batching, and tokenizer for Ollama-hosted models.
108
+
109
+ Subclasses declare ``dim`` (native vector size) and override
110
+ :meth:`_format_query` to apply the model-specific query-side prompt.
111
+ Document-side text is sent verbatim by default.
112
+ """
113
+
114
+ dim: int # subclasses set this as a class attribute
115
+
116
+ def __init__(
117
+ self,
118
+ *,
119
+ host: str,
120
+ model: str,
121
+ client: httpx.Client | None = None,
122
+ batch_size: int = _DEFAULT_OLLAMA_BATCH,
123
+ timeout: float = _DEFAULT_OLLAMA_TIMEOUT_S,
124
+ keep_alive: str = _DEFAULT_OLLAMA_KEEP_ALIVE,
125
+ ) -> None:
126
+ self._host = host
127
+ self._model = model
128
+ self._batch_size = batch_size
129
+ self._keep_alive = keep_alive
130
+ self._tokenizer = tiktoken.get_encoding("cl100k_base")
131
+ if client is not None:
132
+ self._client = client
133
+ else:
134
+ self._client = httpx.Client(
135
+ base_url=host, timeout=httpx.Timeout(timeout)
136
+ )
137
+
138
+ def _format_query(self, text: str) -> str:
139
+ """Subclass hook: apply the model-specific query-side prompt."""
140
+ raise NotImplementedError
141
+
142
+ def embed(
143
+ self, texts: list[str], *, input_type: str = "document"
144
+ ) -> list[list[float]]:
145
+ """Embed ``texts`` in batches of ``batch_size`` and return all vectors in order.
146
+
147
+ With ``input_type="query"`` each text is wrapped with the subclass's
148
+ :meth:`_format_query`; ``"document"`` (the default) sends the raw
149
+ text. An empty input returns an empty list with no HTTP I/O.
150
+ Raises :class:`OllamaEmbedError` on any HTTP / decode / shape error.
151
+ """
152
+ if not texts:
153
+ return []
154
+ prepared = (
155
+ [self._format_query(t) for t in texts]
156
+ if input_type == "query"
157
+ else list(texts)
158
+ )
159
+ out: list[list[float]] = []
160
+ for start in range(0, len(prepared), self._batch_size):
161
+ batch = prepared[start : start + self._batch_size]
162
+ out.extend(self._embed_batch(batch))
163
+ return out
164
+
165
+ def _embed_batch(self, batch: list[str]) -> list[list[float]]:
166
+ """Send one /api/embed request and return its vectors."""
167
+ try:
168
+ response = self._client.post(
169
+ "/api/embed",
170
+ json={
171
+ "model": self._model,
172
+ "input": batch,
173
+ "keep_alive": _keep_alive_payload(self._keep_alive),
174
+ },
175
+ )
176
+ response.raise_for_status()
177
+ payload = response.json()
178
+ except httpx.HTTPStatusError as e:
179
+ body = e.response.text if e.response is not None else "<no body>"
180
+ raise OllamaEmbedError(
181
+ f"Ollama returned HTTP {e.response.status_code}: {body}"
182
+ ) from e
183
+ except httpx.HTTPError as e:
184
+ raise OllamaEmbedError(f"Ollama request failed: {e}") from e
185
+ except ValueError as e:
186
+ # json.JSONDecodeError is a ValueError — a 200 OK with non-JSON
187
+ # body would otherwise leak as a raw decode error to callers
188
+ # that contract for OllamaEmbedError.
189
+ raise OllamaEmbedError(f"Ollama returned non-JSON response: {e}") from e
190
+ embeddings = payload.get("embeddings")
191
+ if not isinstance(embeddings, list):
192
+ raise OllamaEmbedError(
193
+ f"Ollama response missing 'embeddings' list: {payload!r}"
194
+ )
195
+ if len(embeddings) != len(batch):
196
+ raise OllamaEmbedError(
197
+ f"Ollama returned {len(embeddings)} embeddings for {len(batch)} inputs"
198
+ )
199
+ return [list(v) for v in embeddings]
200
+
201
+ def count_tokens(self, text: str) -> int:
202
+ """Return the number of tokens in ``text`` per the local tiktoken tokenizer."""
203
+ return len(self._tokenizer.encode(text))
204
+
205
+
206
+ class Qwen3Embedder(_OllamaEmbedderBase):
207
+ """Ollama-hosted Qwen3-Embedding-8B (4096 native dims).
208
+
209
+ Query mode prepends an ``Instruct: ... \\nQuery:`` prompt per the model
210
+ card. pgvector's HNSW caps out at 2000 dims for ``vector``, so the chunks
211
+ column for this backend stays index-free; search uses sequential scan
212
+ (acceptable at personal-corpus scale).
213
+ """
214
+
215
+ dim: int = 4096
216
+
217
+ def __init__(
218
+ self,
219
+ *,
220
+ host: str,
221
+ model: str = _QWEN3_DEFAULT_MODEL,
222
+ client: httpx.Client | None = None,
223
+ batch_size: int = _DEFAULT_OLLAMA_BATCH,
224
+ timeout: float = _DEFAULT_OLLAMA_TIMEOUT_S,
225
+ keep_alive: str = _DEFAULT_OLLAMA_KEEP_ALIVE,
226
+ ) -> None:
227
+ super().__init__(
228
+ host=host,
229
+ model=model,
230
+ client=client,
231
+ batch_size=batch_size,
232
+ timeout=timeout,
233
+ keep_alive=keep_alive,
234
+ )
235
+
236
+ def _format_query(self, text: str) -> str:
237
+ return f"Instruct: {_QWEN3_QUERY_TASK}\nQuery:{text}"
238
+
239
+
240
+ class ArcticEmbedder(_OllamaEmbedderBase):
241
+ """Ollama-hosted Snowflake Arctic Embed v2 (1024 native dims).
242
+
243
+ Query mode prepends ``"query: "`` per Snowflake's published guidance
244
+ (https://huggingface.co/Snowflake/snowflake-arctic-embed-l-v2.0):
245
+ "use the query prefix below (just on the query)". Documents get no
246
+ prefix. 1024 dims fits under pgvector's HNSW cap, so the chunks column
247
+ for this backend gets a cosine HNSW index at finalize time.
248
+ """
249
+
250
+ dim: int = 1024
251
+
252
+ def __init__(
253
+ self,
254
+ *,
255
+ host: str,
256
+ model: str = _ARCTIC_DEFAULT_MODEL,
257
+ client: httpx.Client | None = None,
258
+ batch_size: int = _DEFAULT_OLLAMA_BATCH,
259
+ timeout: float = _DEFAULT_OLLAMA_TIMEOUT_S,
260
+ keep_alive: str = _DEFAULT_OLLAMA_KEEP_ALIVE,
261
+ ) -> None:
262
+ super().__init__(
263
+ host=host,
264
+ model=model,
265
+ client=client,
266
+ batch_size=batch_size,
267
+ timeout=timeout,
268
+ keep_alive=keep_alive,
269
+ )
270
+
271
+ def _format_query(self, text: str) -> str:
272
+ return f"{_ARCTIC_QUERY_PREFIX}{text}"
273
+
274
+
275
+ class VoyageEmbedder:
276
+ """Wraps the Voyage AI SDK with batching and offline token counting.
277
+
278
+ Voyage's SDK natively understands ``input_type="query"|"document"`` so we
279
+ pass it through unchanged; no manual prefix dance.
280
+ """
281
+
282
+ dim: int = 1024
283
+
284
+ def __init__(
285
+ self,
286
+ *,
287
+ api_key: str,
288
+ client: Any | None = None,
289
+ batch_size: int = _VOYAGE_DEFAULT_BATCH,
290
+ timeout: float = _DEFAULT_OLLAMA_TIMEOUT_S,
291
+ ) -> None:
292
+ self._batch_size = batch_size
293
+ self._tokenizer = tiktoken.get_encoding("cl100k_base")
294
+ if client is not None:
295
+ self._client = client
296
+ else: # pragma: no cover - exercised only against real Voyage service
297
+ import voyageai
298
+
299
+ # The SDK accepts ``timeout=`` on the client constructor; pass it
300
+ # explicitly per CLAUDE.md "every external HTTP/DB client MUST
301
+ # have explicit timeouts".
302
+ self._client = voyageai.Client( # type: ignore[attr-defined]
303
+ api_key=api_key, timeout=timeout
304
+ )
305
+
306
+ def embed(
307
+ self, texts: list[str], *, input_type: str = "document"
308
+ ) -> list[list[float]]:
309
+ """Embed ``texts`` in batches of ``batch_size`` and return all vectors in order.
310
+
311
+ An empty input returns an empty list with no SDK round-trip (matching the
312
+ Ollama base). Any Voyage SDK failure — rate limit, transport error,
313
+ timeout, or a malformed/API error, all subclasses of
314
+ ``voyageai.error.VoyageError`` — is wrapped in :class:`VoyageEmbedError`
315
+ (an :class:`~brain.errors.EmbedError`) so callers get the same typed
316
+ embed error the Ollama backends raise. The explicit per-request timeout
317
+ set on the SDK client (see :meth:`__init__`) surfaces as a
318
+ ``voyageai.error.Timeout``, which is wrapped here too.
319
+ """
320
+ if not texts:
321
+ return []
322
+ # ``voyageai`` is an optional dependency imported lazily (only the voyage
323
+ # backend constructs this class), so reference its exception base lazily
324
+ # too — a top-level import would break arctic/qwen3-only installs.
325
+ from voyageai.error import VoyageError
326
+
327
+ out: list[list[float]] = []
328
+ for start in range(0, len(texts), self._batch_size):
329
+ batch = texts[start : start + self._batch_size]
330
+ try:
331
+ response = self._client.embed(
332
+ texts=batch, model=_VOYAGE_MODEL, input_type=input_type
333
+ )
334
+ except VoyageError as e:
335
+ raise VoyageEmbedError(f"Voyage embed request failed: {e}") from e
336
+ out.extend(response.embeddings)
337
+ return out
338
+
339
+ def count_tokens(self, text: str) -> int:
340
+ """Return the number of tokens in ``text`` per the local tiktoken tokenizer."""
341
+ return len(self._tokenizer.encode(text))
342
+
343
+
344
+ class NullEmbedder:
345
+ """FTS-only backend: satisfies the Protocol but produces no vectors.
346
+
347
+ Selected via ``BRAIN_EMBEDDER=none`` for a user with no Ollama — ingest,
348
+ lexical (FTS) search, and ``brain doctor`` all work; only the vector leg of
349
+ hybrid search is unavailable. Two contract points make the upgrade path
350
+ painless:
351
+
352
+ - ``dim == 1024`` matches the arctic / voyage schema, so switching to a real
353
+ 1024-dim backend later is a plain ``brain reembed`` backfill — NO
354
+ destructive column rebuild (``db.ensure_embedding_column`` sees the dims
355
+ already agree).
356
+ - ``produces_embeddings = False`` is a duck-typed flag (NOT part of the
357
+ :class:`brain.ingest.Embedder` Protocol — the real backends never declare
358
+ it). Callers check it via ``getattr(embedder, "produces_embeddings",
359
+ True)`` to degrade gracefully: the ingest pipeline stores NULL embeddings
360
+ and :func:`brain.search.hybrid_search` coerces to ``fts_only``.
361
+
362
+ :meth:`count_tokens` uses the same offline ``cl100k_base`` tokenizer as
363
+ every other backend so the chunker's token budgeting is unchanged.
364
+ :meth:`embed` never runs under the ingest / search / doctor paths (they
365
+ degrade earlier); if any other path calls it, it raises
366
+ :class:`EmbedDisabledError` with an upgrade hint rather than crashing
367
+ opaquely.
368
+ """
369
+
370
+ dim: int = 1024
371
+ produces_embeddings: bool = False
372
+
373
+ def __init__(self) -> None:
374
+ self._tokenizer = tiktoken.get_encoding("cl100k_base")
375
+
376
+ def embed(
377
+ self, texts: list[str], *, input_type: str = "document"
378
+ ) -> NoReturn:
379
+ """Always raise :class:`EmbedDisabledError` — the null backend has no vectors.
380
+
381
+ The keyword-only ``input_type`` default mirrors the
382
+ :class:`brain.ingest.Embedder` Protocol so the signature is substitutable
383
+ for the real backends.
384
+ """
385
+ raise EmbedDisabledError(_EMBED_DISABLED_MESSAGE)
386
+
387
+ def count_tokens(self, text: str) -> int:
388
+ """Return the number of tokens in ``text`` per the local tiktoken tokenizer."""
389
+ return len(self._tokenizer.encode(text))
390
+
391
+
392
+ def make_embedder(cfg: Config) -> Embedder:
393
+ """Return the active embedder based on ``BRAIN_EMBEDDER`` config.
394
+
395
+ Dispatches on ``cfg.embedder`` ∈ ``{"arctic", "voyage", "qwen3", "none"}``.
396
+ ``none`` returns the FTS-only :class:`NullEmbedder` (no Ollama / no API key
397
+ required). Raises :class:`ConfigError` when the chosen backend's required
398
+ config is missing (e.g. ``VOYAGE_API_KEY`` for the voyage backend) — earlier
399
+ than the first embed call so ``brain init`` and ``brain doctor`` surface the
400
+ misconfiguration cleanly.
401
+
402
+ Returns the :class:`brain.ingest.Embedder` Protocol; callers should not
403
+ depend on the concrete subclass.
404
+ """
405
+ if cfg.embedder == "none":
406
+ return NullEmbedder()
407
+ if cfg.embedder == "arctic":
408
+ return ArcticEmbedder(host=cfg.ollama_host, keep_alive=cfg.ollama_keep_alive)
409
+ if cfg.embedder == "qwen3":
410
+ return Qwen3Embedder(
411
+ host=cfg.ollama_host,
412
+ model=cfg.qwen3_model,
413
+ keep_alive=cfg.ollama_keep_alive,
414
+ )
415
+ if cfg.embedder == "voyage":
416
+ if cfg.voyage_api_key is None:
417
+ raise ConfigError(
418
+ "BRAIN_EMBEDDER=voyage requires VOYAGE_API_KEY (see .env.example)"
419
+ )
420
+ return VoyageEmbedder(api_key=cfg.voyage_api_key)
421
+ raise ConfigError(
422
+ f"BRAIN_EMBEDDER must be one of: arctic, voyage, qwen3, none "
423
+ f"(got {cfg.embedder!r})"
424
+ )