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
@@ -0,0 +1,975 @@
1
+ """Watchdog-driven Quartz rebuild trigger.
2
+
3
+ Watches the vault for changes and runs :func:`build_and_swap` after a
4
+ quiet period. The architecture is the same shape as
5
+ ``brain.vault.watch`` (Observer thread → handler → debounce timer →
6
+ single-worker serialization) but stripped to one job type — "rebuild
7
+ the site" — because that's the only thing the wiki layer does.
8
+
9
+ Design notes:
10
+
11
+ - Only one build at a time. A ``threading.Lock`` guards
12
+ ``build_and_swap`` so a slow build can't pile up.
13
+ - During a build, additional events accumulate in ``pending_batch``
14
+ and drain into the next build after the in-flight build finishes.
15
+ This means N events fired during one build → exactly one rebuild
16
+ after (carrying all accumulated paths), not N.
17
+ - Editor scratch files (``.#foo``, ``foo~``) and the ``.git`` /
18
+ ``.quartz`` subtrees are filtered before debouncing — there's no
19
+ point waking the build for our own output (loop) or for files
20
+ Quartz won't render anyway.
21
+ - ``observer_factory`` is the test seam: production passes ``None`` and
22
+ watchdog's real ``Observer`` is used; tests pass a fake observer
23
+ with an ``inject(event)`` method to drive the handler synchronously.
24
+ """
25
+ from __future__ import annotations
26
+
27
+ import argparse
28
+ import contextlib
29
+ import logging
30
+ import os
31
+ import sys
32
+ import threading
33
+ import time
34
+ from collections.abc import Callable
35
+ from dataclasses import dataclass, field
36
+ from pathlib import Path
37
+
38
+ from watchdog.events import FileSystemEvent, FileSystemEventHandler
39
+ from watchdog.observers.api import BaseObserver
40
+ from watchdog.observers.polling import PollingObserver as Observer
41
+
42
+ # brain (2026-05-08): use PollingObserver instead of watchdog's default
43
+ # Observer (FSEvents backend). The FSEvents backend silently stops
44
+ # delivering events on Python 3.13 — Observer starts cleanly, stays
45
+ # alive, but never invokes the handler. See identical comment in
46
+ # brain/vault/watch.py for the diagnosis.
47
+ from .build_partial import BrainWikiPartialBuildError, run_build_partial
48
+ from .build_swap import BuildResult, _replace_vault_path, build_and_swap
49
+ from .edit_classifier import EditClassification, classify_edit
50
+ from .fastpath_state import FastpathState, FastpathStateError, write_state
51
+ from .slug import slugify_source_path
52
+
53
+ logger = logging.getLogger(__name__)
54
+
55
+ # Fast-path routing enabled by default. Set ``BRAIN_FASTPATH_ENABLED=false``
56
+ # (or ``0`` / ``no``) at process startup to force every debounced edit through
57
+ # the full ``build_and_swap`` path — useful for bisecting routing regressions
58
+ # without restarting Python. Read once at module load so the value is stable
59
+ # for the lifetime of the watcher process.
60
+ _FASTPATH_ENABLED: bool = os.environ.get("BRAIN_FASTPATH_ENABLED", "true").lower() not in (
61
+ "false",
62
+ "0",
63
+ "no",
64
+ )
65
+
66
+
67
+ # Subdirectories under the vault we never rebuild for. ``.git`` is
68
+ # obvious; ``.quartz`` is critical because that's where *we* write the
69
+ # build output — without this the watcher would loop on its own writes.
70
+ _IGNORED_TOP_DIRS: frozenset[str] = frozenset({".git", ".quartz"})
71
+
72
+
73
+ def _is_single_md_file(batch: frozenset[Path]) -> bool:
74
+ """Return True iff ``batch`` contains exactly one Markdown (``.md``) file.
75
+
76
+ The fast path only handles single-file Markdown edits — multi-file
77
+ batches (including mixed batches of ``.md`` + other types) always route
78
+ to the full ``build_and_swap`` path. Non-``.md`` files (images, CSS,
79
+ JavaScript assets) are similarly never eligible for partial emit.
80
+ """
81
+ if len(batch) != 1:
82
+ return False
83
+ return next(iter(batch)).suffix.lower() == ".md"
84
+
85
+
86
+ @dataclass
87
+ class _WatcherState:
88
+ """Mutable state shared between Observer thread and timer thread.
89
+
90
+ Every field is touched only while holding ``lock``, with two
91
+ intentional exceptions: ``stop_event`` is a thread-safe Event
92
+ (its own lock), and ``timer`` itself is replaced under ``lock``
93
+ but the cancel/start methods are safe to call from outside.
94
+
95
+ Batch queue design (T6b):
96
+
97
+ ``current_batch`` accumulates paths during the debounce window (i.e.
98
+ between event arrival and _fire). When _fire runs and no build is in
99
+ flight, ``current_batch`` is atomically drained into the build call.
100
+
101
+ When _fire runs while a build IS in flight, paths move into
102
+ ``pending_batch`` instead. After the in-flight build finishes,
103
+ ``_drain_pending`` drains ``pending_batch`` into a follow-up build.
104
+ This ensures no path is ever dropped — contrast the old ``pending:
105
+ bool`` design where only a "there is something pending" flag was kept
106
+ and the actual paths were lost.
107
+
108
+ All reads and writes of ``current_batch`` and ``pending_batch`` MUST
109
+ be performed while holding ``lock``.
110
+ """
111
+
112
+ lock: threading.Lock = field(default_factory=threading.Lock)
113
+ timer: threading.Timer | None = None
114
+ # Paths that triggered events during the current debounce window.
115
+ # Populated by on_any_event (under ``lock``). _fire drains this
116
+ # atomically: either into a new build (no in-flight build) or into
117
+ # ``pending_batch`` (in-flight build running).
118
+ current_batch: set[Path] = field(default_factory=set)
119
+ # Paths that arrived while a build was in flight. Drained by
120
+ # _drain_pending once the in-flight build completes.
121
+ pending_batch: set[Path] = field(default_factory=set)
122
+ # Build serialization: only one build call runs at a time.
123
+ # ``running`` is true between "build started" and "build finished".
124
+ # ``pending_batch`` being non-empty implies a follow-up build is needed.
125
+ running: bool = False
126
+ # refresh_related single-flight: same pattern as running/pending,
127
+ # but for the post-build refresh_related call. Builds skip
128
+ # refresh_related inline (it costs ~70s on a 1100-doc vault); this
129
+ # state coordinates the daemon thread that runs it after each build.
130
+ refresh_lock: threading.Lock = field(default_factory=threading.Lock)
131
+ refresh_running: bool = False
132
+ refresh_pending: bool = False
133
+ stop_event: threading.Event = field(default_factory=threading.Event)
134
+
135
+
136
+ def run_watcher(
137
+ vault: Path,
138
+ *,
139
+ quartz_dir: Path | None = None,
140
+ debounce_seconds: float = 1.5,
141
+ keep: int = 3,
142
+ on_build: Callable[[BuildResult], None] | None = None,
143
+ observer_factory: Callable[[], BaseObserver] | None = None,
144
+ refresh_runner: Callable[[], None] | None = None,
145
+ ) -> None:
146
+ """Block until the stop event fires, rebuilding on debounced events.
147
+
148
+ ``vault`` is watched recursively. ``quartz_dir`` defaults to
149
+ ``vault/.quartz`` and is forwarded to :func:`build_and_swap`.
150
+ ``debounce_seconds`` is the quiet period before any event triggers a
151
+ build; rapid bursts (1000 events from a save-format-save IDE cycle)
152
+ collapse to one build.
153
+
154
+ ``keep`` is the post-build GC window — see :func:`build_and_swap`.
155
+
156
+ ``on_build`` fires once per *successful* build with the
157
+ ``BuildResult``. Production wires this to a logging hook so the
158
+ user can tail ``/tmp/brain-build.log`` and see one line per build.
159
+ Tests use it to capture and assert on results.
160
+
161
+ ``observer_factory`` defaults to watchdog's stock ``Observer``;
162
+ tests pass a fake to drive events synchronously without touching
163
+ the real FSEvents/inotify subsystem.
164
+
165
+ ``refresh_runner`` is forwarded to :class:`_Handler` as its
166
+ background-refresh injection seam. When ``None`` (the default),
167
+ the handler runs the real ``refresh_related`` DB/vault path after
168
+ each build. Tests pass a no-op or spy runner here so they can
169
+ exercise handler logic without touching the real DB or vault. See
170
+ :class:`_Handler` for the full contract.
171
+
172
+ The function returns when ``state.stop_event`` is set — production
173
+ flips it from a SIGINT/SIGTERM handler installed by :func:`main`;
174
+ tests flip it directly.
175
+ """
176
+ state = _WatcherState()
177
+ handler = _Handler(
178
+ state=state,
179
+ vault=vault,
180
+ quartz_dir=quartz_dir,
181
+ debounce_seconds=debounce_seconds,
182
+ keep=keep,
183
+ on_build=on_build,
184
+ refresh_runner=refresh_runner,
185
+ )
186
+ factory: Callable[[], BaseObserver] = observer_factory or Observer
187
+ observer = factory()
188
+ # brain (2026-05-08): schedule per non-hidden top-level subdir + root
189
+ # non-recursively, instead of `recursive=True` on the vault root.
190
+ # PollingObserver snapshots every file in the watched tree per poll
191
+ # cycle; with `recursive=True` on a vault containing
192
+ # `<vault>/.quartz/node_modules` (~60k+ files) the snapshot starves
193
+ # the polling thread and edits stop being detected. _should_trigger
194
+ # already filters hidden dirs from delivered events, so excluding
195
+ # them from the SCANNED tree is correct + cheap.
196
+ scheduled_names: list[str] = []
197
+ observer.schedule(handler, str(vault), recursive=False)
198
+ for child in vault.iterdir():
199
+ if child.is_dir() and not child.name.startswith("."):
200
+ observer.schedule(handler, str(child), recursive=True)
201
+ scheduled_names.append(child.name)
202
+ logger.info(
203
+ "wiki watcher: scoped polling on top-level dirs: %s",
204
+ ", ".join(scheduled_names) or "(none)",
205
+ )
206
+ observer.start()
207
+
208
+ try:
209
+ state.stop_event.wait()
210
+ finally:
211
+ # Cancel any pending debounce timer so it doesn't fire after we
212
+ # return — otherwise the test's tmp_path may already be torn
213
+ # down by the time the timer thread runs.
214
+ with state.lock:
215
+ if state.timer is not None:
216
+ state.timer.cancel()
217
+ state.timer = None
218
+ observer.stop()
219
+ observer.join(timeout=5.0)
220
+
221
+
222
+ # ---------------------------------------------------------------------------
223
+ # Handler + filtering.
224
+ # ---------------------------------------------------------------------------
225
+
226
+
227
+ class _Handler(FileSystemEventHandler):
228
+ """Watchdog handler that gates events into the debounce + build path.
229
+
230
+ Kept thin: filtering lives in :func:`_should_trigger`, debouncing
231
+ lives in :func:`_schedule`, the actual build lives in
232
+ :func:`_run_build`. The handler just wires them together so each
233
+ piece is independently testable.
234
+
235
+ ``refresh_runner`` — optional injection seam for the background
236
+ refresh path. When ``None`` (the default), each refresh cycle calls
237
+ :meth:`_run_refresh_related_once`, which loads ``Config`` and calls
238
+ the real ``refresh_related`` against the DB and vault. When a
239
+ runner is provided, the loop calls it instead and skips the
240
+ ``Config.load()`` / ``refresh_related()`` path entirely. The
241
+ runner must be thread-safe: it is invoked from the daemon refresh
242
+ thread, not the main thread. It must run synchronously to
243
+ completion before returning; the loop's drain check depends on it.
244
+ Tests use this seam to inject a spy or a no-op so they can
245
+ exercise handler logic without touching the real DB or vault.
246
+ """
247
+
248
+ def __init__(
249
+ self,
250
+ *,
251
+ state: _WatcherState,
252
+ vault: Path,
253
+ quartz_dir: Path | None,
254
+ debounce_seconds: float,
255
+ keep: int,
256
+ on_build: Callable[[BuildResult], None] | None,
257
+ refresh_runner: Callable[[], None] | None = None,
258
+ ) -> None:
259
+ self._state = state
260
+ self._vault = vault
261
+ self._quartz_dir = quartz_dir
262
+ self._debounce_seconds = debounce_seconds
263
+ self._keep = keep
264
+ self._on_build = on_build
265
+ self._refresh_runner = refresh_runner
266
+ # Part 4 (T6b): read state.json on startup for telemetry only.
267
+ # We use the state purely for logging — it never overrides routing.
268
+ self._read_startup_state()
269
+
270
+ def on_any_event(self, event: FileSystemEvent) -> None:
271
+ # Funnel everything through one classifier so the filter set
272
+ # stays in one place. ``on_any_event`` fires for every type
273
+ # (created/modified/deleted/moved) including directory events,
274
+ # which we drop unconditionally.
275
+ try:
276
+ if not _should_trigger(event, self._vault):
277
+ return
278
+ except Exception:
279
+ # A misbehaving event (bad utf-8 path, etc.) must never
280
+ # kill the Observer thread. Log and move on.
281
+ logger.exception("wiki watcher: filter raised on %r", event)
282
+ return
283
+ # Accumulate the path for batch-size routing detection (T6a/T6b).
284
+ # If a build is already in flight, route to pending_batch so the
285
+ # path is preserved for the follow-up build. Otherwise add to
286
+ # current_batch for the next debounce fire.
287
+ # All reads/writes of current_batch and pending_batch are
288
+ # protected by state.lock.
289
+ #
290
+ # Fix (T6b stale-timer): only schedule a debounce timer when the
291
+ # path goes into current_batch (i.e. no build is in flight).
292
+ # When running=True the path enters pending_batch and _drain_pending
293
+ # will start the follow-up build — no timer is needed or wanted.
294
+ # Scheduling a timer here when running=True would leave a stale timer
295
+ # that fires after _drain_pending clears current_batch; _fire would
296
+ # then see running=False + empty current_batch and call
297
+ # _run_build(frozenset()), routing an empty batch to _do_full_build().
298
+ should_schedule = False
299
+ raw = getattr(event, "src_path", None)
300
+ if raw:
301
+ path = _to_path(raw)
302
+ with self._state.lock:
303
+ if self._state.running:
304
+ self._state.pending_batch.add(path)
305
+ else:
306
+ self._state.current_batch.add(path)
307
+ should_schedule = True
308
+ if should_schedule:
309
+ self._schedule()
310
+
311
+ def _schedule(self) -> None:
312
+ """Reset the debounce timer to ``debounce_seconds`` from now."""
313
+ with self._state.lock:
314
+ if self._state.timer is not None:
315
+ self._state.timer.cancel()
316
+ timer = threading.Timer(self._debounce_seconds, self._fire)
317
+ timer.daemon = True
318
+ self._state.timer = timer
319
+ timer.start()
320
+
321
+ def _fire(self) -> None:
322
+ """Debounce expired — request a build (or queue one).
323
+
324
+ Batch-queue semantics (T6b):
325
+
326
+ - If a build is in flight (``running=True``): drain ``current_batch``
327
+ INTO ``pending_batch`` (union), clear ``current_batch``, and return.
328
+ The in-flight build's ``_drain_pending`` will pick up the merged
329
+ pending_batch when it finishes.
330
+ - Otherwise: drain ``current_batch`` → ``batch`` (frozenset), set
331
+ ``running=True``, clear ``current_batch``, then run the build
332
+ outside the lock.
333
+
334
+ All state reads/writes are under ``state.lock``,
335
+ which is the single lock covering both batch sets and ``running``.
336
+ """
337
+ with self._state.lock:
338
+ self._state.timer = None
339
+ if self._state.running:
340
+ # In-flight build: preserve paths in pending_batch.
341
+ self._state.pending_batch.update(self._state.current_batch)
342
+ self._state.current_batch.clear()
343
+ return
344
+ # Fix (T6b stale-timer): defense in depth — if current_batch is
345
+ # empty (e.g. a stale timer fired after _drain_pending already
346
+ # consumed the batch and cleared running), return without starting
347
+ # a build. Without this guard, _run_build(frozenset()) routes an
348
+ # empty batch to _do_full_build(), triggering a spurious full
349
+ # rebuild. With Fix #1 this path should be unreachable in normal
350
+ # operation, but we defend against any future code path that might
351
+ # install a timer without a corresponding current_batch entry.
352
+ if not self._state.current_batch:
353
+ return
354
+ # No in-flight build: drain current_batch and start one.
355
+ batch: frozenset[Path] = frozenset(self._state.current_batch)
356
+ self._state.current_batch.clear()
357
+ self._state.running = True
358
+
359
+ try:
360
+ self._run_build(batch)
361
+ finally:
362
+ self._drain_pending()
363
+
364
+ def _drain_pending(self) -> None:
365
+ """Run a single follow-up build if paths were queued during the last build.
366
+
367
+ Loops because a follow-up build is itself an opportunity for
368
+ more events to land while it runs — but at every iteration we
369
+ only run *one* build, so even a constantly-saving editor can't
370
+ starve us out.
371
+
372
+ Batch-queue semantics (T6b):
373
+
374
+ At each iteration:
375
+ - If ``pending_batch`` is empty → clear ``running`` and return.
376
+ - Otherwise → drain ``pending_batch`` → follow_batch (frozenset),
377
+ keep ``running=True``, then run the follow-up build outside the lock.
378
+ ``pending_batch`` is cleared atomically so any events arriving during
379
+ the follow-up build accumulate into a fresh ``pending_batch`` and will
380
+ be drained by the *next* iteration.
381
+ """
382
+ while True:
383
+ with self._state.lock:
384
+ if not self._state.pending_batch:
385
+ self._state.running = False
386
+ return
387
+ follow_batch: frozenset[Path] = frozenset(self._state.pending_batch)
388
+ self._state.pending_batch.clear()
389
+ # running stays True — we're about to run another build.
390
+ # _run_build already logs via its own try/except; suppressing
391
+ # here just keeps the drain loop alive across transient
392
+ # failures so a queued follow-up rebuild isn't stranded.
393
+ with contextlib.suppress(Exception):
394
+ self._run_build(follow_batch)
395
+
396
+ def _run_build(self, batch: frozenset[Path]) -> None:
397
+ """Route ``batch`` to the fast path or the full build.
398
+
399
+ Fast path is attempted when:
400
+ - :data:`_FASTPATH_ENABLED` is ``True`` (env gate), AND
401
+ - exactly one Markdown file changed in the debounce window.
402
+
403
+ All other cases (multi-file batch, non-md file, fast-path
404
+ failure) fall through to :meth:`_do_full_build`.
405
+ """
406
+ if _FASTPATH_ENABLED and _is_single_md_file(batch):
407
+ changed_file = next(iter(batch))
408
+ try:
409
+ self._try_fast_path(changed_file)
410
+ except Exception:
411
+ logger.exception(
412
+ "wiki watcher: fast-path routing failed unexpectedly;"
413
+ " falling back to full build"
414
+ )
415
+ self._do_full_build()
416
+ else:
417
+ self._do_full_build()
418
+
419
+ def _try_fast_path(self, changed_file: Path) -> None:
420
+ """Attempt a fast partial emit; fall back to full build on any failure.
421
+
422
+ Routing (in order):
423
+ 1. Compute slug — ValueError (path outside vault) → full build.
424
+ 2. Unsupported-slug pre-check (index / tags/* / */index) → full build.
425
+ 3. Classify via manifest fingerprint — NON_TRIVIAL → full build.
426
+ 4. Resolve workspace/current → missing → full build.
427
+ 5. Call :func:`run_build_partial`; :class:`BrainWikiPartialBuildError`
428
+ → log warning → full build.
429
+ 6. Success → log timing + schedule refresh_related.
430
+ """
431
+ workspace = (
432
+ self._quartz_dir if self._quartz_dir is not None else self._vault / ".quartz"
433
+ )
434
+
435
+ # Step 1: slug computation — ValueError means path outside vault.
436
+ try:
437
+ slug = slugify_source_path(changed_file, self._vault)
438
+ except ValueError:
439
+ logger.debug(
440
+ "wiki watcher: fast-path slug error (path outside vault=%s) → full build",
441
+ self._vault,
442
+ )
443
+ self._do_full_build()
444
+ return
445
+
446
+ # Step 2: unsupported-slug pre-check. These slug shapes require a
447
+ # full build; skipping the subprocess avoids subprocess overhead for a
448
+ # guaranteed-full-build case. The Node guard (T4 exit 6) remains as a
449
+ # direct-CLI safety net for callers that bypass this Python layer.
450
+ if slug == "index" or slug.startswith("tags/") or slug.endswith("/index"):
451
+ logger.debug(
452
+ "wiki watcher: unsupported fast-path slug %r → full build", slug
453
+ )
454
+ self._do_full_build()
455
+ return
456
+
457
+ # Step 3: classify the edit via manifest fingerprint.
458
+ fastpath_dir = workspace / ".cache" / "fastpath"
459
+ result = classify_edit(
460
+ fastpath_dir=fastpath_dir,
461
+ source_path=changed_file,
462
+ vault_root=self._vault,
463
+ )
464
+ if result.classification == EditClassification.NON_TRIVIAL:
465
+ logger.debug(
466
+ "wiki watcher: non-trivial edit (reason=%r) → full build",
467
+ result.reason,
468
+ )
469
+ self._do_full_build()
470
+ return
471
+
472
+ # Step 4: resolve the active build dir from the workspace/current symlink.
473
+ current_link = workspace / "current"
474
+ if not current_link.exists():
475
+ logger.debug("wiki watcher: no current build dir → full build")
476
+ self._do_full_build()
477
+ return
478
+ try:
479
+ build_dir = current_link.resolve(strict=True)
480
+ except (OSError, RuntimeError):
481
+ logger.debug("wiki watcher: cannot resolve current build dir → full build")
482
+ self._do_full_build()
483
+ return
484
+
485
+ # Step 5: attempt the partial build.
486
+ try:
487
+ partial_result = run_build_partial(
488
+ slug=slug,
489
+ vault_dir=self._vault,
490
+ build_dir=build_dir,
491
+ workspace_dir=workspace,
492
+ timeout_s=30.0,
493
+ )
494
+ except BrainWikiPartialBuildError as exc:
495
+ logger.warning(
496
+ "wiki: build-partial failed (kind=%s, slug=%s): %s; falling back to full build",
497
+ exc.kind.value,
498
+ exc.slug,
499
+ exc,
500
+ )
501
+ # Increment partial failure counter in state.json before falling back.
502
+ self._increment_partial_failure_count(fastpath_dir=fastpath_dir)
503
+ self._do_full_build()
504
+ return
505
+
506
+ # Step 6: success — update state.json (advisory, non-blocking).
507
+ logger.info(
508
+ "wiki: build-partial slug=%s elapsed=%dms",
509
+ slug,
510
+ partial_result.elapsed_ms,
511
+ )
512
+ self._update_state_after_partial(fastpath_dir=fastpath_dir, slug=slug)
513
+ # Kick off (or queue) the post-build refresh_related run — same
514
+ # single-flight pattern as the full build path.
515
+ self._schedule_refresh_related()
516
+
517
+ def _do_full_build(self) -> None:
518
+ """Call :func:`build_and_swap`; log + ignore failures."""
519
+ try:
520
+ result = build_and_swap(
521
+ self._vault,
522
+ quartz_dir=self._quartz_dir,
523
+ keep=self._keep,
524
+ # Skip the inline ~70s hybrid-search recompute. The
525
+ # post-build refresh thread below picks it up so the
526
+ # next build emits fresh related-docs JSON without
527
+ # blocking edit-to-UI on this one.
528
+ refresh_related_inline=False,
529
+ )
530
+ except Exception:
531
+ logger.exception("wiki watcher: build failed")
532
+ return
533
+ # Build succeeded — update state.json (advisory, non-blocking).
534
+ workspace = (
535
+ self._quartz_dir if self._quartz_dir is not None else self._vault / ".quartz"
536
+ )
537
+ fastpath_dir = workspace / ".cache" / "fastpath"
538
+ self._update_state_after_full(fastpath_dir=fastpath_dir, build_id=result.build_id)
539
+ # Kick off (or queue) a background refresh_related run.
540
+ # Single-flight: if one's already in flight, mark pending so
541
+ # it re-runs once after it finishes.
542
+ self._schedule_refresh_related()
543
+ if self._on_build is not None:
544
+ try:
545
+ self._on_build(result)
546
+ except Exception:
547
+ logger.exception("wiki watcher: on_build hook raised")
548
+
549
+ # ------------------------------------------------------------------
550
+ # state.json helpers (Parts 3 + 4 of T6b)
551
+ # ------------------------------------------------------------------
552
+
553
+ def _read_startup_state(self) -> None:
554
+ """Read state.json on watcher startup — advisory / telemetry only.
555
+
556
+ Never raises. On missing file, IO error, or parse error, logs at
557
+ INFO level and continues. Watcher_pid mismatch (stale state from
558
+ a prior watcher) is also logged at INFO. The result is NEVER used
559
+ as build-routing authority — only for informational logging.
560
+ """
561
+ workspace = (
562
+ self._quartz_dir if self._quartz_dir is not None else self._vault / ".quartz"
563
+ )
564
+ fastpath_dir = workspace / ".cache" / "fastpath"
565
+ if not fastpath_dir.exists():
566
+ logger.debug(
567
+ "wiki watcher: fastpath_dir %s not found at startup; "
568
+ "state.json will be written on first successful build",
569
+ fastpath_dir,
570
+ )
571
+ return
572
+ from .fastpath_state import read_state # local import — avoids startup overhead
573
+
574
+ try:
575
+ state = read_state(fastpath_dir)
576
+ except FastpathStateError as exc:
577
+ logger.info(
578
+ "wiki watcher: startup state.json unreadable (%s); "
579
+ "treating as fresh start",
580
+ exc,
581
+ )
582
+ return
583
+
584
+ if state is None:
585
+ # Missing file or stale PID (read_state returns None for both).
586
+ # Check if there WAS a file with a different PID to emit the
587
+ # stale-PID log line. We do this by reading raw JSON directly.
588
+ try:
589
+ import json as _json
590
+
591
+ raw = (fastpath_dir / "state.json").read_text(encoding="utf-8")
592
+ data = _json.loads(raw)
593
+ prev_pid = data.get("watcher_pid")
594
+ if prev_pid is not None and prev_pid != os.getpid():
595
+ logger.info(
596
+ "wiki watcher: previous watcher pid=%s; this is a fresh watcher",
597
+ prev_pid,
598
+ )
599
+ except (OSError, ValueError):
600
+ pass # File missing or malformed — already handled above.
601
+ return
602
+
603
+ logger.info(
604
+ "wiki watcher: resumed from state.json (last_full_at_ms=%d, "
605
+ "last_partial_at_ms=%d, consecutive_partial_failures=%d)",
606
+ state.last_full_at_ms,
607
+ state.last_partial_at_ms,
608
+ state.consecutive_partial_failures,
609
+ )
610
+
611
+ def _update_state_after_full(self, *, fastpath_dir: Path, build_id: str) -> None:
612
+ """Write state.json after a successful full build.
613
+
614
+ Resets ``consecutive_partial_failures`` to 0, records
615
+ ``last_full_at_ms``, and stamps ``watcher_pid``. Non-blocking:
616
+ if the fastpath_dir does not yet exist or any IO error occurs,
617
+ logs a warning and continues — state.json is advisory only.
618
+ """
619
+ if not fastpath_dir.exists():
620
+ # fastpath dir is created by the Node full-build hook (T2).
621
+ # If it doesn't exist yet, state.json cannot be written.
622
+ logger.debug(
623
+ "wiki watcher: fastpath_dir %s missing; skipping state.json write",
624
+ fastpath_dir,
625
+ )
626
+ return
627
+ from .fastpath_state import read_state # local import avoids circular deps
628
+
629
+ try:
630
+ existing = read_state(fastpath_dir)
631
+ except FastpathStateError:
632
+ existing = None
633
+
634
+ now_ms = int(time.time() * 1000)
635
+ new_state = FastpathState(
636
+ version=1,
637
+ watcher_pid=os.getpid(),
638
+ last_partial_at_ms=existing.last_partial_at_ms if existing is not None else 0,
639
+ last_full_at_ms=now_ms,
640
+ last_partial_slug=existing.last_partial_slug if existing is not None else None,
641
+ consecutive_partial_failures=0,
642
+ )
643
+ try:
644
+ write_state(fastpath_dir, new_state)
645
+ except FastpathStateError:
646
+ logger.warning(
647
+ "wiki watcher: failed to write state.json after full build (build_id=%s)",
648
+ build_id,
649
+ exc_info=True,
650
+ )
651
+
652
+ def _update_state_after_partial(self, *, fastpath_dir: Path, slug: str) -> None:
653
+ """Write state.json after a successful partial build.
654
+
655
+ Increments (resets to 0), records ``last_partial_at_ms``, and
656
+ stores the slug for telemetry. Non-blocking on IO error.
657
+ """
658
+ if not fastpath_dir.exists():
659
+ logger.debug(
660
+ "wiki watcher: fastpath_dir %s missing; skipping state.json write",
661
+ fastpath_dir,
662
+ )
663
+ return
664
+ from .fastpath_state import read_state # local import avoids circular deps
665
+
666
+ try:
667
+ existing = read_state(fastpath_dir)
668
+ except FastpathStateError:
669
+ existing = None
670
+
671
+ now_ms = int(time.time() * 1000)
672
+ new_state = FastpathState(
673
+ version=1,
674
+ watcher_pid=os.getpid(),
675
+ last_partial_at_ms=now_ms,
676
+ last_full_at_ms=existing.last_full_at_ms if existing is not None else 0,
677
+ last_partial_slug=slug,
678
+ consecutive_partial_failures=0,
679
+ )
680
+ try:
681
+ write_state(fastpath_dir, new_state)
682
+ except FastpathStateError:
683
+ logger.warning(
684
+ "wiki watcher: failed to write state.json after partial build (slug=%s)",
685
+ slug,
686
+ exc_info=True,
687
+ )
688
+
689
+ def _increment_partial_failure_count(self, *, fastpath_dir: Path) -> None:
690
+ """Increment ``consecutive_partial_failures`` in state.json.
691
+
692
+ Called on a partial-build failure so the watcher can force a
693
+ full build after N consecutive partial failures (safety net —
694
+ caller responsibility; this method only increments the counter).
695
+ Non-blocking on IO error.
696
+ """
697
+ if not fastpath_dir.exists():
698
+ logger.debug(
699
+ "wiki watcher: fastpath_dir %s missing; skipping partial failure counter update",
700
+ fastpath_dir,
701
+ )
702
+ return
703
+ from .fastpath_state import read_state # local import avoids circular deps
704
+
705
+ try:
706
+ existing = read_state(fastpath_dir)
707
+ except FastpathStateError:
708
+ existing = None
709
+
710
+ current_failures = (
711
+ existing.consecutive_partial_failures if existing is not None else 0
712
+ )
713
+ new_state = FastpathState(
714
+ version=1,
715
+ watcher_pid=os.getpid(),
716
+ last_partial_at_ms=existing.last_partial_at_ms if existing is not None else 0,
717
+ last_full_at_ms=existing.last_full_at_ms if existing is not None else 0,
718
+ last_partial_slug=existing.last_partial_slug if existing is not None else None,
719
+ consecutive_partial_failures=current_failures + 1,
720
+ )
721
+ try:
722
+ write_state(fastpath_dir, new_state)
723
+ except FastpathStateError:
724
+ logger.warning(
725
+ "wiki watcher: failed to write state.json for partial failure counter",
726
+ exc_info=True,
727
+ )
728
+
729
+ def _schedule_refresh_related(self) -> None:
730
+ """Spawn a daemon thread to run refresh_related, single-flight.
731
+
732
+ If a refresh is already running, just set ``refresh_pending`` —
733
+ the running thread's drain loop will see the flag when it
734
+ finishes and run exactly one more refresh.
735
+ """
736
+ with self._state.refresh_lock:
737
+ if self._state.refresh_running:
738
+ self._state.refresh_pending = True
739
+ return
740
+ self._state.refresh_running = True
741
+ t = threading.Thread(
742
+ target=self._refresh_related_loop,
743
+ name="brain-wiki-refresh-related",
744
+ daemon=True,
745
+ )
746
+ t.start()
747
+
748
+ def _refresh_related_loop(self) -> None:
749
+ """Run ``refresh_related`` until no more pending flag is set.
750
+
751
+ Mirrors ``_drain_pending`` for builds: after each refresh the
752
+ thread checks if another build landed during its run, and if
753
+ so runs exactly one more refresh. This keeps the related-docs
754
+ JSON converging on the latest DB state without spawning
755
+ unbounded concurrent refreshes.
756
+
757
+ When ``self._refresh_runner`` is set (injected via
758
+ :class:`_Handler.__init__`), that runner is invoked instead
759
+ of :meth:`_run_refresh_related_once` — no ``Config.load()``
760
+ or ``refresh_related`` is called. Production-default behavior
761
+ is ``None``; tests inject a runner here.
762
+ """
763
+ while True:
764
+ try:
765
+ if self._refresh_runner is not None:
766
+ self._refresh_runner()
767
+ else:
768
+ self._run_refresh_related_once()
769
+ except Exception:
770
+ # refresh_related catches its own DB/IO errors and returns
771
+ # a summary; getting here means an unexpected programmer
772
+ # error. Log and let the drain decide whether to retry.
773
+ logger.exception("wiki watcher: refresh_related raised")
774
+ with self._state.refresh_lock:
775
+ if not self._state.refresh_pending:
776
+ self._state.refresh_running = False
777
+ return
778
+ self._state.refresh_pending = False
779
+
780
+ def _run_refresh_related_once(self) -> None:
781
+ """Best-effort single invocation of ``refresh_related``."""
782
+ # Imports inside the function so a Config-load failure (no
783
+ # DATABASE_URL on PATH for whatever reason) doesn't kill the
784
+ # whole watcher process at startup.
785
+ try:
786
+ from ..config import Config, ConfigError
787
+ from .build_related import refresh_related
788
+ except ImportError:
789
+ logger.exception("wiki watcher: refresh_related import failed")
790
+ return
791
+ try:
792
+ cfg = Config.load()
793
+ except ConfigError as exc:
794
+ logger.warning(
795
+ "wiki watcher: refresh_related skipped (Config.load failed: %s)",
796
+ exc,
797
+ )
798
+ return
799
+ target_vault = self._vault.expanduser().resolve()
800
+ cfg_for_build = (
801
+ cfg
802
+ if cfg.vault_path == target_vault
803
+ else _replace_vault_path(cfg, target_vault)
804
+ )
805
+ refresh_related(cfg_for_build)
806
+
807
+
808
+ def _should_trigger(event: FileSystemEvent, vault: Path) -> bool:
809
+ """Return True iff this event should reset the debounce timer.
810
+
811
+ Filtering rules:
812
+
813
+ - Directory events skipped — Quartz cares about file content, not
814
+ directory mtime.
815
+ - Paths under ``<vault>/.git`` or ``<vault>/.quartz`` skipped —
816
+ ``.git`` is irrelevant to the rendered site, and ``.quartz``
817
+ contains our *own* build output (a loop trigger).
818
+ - Editor-artifact paths skipped — Emacs lock files (``.#foo``)
819
+ and tilde-suffix backups (``foo~``) shouldn't kick a 40s build.
820
+ - Everything else (any extension, any depth) accepted: a vault
821
+ that includes images, custom CSS, or non-``.md`` source files
822
+ should still rebuild on those changes.
823
+ """
824
+ if event.is_directory:
825
+ return False
826
+ raw = getattr(event, "src_path", None)
827
+ if not raw:
828
+ return False
829
+ path = _to_path(raw)
830
+ if not _within(path, vault):
831
+ # Watchdog can occasionally surface paths outside the watched
832
+ # tree (e.g. when a file is moved out); we don't rebuild for
833
+ # those.
834
+ return False
835
+ name = path.name
836
+ if name.startswith(".#") or name.endswith("~"):
837
+ return False
838
+ try:
839
+ relative = path.resolve().relative_to(vault.resolve())
840
+ except (OSError, ValueError):
841
+ try:
842
+ relative = path.relative_to(vault)
843
+ except ValueError:
844
+ return False
845
+ parts = relative.parts
846
+ if not parts:
847
+ return False
848
+ if parts[0] in _IGNORED_TOP_DIRS:
849
+ return False
850
+ # Exclude our own writes to <vault>/static/related/<slug>.json — the
851
+ # post-build refresh thread writes those, and a fresh build is the
852
+ # ONLY thing that would publish them. If we treat those writes as
853
+ # rebuild triggers we get an infinite loop:
854
+ # build → spawn refresh thread → write JSON → fsevent → another
855
+ # build → spawn another refresh → another JSON write → …
856
+ # See `_schedule_refresh_related` in this module.
857
+ return not (len(parts) >= 2 and parts[0] == "static" and parts[1] == "related")
858
+
859
+
860
+ def _to_path(raw: str | bytes) -> Path:
861
+ """Decode a watchdog event path (``bytes | str``) to ``Path``.
862
+
863
+ Mirrors ``brain.vault.watch._to_path`` — Linux inotify can surface
864
+ raw bytes; macOS FSEvents always sends str; we normalize eagerly.
865
+ """
866
+ if isinstance(raw, bytes):
867
+ return Path(os.fsdecode(raw))
868
+ return Path(raw)
869
+
870
+
871
+ def _within(path: Path, vault: Path) -> bool:
872
+ """Cheap lexical check: is ``path`` under ``vault``?
873
+
874
+ Used as a pre-filter before the more expensive ``resolve()`` call
875
+ in :func:`_should_trigger` — handles the common case where the
876
+ event path is already absolute and the vault path is canonical.
877
+ """
878
+ try:
879
+ path.relative_to(vault)
880
+ return True
881
+ except ValueError:
882
+ return False
883
+
884
+
885
+ # ---------------------------------------------------------------------------
886
+ # CLI entry point.
887
+ # ---------------------------------------------------------------------------
888
+
889
+
890
+ def main(argv: list[str] | None = None) -> None:
891
+ """Console entry — ``python -m brain.wiki.build_watcher --vault ...``.
892
+
893
+ Parses CLI flags, optionally runs an initial synchronous build (for
894
+ cold starts where ``current/`` doesn't exist yet), then enters the
895
+ watcher loop and blocks until SIGINT/SIGTERM. ``brain-up`` spawns
896
+ this as a background process; ``brain-down`` kills it via PID file.
897
+ """
898
+ parser = argparse.ArgumentParser(
899
+ prog="brain.wiki.build_watcher",
900
+ description="Watch a vault and trigger atomic Quartz rebuilds.",
901
+ )
902
+ parser.add_argument("--vault", required=True, type=Path, help="Vault root path.")
903
+ parser.add_argument(
904
+ "--quartz-dir",
905
+ type=Path,
906
+ default=None,
907
+ help="Quartz workspace dir (default: <vault>/.quartz).",
908
+ )
909
+ parser.add_argument(
910
+ "--debounce-seconds",
911
+ type=float,
912
+ default=1.5,
913
+ help="Quiet period before a rebuild (default: 1.5s).",
914
+ )
915
+ parser.add_argument(
916
+ "--keep",
917
+ type=int,
918
+ default=3,
919
+ help="How many old build dirs to retain after each swap (default: 3).",
920
+ )
921
+ parser.add_argument(
922
+ "--initial-build",
923
+ action="store_true",
924
+ help="Run one synchronous build before starting the watcher.",
925
+ )
926
+ args = parser.parse_args(argv)
927
+
928
+ logging.basicConfig(
929
+ level=logging.INFO,
930
+ format="%(asctime)s %(levelname)s %(name)s: %(message)s",
931
+ )
932
+
933
+ vault: Path = args.vault.expanduser().resolve()
934
+ quartz_dir: Path | None = (
935
+ args.quartz_dir.expanduser().resolve() if args.quartz_dir is not None else None
936
+ )
937
+
938
+ if args.initial_build:
939
+ _run_initial_build(vault, quartz_dir=quartz_dir, keep=args.keep)
940
+
941
+ logger.info("wiki watcher: started for vault=%s", vault)
942
+ run_watcher(
943
+ vault,
944
+ quartz_dir=quartz_dir,
945
+ debounce_seconds=args.debounce_seconds,
946
+ keep=args.keep,
947
+ )
948
+
949
+
950
+ def _run_initial_build(
951
+ vault: Path, *, quartz_dir: Path | None, keep: int
952
+ ) -> BuildResult | None:
953
+ """Run one synchronous ``build_and_swap`` before the watcher starts.
954
+
955
+ Surfaced as a separate function so :func:`main` stays small and
956
+ tests can drive the cold-start path directly without spinning up
957
+ the whole watcher. Returns the build result on success, ``None``
958
+ on failure (failures are logged; the watcher still starts so the
959
+ next vault edit gets another shot).
960
+ """
961
+ try:
962
+ result = build_and_swap(vault, quartz_dir=quartz_dir, keep=keep)
963
+ except Exception:
964
+ logger.exception("wiki watcher: initial build failed")
965
+ return None
966
+ logger.info(
967
+ "wiki watcher: initial build %s done in %.2fs",
968
+ result.build_id,
969
+ result.elapsed_seconds,
970
+ )
971
+ return result
972
+
973
+
974
+ if __name__ == "__main__": # pragma: no cover — exercised via subprocess in CLI
975
+ main(sys.argv[1:])