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/vault/watch.py ADDED
@@ -0,0 +1,1052 @@
1
+ """Long-running watcher for ``brain vault sync --watch``.
2
+
3
+ A watcher daemon that reconciles vault edits into the DB without the user
4
+ having to run ``brain vault sync`` after every save. The lifecycle is:
5
+
6
+ 1. Run a one-shot ``sync_vault`` on startup so any edits made while the
7
+ watcher was off get caught up.
8
+ 2. Start a watchdog ``Observer`` that emits filesystem events from the OS
9
+ (FSEvents on macOS, inotify on Linux). The Observer runs on its own
10
+ thread and pushes raw events through ``_handle_event``.
11
+ 3. ``_handle_event`` filters out non-``.md`` paths, hidden directories,
12
+ templates, and attachments, then debounces per absolute path with a
13
+ ``threading.Timer``. When a path's debounce fires it enqueues a
14
+ ``_Job`` onto the worker queue.
15
+ 4. A single worker thread consumes the queue serially. It owns the only
16
+ DB connection used by the watcher (we never share a psycopg connection
17
+ across threads). Each job runs ``sync_one_file`` (created/modified) or
18
+ removes the row + relinks (deleted) for the affected path. Errors from
19
+ one job never kill the worker — they're logged and the loop continues.
20
+ 5. SIGINT / SIGTERM flip a ``threading.Event``; the main thread stops the
21
+ Observer, waits for any in-flight debounce timers to fire (so pending
22
+ edits drain instead of getting dropped), then joins the worker.
23
+
24
+ Concurrency invariants worth restating:
25
+
26
+ - Exactly one psycopg connection lives in the worker thread. The Observer
27
+ thread never touches the DB. The main thread never touches the DB
28
+ after the startup sync returns. (Sharing connections across threads
29
+ triggers psycopg ``InterfaceError: another command is already in
30
+ progress`` under load.)
31
+ - ``_pending_timers`` and ``_jobs`` are only touched while holding
32
+ ``_state_lock`` — a short, fine-grained mutex that doesn't block the
33
+ worker (the worker holds the queue's lock instead).
34
+ - The debounce buffer is capped (``_MAX_PENDING``) so a runaway editor
35
+ spamming events can't OOM the process; on overflow we trigger an
36
+ immediate full sync and clear the buffer.
37
+
38
+ Fence-only writes (Phase D, Task D.5):
39
+
40
+ The metadata-aware linker rewrites a fenced ``BRAIN_DERIVED_*`` section
41
+ inside every affected ``_ingested/`` body whenever derived edges are
42
+ rebuilt (`docs/specs/2026-04-30-derived-edges-in-bodies-design.md`).
43
+ Decision Q4=(b) means the renderer rewrites the fence even when its
44
+ content is byte-identical, which guarantees a filesystem mtime bump and
45
+ therefore another watch event. Without dedup, that event would re-enter
46
+ ``sync_one_file`` → ``rebuild_derived_for`` → ``rewrite_derived_fences``
47
+ and we'd loop forever.
48
+
49
+ The dedup lives in the worker: every successful sync caches
50
+ ``strip_fence(file_contents)`` keyed by absolute path. When a later
51
+ event fires for that path, the worker reads the file, strips the fence,
52
+ and compares to the cache. Fence-only changes (or no change at all) skip
53
+ ``sync_one_file``. Cold start (first sight of a path), cache miss after a
54
+ delete, or any genuine body change all fall through to the normal sync
55
+ path, so we never silently drop a real edit.
56
+ """
57
+ from __future__ import annotations
58
+
59
+ import contextlib
60
+ import logging
61
+ import os
62
+ import queue
63
+ import signal
64
+ import threading
65
+ from collections.abc import Callable
66
+ from dataclasses import dataclass, field
67
+ from pathlib import Path
68
+ from typing import TYPE_CHECKING, Any, Literal
69
+
70
+ import psycopg
71
+ from watchdog.events import (
72
+ FileSystemEvent,
73
+ FileSystemEventHandler,
74
+ )
75
+ from watchdog.observers.api import BaseObserver
76
+ from watchdog.observers.polling import PollingObserver as Observer
77
+
78
+ # brain (2026-05-08): use PollingObserver instead of watchdog's default
79
+ # Observer (which on macOS dispatches via FSEventsObserver). On Python 3.13
80
+ # the FSEvents backend silently stops delivering events: the Observer
81
+ # starts cleanly and stays alive, but `on_any_event` is never called.
82
+ # No error, no log. Both this watcher and `brain.wiki.build_watcher` were
83
+ # affected — every edit to a vault `.md` was invisible until manual sync.
84
+ # PollingObserver scans the tree on a timer (default 1s) and synthesizes
85
+ # events from mtime/size diffs. CPU cost is a stat() per file per second
86
+ # (negligible for ~1100 docs); latency is the polling interval.
87
+ from ..ingest import Embedder
88
+ from .derived_links.fence import strip_fence
89
+ from .sync import SyncReport, sync_one_file, sync_vault
90
+
91
+ if TYPE_CHECKING:
92
+ from ..graph_rag.sync import GraphSyncer
93
+
94
+ logger = logging.getLogger(__name__)
95
+
96
+
97
+ # Per-file actions the worker can run. A within-vault markdown rename is a
98
+ # single ``move`` job (carrying both src + dst) so the worker can UPDATE the
99
+ # row's ``vault_path`` in place — preserving the document id and, with it,
100
+ # every incoming backlink (``links.dst_document_id``). Moves whose
101
+ # destination leaves the vault (or stops being a ``.md`` file) still
102
+ # decompose into ``delete(src)`` + ``upsert(dst)`` in ``_classify_event``.
103
+ _Action = Literal["upsert", "delete", "move"]
104
+
105
+ # Hard cap on the debounce buffer. If the user (or an editor's autosave
106
+ # loop) outpaces this, we fall back to a full vault sync to recover —
107
+ # conceptually an admission-control valve, not a normal code path.
108
+ _MAX_PENDING = 1000
109
+
110
+
111
+ @dataclass
112
+ class WatchConfig:
113
+ """User-facing knobs for the watcher.
114
+
115
+ ``debounce_ms`` is the per-path quiet period: rapid bursts (e.g. an
116
+ editor's "save → reformat → save again" cycle) collapse into one sync
117
+ after the path has been quiet for this long. The production default
118
+ (500 ms) is comfortable on macOS FSEvents which itself coalesces;
119
+ tests use a much smaller value (10 ms) to stay snappy without
120
+ sleeping.
121
+
122
+ ``prune`` is plumbed through to the startup ``sync_vault`` call only —
123
+ we don't auto-prune in response to single-file delete events because
124
+ we don't want a race between "file moved to a temp dir while editor
125
+ saves" and "row gone from DB" to cause data loss. A delete event
126
+ triggers a row-removal for that single document, which is precise.
127
+
128
+ ``on_event`` is a test-only hook called once per debounced job *just
129
+ before* it's enqueued. Production passes ``None``; tests pass a
130
+ closure to assert which paths actually made it through filtering.
131
+ """
132
+
133
+ vault_path: Path
134
+ debounce_ms: int = 500
135
+ prune: bool = False
136
+ on_event: Callable[[_Action, Path], None] | None = None
137
+ # Plumbed from ``brain vault sync --no-link-rewrite``: when False, neither
138
+ # the startup full sync nor the per-file sync will rewrite vault-tier
139
+ # ``[[…]]`` markers to vault-root-relative path form. The rewrite is on
140
+ # by default — ``False`` is opt-out for users who want to preserve the
141
+ # exact authored shape of their wiki-links.
142
+ link_rewrite: bool = True
143
+ # Plumbed from ``Config.owner_participants`` (env: BRAIN_OWNER_PARTICIPANTS):
144
+ # identifiers (emails / display names, lowercased + trimmed at config-load
145
+ # time) stripped from each ``DocSnapshot.participant_keys`` before R2/R3
146
+ # rule evaluation in the linker pass. Empty frozenset (default) disables
147
+ # the filter — used by every existing test.
148
+ owner_participants: frozenset[str] = frozenset()
149
+
150
+
151
+ @dataclass
152
+ class _Job:
153
+ """One unit of work for the worker thread.
154
+
155
+ ``dest_path`` is only set for ``move`` jobs — it carries the rename
156
+ destination so the worker can UPDATE ``documents.vault_path`` from
157
+ ``abs_path`` (the source) to ``dest_path`` in place, preserving the
158
+ document id and every incoming backlink. It stays ``None`` for every
159
+ other action.
160
+ """
161
+
162
+ action: _Action
163
+ abs_path: Path
164
+ dest_path: Path | None = None
165
+
166
+
167
+ @dataclass
168
+ class _WatcherState:
169
+ """Mutable state shared across threads — guarded by ``lock``."""
170
+
171
+ pending_timers: dict[Path, threading.Timer] = field(default_factory=dict)
172
+ lock: threading.Lock = field(default_factory=threading.Lock)
173
+ stop_event: threading.Event = field(default_factory=threading.Event)
174
+ jobs: queue.Queue[_Job | None] = field(default_factory=queue.Queue)
175
+ # ``processed`` and ``errors`` accumulate over the watcher's lifetime
176
+ # so the final shutdown report can summarize "watched 14 files; 1
177
+ # error". Only the worker thread touches them, so no lock is needed.
178
+ processed: int = 0
179
+ errors: int = 0
180
+ skipped_fence_only: int = 0
181
+ # Cache of the fence-stripped body last observed AFTER a successful
182
+ # sync, keyed by absolute path. Lets the worker detect fence-only
183
+ # rewrites (the linker's `BRAIN_DERIVED_*` section regen) and skip a
184
+ # redundant ``sync_one_file`` that would just re-trigger the same
185
+ # rewrite, looping forever. Only the worker thread touches this dict
186
+ # — no lock is needed for the same reason ``processed`` doesn't
187
+ # take one.
188
+ body_cache: dict[Path, str] = field(default_factory=dict)
189
+
190
+
191
+ def run_watcher(
192
+ conn_factory: Callable[[], psycopg.Connection[Any]],
193
+ *,
194
+ embedder: Embedder,
195
+ config: WatchConfig,
196
+ observer_factory: Callable[[], BaseObserver] | None = None,
197
+ install_signal_handlers: bool = True,
198
+ graph_syncer: GraphSyncer | None = None,
199
+ ) -> SyncReport:
200
+ """Block until SIGINT/SIGTERM, watching ``config.vault_path``.
201
+
202
+ On entry, performs a one-shot full sync so any edits made while the
203
+ watcher was off are picked up. Then starts a watchdog ``Observer``
204
+ plus a worker thread, and parks the main thread on
205
+ ``state.stop_event`` until a signal fires.
206
+
207
+ Returns the startup ``SyncReport`` so the CLI can print "watching
208
+ after initial sync: created 3, updated 0…" before parking.
209
+
210
+ ``conn_factory`` MUST return a fresh psycopg connection each call;
211
+ the watcher takes ownership of exactly one connection (lives in the
212
+ worker thread for the duration of the watcher) and closes it on
213
+ shutdown. Production passes ``lambda: psycopg.connect(database_url)``
214
+ via the CLI; tests pass a closure that opens the test DB.
215
+
216
+ ``observer_factory`` defaults to watchdog's real ``Observer``; tests
217
+ inject a fake to avoid actually touching the OS watcher subsystem.
218
+
219
+ ``install_signal_handlers`` defaults to True. Tests set this to False
220
+ so they can drive shutdown via ``state.stop_event`` directly without
221
+ interfering with pytest's own signal handling.
222
+ """
223
+ state = _WatcherState()
224
+
225
+ # 1. Startup sync. Use a short-lived connection scoped to this call —
226
+ # we don't want to hold a connection while parked on the stop event.
227
+ startup_conn = conn_factory()
228
+ try:
229
+ startup_conn.autocommit = True
230
+ report = sync_vault(
231
+ startup_conn,
232
+ embedder=embedder,
233
+ vault_path=config.vault_path,
234
+ prune=config.prune,
235
+ dry_run=False,
236
+ link_rewrite=config.link_rewrite,
237
+ owner_participants=config.owner_participants,
238
+ graph_syncer=graph_syncer,
239
+ )
240
+ finally:
241
+ startup_conn.close()
242
+
243
+ # 2. Worker thread. Owns the long-lived DB connection and consumes
244
+ # jobs from the queue. ``daemon=True`` so a crashing worker
245
+ # doesn't prevent process exit; we still join below for clean
246
+ # shutdown.
247
+ worker_conn = conn_factory()
248
+ worker_conn.autocommit = True
249
+ worker_thread = threading.Thread(
250
+ target=_worker_loop,
251
+ args=(worker_conn, embedder, config, state, graph_syncer),
252
+ name="brain-vault-watcher-worker",
253
+ daemon=True,
254
+ )
255
+ worker_thread.start()
256
+
257
+ # 3. Observer + handler. The handler is a thin shim that turns
258
+ # watchdog events into ``_Job``s via the debounce path. The
259
+ # Observer runs on its own thread (managed by watchdog).
260
+ factory = observer_factory or Observer
261
+ observer = factory()
262
+ # ``scheduled_dirs`` (shared by reference with the handler) tracks every
263
+ # top-level directory that already has a recursive watch, so the handler
264
+ # can schedule one for a NEW top-level dir created after startup without
265
+ # double-scheduling the ones we set up here.
266
+ scheduled_dirs: set[Path] = set()
267
+ handler = _Handler(
268
+ config=config,
269
+ state=state,
270
+ observer=observer,
271
+ scheduled_dirs=scheduled_dirs,
272
+ )
273
+ # brain (2026-05-08): schedule per non-hidden top-level subdir + root
274
+ # non-recursively, instead of `recursive=True` on the vault root.
275
+ # PollingObserver snapshots every file in the watched tree on every
276
+ # poll cycle; with `recursive=True` on a vault that contains
277
+ # `<vault>/.quartz/node_modules` (~60k+ files) the snapshot work
278
+ # starves the polling thread and edits stop being detected. The
279
+ # event filter (_filter_path) already drops paths under hidden dirs,
280
+ # so excluding them from the WATCHED tree is correct + cheap.
281
+ #
282
+ # brain (2026-07-12, Task 3.1): a NEW top-level dir created after this
283
+ # loop runs is scheduled on the fly by the handler (see
284
+ # _Handler._maybe_watch_new_directory), so files inside it are no longer
285
+ # invisible until restart.
286
+ scheduled_names: list[str] = []
287
+ observer.schedule(handler, str(config.vault_path), recursive=False)
288
+ for child in config.vault_path.iterdir():
289
+ if child.is_dir() and not child.name.startswith("."):
290
+ observer.schedule(handler, str(child), recursive=True)
291
+ # A subdir that vanished between iterdir and resolve leaves the
292
+ # watch above standing; just skip the dedup bookkeeping for it.
293
+ with contextlib.suppress(OSError):
294
+ scheduled_dirs.add(child.resolve())
295
+ scheduled_names.append(child.name)
296
+ logger.info(
297
+ "vault watcher: scoped polling on top-level dirs: %s",
298
+ ", ".join(scheduled_names) or "(none)",
299
+ )
300
+ observer.start()
301
+
302
+ # 4. Signal handlers. SIGINT for Ctrl-C, SIGTERM for systemd /
303
+ # process managers. We can only install these from the main
304
+ # thread — Python enforces this — and the CLI calls run_watcher
305
+ # from the main thread, so this is fine. Tests set
306
+ # ``install_signal_handlers=False`` to skip this.
307
+ previous_handlers: dict[signal.Signals, Any] = {}
308
+ if install_signal_handlers:
309
+ def _signal_handler(signum: int, _frame: Any) -> None:
310
+ logger.info("vault watcher: received signal %s, shutting down", signum)
311
+ state.stop_event.set()
312
+
313
+ for sig in (signal.SIGINT, signal.SIGTERM):
314
+ previous_handlers[sig] = signal.signal(sig, _signal_handler)
315
+
316
+ try:
317
+ # Block until a signal (or test) flips the stop_event.
318
+ state.stop_event.wait()
319
+ finally:
320
+ # Restore previous signal handlers so a follow-up CliRunner /
321
+ # KeyboardInterrupt still behaves as the test runner expects.
322
+ for sig, prev in previous_handlers.items():
323
+ signal.signal(sig, prev)
324
+
325
+ # 5. Shutdown.
326
+ observer.stop()
327
+ observer.join(timeout=5.0)
328
+
329
+ # Drain any in-flight debounce timers: cancel them, then enqueue
330
+ # an immediate job for each path that had a pending timer so the
331
+ # worker still picks up the user's last edit before exiting.
332
+ with state.lock:
333
+ pending_paths = list(state.pending_timers.keys())
334
+ for timer in state.pending_timers.values():
335
+ timer.cancel()
336
+ state.pending_timers.clear()
337
+ for path in pending_paths:
338
+ _enqueue(state, _Job(action="upsert", abs_path=path), config=config)
339
+
340
+ # Sentinel tells the worker its queue is now closed.
341
+ state.jobs.put(None)
342
+ worker_thread.join(timeout=10.0)
343
+ if worker_thread.is_alive():
344
+ # The worker is mid-statement (a sync_vault call, an embed
345
+ # request, etc.) past our 10s patience. Closing the
346
+ # connection from the main thread now would race against
347
+ # the in-flight statement and raise from the worker thread
348
+ # while we're trying to return cleanly. The thread is a
349
+ # daemon — it carries the orphaned connection on its back,
350
+ # and Postgres reaps the backend after its idle timeout.
351
+ logger.warning(
352
+ "vault watcher: worker thread did not exit within 10s — "
353
+ "leaking its connection to avoid a mid-statement close race"
354
+ )
355
+ else:
356
+ try:
357
+ worker_conn.close()
358
+ except psycopg.Error:
359
+ # Connection may already be closed (e.g. server-side timeout) —
360
+ # nothing to recover, just don't crash on shutdown.
361
+ logger.debug(
362
+ "vault watcher: worker connection already closed"
363
+ )
364
+
365
+ logger.info(
366
+ "vault watcher: stopped after %d events (%d errors)",
367
+ state.processed,
368
+ state.errors,
369
+ )
370
+ return report
371
+
372
+
373
+ # ---------------------------------------------------------------------------
374
+ # Internal helpers — event classification, debouncing, worker loop.
375
+ # ---------------------------------------------------------------------------
376
+
377
+
378
+ class _Handler(FileSystemEventHandler):
379
+ """Watchdog handler that funnels events into our debounce path.
380
+
381
+ Kept deliberately small: classification + filtering live in
382
+ ``_classify_event``, debouncing lives in ``_schedule_debounced``,
383
+ job execution lives in ``_worker_loop``. The handler just glues
384
+ them together.
385
+ """
386
+
387
+ def __init__(
388
+ self,
389
+ *,
390
+ config: WatchConfig,
391
+ state: _WatcherState,
392
+ observer: BaseObserver | None = None,
393
+ scheduled_dirs: set[Path] | None = None,
394
+ ) -> None:
395
+ self._config = config
396
+ self._state = state
397
+ # ``observer`` + ``scheduled_dirs`` let the handler schedule a
398
+ # recursive watch on a NEW top-level directory created after startup
399
+ # (see :meth:`_maybe_watch_new_directory`). Both are optional so a
400
+ # handler can be constructed standalone in tests; production always
401
+ # passes them from :func:`run_watcher`.
402
+ self._observer = observer
403
+ self._scheduled_dirs = (
404
+ scheduled_dirs if scheduled_dirs is not None else set()
405
+ )
406
+
407
+ def on_any_event(self, event: FileSystemEvent) -> None:
408
+ # ``on_any_event`` fires for every event type — we route
409
+ # everything through one classifier rather than per-type
410
+ # handlers so the filtering logic lives in one place.
411
+ try:
412
+ # A brand-new top-level directory (and every file under it) is
413
+ # invisible to the startup-enumerated recursive watches; schedule
414
+ # one on the fly before classifying the (directory) event, which
415
+ # itself has no per-file action.
416
+ self._maybe_watch_new_directory(event)
417
+ classified = _classify_event(event, self._config.vault_path)
418
+ except Exception:
419
+ # Defensive: a misbehaving event must never kill the
420
+ # observer thread. Log and move on.
421
+ logger.exception("vault watcher: classify failed for %r", event)
422
+ return
423
+ if classified is None:
424
+ return
425
+ for action, path, dest in classified:
426
+ _schedule_debounced(self._config, self._state, action, path, dest)
427
+
428
+ def _maybe_watch_new_directory(self, event: FileSystemEvent) -> None:
429
+ """Schedule a recursive watch for a NEW top-level directory.
430
+
431
+ The vault root is watched non-recursively and per-subdir recursive
432
+ watches are enumerated only once, at startup, so a top-level directory
433
+ created afterwards — and every ``.md`` file under it — would be
434
+ invisible until the watcher restarted. When such a directory is
435
+ created we schedule a recursive watch on it (so future edits inside it
436
+ are seen) and enqueue any ``.md`` files that already exist in it (they
437
+ predate the watch, so the polling observer treats them as baseline and
438
+ never emits a create event for them).
439
+
440
+ Only NEW directories DIRECTLY under the vault root are handled: a
441
+ nested directory is already covered by its top-level ancestor's
442
+ recursive watch, and scheduling a second watch would double-deliver
443
+ every event. Hidden directories (``.git/`` etc.) are skipped, matching
444
+ the startup schedule filter.
445
+ """
446
+ if self._observer is None:
447
+ return
448
+ if not event.is_directory or event.event_type != "created":
449
+ return
450
+ new_dir = _to_path(event.src_path)
451
+ vault_path = self._config.vault_path
452
+ try:
453
+ resolved = new_dir.resolve()
454
+ relative = resolved.relative_to(vault_path.resolve())
455
+ except (ValueError, OSError):
456
+ return
457
+ # Top-level == exactly one path component directly under the root.
458
+ if len(relative.parts) != 1 or relative.parts[0].startswith("."):
459
+ return
460
+ if resolved in self._scheduled_dirs:
461
+ return
462
+ self._observer.schedule(self, str(new_dir), recursive=True)
463
+ self._scheduled_dirs.add(resolved)
464
+ logger.info(
465
+ "vault watcher: scheduled recursive watch on new top-level dir %s",
466
+ relative.parts[0],
467
+ )
468
+ # Enqueue notes that already exist inside the new dir — they predate
469
+ # the watch and would otherwise never generate a create event.
470
+ try:
471
+ existing = sorted(new_dir.rglob("*.md"))
472
+ except OSError:
473
+ return
474
+ for md in existing:
475
+ filtered = _filter_path(md, vault_path)
476
+ if filtered is not None:
477
+ _schedule_debounced(
478
+ self._config, self._state, "upsert", filtered, None
479
+ )
480
+
481
+
482
+ def _classify_event(
483
+ event: FileSystemEvent, vault_path: Path
484
+ ) -> list[tuple[_Action, Path, Path | None]] | None:
485
+ """Decide what to do with a watchdog event.
486
+
487
+ Returns a list of ``(action, absolute_path, dest_path)`` tuples — usually
488
+ one entry with ``dest_path=None``. A within-vault ``.md`` rename becomes a
489
+ single ``("move", src, dst)`` so the worker can UPDATE the row's
490
+ ``vault_path`` in place, preserving the document id and every incoming
491
+ backlink. A move whose destination leaves the vault (or stops being a
492
+ ``.md`` file) still decomposes into ``("delete", src, None)`` +
493
+ ``("upsert", dst, None)``. Returns ``None`` to skip the event entirely.
494
+
495
+ Note: editors that emulate a rename as an atomic delete+create (and
496
+ cross-device moves) emit no ``FileMovedEvent`` — those arrive as separate
497
+ ``deleted`` / ``created`` events and keep the delete+upsert behavior, so a
498
+ heavily-linked note renamed that way still relies on a full
499
+ ``brain vault sync`` to restore its incoming backlinks.
500
+
501
+ Filtering rules (kept conservative — the worker can always re-derive
502
+ state from the DB if a real edit slipped past, but we do NOT want to
503
+ waste cycles on every byte-level FSEvent):
504
+
505
+ - directory events are skipped (we sync per-file)
506
+ - non-``.md`` paths are skipped (editor temp files, ``.DS_Store``,
507
+ attachments, etc.)
508
+ - paths with any hidden component (starts with ``.``) are skipped —
509
+ catches ``.git/``, ``.obsidian/``, VSCode's ``.vscode/`` etc.
510
+ - paths under ``_templates/`` or ``_attachments/`` are skipped —
511
+ the sync engine itself ignores these so syncing them is wasted work
512
+ """
513
+ if event.is_directory:
514
+ return None
515
+
516
+ event_type = event.event_type # 'created' | 'modified' | 'deleted' | 'moved'
517
+ if event_type == "moved":
518
+ # Watchdog's FileMovedEvent exposes both src and dest. Path values
519
+ # are typed as ``bytes | str`` upstream — decode bytes paths via
520
+ # ``os.fsdecode`` so Path() accepts them on every platform.
521
+ src = _filter_path(_to_path(event.src_path), vault_path)
522
+ dest_attr = getattr(event, "dest_path", None)
523
+ dst = _filter_path(_to_path(dest_attr), vault_path) if dest_attr else None
524
+ if src is not None and dst is not None:
525
+ # Within-vault markdown rename: thread it as a single ``move`` so
526
+ # the worker UPDATEs ``vault_path`` in place instead of
527
+ # delete-cascading (then re-inserting) the row, which would wipe
528
+ # every incoming backlink pointing at the moved doc.
529
+ return [("move", src, dst)]
530
+ results: list[tuple[_Action, Path, Path | None]] = []
531
+ if src is not None:
532
+ results.append(("delete", src, None))
533
+ if dst is not None:
534
+ results.append(("upsert", dst, None))
535
+ return results or None
536
+
537
+ path = _filter_path(_to_path(event.src_path), vault_path)
538
+ if path is None:
539
+ return None
540
+ action: _Action = "delete" if event_type == "deleted" else "upsert"
541
+ return [(action, path, None)]
542
+
543
+
544
+ def _to_path(raw: str | bytes) -> Path:
545
+ """Coerce a watchdog event path (``bytes | str``) into a ``Path``.
546
+
547
+ Watchdog declares event paths as ``bytes | str`` because some
548
+ backends (e.g. inotify on Linux) can surface raw bytes. We decode
549
+ eagerly via ``os.fsdecode`` so the rest of the watcher uses
550
+ ``pathlib.Path`` exclusively.
551
+ """
552
+ if isinstance(raw, bytes):
553
+ return Path(os.fsdecode(raw))
554
+ return Path(raw)
555
+
556
+
557
+ def _filter_path(path: Path, vault_path: Path) -> Path | None:
558
+ """Return the absolute path if it's a syncable ``.md`` file, else None.
559
+
560
+ The full filter ladder lives here so ``_classify_event`` (which
561
+ handles move events with two paths) can reuse it without
562
+ duplication.
563
+ """
564
+ abs_path = path if path.is_absolute() else (vault_path / path)
565
+ try:
566
+ relative = abs_path.resolve().relative_to(vault_path.resolve())
567
+ except ValueError:
568
+ # Outside the vault — should not happen since the Observer
569
+ # watches inside the vault, but defensive.
570
+ return None
571
+ except OSError:
572
+ # ``resolve()`` can fail on a vanished file (deleted events).
573
+ # Fall back to lexical comparison: strip the vault prefix
574
+ # without touching the FS.
575
+ try:
576
+ relative = abs_path.relative_to(vault_path)
577
+ except ValueError:
578
+ return None
579
+ if abs_path.suffix.lower() != ".md":
580
+ return None
581
+ parts = relative.parts
582
+ if not parts:
583
+ return None
584
+ # Hidden directories anywhere in the path — covers .git, .obsidian,
585
+ # editor scratch dirs, etc.
586
+ if any(part.startswith(".") for part in parts):
587
+ return None
588
+ first = parts[0]
589
+ if first in {"_templates", "_attachments"}:
590
+ return None
591
+ return abs_path
592
+
593
+
594
+ def _schedule_debounced(
595
+ config: WatchConfig,
596
+ state: _WatcherState,
597
+ action: _Action,
598
+ path: Path,
599
+ dest: Path | None = None,
600
+ ) -> None:
601
+ """Cancel any pending timer for ``path`` and start a new one.
602
+
603
+ Each new event for the same path resets the debounce timer to
604
+ ``config.debounce_ms`` from now. Once the path goes ``debounce_ms``
605
+ without a new event, the timer fires and enqueues a job. ``dest`` is the
606
+ rename destination for a ``move`` action (``None`` otherwise); the
607
+ debounce is keyed on ``path`` (the source), so repeated moves of the same
608
+ source coalesce and the last one's destination wins — the desired
609
+ outcome for a burst of rename events.
610
+
611
+ If the pending-timer dict overflows (a misbehaving editor or a
612
+ bulk-replace operation), we fall back to "trigger an immediate full
613
+ sync, then clear the buffer" — bounded resource usage trumps
614
+ incremental optimization here.
615
+ """
616
+ delay = max(config.debounce_ms, 0) / 1000.0
617
+
618
+ def _fire() -> None:
619
+ with state.lock:
620
+ state.pending_timers.pop(path, None)
621
+ _enqueue(
622
+ state,
623
+ _Job(action=action, abs_path=path, dest_path=dest),
624
+ config=config,
625
+ )
626
+
627
+ with state.lock:
628
+ existing = state.pending_timers.pop(path, None)
629
+ if existing is not None:
630
+ existing.cancel()
631
+ if len(state.pending_timers) >= _MAX_PENDING:
632
+ # Buffer overflow → drop everything, queue a full-vault sync.
633
+ for timer in state.pending_timers.values():
634
+ timer.cancel()
635
+ state.pending_timers.clear()
636
+ logger.warning(
637
+ "vault watcher: debounce buffer overflow (%d pending) — "
638
+ "scheduling full sync",
639
+ _MAX_PENDING,
640
+ )
641
+ _enqueue(
642
+ state,
643
+ _Job(action="upsert", abs_path=config.vault_path),
644
+ config=config,
645
+ )
646
+ return
647
+ timer = threading.Timer(delay, _fire)
648
+ timer.daemon = True
649
+ state.pending_timers[path] = timer
650
+ timer.start()
651
+
652
+
653
+ def _enqueue(
654
+ state: _WatcherState,
655
+ job: _Job,
656
+ *,
657
+ config: WatchConfig,
658
+ ) -> None:
659
+ """Push a job onto the worker queue, after the optional test hook."""
660
+ if config.on_event is not None:
661
+ try:
662
+ config.on_event(job.action, job.abs_path)
663
+ except Exception:
664
+ logger.exception("vault watcher: on_event hook raised")
665
+ state.jobs.put(job)
666
+
667
+
668
+ def _worker_loop(
669
+ conn: psycopg.Connection[Any],
670
+ embedder: Embedder,
671
+ config: WatchConfig,
672
+ state: _WatcherState,
673
+ graph_syncer: GraphSyncer | None = None,
674
+ ) -> None:
675
+ """Consume jobs serially. Owns the DB connection.
676
+
677
+ Four classes of work, distinguished by ``job.action`` / ``job.abs_path``:
678
+
679
+ 1. ``upsert`` of a regular file → ``sync_one_file`` (preceded by a
680
+ fence-only-write check; see :func:`_is_fence_only_write`)
681
+ 2. ``delete`` of a regular file → DB row removal + link cleanup
682
+ (we don't touch disk; the file is already gone) plus body-cache
683
+ eviction so a re-creation event hits the cold-start path
684
+ 3. ``move`` of a regular file → ``_handle_move``: UPDATE the row's
685
+ ``vault_path`` in place (preserving the document id and every
686
+ incoming backlink) then upsert the destination
687
+ 4. ``upsert`` of the vault root path → full ``sync_vault`` (the
688
+ overflow-recovery path) plus full body-cache flush so subsequent
689
+ per-file events re-prime the cache from the post-sync state
690
+
691
+ Each call is wrapped in try/except so a single-file failure logs
692
+ but doesn't kill the worker — the watcher should be self-healing.
693
+ """
694
+ while True:
695
+ job = state.jobs.get()
696
+ if job is None:
697
+ return
698
+ try:
699
+ if job.abs_path == config.vault_path and job.action == "upsert":
700
+ # Overflow-recovery: do a full sync.
701
+ sync_vault(
702
+ conn,
703
+ embedder=embedder,
704
+ vault_path=config.vault_path,
705
+ prune=False, # never auto-prune in watch mode
706
+ dry_run=False,
707
+ link_rewrite=config.link_rewrite,
708
+ owner_participants=config.owner_participants,
709
+ graph_syncer=graph_syncer,
710
+ )
711
+ # The full sync may have rewritten any number of fences;
712
+ # the cache entries from before the overflow window are
713
+ # now stale. Clearing forces the next per-file event to
714
+ # take the cold-start path (one extra sync per file,
715
+ # then dedup resumes) — safer than a half-stale map.
716
+ state.body_cache.clear()
717
+ elif job.action == "move" and job.dest_path is not None:
718
+ # Within-vault rename: UPDATE vault_path in place so the
719
+ # document id (and every incoming backlink) survives, then
720
+ # refresh the destination. See :func:`_handle_move`.
721
+ _handle_move(
722
+ conn,
723
+ embedder=embedder,
724
+ config=config,
725
+ state=state,
726
+ src_abs=job.abs_path,
727
+ dst_abs=job.dest_path,
728
+ graph_syncer=graph_syncer,
729
+ )
730
+ elif job.action == "delete":
731
+ # Defensive re-stat: a "deleted" fsevent can fire spuriously
732
+ # when a file is atomically replaced. ``_atomic.atomic_write_text``
733
+ # does ``os.replace(tmp, path)`` (used by ``rewrite_tags`` for
734
+ # ``brain tag`` and by the sync engine itself), and on macOS
735
+ # APFS the rename can surface as a ``deleted`` on the original
736
+ # path with the follow-up ``created`` dropped or coalesced by
737
+ # watchdog. If we trust the action label blindly we'd
738
+ # ``DELETE FROM documents`` for a perfectly intact file —
739
+ # discovered live on 2026-05-01 by ``brain tag`` round-trip.
740
+ # The on-disk file is the source of truth here: re-stat
741
+ # before deletion. If the file is still there, treat as a
742
+ # plain upsert (which will reconcile any frontmatter changes
743
+ # the producer just made).
744
+ if job.abs_path.exists():
745
+ logger.debug(
746
+ "vault watcher: ignoring stale delete for %s "
747
+ "(file still exists — likely an atomic-replace event)",
748
+ job.abs_path,
749
+ )
750
+ if _is_fence_only_write(state, job.abs_path):
751
+ state.skipped_fence_only += 1
752
+ else:
753
+ report = sync_one_file(
754
+ conn,
755
+ embedder=embedder,
756
+ vault_path=config.vault_path,
757
+ file_path=job.abs_path,
758
+ link_rewrite=config.link_rewrite,
759
+ owner_participants=config.owner_participants,
760
+ graph_syncer=graph_syncer,
761
+ )
762
+ _cache_after_sync(state, job.abs_path, report)
763
+ else:
764
+ _handle_delete(
765
+ conn,
766
+ job.abs_path,
767
+ config.vault_path,
768
+ graph_syncer=graph_syncer,
769
+ )
770
+ # Drop the cache entry so a future creation event for
771
+ # the same path is treated as cold-start, not a
772
+ # spurious fence-only no-op.
773
+ state.body_cache.pop(job.abs_path, None)
774
+ else:
775
+ if not job.abs_path.exists():
776
+ # Symmetric to the stale-delete guard above: an
777
+ # ``upsert`` event can survive past the file's
778
+ # actual existence, e.g. a transient editor temp
779
+ # file, a ``brain note new`` followed immediately
780
+ # by ``brain rm``, or any tool that creates +
781
+ # removes within the debounce window. Without this
782
+ # guard ``sync_one_file`` would raise
783
+ # ``FileNotFoundError``, the outer ``except`` would
784
+ # bump ``state.errors``, and the shutdown report
785
+ # would falsely flag a phantom failure. Skip the
786
+ # job entirely (``continue``) so neither
787
+ # ``processed`` nor ``errors`` advances — there
788
+ # was no real work to count.
789
+ logger.debug(
790
+ "vault watcher: ignoring stale upsert for %s "
791
+ "(file no longer exists — likely a transient "
792
+ "create+delete)",
793
+ job.abs_path,
794
+ )
795
+ continue
796
+ if _is_fence_only_write(state, job.abs_path):
797
+ state.skipped_fence_only += 1
798
+ logger.debug(
799
+ "vault watcher: skipping fence-only write for %s",
800
+ job.abs_path,
801
+ )
802
+ else:
803
+ report = sync_one_file(
804
+ conn,
805
+ embedder=embedder,
806
+ vault_path=config.vault_path,
807
+ file_path=job.abs_path,
808
+ link_rewrite=config.link_rewrite,
809
+ owner_participants=config.owner_participants,
810
+ graph_syncer=graph_syncer,
811
+ )
812
+ # Prime the cache from the baseline sync indexed rather
813
+ # than a fresh disk read: the fence the linker rewrote is
814
+ # excluded by strip_fence, and a user edit that raced
815
+ # sync's write must NOT be baselined in (which would mask
816
+ # its follow-up event as fence-only). See _cache_after_sync.
817
+ _cache_after_sync(state, job.abs_path, report)
818
+ state.processed += 1
819
+ except Exception:
820
+ state.errors += 1
821
+ logger.exception(
822
+ "vault watcher: worker failed on %s %s",
823
+ job.action,
824
+ job.abs_path,
825
+ )
826
+
827
+
828
+ def _is_fence_only_write(state: _WatcherState, abs_path: Path) -> bool:
829
+ """Return True iff ``abs_path`` differs from cache only inside the fence.
830
+
831
+ Reads the file at ``abs_path``, strips its ``BRAIN_DERIVED_*`` fence
832
+ region, and compares the result to the cached fence-stripped body
833
+ recorded after the most recent successful sync. Equal means the
834
+ write was a fence regeneration (`docs/specs/2026-04-30-derived-edges-in-bodies-design.md`)
835
+ and re-running ``sync_one_file`` would just produce the same rewrite,
836
+ looping forever — caller should skip.
837
+
838
+ Returns ``False`` (i.e. don't skip; run sync) when:
839
+
840
+ - There's no cache entry for ``abs_path`` (cold start — spec
841
+ requires first-sight syncs to run).
842
+ - The file can't be read (vanished between debounce and worker
843
+ pickup, permissions error, etc.) — let ``sync_one_file`` see the
844
+ same problem and report it through the existing error path.
845
+ - The fence-stripped body genuinely changed.
846
+ """
847
+ try:
848
+ current = abs_path.read_text(encoding="utf-8")
849
+ except OSError:
850
+ return False
851
+ cached = state.body_cache.get(abs_path)
852
+ if cached is None:
853
+ return False
854
+ return cached == strip_fence(current)
855
+
856
+
857
+ def _refresh_body_cache(state: _WatcherState, abs_path: Path) -> None:
858
+ """Update the body cache for ``abs_path`` from its current on-disk content.
859
+
860
+ Called after every successful ``sync_one_file`` so the cached
861
+ fence-stripped body reflects whatever the linker just rendered. The
862
+ next watch event for the same path can then detect a fence-only
863
+ rewrite and short-circuit.
864
+
865
+ If the file vanished between sync and this read, the stale entry is
866
+ evicted rather than left out of sync with disk.
867
+ """
868
+ try:
869
+ current = abs_path.read_text(encoding="utf-8")
870
+ except OSError:
871
+ state.body_cache.pop(abs_path, None)
872
+ return
873
+ state.body_cache[abs_path] = strip_fence(current)
874
+
875
+
876
+ def _cache_after_sync(
877
+ state: _WatcherState,
878
+ abs_path: Path,
879
+ report: SyncReport | None,
880
+ ) -> None:
881
+ """Prime the fence-only-write cache after a per-file sync (Task 3.2).
882
+
883
+ Prefers the fence-stripped baseline sync reported (``report.body_baseline``
884
+ — the content it actually INDEXED) over a fresh disk read. A disk read here
885
+ would baseline a racing user edit that landed between sync's write and this
886
+ call, so the follow-up event for that edit would be misclassified as
887
+ fence-only and skipped — the DB would never catch up until a full sync.
888
+
889
+ Falls back to re-reading disk when sync did not report a baseline (a
890
+ skipped documentation file, or a test double that returns no report), which
891
+ preserves the pre-fix behavior for those cases.
892
+ """
893
+ if report is not None and report.body_baseline is not None:
894
+ state.body_cache[abs_path] = report.body_baseline
895
+ else:
896
+ _refresh_body_cache(state, abs_path)
897
+
898
+
899
+ def _vault_relative_posix(abs_path: Path, vault_path: Path) -> str | None:
900
+ """Return ``abs_path`` relative to the vault as a POSIX string, or None.
901
+
902
+ Mirrors the ``resolve()``-then-lexical-fallback ladder used elsewhere in
903
+ the watcher so a vanished path (a delete or move source, already gone
904
+ from disk) still yields the correct relative key without touching the FS.
905
+ Returns ``None`` when the path is outside the vault.
906
+ """
907
+ try:
908
+ return abs_path.relative_to(vault_path.resolve()).as_posix()
909
+ except ValueError:
910
+ try:
911
+ return abs_path.relative_to(vault_path).as_posix()
912
+ except ValueError:
913
+ return None
914
+
915
+
916
+ def _handle_delete(
917
+ conn: psycopg.Connection[Any],
918
+ abs_path: Path,
919
+ vault_path: Path,
920
+ *,
921
+ graph_syncer: GraphSyncer | None = None,
922
+ ) -> None:
923
+ """Remove the vault-tier ``documents`` row for ``abs_path``.
924
+
925
+ Why not call ``sync_vault(prune=True)``? Two reasons: (1) we'd be
926
+ doing a full walk for a single deletion, which is wasteful, and (2)
927
+ auto-prune in ``sync_vault`` only fires when *every* file's been
928
+ walked — for a focused delete event we want to act on just this
929
+ path. So instead we look up the row by ``vault_path`` and delete
930
+ it; ``ON DELETE CASCADE`` handles chunks + outgoing links.
931
+
932
+ Ingested-tier rows are intentionally never auto-deleted in watch
933
+ mode — those mirror an upstream system (Krisp, Slack, Gmail), and a
934
+ transient ``.md`` removal under ``_ingested/`` shouldn't blow away
935
+ the canonical DB record. The user can always re-export.
936
+
937
+ Idempotent: a delete event for a path with no DB row is a no-op.
938
+
939
+ Schema-drift defense: the migration ``003_vault_model.sql`` declares
940
+ a unique partial index ``documents_vault_path_idx`` on
941
+ ``vault_path`` WHERE ``vault_path IS NOT NULL``, so this DELETE
942
+ matches at most one row in normal operation. We still run the SQL
943
+ with ``RETURNING id`` and log a loud ``logger.error`` if the result
944
+ set has more than one row — observability for a future schema bug
945
+ (manual ``ALTER``, dropped index, migration regression). The
946
+ surplus rows are still deleted (we don't raise) so the user isn't
947
+ left with a half-finished delete and orphaned chunks; the error log
948
+ is what surfaces the invariant break.
949
+ """
950
+ relative = _vault_relative_posix(abs_path, vault_path)
951
+ if relative is None:
952
+ logger.debug(
953
+ "vault watcher: delete event for %s outside vault — skipping",
954
+ abs_path,
955
+ )
956
+ return
957
+ deleted = conn.execute(
958
+ "DELETE FROM documents WHERE kind = 'vault' AND vault_path = %s "
959
+ "RETURNING id",
960
+ (relative,),
961
+ ).fetchall()
962
+ if len(deleted) > 1:
963
+ logger.error(
964
+ "vault watcher: deleted %d rows for vault_path=%s — "
965
+ "schema uniqueness invariant broken (expected 0 or 1)",
966
+ len(deleted),
967
+ relative,
968
+ )
969
+ # Wave G1-c: drop each deleted doc from the people graph (best-effort /
970
+ # never-raises; no-op when graph sync is disabled or AGE is absent). The
971
+ # worker connection is autocommit, so the DELETE above already committed.
972
+ if graph_syncer is not None:
973
+ for row in deleted:
974
+ graph_syncer.remove(conn, str(row[0]))
975
+
976
+
977
+ def _handle_move(
978
+ conn: psycopg.Connection[Any],
979
+ *,
980
+ embedder: Embedder,
981
+ config: WatchConfig,
982
+ state: _WatcherState,
983
+ src_abs: Path,
984
+ dst_abs: Path,
985
+ graph_syncer: GraphSyncer | None = None,
986
+ ) -> None:
987
+ """Handle a within-vault markdown rename WITHOUT destroying incoming links.
988
+
989
+ The old classifier decomposed a move into ``delete(src) + upsert(dst)``.
990
+ The delete ran ``DELETE FROM documents WHERE vault_path=src`` whose
991
+ ``ON DELETE CASCADE`` wiped every ``links`` row pointing AT the moved doc
992
+ (its incoming backlinks); the follow-up upsert only restored the doc's
993
+ OUTGOING links. Renaming a heavily-linked note therefore silently
994
+ orphaned every backlink until the next full ``brain vault sync``.
995
+
996
+ The fix mirrors the full-sync rename semantics: UPDATE the row's
997
+ ``vault_path`` in place so the document id — and thus every incoming
998
+ ``links`` row — survives, then run the normal per-file upsert on the
999
+ destination to refresh content / tags / chunks / outgoing links / mirror
1000
+ bookkeeping (it matches the row by frontmatter id and updates it in
1001
+ place). The UPDATE is a single statement, so this keeps the DB write
1002
+ window no larger than the old DELETE — it does not widen the historically
1003
+ deadlock-prone lock footprint.
1004
+
1005
+ A move whose destination has vanished by the time the worker runs
1006
+ (a move-then-delete inside the debounce window) can't be applied in
1007
+ place — there's no file to sync, and pointing ``vault_path`` at a missing
1008
+ file would strand the row. In that case we fall back to a plain delete of
1009
+ the source row, symmetric to the stale-upsert existence guard in the
1010
+ worker loop.
1011
+ """
1012
+ if not dst_abs.exists():
1013
+ _handle_delete(
1014
+ conn, src_abs, config.vault_path, graph_syncer=graph_syncer
1015
+ )
1016
+ state.body_cache.pop(src_abs, None)
1017
+ state.body_cache.pop(dst_abs, None)
1018
+ return
1019
+
1020
+ src_rel = _vault_relative_posix(src_abs, config.vault_path)
1021
+ dst_rel = _vault_relative_posix(dst_abs, config.vault_path)
1022
+ if src_rel is not None and dst_rel is not None and src_rel != dst_rel:
1023
+ # Preserve the document id (and every incoming ``links`` row) by
1024
+ # moving ``vault_path`` in place instead of delete + re-insert. The
1025
+ # worker connection is autocommit, so this commits immediately — the
1026
+ # same single-statement footprint as ``_handle_delete``'s DELETE.
1027
+ conn.execute(
1028
+ "UPDATE documents SET vault_path = %s "
1029
+ "WHERE kind = 'vault' AND vault_path = %s",
1030
+ (dst_rel, src_rel),
1031
+ )
1032
+
1033
+ # Refresh content / mirror bookkeeping on the destination. This matches
1034
+ # the row by frontmatter id and updates it in place (full-sync
1035
+ # semantics), re-materializing the moved doc's OUTGOING links and retrying
1036
+ # any previously-unresolved refs scoped to it.
1037
+ report = sync_one_file(
1038
+ conn,
1039
+ embedder=embedder,
1040
+ vault_path=config.vault_path,
1041
+ file_path=dst_abs,
1042
+ link_rewrite=config.link_rewrite,
1043
+ owner_participants=config.owner_participants,
1044
+ graph_syncer=graph_syncer,
1045
+ )
1046
+
1047
+ # The source path's cached fence-stripped body is now stale (file gone);
1048
+ # prime the destination from the baseline sync indexed (not a fresh disk
1049
+ # read) so a racing edit isn't masked while the follow-up event from
1050
+ # sync's own write is still recognized as fence-only.
1051
+ state.body_cache.pop(src_abs, None)
1052
+ _cache_after_sync(state, dst_abs, report)