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/__init__.py ADDED
File without changes
brain/__main__.py ADDED
@@ -0,0 +1,18 @@
1
+ """Enable ``python -m brain [command]`` invocation of the brain CLI.
2
+
3
+ This module is the standard entry-point for Python's ``-m`` flag:
4
+
5
+ python -m brain init
6
+ python -m brain doctor
7
+ python -m brain search "..."
8
+
9
+ It delegates directly to the Typer app in :mod:`brain.cli`, which
10
+ processes ``sys.argv`` and dispatches to the appropriate subcommand.
11
+ ``brain setup`` uses this path (via ``sys.executable``) to invoke ``brain
12
+ init`` and ``brain doctor`` inside the same virtualenv that is running the
13
+ installer, without relying on the ``brain`` console-script being on ``PATH``.
14
+ """
15
+
16
+ from brain.cli import app
17
+
18
+ app()
@@ -0,0 +1,445 @@
1
+ """`brain capture` quick-capture inbox sub-app (Plan 09 Phases 1-2)."""
2
+ from __future__ import annotations
3
+
4
+ import sys
5
+ from datetime import date
6
+ from typing import Any
7
+
8
+ import psycopg
9
+ import typer
10
+
11
+ from brain import capture as capture_mod
12
+ from brain.config import Config
13
+ from brain.db import connect
14
+ from brain.errors import EnrichmentError, OllamaUnavailable, VaultNoteSyncError
15
+ from brain.format import emit_json
16
+ from brain.ingest import apply_tags
17
+ from brain.queries import (
18
+ DocumentRow,
19
+ count_documents_with_tag,
20
+ list_documents,
21
+ list_existing_tags,
22
+ )
23
+ from brain.tags import normalize_tags
24
+ from brain.vault.note_builder import create_vault_note
25
+
26
+ # The always-on tag every capture carries until it is reviewed out of the inbox.
27
+ _INBOX_TAG = "inbox"
28
+ # Default number of inbox items surfaced by ``brain capture review``.
29
+ _REVIEW_DEFAULT_LIMIT = 10
30
+ # Upper bound on items listed by ``brain capture list`` (the inbox is small by
31
+ # design; this caps a runaway listing without paginating).
32
+ _INBOX_LIST_LIMIT = 100
33
+ # Cap on brand-new (non-vocabulary) tags applied per item in ``--auto`` mode.
34
+ _AUTO_MAX_NEW = 1
35
+
36
+ capture_app = typer.Typer(
37
+ name="capture",
38
+ help=(
39
+ "Quick-capture inbox: jot a thought into the brain, tagged `inbox`. "
40
+ "Pipe text on stdin or pass --text; review later with the inbox tools."
41
+ ),
42
+ invoke_without_command=True,
43
+ no_args_is_help=False,
44
+ )
45
+
46
+
47
+ @capture_app.callback(invoke_without_command=True)
48
+ def capture(
49
+ ctx: typer.Context,
50
+ title: str | None = typer.Option(
51
+ None, "--title", help="Document title. Defaults to a date-stamped auto-title."
52
+ ),
53
+ text: str | None = typer.Option(
54
+ None, "--text", help="Capture content inline instead of piping stdin."
55
+ ),
56
+ tag: list[str] = typer.Option(
57
+ [], "--tag", "-t", help="Extra tag(s) applied alongside the always-on `inbox` tag."
58
+ ),
59
+ ) -> None:
60
+ """Capture a quick note into the inbox (tagged `inbox`).
61
+
62
+ Content comes from ``--text`` when provided, otherwise from stdin. Each
63
+ capture authors a vault-tier note under ``capture/`` (visible in the
64
+ Quartz UI without the "Show ingested" toggle). No inline graph sync is
65
+ performed — captures are picked up by the normal ``brain-rebuild`` cycle.
66
+
67
+ No inline enrichment is performed — ``documents.summary`` stays ``NULL``
68
+ until ``brain enrich --backfill`` (or a full ``brain-rebuild``) runs, so
69
+ ``brain capture review --auto`` will defer freshly-captured items until
70
+ a summary is present.
71
+ """
72
+ # Phase 2 registers `review` / `list` subcommands under this app; when a
73
+ # subcommand is invoked the callback must defer to it instead of capturing.
74
+ if ctx.invoked_subcommand is not None:
75
+ return
76
+
77
+ content = text if text is not None else sys.stdin.read()
78
+ if not content.strip():
79
+ typer.secho(
80
+ "capture content is empty (pass --text or pipe content on stdin)",
81
+ fg="red",
82
+ err=True,
83
+ )
84
+ raise typer.Exit(code=1)
85
+
86
+ cfg = Config.load()
87
+ if cfg.vault_path is None:
88
+ typer.secho(
89
+ "vault path is not configured — set BRAIN_VAULT_PATH or run "
90
+ "`brain vault init`",
91
+ fg="red",
92
+ err=True,
93
+ )
94
+ raise typer.Exit(code=1)
95
+
96
+ resolved_title = (
97
+ title.strip()
98
+ if title and title.strip()
99
+ else capture_mod.make_capture_title(
100
+ content, today=date.today(), max_words=cfg.capture_title_words
101
+ )
102
+ )
103
+ # Normalize at the capture boundary so the vault frontmatter tags are
104
+ # queryable by exact tag filters. "inbox" is already canonical; prepending
105
+ # it never collides.
106
+ tags = normalize_tags(["inbox", *tag])
107
+
108
+ # Resolve the embedder factory through ``brain.cli`` at call time: importing
109
+ # at module load would create an import cycle (cli imports this module), and
110
+ # the test suite monkeypatches ``brain.cli._build_embedder`` — reading the
111
+ # attribute off the module lazily honors that patch.
112
+ from brain import cli as _cli
113
+
114
+ embedder = _cli._build_embedder(cfg) # type: ignore[attr-defined]
115
+ try:
116
+ with connect(cfg.database_url) as conn:
117
+ conn.autocommit = True
118
+ doc_id = create_vault_note(
119
+ conn,
120
+ cfg=cfg,
121
+ vault_path=cfg.vault_path,
122
+ title=resolved_title,
123
+ body=content,
124
+ tags=tags,
125
+ folder="capture",
126
+ embedder=embedder,
127
+ )
128
+ except VaultNoteSyncError as exc:
129
+ for path, reason in exc.errors:
130
+ typer.secho(f"sync error: {path}: {reason}", fg="red", err=True)
131
+ raise typer.Exit(code=1) from exc
132
+
133
+ typer.echo(f"✓ captured {doc_id[:8]} ({resolved_title}) [inbox]")
134
+
135
+
136
+ # ---------------------------------------------------------------------------
137
+ # Phase 2 — inbox review + list subcommands.
138
+ # ---------------------------------------------------------------------------
139
+
140
+
141
+ def _fetch_summaries(
142
+ conn: psycopg.Connection[Any], ids: list[str]
143
+ ) -> dict[str, str | None]:
144
+ """Return ``{document_id: summary}`` for ``ids`` in one round-trip.
145
+
146
+ ``list_documents`` returns the light projection (``summary`` is always
147
+ ``None``), so the review views fetch summaries separately. A single
148
+ ``= ANY`` query avoids an N+1 over the inbox.
149
+ """
150
+ if not ids:
151
+ return {}
152
+ rows = conn.execute(
153
+ "SELECT id::text, summary FROM documents WHERE id = ANY(%s::uuid[])",
154
+ (ids,),
155
+ ).fetchall()
156
+ return {str(r[0]): r[1] for r in rows}
157
+
158
+
159
+ def _count_inbox(conn: psycopg.Connection[Any]) -> int:
160
+ """Return the number of documents still carrying the ``inbox`` tag.
161
+
162
+ Delegates to :func:`brain.queries.count_documents_with_tag` — the same
163
+ shared helper the ``brain doctor`` inbox WARN uses — so the inbox-size
164
+ query lives in exactly one place.
165
+ """
166
+ return count_documents_with_tag(conn, _INBOX_TAG)
167
+
168
+
169
+ def _print_item(
170
+ *, index: int, total: int, row: DocumentRow, summary: str | None
171
+ ) -> None:
172
+ """Render one inbox item (id + title, optional summary, current tags).
173
+
174
+ Never logs the document body — only the title, summary, and tags, per the
175
+ "no document content at INFO" boundary.
176
+ """
177
+ typer.echo(f"[{index}/{total}] {row.id[:8]} {row.title}")
178
+ if summary:
179
+ typer.echo(f" summary: {summary}")
180
+ typer.echo(f" tags: {', '.join(row.tags)}")
181
+
182
+
183
+ def _discard_item(
184
+ conn: psycopg.Connection[Any],
185
+ *,
186
+ cfg: Config,
187
+ row: DocumentRow,
188
+ graph_syncer: Any,
189
+ ) -> None:
190
+ """Confirm + delete one inbox document (row, chunks, vault mirror).
191
+
192
+ Prints the destructive confirmation and only proceeds on an exact ``y`` /
193
+ ``Y``. The ``DELETE FROM documents`` cascades to chunks and to the relational
194
+ graph source-of-truth tables (``graph_entity_mentions``,
195
+ ``graph_edge_contributions``, ``graph_index_state``) via ``ON DELETE CASCADE``
196
+ FKs, so no inline relational cleanup is needed.
197
+
198
+ However, *derived* aggregate state (``graph_relationships`` and the AGE mirror)
199
+ is NOT cascade-covered — it requires :meth:`~brain.graph_rag.sync.GraphSyncer.remove`
200
+ to recompute aggregates and GC orphaned vertices/edges. This is only needed
201
+ when the document was previously graph-indexed, so we check
202
+ ``graph_index_state`` BEFORE the DELETE (the watermark row cascade-deletes
203
+ with the document). Fresh captures (no ``graph_index_state`` row) skip the
204
+ AGE work entirely, paying zero cost on discard. This mirrors the behaviour
205
+ of ``brain rm``, which always calls ``graph_syncer.remove``.
206
+
207
+ ``brain.cli._rm_unlink_vault_mirror`` removes the on-disk mirror with the
208
+ same guards/contract as ``brain rm``.
209
+ """
210
+ answer = typer.prompt(
211
+ f'Discard "{row.title}"? This cannot be undone. [y/N]',
212
+ default="n",
213
+ show_default=False,
214
+ prompt_suffix=" ",
215
+ ).strip()
216
+ if answer not in ("y", "Y"):
217
+ typer.echo(f" kept {row.id[:8]}")
218
+ return
219
+ # Read the graph watermark BEFORE the DELETE — it cascade-deletes with the doc.
220
+ _gis_row = conn.execute(
221
+ "SELECT EXISTS(SELECT 1 FROM graph_index_state WHERE document_id = %s)",
222
+ (row.id,),
223
+ ).fetchone()
224
+ had_graph_state: bool = bool(_gis_row[0]) if _gis_row is not None else False
225
+ # vault_path is not in the list projection — fetch it for the unlink.
226
+ vrow = conn.execute(
227
+ "SELECT vault_path FROM documents WHERE id = %s", (row.id,)
228
+ ).fetchone()
229
+ vault_path_rel: str | None = vrow[0] if vrow else None
230
+ # DELETE cascades to chunks and relational graph source-of-truth tables via FK.
231
+ conn.execute("DELETE FROM documents WHERE id = %s", (row.id,))
232
+ # For docs that were graph-indexed, clean up derived aggregate/AGE state.
233
+ # Fresh captures (no graph_index_state row) skip this — zero AGE cost.
234
+ if had_graph_state:
235
+ graph_syncer.remove(conn, row.id)
236
+ # Reuse the shared rm helper for the file-side contract (suffix strings +
237
+ # missing-file tolerance) rather than re-implementing the unlink.
238
+ from brain import cli as _cli
239
+
240
+ suffix = _cli._rm_unlink_vault_mirror(cfg=cfg, vault_path_rel=vault_path_rel)
241
+ typer.echo(f" discarded {row.id[:8]}{suffix}")
242
+
243
+
244
+ def _writeback_routed_tags(
245
+ conn: psycopg.Connection[Any],
246
+ *,
247
+ cfg: Config,
248
+ doc_id: str,
249
+ new_tags: list[str],
250
+ ) -> None:
251
+ """Sync a routed item's vault frontmatter after ``apply_tags``.
252
+
253
+ Captures write a ``capture/<slug>.md`` vault note, so a promote /
254
+ tag / ``--auto`` route that drops ``inbox`` (and adds routing tags) in the
255
+ DB must also rewrite that file's YAML ``tags`` — otherwise the file keeps
256
+ a stale ``inbox`` tag that a later ``brain vault sync`` could reintroduce.
257
+ Reuses :func:`brain.cli._tag_file_writeback` (the same post-``apply_tags``
258
+ writeback ``brain tag`` uses) with ``regenerate_file=False``: a missing
259
+ file degrades to DB-only + a warning rather than being force-regenerated.
260
+ """
261
+ row = conn.execute(
262
+ "SELECT vault_path, kind FROM documents WHERE id = %s", (doc_id,)
263
+ ).fetchone()
264
+ if row is None: # pragma: no cover - apply_tags above proved the row exists
265
+ return
266
+ vault_path_rel: str | None = row[0]
267
+ kind: str = row[1]
268
+ from brain import cli as _cli
269
+
270
+ _cli._tag_file_writeback(
271
+ conn,
272
+ cfg=cfg,
273
+ vault_path_rel=vault_path_rel,
274
+ kind=kind,
275
+ new_tags=new_tags,
276
+ doc_id=doc_id,
277
+ regenerate_file=False,
278
+ )
279
+
280
+
281
+ def _review_interactive(
282
+ conn: psycopg.Connection[Any],
283
+ rows: list[DocumentRow],
284
+ *,
285
+ cfg: Config,
286
+ graph_syncer: Any,
287
+ ) -> None:
288
+ """Drive the per-item ``[p]romote [t]ag [d]iscard [s]kip [q]uit`` loop."""
289
+ summaries = _fetch_summaries(conn, [r.id for r in rows])
290
+ for index, row in enumerate(rows, start=1):
291
+ _print_item(
292
+ index=index, total=len(rows), row=row, summary=summaries.get(row.id)
293
+ )
294
+ raw = typer.prompt(
295
+ "[p]romote [t]ag [d]iscard [s]kip [q]uit >",
296
+ default="s",
297
+ show_default=False,
298
+ prompt_suffix=" ",
299
+ ).strip()
300
+ parts = raw.split()
301
+ command = parts[0].lower() if parts else "s"
302
+ args = parts[1:]
303
+ if command == "q":
304
+ break
305
+ if command == "p":
306
+ new_tags = apply_tags(conn, row.id, remove=[_INBOX_TAG])
307
+ _writeback_routed_tags(conn, cfg=cfg, doc_id=row.id, new_tags=new_tags)
308
+ typer.echo(f" promoted {row.id[:8]}")
309
+ elif command == "t":
310
+ if not args:
311
+ typer.echo(" no tags given; left in inbox")
312
+ continue
313
+ new_tags = apply_tags(conn, row.id, add=args, remove=[_INBOX_TAG])
314
+ _writeback_routed_tags(conn, cfg=cfg, doc_id=row.id, new_tags=new_tags)
315
+ applied = " +".join(normalize_tags(args))
316
+ typer.echo(f" tagged {row.id[:8]}: +{applied}")
317
+ elif command == "d":
318
+ _discard_item(conn, cfg=cfg, row=row, graph_syncer=graph_syncer)
319
+ elif command == "s":
320
+ typer.echo(f" skipped {row.id[:8]}")
321
+ else:
322
+ typer.echo(f" unknown command {command!r}; left in inbox")
323
+ typer.echo(f"{_count_inbox(conn)} item(s) remaining in inbox")
324
+
325
+
326
+ def _review_auto(
327
+ conn: psycopg.Connection[Any],
328
+ rows: list[DocumentRow],
329
+ *,
330
+ cfg: Config,
331
+ enricher: Any,
332
+ ) -> None:
333
+ """Non-interactive routing: LLM-propose tags per item, remove ``inbox``.
334
+
335
+ Items without a summary (the LLM is unreliable on raw bodies) or for which
336
+ enrichment fails are reported and left in the inbox — a single bad item
337
+ never aborts the batch.
338
+ """
339
+ summaries = _fetch_summaries(conn, [r.id for r in rows])
340
+ # Hoist the vocabulary query outside the loop — one round-trip for N items.
341
+ existing_vocab = list_existing_tags(conn)
342
+ for row in rows:
343
+ summary = summaries.get(row.id)
344
+ if not summary:
345
+ typer.echo(
346
+ f"{row.id[:8]} left in inbox (no summary; run "
347
+ "`brain enrich --backfill`)"
348
+ )
349
+ continue
350
+ try:
351
+ proposal = enricher.propose_tags(
352
+ title=row.title,
353
+ summary=summary,
354
+ existing_vocab=existing_vocab,
355
+ current_tags=[_INBOX_TAG],
356
+ max_new=_AUTO_MAX_NEW,
357
+ )
358
+ except (OllamaUnavailable, EnrichmentError) as exc:
359
+ typer.echo(
360
+ f"{row.id[:8]} left in inbox (enrichment failed: "
361
+ f"{type(exc).__name__})"
362
+ )
363
+ continue
364
+ accepted = list(proposal.existing) + list(proposal.new[:_AUTO_MAX_NEW])
365
+ if not accepted:
366
+ typer.echo(f"{row.id[:8]} no tags proposed; left in inbox")
367
+ continue
368
+ new_tags = apply_tags(conn, row.id, add=accepted, remove=[_INBOX_TAG])
369
+ _writeback_routed_tags(conn, cfg=cfg, doc_id=row.id, new_tags=new_tags)
370
+ typer.echo(f"{row.id[:8]} routed: +{' +'.join(accepted)} (inbox removed)")
371
+
372
+
373
+ @capture_app.command("review")
374
+ def capture_review(
375
+ auto: bool = typer.Option(
376
+ False,
377
+ "--auto",
378
+ help="Non-interactive: LLM-route each item out of the inbox via tags.",
379
+ ),
380
+ limit: int = typer.Option(
381
+ _REVIEW_DEFAULT_LIMIT,
382
+ "--limit",
383
+ "-n",
384
+ help="Max inbox items to review this pass.",
385
+ ),
386
+ ) -> None:
387
+ """Review the quick-capture inbox: promote, tag, or discard each item.
388
+
389
+ Interactive by default — per item choose ``[p]romote`` (drop the ``inbox``
390
+ tag), ``[t]ag TAG ...`` (add tags + drop ``inbox``), ``[d]iscard`` (delete
391
+ after an explicit ``y``/``Y`` confirmation), ``[s]kip``, or ``[q]uit``.
392
+ ``--auto`` routes every item non-interactively via the local-Ollama tag
393
+ proposer (items without a summary are left untouched).
394
+ """
395
+ cfg = Config.load()
396
+ from brain import cli as _cli
397
+
398
+ with connect(cfg.database_url) as conn:
399
+ conn.autocommit = True
400
+ rows = list_documents(conn, tag=_INBOX_TAG, limit=limit)
401
+ if not rows:
402
+ typer.echo("inbox is empty")
403
+ return
404
+ if auto:
405
+ enricher = _cli._build_enricher(cfg)
406
+ _review_auto(conn, rows, cfg=cfg, enricher=enricher)
407
+ return
408
+ graph_syncer = _cli._build_graph_syncer(cfg)
409
+ _review_interactive(conn, rows, cfg=cfg, graph_syncer=graph_syncer)
410
+
411
+
412
+ @capture_app.command("list")
413
+ def capture_list(
414
+ json_output: bool = typer.Option(False, "--json", help="Emit JSON instead."),
415
+ ) -> None:
416
+ """List the documents currently sitting in the quick-capture inbox.
417
+
418
+ Mirrors ``brain list`` formatting: a compact ``<id> <kind> <type> <title>``
419
+ table by default, or a JSON array (each item carrying its ``tags``, which
420
+ always include ``inbox``) under ``--json``.
421
+ """
422
+ cfg = Config.load()
423
+ with connect(cfg.database_url) as conn:
424
+ rows = list_documents(conn, tag=_INBOX_TAG, limit=_INBOX_LIST_LIMIT)
425
+ if json_output:
426
+ emit_json(
427
+ [
428
+ {
429
+ "id": r.id,
430
+ "title": r.title,
431
+ "content_type": r.content_type,
432
+ "tags": r.tags,
433
+ "source_kind": r.source_kind,
434
+ "ingested_at": r.ingested_at,
435
+ }
436
+ for r in rows
437
+ ]
438
+ )
439
+ return
440
+ if not rows:
441
+ typer.echo("inbox is empty")
442
+ return
443
+ for r in rows:
444
+ kind = r.source_kind or "manual"
445
+ typer.echo(f"{r.id[:8]} {kind:<8} {r.content_type:<10} {r.title}")
brain/_compose.py ADDED
@@ -0,0 +1,52 @@
1
+ """Helper that wraps every `docker compose` invocation with the correct flags.
2
+
3
+ Centralising this prevents call sites from drifting — every invocation MUST
4
+ use both ``-f <BRAIN_HOME>/docker-compose.yml`` AND ``--project-name`` (default
5
+ ``brain``). Without ``--project-name``, the project name defaults to cwd
6
+ basename and shells from different directories can target different compose
7
+ projects against the same compose file.
8
+ """
9
+ import os
10
+ from pathlib import Path
11
+
12
+ from .config import _brain_home_root
13
+
14
+ # Default compose project name. Overridable via ``BRAIN_COMPOSE_PROJECT`` so a
15
+ # second stack (QA, a throwaway ``BRAIN_HOME``) targets an isolated project +
16
+ # container name instead of colliding with the real ``brain`` stack.
17
+ DEFAULT_COMPOSE_PROJECT = "brain"
18
+
19
+
20
+ def compose_project_name() -> str:
21
+ """Return the active compose project name (``$BRAIN_COMPOSE_PROJECT`` or ``brain``).
22
+
23
+ Single source of truth so :func:`compose_cmd` and the setup-time compose
24
+ render (``container_name``) stay in lock-step — a non-default project must
25
+ both switch ``--project-name`` and derive a non-colliding container name.
26
+ """
27
+ return os.environ.get("BRAIN_COMPOSE_PROJECT") or DEFAULT_COMPOSE_PROJECT
28
+
29
+
30
+ def compose_cmd(*args: str, brain_home: Path | None = None) -> list[str]:
31
+ """Build a docker-compose argv list with mandatory -f and --project-name flags.
32
+
33
+ Resolves ``$BRAIN_HOME`` via :func:`brain.config._brain_home_root` — so the
34
+ compose file path is consistent with the rest of the runtime. The optional
35
+ ``brain_home`` kwarg overrides the resolved path and is the recommended seam
36
+ for tests: pass ``tmp_path / ".brain"`` rather than monkeypatching env vars.
37
+
38
+ ``--project-name`` comes from :func:`compose_project_name` so the default
39
+ stays ``brain`` for real users while ``BRAIN_COMPOSE_PROJECT`` isolates a
40
+ parallel stack.
41
+ """
42
+ home = brain_home if brain_home is not None else _brain_home_root()
43
+ compose_file = home / "docker-compose.yml"
44
+ return [
45
+ "docker",
46
+ "compose",
47
+ "-f",
48
+ str(compose_file),
49
+ "--project-name",
50
+ compose_project_name(),
51
+ *args,
52
+ ]