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/bin/monitor.py ADDED
@@ -0,0 +1,570 @@
1
+ """brain-monitor — diagnose the brain sync + build watchers."""
2
+
3
+ import argparse
4
+ import contextlib
5
+ import os
6
+ import re
7
+ import signal
8
+ import subprocess
9
+ import sys
10
+ import threading
11
+ import time
12
+ import urllib.error
13
+ import urllib.request
14
+ from datetime import datetime
15
+ from pathlib import Path
16
+ from types import FrameType
17
+
18
+ from ..config import _brain_home_root
19
+
20
+ # ---------------------------------------------------------------------------
21
+ # Env defaults
22
+ # ---------------------------------------------------------------------------
23
+
24
+
25
+ def _vault() -> Path:
26
+ return Path(os.environ.get("BRAIN_VAULT_PATH", str(Path.home() / "brain-vault")))
27
+
28
+
29
+ def _port() -> int:
30
+ return int(os.environ.get("BRAIN_WIKI_PORT", "8080"))
31
+
32
+
33
+ # ---------------------------------------------------------------------------
34
+ # Runtime state paths.
35
+ #
36
+ # PID files live under $BRAIN_HOME/run/, matching the foreground wrapper
37
+ # scripts (`_brain-{watcher,build}-fg.sh`). They used to default to
38
+ # /tmp/brain-{watch,build}.pid, but macOS's tmp_cleaner reaps files left
39
+ # untouched for 3 days — and these daemons write their pid file once at launch
40
+ # then run for weeks, so the /tmp pid file silently vanished and we misreported
41
+ # a live daemon as "stopped". $BRAIN_HOME/run/ is not reaped. The BRAIN_*_PID
42
+ # env overrides mirror the shell shims (and let the test suite point them at a
43
+ # tmp dir). Log files live at
44
+ # `$BRAIN_HOME/logs/com.brain.{watcher,build}.{out,err}.log` (the launchd
45
+ # plists redirect StandardOut/StandardError there).
46
+ # ---------------------------------------------------------------------------
47
+
48
+ WATCH_PID = Path(
49
+ os.environ.get("BRAIN_WATCH_PID", str(_brain_home_root() / "run" / "brain-watch.pid"))
50
+ )
51
+ BUILD_PID = Path(
52
+ os.environ.get("BRAIN_BUILD_PID", str(_brain_home_root() / "run" / "brain-build.pid"))
53
+ )
54
+
55
+
56
+ def _watch_log() -> Path:
57
+ return _brain_home_root() / "logs" / "com.brain.watcher.out.log"
58
+
59
+
60
+ def _build_log() -> Path:
61
+ return _brain_home_root() / "logs" / "com.brain.build.out.log"
62
+
63
+ # Directories to skip when walking the vault tree (mirrors the bash `find`
64
+ # -not -path invocations that used BSD-only stat -f flags).
65
+ _VAULT_SKIP_DIRS: frozenset[str] = frozenset({".quartz", ".git"})
66
+
67
+
68
+ # ---------------------------------------------------------------------------
69
+ # Build-log noise filter — mirrors the bash grep -Ev shape exactly.
70
+ # Also drops blank lines so trailing KaTeX bursts don't pollute the tail.
71
+ # ---------------------------------------------------------------------------
72
+
73
+ _BUILD_LOG_NOISE = re.compile(
74
+ r"^\s*$"
75
+ r"|LaTeX-incompatible input"
76
+ r"|No character metrics for"
77
+ r"|Warning: couldn't find git repository"
78
+ r"|^Parsing input files using"
79
+ r"|^Cleaned output directory"
80
+ )
81
+
82
+
83
+ # ---------------------------------------------------------------------------
84
+ # Cross-platform helpers (replace the BSD-only stat -f calls in the bash)
85
+ # ---------------------------------------------------------------------------
86
+
87
+
88
+ def _pid_alive(pid_file: Path) -> bool:
89
+ """Return True if the process whose PID is stored in *pid_file* is running."""
90
+ if not pid_file.is_file():
91
+ return False
92
+ try:
93
+ pid = int(pid_file.read_text().strip())
94
+ except (ValueError, OSError):
95
+ return False
96
+ try:
97
+ os.kill(pid, 0) # signal 0 = existence check only
98
+ except (OSError, ProcessLookupError):
99
+ return False
100
+ return True
101
+
102
+
103
+ def _pid_or_dash(pid_file: Path) -> str:
104
+ """Return the PID from *pid_file* as a string, or '-' if missing/unreadable."""
105
+ if not pid_file.is_file():
106
+ return "-"
107
+ try:
108
+ return pid_file.read_text().strip() or "-"
109
+ except OSError:
110
+ return "-"
111
+
112
+
113
+ def _current_build_id(vault: Path) -> str:
114
+ """Return the current build ID from vault/.quartz/current/.build-id."""
115
+ build_id_file = vault / ".quartz" / "current" / ".build-id"
116
+ if not build_id_file.is_file():
117
+ return "(none)"
118
+ try:
119
+ return build_id_file.read_text().strip()
120
+ except OSError:
121
+ return "(none)"
122
+
123
+
124
+ def _recent_builds(vault: Path, n: int = 5) -> list[str]:
125
+ """Return the *n* most-recent build directory names (lexical == chronological)."""
126
+ builds_dir = vault / ".quartz" / "builds"
127
+ if not builds_dir.is_dir():
128
+ return []
129
+ names = sorted(p.name for p in builds_dir.iterdir())
130
+ return names[-n:]
131
+
132
+
133
+ def _recent_vault_files(vault: Path, n: int = 5) -> list[tuple[float, str]]:
134
+ """Return the *n* most-recently-modified vault files as (mtime, path) tuples.
135
+
136
+ Excludes .quartz/, .git/ sub-trees and .DS_Store files, matching the bash
137
+ ``find … -not -path … -not -name .DS_Store`` invocation that used
138
+ BSD-only ``stat -f '%m %N'`` for cross-platform mtime retrieval.
139
+ """
140
+ results: list[tuple[float, str]] = []
141
+ for root, dirs, files in os.walk(str(vault)):
142
+ # Prune skip-dirs in-place so os.walk won't descend into them.
143
+ dirs[:] = [d for d in dirs if d not in _VAULT_SKIP_DIRS]
144
+ for fname in files:
145
+ if fname == ".DS_Store":
146
+ continue
147
+ p = Path(root) / fname
148
+ try:
149
+ mtime = p.stat().st_mtime
150
+ results.append((mtime, str(p)))
151
+ except OSError:
152
+ continue
153
+ results.sort(key=lambda x: x[0], reverse=True)
154
+ return results[:n]
155
+
156
+
157
+ def _mtime_human(p: Path, fmt: str = "%Y-%m-%d %H:%M:%S") -> str:
158
+ """Return the mtime of *p* formatted according to *fmt*, or '?' on error.
159
+
160
+ Replaces the BSD-only ``stat -f '%Sm' -t '<fmt>'`` calls in the bash.
161
+ """
162
+ try:
163
+ return datetime.fromtimestamp(p.stat().st_mtime).strftime(fmt)
164
+ except OSError:
165
+ return "?"
166
+
167
+
168
+ def _filter_build_log_line(line: str) -> bool:
169
+ """Return True if *line* should be KEPT (i.e. it is not noise)."""
170
+ return not bool(_BUILD_LOG_NOISE.search(line))
171
+
172
+
173
+ def _url_responds(url: str, timeout: float = 2.0) -> bool:
174
+ """Return True if *url* returns a non-error response within *timeout* seconds."""
175
+ try:
176
+ with urllib.request.urlopen(url, timeout=timeout) as resp:
177
+ return int(resp.status) < 400
178
+ except (urllib.error.URLError, OSError, ValueError):
179
+ return False
180
+
181
+
182
+ def _pgrep_count(pattern: str) -> int:
183
+ """Return the number of running processes matching *pattern* (0 on any error)."""
184
+ try:
185
+ result = subprocess.run(
186
+ ["pgrep", "-f", pattern],
187
+ capture_output=True,
188
+ text=True,
189
+ timeout=5,
190
+ )
191
+ return len([ln for ln in result.stdout.splitlines() if ln.strip()])
192
+ except (OSError, subprocess.TimeoutExpired):
193
+ return 0
194
+
195
+
196
+ # ---------------------------------------------------------------------------
197
+ # Snapshot mode
198
+ # ---------------------------------------------------------------------------
199
+
200
+
201
+ def _snapshot(vault: Path, port: int) -> int:
202
+ """Print a status snapshot of both watchers and recent activity."""
203
+ sync_pid = _pid_or_dash(WATCH_PID)
204
+ build_pid = _pid_or_dash(BUILD_PID)
205
+ sync_state = "running" if _pid_alive(WATCH_PID) else "stopped"
206
+ build_state = "running" if _pid_alive(BUILD_PID) else "stopped"
207
+ url = f"http://localhost:{port}"
208
+
209
+ print(f"🧠 brain-monitor vault={vault}")
210
+ print()
211
+ print(f" sync watcher {sync_state:<7} pid={sync_pid:<7} log={_watch_log()}")
212
+ print(f" build watcher {build_state:<7} pid={build_pid:<7} log={_build_log()}")
213
+
214
+ # Cross-check: PID file says one thing; ps may say another. Surface mismatches.
215
+ sync_seen = _pgrep_count("brain vault sync --watch")
216
+ build_seen = _pgrep_count("brain.wiki.build_watcher")
217
+ if sync_seen != 1 or build_seen != 1:
218
+ print()
219
+ print(" ⚠️ process count vs pid file:")
220
+ print(f" sync_watcher pgrep={sync_seen} pidfile={sync_pid}")
221
+ print(f" build_watcher pgrep={build_seen} pidfile={build_pid}")
222
+ print(" (>1 = orphan running; 0 with pidfile = stale pid)")
223
+
224
+ # Current build
225
+ print()
226
+ print(" current build:")
227
+ current = vault / ".quartz" / "current"
228
+ if current.is_symlink():
229
+ target = os.readlink(str(current))
230
+ print(f" symlink → {target}")
231
+ print(f" build-id {_current_build_id(vault)}")
232
+ index_html = current / "index.html"
233
+ if index_html.is_file():
234
+ age = _mtime_human(index_html, "%Y-%m-%d %H:%M:%S")
235
+ print(f" last build {age}")
236
+ else:
237
+ print(" (no symlink — run brain-up to bootstrap)")
238
+
239
+ # Recent builds
240
+ builds_dir = vault / ".quartz" / "builds"
241
+ if builds_dir.is_dir():
242
+ print()
243
+ print(" recent builds:")
244
+ cur_id = _current_build_id(vault)
245
+ for b in _recent_builds(vault, 5):
246
+ mark = "→ " if b == cur_id else " "
247
+ mt = _mtime_human(builds_dir / b, "%H:%M:%S")
248
+ print(f" {mark}{mt} {b}")
249
+
250
+ # Recent vault edits
251
+ print()
252
+ print(" recent vault edits:")
253
+ for mtime, path in _recent_vault_files(vault, 5):
254
+ t = datetime.fromtimestamp(mtime).strftime("%H:%M:%S")
255
+ print(f" {t} {path}")
256
+
257
+ # Wiki URL
258
+ print()
259
+ print(" wiki url:")
260
+ if _url_responds(f"{url}/"):
261
+ print(f" {url} reachable ✅")
262
+ else:
263
+ print(f" {url} unreachable ⚠️")
264
+
265
+ # Last 8 build-log lines (filtered)
266
+ print()
267
+ print(" last 8 build-log lines (filtered):")
268
+ if _build_log().is_file() and _build_log().stat().st_size > 0:
269
+ try:
270
+ tail_lines = _build_log().read_text(errors="replace").splitlines()[-1000:]
271
+ filtered = [ln for ln in tail_lines if _filter_build_log_line(ln)]
272
+ last_8 = filtered[-8:]
273
+ except OSError:
274
+ last_8 = []
275
+ if last_8:
276
+ for ln in last_8:
277
+ print(f" {ln}")
278
+ else:
279
+ print(
280
+ " (only KaTeX/git-warnings in the tail window — filter saw nothing else)"
281
+ )
282
+ else:
283
+ print(" (empty)")
284
+
285
+ # Last 5 sync-log lines
286
+ print()
287
+ print(" last 5 sync-log lines:")
288
+ if _watch_log().is_file() and _watch_log().stat().st_size > 0:
289
+ try:
290
+ watch_lines = _watch_log().read_text(errors="replace").splitlines()[-5:]
291
+ for ln in watch_lines:
292
+ print(f" {ln}")
293
+ except OSError:
294
+ print(" (error reading watch log)")
295
+ else:
296
+ print(" (empty)")
297
+
298
+ return 0
299
+
300
+
301
+ # ---------------------------------------------------------------------------
302
+ # Live tail mode
303
+ # ---------------------------------------------------------------------------
304
+
305
+
306
+ def _tail(vault: Path) -> int:
307
+ """Stream both watcher logs to stdout with source prefixes."""
308
+ if not _build_log().is_file() and not _watch_log().is_file():
309
+ print("no logs to tail (neither watcher has started)", file=sys.stderr)
310
+ return 1
311
+
312
+ print("tailing watchers — Ctrl-C to stop")
313
+ print(f" sync: {_watch_log()}")
314
+ print(f" build: {_build_log()} (KaTeX/git-warnings filtered)")
315
+ print()
316
+
317
+ procs: list[subprocess.Popen[str]] = []
318
+
319
+ def _stream(log_path: Path, prefix: str, filtered: bool) -> None:
320
+ """Tail *log_path* and print prefixed lines to stdout."""
321
+ try:
322
+ proc: subprocess.Popen[str] = subprocess.Popen(
323
+ ["tail", "-n", "0", "-F", str(log_path)],
324
+ stdout=subprocess.PIPE,
325
+ stderr=subprocess.DEVNULL,
326
+ text=True,
327
+ bufsize=1, # line-buffered: each line flushed immediately (matches sed -u)
328
+ )
329
+ procs.append(proc)
330
+ if proc.stdout is None:
331
+ return
332
+ for raw_line in proc.stdout:
333
+ stripped = raw_line.rstrip("\n")
334
+ if filtered and not _filter_build_log_line(stripped):
335
+ continue
336
+ print(f"{prefix}{stripped}", flush=True)
337
+ except (OSError, ValueError):
338
+ pass
339
+
340
+ def _cleanup(signum: int, frame: FrameType | None) -> None:
341
+ for p in procs:
342
+ with contextlib.suppress(OSError):
343
+ p.terminate()
344
+ raise SystemExit(0)
345
+
346
+ signal.signal(signal.SIGINT, _cleanup)
347
+ signal.signal(signal.SIGTERM, _cleanup)
348
+
349
+ threads = [
350
+ threading.Thread(target=_stream, args=(_watch_log(), "[sync ] ", False), daemon=True),
351
+ threading.Thread(target=_stream, args=(_build_log(), "[build] ", True), daemon=True),
352
+ ]
353
+ for t in threads:
354
+ t.start()
355
+ for t in threads:
356
+ t.join()
357
+
358
+ return 0
359
+
360
+
361
+ # ---------------------------------------------------------------------------
362
+ # Probe mode
363
+ # ---------------------------------------------------------------------------
364
+
365
+
366
+ def _probe(vault: Path, timeout: int = 90) -> int:
367
+ """Write a probe file into vault and time the build watcher's response."""
368
+ if not vault.is_dir():
369
+ print(f"vault not found at {vault}", file=sys.stderr)
370
+ return 1
371
+ if not _pid_alive(BUILD_PID):
372
+ print("build watcher not running — start with brain-up", file=sys.stderr)
373
+ return 1
374
+
375
+ stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
376
+ probe_id = f"probe-{stamp}-{os.getpid()}"
377
+ probe_file = vault / f"_brain_monitor_{probe_id}.md"
378
+ baseline = _current_build_id(vault)
379
+ now_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
380
+
381
+ probe_file.write_text(
382
+ f"---\n"
383
+ f"title: brain-monitor probe {probe_id}\n"
384
+ f"---\n"
385
+ f"\n"
386
+ f"This file was written by brain-monitor at {now_str}.\n"
387
+ f"Marker: {probe_id}\n"
388
+ )
389
+
390
+ print(f"🧠 probe {probe_id}")
391
+ print(f" wrote {probe_file}")
392
+ print(f" baseline build-id: {baseline}")
393
+ print(f" waiting up to {timeout}s for the build watcher to react...")
394
+ print()
395
+
396
+ started = time.monotonic()
397
+ swap_elapsed: str | None = None
398
+ rendered_elapsed: str | None = None
399
+ current_dir = vault / ".quartz" / "current"
400
+
401
+ try:
402
+ while True:
403
+ elapsed = int(time.monotonic() - started)
404
+ cur = _current_build_id(vault)
405
+
406
+ if swap_elapsed is None and cur != baseline and cur != "(none)":
407
+ swap_elapsed = f"{elapsed}s"
408
+ print(f" +{elapsed}s current swapped → {cur}")
409
+
410
+ if rendered_elapsed is None:
411
+ matches = list(current_dir.glob(f"_brain_monitor_{probe_id}*"))
412
+ if matches:
413
+ rendered_elapsed = f"{elapsed}s"
414
+ print(f" +{elapsed}s probe HTML appeared in current/")
415
+ break
416
+
417
+ if elapsed >= timeout:
418
+ break
419
+
420
+ time.sleep(1)
421
+ finally:
422
+ with contextlib.suppress(OSError):
423
+ probe_file.unlink(missing_ok=True)
424
+
425
+ print()
426
+ print(" result:")
427
+ if swap_elapsed is not None and rendered_elapsed is not None:
428
+ print(" ✅ end-to-end OK")
429
+ print(f" swap to new build: {swap_elapsed}")
430
+ print(f" probe content live: {rendered_elapsed}")
431
+ elif swap_elapsed is not None:
432
+ print(f" ⚠️ build ran ({swap_elapsed}) but probe HTML never appeared in current/")
433
+ print(" likely a build_swap mismatch or the probe was filtered out")
434
+ print(f" inspect: tail -n 200 {_build_log()} | grep -i {probe_id}")
435
+ else:
436
+ print(f" ❌ no rebuild within {timeout}s — watcher is not seeing the vault")
437
+ print(" things to check:")
438
+ print(
439
+ " pgrep -af brain.wiki.build_watcher"
440
+ " # one process? right --vault?"
441
+ )
442
+ print(" pgrep -af 'brain vault sync --watch' # same")
443
+ print(f" tail -n 50 {_build_log()}")
444
+ print(f" tail -n 50 {_watch_log()}")
445
+ print(" brain-down && brain-up # nuclear option")
446
+
447
+ return 0
448
+
449
+
450
+ # ---------------------------------------------------------------------------
451
+ # Argument parser
452
+ # ---------------------------------------------------------------------------
453
+
454
+
455
+ def _make_parser() -> argparse.ArgumentParser:
456
+ parser = argparse.ArgumentParser(
457
+ prog="brain-monitor",
458
+ description=(
459
+ "Diagnose the brain sync + build watchers.\n\n"
460
+ "Modes:\n"
461
+ " (default) Snapshot of both watchers and recent activity.\n"
462
+ " -f/--tail Live stream of relevant events from both logs.\n"
463
+ " probe [N] Touch a vault file and time the rebuild (timeout N sec).\n"
464
+ ),
465
+ formatter_class=argparse.RawDescriptionHelpFormatter,
466
+ epilog=(
467
+ "Env vars:\n"
468
+ " BRAIN_VAULT_PATH default ~/brain-vault\n"
469
+ " BRAIN_WIKI_PORT default 8080"
470
+ ),
471
+ )
472
+ # Accept all bash-compatible positional aliases. "tail" and "help" are
473
+ # kept as aliases so existing scripts using the bash form still work.
474
+ parser.add_argument(
475
+ "command",
476
+ nargs="?",
477
+ choices=["probe", "snapshot", "status", "tail", "help"],
478
+ default=None,
479
+ metavar="COMMAND",
480
+ help="probe | snapshot | status | tail | help (default: snapshot)",
481
+ )
482
+ parser.add_argument(
483
+ "-f",
484
+ "--tail",
485
+ action="store_true",
486
+ help="Live-stream both watcher logs (KaTeX noise filtered on build log).",
487
+ )
488
+ parser.add_argument(
489
+ "--timeout",
490
+ type=int,
491
+ default=90,
492
+ metavar="SECONDS",
493
+ help="Timeout for probe mode in seconds (default: 90).",
494
+ )
495
+ return parser
496
+
497
+
498
+ # ---------------------------------------------------------------------------
499
+ # Entry points
500
+ # ---------------------------------------------------------------------------
501
+
502
+
503
+ def main(argv: list[str] | None = None) -> int:
504
+ """Parse args and dispatch to the appropriate mode. Returns exit code."""
505
+ parser = _make_parser()
506
+ # parse_known_args is used solely to allow `brain-monitor probe 60`
507
+ # (positional timeout — bash backward-compat). The leftover `extra` list
508
+ # is validated immediately below; anything unexpected is still an error.
509
+ try:
510
+ args, extra = parser.parse_known_args(argv)
511
+ except SystemExit as exc:
512
+ code = exc.code
513
+ if isinstance(code, int):
514
+ return code
515
+ return 0
516
+
517
+ vault = _vault()
518
+ port = _port()
519
+
520
+ cmd: str = str(args.command) if args.command else "snapshot"
521
+
522
+ # Validate leftover tokens from parse_known_args BEFORE dispatching to any
523
+ # mode (including --tail). The only valid case for a non-empty `extra` is
524
+ # `probe <N>` where N is a bare integer (bash compat for the positional
525
+ # timeout). Anything else — unknown flags, extra words on --tail or any
526
+ # other command — is an error so that e.g. `brain-monitor --tail --bogus`
527
+ # or `brain-monitor snapshot --x` fail loudly rather than being silently
528
+ # swallowed.
529
+ if extra:
530
+ probe_timeout_ok = cmd == "probe" and len(extra) == 1 and extra[0].isdigit()
531
+ if not probe_timeout_ok:
532
+ print(
533
+ f"brain-monitor: error: unrecognized arguments: {' '.join(extra)}",
534
+ file=sys.stderr,
535
+ )
536
+ parser.print_usage(sys.stderr)
537
+ return 2
538
+
539
+ # -f / --tail flag takes precedence over any positional.
540
+ if args.tail:
541
+ return _tail(vault)
542
+
543
+ if cmd in ("snapshot", "status"):
544
+ return _snapshot(vault, port)
545
+
546
+ # "tail" as a bare positional: bash-compat alias for -f/--tail.
547
+ if cmd == "tail":
548
+ return _tail(vault)
549
+
550
+ # "help" as a bare positional: bash-compat alias for -h/--help.
551
+ if cmd == "help":
552
+ parser.print_help()
553
+ return 0
554
+
555
+ if cmd == "probe":
556
+ # Accept positional timeout for bash compat: `brain-monitor probe 60`
557
+ timeout = args.timeout
558
+ if extra and extra[0].isdigit():
559
+ timeout = int(extra[0])
560
+ return _probe(vault, timeout)
561
+
562
+ # Defensive: argparse choices= already rejects truly unknown values.
563
+ print(f"unknown command: {cmd}", file=sys.stderr)
564
+ parser.print_help(sys.stderr)
565
+ return 2
566
+
567
+
568
+ def cli_main() -> None:
569
+ """Console-script entry point. Calls sys.exit with main()'s return code."""
570
+ sys.exit(main())
brain/bin/rebuild.py ADDED
@@ -0,0 +1,8 @@
1
+ """brain-rebuild — full-corpus rebuild orchestrator (see brain.maintenance)."""
2
+ import sys
3
+
4
+ from ..maintenance import main as _main
5
+
6
+
7
+ def main() -> None:
8
+ raise SystemExit(_main(sys.argv[1:]))
brain/bin/status.py ADDED
@@ -0,0 +1,8 @@
1
+ """brain-status — show the brain runtime status."""
2
+ import sys
3
+
4
+ from ._launcher import exec_shim
5
+
6
+
7
+ def main() -> None:
8
+ exec_shim("brain-status", sys.argv[1:])
brain/bin/up.py ADDED
@@ -0,0 +1,8 @@
1
+ """brain-up — start the wiki + supervised watchers."""
2
+ import sys
3
+
4
+ from ._launcher import exec_shim
5
+
6
+
7
+ def main() -> None:
8
+ exec_shim("brain-up", sys.argv[1:])