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/sync.py ADDED
@@ -0,0 +1,1513 @@
1
+ """Vault → DB sync engine.
2
+
3
+ Walks a vault folder, reconciles every ``.md`` file against the
4
+ ``documents`` table by frontmatter ``id``, materializes ``[[wiki-links]]``
5
+ into the ``links`` / ``unresolved_links`` tables, and (optionally) prunes
6
+ vault-tier rows whose files vanished.
7
+
8
+ **Tier-aware metadata contract:**
9
+
10
+ - For ``kind='vault'`` rows the file is authoritative — sync overwrites
11
+ ``documents.metadata`` from the file's frontmatter on every pass. Aliases,
12
+ freeform keys, everything in the YAML header round-trips into JSONB.
13
+ - For ``kind='ingested'`` rows the **DB** is authoritative for metadata.
14
+ ``_ingested/`` files are content/title/tags mirrors, not metadata mirrors.
15
+ Re-syncing an ingested-tier file does NOT overwrite ``documents.metadata``
16
+ (e.g. ``date``, ``duration_min``, source-specific fields populated during
17
+ the original ingest are preserved). New aliases written into an ingested
18
+ file ARE merged in — they're authored by the user — but everything else
19
+ the DB owns stays put.
20
+
21
+ **Atomicity:** each file's upsert runs in a single transaction. The disk
22
+ write that stamps an assigned ``id`` back into the file's frontmatter is
23
+ deferred until after the DB transaction commits, so a DB crash mid-sync
24
+ leaves the file untouched (the prior write-first-then-DB ordering could
25
+ leave a file id-stamped on disk with no DB row to back it).
26
+ """
27
+ from __future__ import annotations
28
+
29
+ import hashlib
30
+ import json
31
+ import logging
32
+ import uuid
33
+ from collections.abc import Iterator
34
+ from dataclasses import dataclass, field
35
+ from pathlib import Path
36
+ from typing import TYPE_CHECKING, Any
37
+
38
+ import psycopg
39
+ import yaml
40
+
41
+ from ..ingest import Embedder, _chunk_search_metadata
42
+ from ..ingest.chunker import chunk_text
43
+ from ..ingest.sub_tokens import extract_sub_tokens
44
+ from ..queries import sync_chunk_search_metadata
45
+ from ..tags import normalize_tags
46
+ from .derived_links import DirectoryStore, rebuild_derived_for
47
+ from .derived_links.fence import rewrite_derived_fences, strip_fence
48
+ from .frontmatter import body_hash, dump_frontmatter, parse_frontmatter
49
+ from .link_rewrite import rewrite_vault_links
50
+ from .links import ParsedLink, parse_wiki_links
51
+ from .resolver import resolve_link, title_collisions
52
+
53
+ if TYPE_CHECKING:
54
+ from ..graph_rag.sync import GraphSyncer
55
+
56
+ # Top-level vault directories (or anything starting with ``_``) that the sync
57
+ # engine never treats as authored notes. ``_templates`` is editor scaffolding,
58
+ # ``_attachments`` is binaries (non-Markdown), and ``_ingested`` is the
59
+ # read-only mirror tier (treated specially below).
60
+ _TEMPLATE_DIR_NAME = "_templates"
61
+ _ATTACHMENTS_DIR_NAME = "_attachments"
62
+ _INGESTED_DIR_NAME = "_ingested"
63
+
64
+ logger = logging.getLogger(__name__)
65
+
66
+
67
+ @dataclass
68
+ class SyncReport:
69
+ """Aggregate counters returned from :func:`sync_vault`.
70
+
71
+ Each counter is incremented exactly once per file, except
72
+ ``links_resolved`` / ``links_unresolved`` which count link insertions
73
+ (a file with three links contributes three to one or both of those).
74
+
75
+ ``derived_links`` counts ``derived_links`` rows inserted by the
76
+ metadata-aware linker pass that runs at the end of every non-dry-run
77
+ sync (see :func:`brain.vault.derived_links.rebuild_derived_for`). The
78
+ counter stays ``0`` on dry-run and on syncs that touched no Gmail/Krisp
79
+ docs.
80
+
81
+ ``errors`` lists files we couldn't sync along with a short human-readable
82
+ reason; the file is skipped (no DB write) and the run continues.
83
+ """
84
+
85
+ created: int = 0
86
+ updated: int = 0
87
+ skipped: int = 0
88
+ deleted: int = 0
89
+ warned: int = 0
90
+ links_resolved: int = 0
91
+ links_unresolved: int = 0
92
+ id_assigned: int = 0
93
+ derived_links: int = 0
94
+ # Phase D — count of ``_ingested/`` files whose derived-edges fence was
95
+ # rewritten this pass. Driven by ``rebuild_derived_for``'s affected-ids
96
+ # set; stays at ``0`` on dry-run, on syncs with no affected ingested
97
+ # docs, and when the renderer skips every candidate (vault-tier,
98
+ # missing mirror file, etc.).
99
+ fences_written: int = 0
100
+ # Vault-tier files whose ``[[…]]`` markers were rewritten to
101
+ # vault-root-relative path form so Quartz can resolve them without a
102
+ # frontmatter lookup. Counted by :func:`brain.vault.link_rewrite.
103
+ # rewrite_vault_links`; stays at ``0`` on dry-run, on syncs that
104
+ # touched only ingested-tier files, when ``--no-link-rewrite`` is in
105
+ # effect, and when every link is already in canonical path form
106
+ # (idempotent re-sync).
107
+ links_rewritten: int = 0
108
+ # Fence-stripped body baseline the vault watcher caches for fence-only-write
109
+ # detection. Set only by the single-file path (:func:`_sync_one` via
110
+ # :func:`sync_one_file`) and reflects the content sync actually INDEXED —
111
+ # NOT a fresh disk read that could have picked up a racing user edit. The
112
+ # watcher prefers this over re-reading disk so an edit landing between
113
+ # sync's write and the cache refresh is never masked as a no-op. Stays
114
+ # ``None`` on dry-run, on documentation-skips, and on bulk ``sync_vault``
115
+ # aggregate reports (only the per-file watcher path consumes it).
116
+ body_baseline: str | None = None
117
+ errors: list[tuple[Path, str]] = field(default_factory=list)
118
+
119
+
120
+ @dataclass
121
+ class _WalkedFile:
122
+ """Internal projection of one ``.md`` file the walker handed us."""
123
+
124
+ abs_path: Path
125
+ relative_posix: str # always POSIX-style (forward slashes)
126
+ classification: str # 'vault' or 'ingested' — derived from the path
127
+
128
+
129
+ def sync_vault(
130
+ conn: psycopg.Connection[Any],
131
+ *,
132
+ embedder: Embedder,
133
+ vault_path: Path,
134
+ prune: bool = False,
135
+ dry_run: bool = False,
136
+ link_rewrite: bool = True,
137
+ owner_participants: frozenset[str] = frozenset(),
138
+ graph_syncer: GraphSyncer | None = None,
139
+ ) -> SyncReport:
140
+ """Reconcile every ``.md`` file under ``vault_path`` into the DB.
141
+
142
+ Steps:
143
+
144
+ 1. Walk the vault, collecting ``.md`` files (skipping ``_templates/``
145
+ and ``_attachments/``).
146
+ 2. For each file: parse frontmatter, assign an ``id`` if missing
147
+ (writing back to disk), compute the body hash, then upsert the
148
+ ``documents`` row + chunks. Re-parse links and materialize them.
149
+ 3. After the walk, look at every vault-tier ``documents`` row whose
150
+ ``vault_path`` was NOT seen and either prune (``prune=True``) or
151
+ warn.
152
+ 4. Re-resolution pass: for every previously-unresolved link whose
153
+ ``src_document_id`` was processed in this run, retry the lookup
154
+ so dangling refs that just got their target created turn into
155
+ resolved links.
156
+ 5. Linker pass: rebuild ``derived_links`` for every touched doc so the
157
+ metadata-derived (R1 shared_thread / R2 shared_participant /
158
+ R3 same_day_participant) edges stay consistent with the latest
159
+ Gmail / Krisp metadata. The pass is scoped to ``seen_doc_ids`` and
160
+ the runner filters non-linkable (vault-tier) ids itself.
161
+
162
+ ``embedder`` is an explicit dependency (Dependency Inversion): the sync
163
+ engine never reaches for a global. Tests pass a fake; production passes
164
+ whatever ``brain.embeddings.make_embedder`` returned.
165
+
166
+ With ``dry_run=True`` no writes happen — neither to the DB nor to disk
167
+ (no id assignment, no link materialization, no derived-links rebuild).
168
+ The returned report still counts the actions that *would* have been
169
+ taken for steps 1–3; ``derived_links`` stays at 0 because the linker is
170
+ skipped entirely on dry-run.
171
+ """
172
+ report = SyncReport()
173
+ if not vault_path.is_dir():
174
+ report.errors.append((vault_path, "vault path does not exist or is not a directory"))
175
+ return report
176
+
177
+ walked: list[_WalkedFile] = list(_walk_vault(vault_path))
178
+ seen_doc_ids: set[str] = set()
179
+ seen_relative: set[str] = set()
180
+
181
+ for walked_file in walked:
182
+ seen_relative.add(walked_file.relative_posix)
183
+ try:
184
+ doc_id = _sync_one(
185
+ conn,
186
+ embedder=embedder,
187
+ vault_path=vault_path,
188
+ walked=walked_file,
189
+ report=report,
190
+ dry_run=dry_run,
191
+ )
192
+ except _SyncError as e:
193
+ report.errors.append((walked_file.abs_path, str(e)))
194
+ continue
195
+ if doc_id is not None:
196
+ seen_doc_ids.add(doc_id)
197
+
198
+ # Step 3: detect missing-on-disk vault-tier rows.
199
+ pruned_ids = _process_missing(
200
+ conn,
201
+ seen_relative=seen_relative,
202
+ report=report,
203
+ prune=prune,
204
+ dry_run=dry_run,
205
+ )
206
+
207
+ # Step 4: resolution pass for unresolved links from just-processed docs.
208
+ if not dry_run and seen_doc_ids:
209
+ _retry_unresolved(conn, seen_doc_ids, report)
210
+
211
+ # Step 5: rebuild metadata-derived edges (R1/R2/R3) for the touched docs.
212
+ # Skipped on dry-run to honor the "no DB writes" contract; the linker
213
+ # opens its own transaction internally, so we call it AFTER the per-file
214
+ # transactions in ``_sync_one`` have committed (no nesting). Vault-tier
215
+ # docs in ``seen_doc_ids`` are silently filtered by the runner — only
216
+ # gmail / krisp rows produce edges. ``rebuild_derived_for`` already
217
+ # short-circuits on an empty set, so no extra guard here.
218
+ #
219
+ # Step 6 (Phase D): regenerate the fenced "Related" section in every
220
+ # affected ``_ingested/`` file so Quartz's graph view reflects the
221
+ # latest derived edges. The renderer is also a no-op on an empty
222
+ # affected-set, so it's safe to call unconditionally on the post-linker
223
+ # branch.
224
+ if not dry_run:
225
+ report.derived_links, affected_ids = rebuild_derived_for(
226
+ conn,
227
+ seen_doc_ids,
228
+ directory=DirectoryStore(conn),
229
+ owner_participants=owner_participants,
230
+ )
231
+ report.fences_written = rewrite_derived_fences(
232
+ conn, affected_ids, vault_path=vault_path
233
+ )
234
+ if link_rewrite:
235
+ report.links_rewritten = _rewrite_vault_tier_links(
236
+ conn,
237
+ vault_path=vault_path,
238
+ doc_ids=seen_doc_ids,
239
+ )
240
+
241
+ # Step 7 (wave G1-c): people-aspect graph sync. Runs only on a real
242
+ # (non-dry-run) sync, AFTER the per-file transactions + linker pass have
243
+ # committed, reusing ``conn``. Reconcile every doc we touched (most are
244
+ # watermark-skips — vault-tier rows carry no participants, and ingested
245
+ # mirrors keep DB-authoritative metadata that sync doesn't change — so the
246
+ # graph only moves for the rare synced doc whose person set actually
247
+ # differs) and remove every pruned doc. Both calls are best-effort /
248
+ # never-raise and no-op when graph sync is disabled or AGE is absent.
249
+ if not dry_run and graph_syncer is not None:
250
+ for doc_id in sorted(seen_doc_ids):
251
+ graph_syncer.reconcile(conn, doc_id)
252
+ for doc_id in pruned_ids:
253
+ graph_syncer.remove(conn, doc_id)
254
+
255
+ return report
256
+
257
+
258
+ def sync_one_file(
259
+ conn: psycopg.Connection[Any],
260
+ *,
261
+ embedder: Embedder,
262
+ vault_path: Path,
263
+ file_path: Path,
264
+ link_rewrite: bool = True,
265
+ owner_participants: frozenset[str] = frozenset(),
266
+ graph_syncer: GraphSyncer | None = None,
267
+ ) -> SyncReport:
268
+ """Sync exactly one ``.md`` file under ``vault_path``.
269
+
270
+ Used by the authoring commands (``brain note new``, ``brain daily``,
271
+ ``brain edit`` for vault-tier docs) so we re-index just the file the user
272
+ touched instead of walking the entire vault. The contract matches
273
+ :func:`sync_vault` for the fields it touches:
274
+
275
+ - The file is classified the same way as the full walker (path under
276
+ ``_ingested/`` → ``kind='ingested'``; anywhere else → ``kind='vault'``;
277
+ ``_templates/`` and ``_attachments/`` are explicitly out of bounds —
278
+ passing one returns an error in ``report.errors``).
279
+ - Frontmatter id is auto-assigned + written back to disk if missing
280
+ (same recovery semantics as a full sync).
281
+ - Wiki-links from this file are re-materialized; targets that exist
282
+ anywhere in the DB resolve, others land in ``unresolved_links``.
283
+ - The link-retry pass runs scoped to this single document so a body
284
+ that just gained ``[[Foo]]`` (with Foo.md already in the DB from a
285
+ prior sync) ends up resolved.
286
+ - The metadata-derived linker pass also runs scoped to this single
287
+ document so any ``derived_links`` rows touching it stay current.
288
+ Vault-tier docs are filtered out by the runner — only Gmail / Krisp
289
+ ids contribute edges.
290
+
291
+ What's intentionally NOT re-checked: the rest of the vault. If another
292
+ file's ``[[<this title>]]`` was previously unresolved, a full
293
+ ``brain vault sync`` is still required to convert that dangling ref —
294
+ otherwise we'd have to walk every file on every authoring action, which
295
+ defeats the point of the helper. The follow-up sync is cheap (body-hash
296
+ short-circuits every unchanged file).
297
+
298
+ ``file_path`` may be absolute or relative to ``vault_path``; we
299
+ normalize before classifying. A path outside ``vault_path``, a non-file,
300
+ or a non-``.md`` file each surfaces in ``report.errors`` rather than
301
+ raising — the caller is the CLI and a clean error message beats a
302
+ traceback.
303
+ """
304
+ report = SyncReport()
305
+ if not vault_path.is_dir():
306
+ report.errors.append(
307
+ (vault_path, "vault path does not exist or is not a directory")
308
+ )
309
+ return report
310
+
311
+ abs_path = (
312
+ file_path
313
+ if file_path.is_absolute()
314
+ else (vault_path / file_path)
315
+ ).resolve()
316
+ try:
317
+ relative = abs_path.relative_to(vault_path.resolve())
318
+ except ValueError:
319
+ report.errors.append(
320
+ (abs_path, "file is not under the vault path")
321
+ )
322
+ return report
323
+
324
+ if abs_path.suffix.lower() != ".md":
325
+ report.errors.append((abs_path, "not a .md file"))
326
+ return report
327
+ if not abs_path.is_file():
328
+ report.errors.append((abs_path, "file does not exist"))
329
+ return report
330
+
331
+ parts = relative.parts
332
+ if not parts:
333
+ report.errors.append((abs_path, "empty relative path"))
334
+ return report
335
+ first = parts[0]
336
+ if first in {_TEMPLATE_DIR_NAME, _ATTACHMENTS_DIR_NAME}:
337
+ report.errors.append(
338
+ (abs_path, f"path is under {first}/ — not a syncable note")
339
+ )
340
+ return report
341
+ if _has_hidden_component(parts):
342
+ report.errors.append(
343
+ (abs_path, "path contains a hidden directory component (starts with '.')")
344
+ )
345
+ return report
346
+
347
+ classification = "ingested" if first == _INGESTED_DIR_NAME else "vault"
348
+ walked = _WalkedFile(
349
+ abs_path=abs_path,
350
+ relative_posix=relative.as_posix(),
351
+ classification=classification,
352
+ )
353
+
354
+ try:
355
+ doc_id = _sync_one(
356
+ conn,
357
+ embedder=embedder,
358
+ vault_path=vault_path,
359
+ walked=walked,
360
+ report=report,
361
+ dry_run=False,
362
+ )
363
+ except _SyncError as e:
364
+ report.errors.append((walked.abs_path, str(e)))
365
+ return report
366
+
367
+ if doc_id is not None:
368
+ _retry_unresolved(conn, {doc_id}, report)
369
+ # Mirror ``sync_vault``: rebuild metadata-derived edges scoped to the
370
+ # one doc we just processed. ``sync_one_file`` has no dry_run mode,
371
+ # so the linker always runs here. Non-linkable (vault-tier) doc ids
372
+ # are filtered by the runner.
373
+ #
374
+ # Phase D step 6: regenerate the fenced "Related" section in every
375
+ # ``_ingested/`` file the linker touched. ``rewrite_derived_fences``
376
+ # is also a no-op on an empty affected-set.
377
+ report.derived_links, affected_ids = rebuild_derived_for(
378
+ conn,
379
+ {doc_id},
380
+ directory=DirectoryStore(conn),
381
+ owner_participants=owner_participants,
382
+ )
383
+ report.fences_written = rewrite_derived_fences(
384
+ conn, affected_ids, vault_path=vault_path
385
+ )
386
+ # Vault-tier link rewrite — only runs for the one doc we just
387
+ # processed, and only when the path lives under a vault-tier
388
+ # directory (the rewriter is a no-op for ingested-tier mirrors).
389
+ if (
390
+ link_rewrite
391
+ and walked.classification == "vault"
392
+ and rewrite_vault_links(
393
+ walked.abs_path,
394
+ document_id=doc_id,
395
+ conn=conn,
396
+ )
397
+ ):
398
+ report.links_rewritten += 1
399
+
400
+ # Wave G1-c: reconcile the single touched doc into the people graph
401
+ # (best-effort / never-raises; a no-op when graph sync is disabled or
402
+ # AGE is absent). Runs after the per-file + linker transactions
403
+ # committed, reusing ``conn``.
404
+ if graph_syncer is not None:
405
+ graph_syncer.reconcile(conn, doc_id)
406
+
407
+ return report
408
+
409
+
410
+ # ---------------------------------------------------------------------------
411
+ # Internal helpers.
412
+ # ---------------------------------------------------------------------------
413
+
414
+
415
+ class _SyncError(Exception):
416
+ """Per-file sync failure; the run continues with the next file."""
417
+
418
+
419
+ def _violation_constraint(exc: psycopg.errors.UniqueViolation) -> str:
420
+ """Extract a human-readable constraint name from a ``UniqueViolation``.
421
+
422
+ Falls back to a sentinel when ``exc.diag.constraint_name`` is empty —
423
+ psycopg's diagnostic fields are documented as nullable, and we'd rather
424
+ surface a stable label in :class:`SyncReport.errors` than crash a second
425
+ time formatting the original error.
426
+ """
427
+ name = getattr(exc.diag, "constraint_name", None)
428
+ return name or "<unknown>"
429
+
430
+
431
+ def _rewrite_vault_tier_links(
432
+ conn: psycopg.Connection[Any],
433
+ *,
434
+ vault_path: Path,
435
+ doc_ids: set[str],
436
+ ) -> int:
437
+ """Rewrite ``[[…]]`` markers in every vault-tier file in ``doc_ids``.
438
+
439
+ Pulls each touched doc's ``kind`` + ``vault_path`` in one batch query
440
+ so we don't issue N round-trips on a full-vault sync, then dispatches
441
+ to :func:`brain.vault.link_rewrite.rewrite_vault_links` for each
442
+ vault-tier file that exists on disk. Ingested-tier docs are silently
443
+ filtered — wiki-link rewriting is contractually a vault-tier-only
444
+ operation (their ``[[…]]`` markers would all be inside the
445
+ auto-generated derived-edges fence, which is itself regenerated each
446
+ sync).
447
+
448
+ Empty input short-circuits to ``0`` — no DB round-trip, no FS scan.
449
+ Returns the count of files actually rewritten on disk; files that
450
+ were already in canonical path form (idempotent fast path) and files
451
+ skipped due to read/write errors do not contribute to the count.
452
+ """
453
+ if not doc_ids:
454
+ return 0
455
+ rows = conn.execute(
456
+ "SELECT id::text, vault_path FROM documents "
457
+ "WHERE id = ANY(%s) AND kind = 'vault'",
458
+ (sorted(doc_ids),),
459
+ ).fetchall()
460
+ rewritten = 0
461
+ for doc_id, vp in rows:
462
+ if not vp:
463
+ # Vault-tier rows are supposed to have a ``vault_path``; defensive
464
+ # skip for the corrupted-row case (no file to rewrite).
465
+ continue
466
+ target = vault_path / str(vp)
467
+ if not target.is_file():
468
+ continue
469
+ if rewrite_vault_links(
470
+ target,
471
+ document_id=str(doc_id),
472
+ conn=conn,
473
+ ):
474
+ rewritten += 1
475
+ return rewritten
476
+
477
+
478
+ def _has_hidden_component(parts: tuple[str, ...]) -> bool:
479
+ """Return True if any path component starts with ``.``.
480
+
481
+ Mirrors :func:`brain.vault.watch._filter_path`'s hidden-directory test
482
+ so the watcher and the one-shot sync agree on what's a non-syncable
483
+ path. ``.git/``, ``.quartz/`` (Quartz workspace), ``.obsidian/`` (Obsidian
484
+ config), VSCode's ``.vscode/``, etc. are all unwanted descents — those
485
+ trees contain ``.md`` files (issue templates, Quartz docs) that aren't
486
+ user notes and don't carry our frontmatter contract.
487
+ """
488
+ return any(part.startswith(".") for part in parts)
489
+
490
+
491
+ def _walk_vault(vault_path: Path) -> Iterator[_WalkedFile]:
492
+ """Yield every Markdown file the sync should process.
493
+
494
+ Skips ``_templates/`` and ``_attachments/`` entirely, plus any path
495
+ whose components include a hidden directory (``.git/``, ``.quartz/``,
496
+ ``.obsidian/``, …). Files under ``_ingested/<source>/...`` are still
497
+ yielded but classified as ``ingested`` so the caller treats them with
498
+ ``kind='ingested'``. All other ``.md`` files are vault-tier.
499
+
500
+ Paths are yielded in deterministic (sorted) order so test assertions on
501
+ iteration order are stable.
502
+ """
503
+ for path in sorted(vault_path.rglob("*.md")):
504
+ if not path.is_file():
505
+ continue
506
+ try:
507
+ relative = path.relative_to(vault_path)
508
+ except ValueError:
509
+ # ``rglob`` always produces children of ``vault_path``; the
510
+ # relative_to should never fail. Defensive: skip and move on.
511
+ continue
512
+ parts = relative.parts
513
+ if not parts:
514
+ continue
515
+ first = parts[0]
516
+ if first in {_TEMPLATE_DIR_NAME, _ATTACHMENTS_DIR_NAME}:
517
+ continue
518
+ if _has_hidden_component(parts):
519
+ # Hidden directories (``.git/``, ``.quartz/``, ``.obsidian/``, …)
520
+ # are tooling state, not authored notes. The watcher's
521
+ # ``_filter_path`` already skips these; the one-shot sync now
522
+ # matches that behavior so a vault containing a Quartz workspace
523
+ # or any other dotted tree doesn't pollute the DB.
524
+ continue
525
+ classification = "ingested" if first == _INGESTED_DIR_NAME else "vault"
526
+ yield _WalkedFile(
527
+ abs_path=path,
528
+ relative_posix=relative.as_posix(),
529
+ classification=classification,
530
+ )
531
+
532
+
533
+ def _sync_one(
534
+ conn: psycopg.Connection[Any],
535
+ *,
536
+ embedder: Embedder,
537
+ vault_path: Path,
538
+ walked: _WalkedFile,
539
+ report: SyncReport,
540
+ dry_run: bool,
541
+ ) -> str | None:
542
+ """Sync a single file. Returns the document id (or None on dry-run / skip).
543
+
544
+ Wraps the per-file work in a single transaction (when not dry-run) so a
545
+ DB error mid-file leaves no half-written state — chunks, document row,
546
+ and links all commit together or not at all.
547
+ """
548
+ try:
549
+ text = walked.abs_path.read_text(encoding="utf-8")
550
+ except OSError as e:
551
+ raise _SyncError(f"could not read file: {e}") from e
552
+
553
+ try:
554
+ frontmatter, body = parse_frontmatter(text)
555
+ except (ValueError, yaml.YAMLError) as e:
556
+ raise _SyncError(f"malformed frontmatter: {e}") from e
557
+
558
+ # Files without any frontmatter block at all (no opening ``---``) are
559
+ # treated as tooling-managed documentation (vault README, ``_ingested/``
560
+ # README written by ``brain vault init``). They're intentional, not
561
+ # malformed, and don't belong in the search index — silently skip.
562
+ if not frontmatter and not text.lstrip().startswith("---"):
563
+ logger.debug(
564
+ "vault sync: %s has no frontmatter — skipping as documentation",
565
+ walked.relative_posix,
566
+ )
567
+ return None
568
+
569
+ # Validate / coerce the frontmatter mapping into a strongly typed view.
570
+ title = frontmatter.get("title")
571
+ if not isinstance(title, str) or not title.strip():
572
+ raise _SyncError("missing or empty 'title' in frontmatter")
573
+
574
+ raw_id = frontmatter.get("id")
575
+ needs_disk_write = False
576
+ if not isinstance(raw_id, str) or not raw_id:
577
+ report.id_assigned += 1
578
+ if dry_run:
579
+ # In dry-run we don't write to disk, but we still want to count
580
+ # the would-create + planned link materialization. A fresh UUID
581
+ # gives the rest of the function a stable key for the simulated
582
+ # documents row; the user doesn't see this synthetic id.
583
+ document_id = str(uuid.uuid4())
584
+ else:
585
+ # Recovery branch: a previous sync may have committed the DB row
586
+ # but crashed before stamping the id back to disk. If the file's
587
+ # vault_path matches an existing vault-tier row, reuse that id —
588
+ # otherwise we'd orphan the prior row (different id) and produce
589
+ # two DB rows for the same file across runs. Only matches by
590
+ # path; matching by title would risk false positives.
591
+ existing_by_path = conn.execute(
592
+ "SELECT id::text FROM documents "
593
+ "WHERE vault_path = %s AND kind = %s "
594
+ "LIMIT 1",
595
+ (walked.relative_posix, walked.classification),
596
+ ).fetchone()
597
+ if existing_by_path is not None:
598
+ document_id = str(existing_by_path[0])
599
+ else:
600
+ document_id = str(uuid.uuid4())
601
+ # Defer the disk write until AFTER the DB upsert commits — a
602
+ # half-written file (id on disk, no DB row) was the prior bug.
603
+ frontmatter = dict(frontmatter)
604
+ frontmatter["id"] = document_id
605
+ needs_disk_write = True
606
+ else:
607
+ document_id = raw_id
608
+
609
+ # ``content_hash`` mirrors ``body_hash`` (frontmatter-stripped, normalized);
610
+ # this is what makes export → sync a no-op on unchanged content. We also
611
+ # compute the legacy "raw body" hash because Phase 1 export wrote rows
612
+ # with ``content_hash = sha256(doc.content)`` — to keep the round-trip a
613
+ # no-op (zero re-embeds) we treat *either* hash matching as "body
614
+ # unchanged" and silently migrate to ``body_hash`` form on the first run.
615
+ new_hash = body_hash(text)
616
+ legacy_hash = _legacy_body_hash(body)
617
+ parsed_tags = _coerce_tag_list(frontmatter.get("tags"))
618
+ tags = normalize_tags(parsed_tags)
619
+ # If the on-disk frontmatter has non-canonical tags (mixed case,
620
+ # underscores, duplicates), schedule a write-back so the file
621
+ # converges with what we're about to upsert into ``documents.tags``.
622
+ # This piggybacks on the existing ``needs_disk_write`` path that the
623
+ # missing-id branch already uses, so we still write the file at most
624
+ # once per sync. ``updated:`` is intentionally NOT bumped here — a tag
625
+ # normalization is a one-time canonicalization, not an authored edit;
626
+ # bumping ``updated:`` on every initial sync would churn git history.
627
+ if tags != parsed_tags:
628
+ frontmatter["tags"] = tags
629
+ needs_disk_write = True
630
+ aliases = _coerce_alias_list(frontmatter.get("aliases"))
631
+ content_type = (
632
+ frontmatter["content_type"]
633
+ if isinstance(frontmatter.get("content_type"), str)
634
+ else "note"
635
+ )
636
+
637
+ # Path classification: path wins over frontmatter when there's a conflict.
638
+ classification = walked.classification
639
+ declared_kind = frontmatter.get("kind")
640
+ if (
641
+ isinstance(declared_kind, str)
642
+ and declared_kind in {"vault", "ingested"}
643
+ and declared_kind != classification
644
+ ):
645
+ logger.warning(
646
+ "vault sync: %s declares kind=%r but its path implies %r — using path",
647
+ walked.relative_posix,
648
+ declared_kind,
649
+ classification,
650
+ )
651
+
652
+ if dry_run:
653
+ # Dry-run: we still count what *would* happen so users see the plan.
654
+ # We don't open a transaction or write to the DB.
655
+ existing = conn.execute(
656
+ "SELECT content_hash, title, tags, kind, vault_path, metadata "
657
+ "FROM documents WHERE id = %s",
658
+ (document_id,),
659
+ ).fetchone()
660
+ normalized_body = _normalized_body(body)
661
+ if existing is None:
662
+ report.created += 1
663
+ else:
664
+ cur_hash, cur_title, cur_tags, _cur_kind, cur_vp, cur_meta = existing
665
+ cur_tags = list(cur_tags or [])
666
+ cur_meta = dict(cur_meta or {})
667
+ planned_metadata = _build_metadata(
668
+ frontmatter,
669
+ aliases,
670
+ tier=classification,
671
+ existing_metadata=cur_meta,
672
+ )
673
+ body_unchanged = cur_hash in (new_hash, legacy_hash)
674
+ metadata_visible_change = (
675
+ cur_title != title
676
+ or sorted(cur_tags) != sorted(tags)
677
+ or cur_vp != walked.relative_posix
678
+ or cur_meta != planned_metadata
679
+ )
680
+ if not body_unchanged or metadata_visible_change:
681
+ report.updated += 1
682
+ else:
683
+ report.skipped += 1
684
+ # Count links as if we materialized them — purely informational on dry-run.
685
+ for parsed in parse_wiki_links(normalized_body):
686
+ target = resolve_link(conn, parsed, exclude_doc_id=document_id)
687
+ if target is None:
688
+ report.links_unresolved += 1
689
+ else:
690
+ report.links_resolved += 1
691
+ return document_id
692
+
693
+ normalized_body = _normalized_body(body)
694
+ with conn.transaction():
695
+ existing = conn.execute(
696
+ "SELECT content_hash, title, tags, content_type, metadata, kind, vault_path "
697
+ "FROM documents WHERE id = %s",
698
+ (document_id,),
699
+ ).fetchone()
700
+ if existing is None:
701
+ # Brand-new row: build metadata fresh (no DB row to merge against).
702
+ metadata = _build_metadata(
703
+ frontmatter, aliases, tier=classification, existing_metadata=None
704
+ )
705
+ try:
706
+ _insert_document(
707
+ conn,
708
+ embedder=embedder,
709
+ document_id=document_id,
710
+ title=title,
711
+ content=normalized_body,
712
+ content_hash=new_hash,
713
+ content_type=content_type,
714
+ tags=tags,
715
+ metadata=metadata,
716
+ kind=classification,
717
+ vault_path=walked.relative_posix,
718
+ source=_source_from_frontmatter(frontmatter, classification),
719
+ external_id=_external_id_from_frontmatter(
720
+ frontmatter, classification
721
+ ),
722
+ )
723
+ except psycopg.errors.UniqueViolation as exc:
724
+ # Mirror drift: the file's vault_path or content_hash already
725
+ # belongs to another row. One bad file must not crash the
726
+ # whole sync — convert to ``_SyncError`` so the outer loop
727
+ # routes it into ``report.errors`` and continues.
728
+ raise _SyncError(
729
+ "unique constraint violation (mirror drift): "
730
+ f"{_violation_constraint(exc)}"
731
+ ) from exc
732
+ report.created += 1
733
+ else:
734
+ (
735
+ cur_hash,
736
+ cur_title,
737
+ cur_tags,
738
+ cur_type,
739
+ cur_meta,
740
+ cur_kind,
741
+ cur_vault_path,
742
+ ) = existing
743
+ cur_tags = list(cur_tags or [])
744
+ cur_meta = dict(cur_meta or {})
745
+ # Tier-aware metadata: ingested-tier preserves DB-owned fields,
746
+ # vault-tier rebuilds from frontmatter. See module docstring.
747
+ metadata = _build_metadata(
748
+ frontmatter,
749
+ aliases,
750
+ tier=classification,
751
+ existing_metadata=cur_meta,
752
+ )
753
+ # Body is unchanged iff cur_hash matches either the new normalized
754
+ # form or the legacy raw form; the latter handles freshly-exported
755
+ # files whose content_hash predates Phase 2's normalization.
756
+ body_unchanged = cur_hash in (new_hash, legacy_hash)
757
+ metadata_changed = cur_meta != metadata
758
+ tags_changed = sorted(cur_tags) != sorted(tags)
759
+ title_changed = cur_title != title
760
+ kind_changed = cur_kind != classification
761
+ vault_path_changed = cur_vault_path != walked.relative_posix
762
+ type_changed = cur_type != content_type
763
+ user_visible_change = (
764
+ metadata_changed
765
+ or tags_changed
766
+ or title_changed
767
+ or kind_changed
768
+ or vault_path_changed
769
+ or type_changed
770
+ )
771
+ if not body_unchanged or user_visible_change:
772
+ try:
773
+ _update_document(
774
+ conn,
775
+ embedder=embedder,
776
+ document_id=document_id,
777
+ title=title,
778
+ content=normalized_body,
779
+ content_hash=new_hash,
780
+ content_type=content_type,
781
+ tags=tags,
782
+ metadata=metadata,
783
+ kind=classification,
784
+ vault_path=walked.relative_posix,
785
+ body_changed=not body_unchanged,
786
+ )
787
+ except psycopg.errors.UniqueViolation as exc:
788
+ # Mirror drift on update: another row already owns the
789
+ # vault_path or content_hash this file is trying to
790
+ # claim. Convert to ``_SyncError`` so the run continues.
791
+ raise _SyncError(
792
+ "unique constraint violation (mirror drift): "
793
+ f"{_violation_constraint(exc)}"
794
+ ) from exc
795
+ report.updated += 1
796
+ elif cur_hash != new_hash:
797
+ # Silent hash migration: body is byte-equivalent under the
798
+ # new normalization, but the stored hash is in the legacy
799
+ # (raw) form. Update the hash without re-embedding so the
800
+ # next run is a clean skip.
801
+ conn.execute(
802
+ "UPDATE documents SET content_hash = %s WHERE id = %s",
803
+ (new_hash, document_id),
804
+ )
805
+ report.skipped += 1
806
+ else:
807
+ report.skipped += 1
808
+
809
+ # Always re-materialize links — cheap, and ensures the link graph is
810
+ # consistent with the body even when we determined the doc itself
811
+ # was a no-op (e.g. a frontmatter-only edit removed a link).
812
+ _materialize_links(
813
+ conn,
814
+ document_id=document_id,
815
+ body=normalized_body,
816
+ title=title,
817
+ report=report,
818
+ )
819
+
820
+ # DB transaction has now committed. Stamp the assigned id (and any
821
+ # normalized tags) back onto disk — if this fails, the DB row is intact
822
+ # and a follow-up sync will detect the orphaned-id-on-disk via the
823
+ # vault_path recovery branch above.
824
+ if needs_disk_write:
825
+ _write_frontmatter_back(
826
+ walked.abs_path,
827
+ frontmatter=frontmatter,
828
+ body=body,
829
+ original_text=text,
830
+ document_id=document_id,
831
+ relative_posix=walked.relative_posix,
832
+ )
833
+
834
+ # Record the fence-stripped body baseline the watcher should cache for
835
+ # fence-only-write detection: the content sync actually INDEXED, NOT a
836
+ # fresh disk read that could have picked up a racing user edit. When we
837
+ # wrote frontmatter back, reconstruct the intended on-disk text from the
838
+ # indexed body so the write-back's own follow-up event is recognized as a
839
+ # no-op; otherwise the file is untouched on disk and equals ``text``.
840
+ baseline_text = (
841
+ dump_frontmatter(frontmatter, body) if needs_disk_write else text
842
+ )
843
+ report.body_baseline = strip_fence(baseline_text)
844
+
845
+ return document_id
846
+
847
+
848
+ def _write_frontmatter_back(
849
+ abs_path: Path,
850
+ *,
851
+ frontmatter: dict[str, Any],
852
+ body: str,
853
+ original_text: str,
854
+ document_id: str,
855
+ relative_posix: str,
856
+ ) -> None:
857
+ """Stamp frontmatter (assigned id / canonical tags) back onto disk without
858
+ clobbering a concurrent user edit.
859
+
860
+ ``_sync_one`` reads the file, then embeds — a multi-second call for a real
861
+ backend. If the user saves the file during that window, the naive
862
+ ``write_text(dump_frontmatter(frontmatter, body))`` would overwrite their
863
+ new text with the stale ``body`` captured before the embed. Guard against
864
+ that: re-read the file; if its bytes changed since the initial read
865
+ (``original_text``), splice ONLY the frontmatter onto the freshly-read
866
+ body so the user's edit survives. If the fresh file can't be read or its
867
+ frontmatter no longer parses, skip the write-back with a WARN — a later
868
+ sync reconciles the missing id rather than risk clobbering the edit.
869
+
870
+ The DB row is one sync behind in this case (it was built from the body we
871
+ read before the embed); the next sync's body-hash check re-embeds the
872
+ freshly-saved content, so the two converge on the following pass.
873
+ """
874
+ try:
875
+ current_text = abs_path.read_text(encoding="utf-8")
876
+ except OSError as e:
877
+ logger.error(
878
+ "vault sync: %s — DB row %s is committed but the file could not "
879
+ "be re-read for id write-back: %s. Next sync will detect the "
880
+ "missing id and rewrite the frontmatter.",
881
+ relative_posix,
882
+ document_id[:8],
883
+ e,
884
+ )
885
+ return
886
+
887
+ body_to_write = body
888
+ if current_text != original_text:
889
+ # A concurrent edit landed between the initial read and now.
890
+ try:
891
+ _fresh_frontmatter, fresh_body = parse_frontmatter(current_text)
892
+ except (ValueError, yaml.YAMLError):
893
+ logger.warning(
894
+ "vault sync: %s changed during sync and now has malformed "
895
+ "frontmatter — skipping id write-back to avoid clobbering the "
896
+ "concurrent edit; next sync will reconcile.",
897
+ relative_posix,
898
+ )
899
+ return
900
+ body_to_write = fresh_body
901
+ logger.info(
902
+ "vault sync: %s changed during sync — splicing frontmatter onto "
903
+ "the freshly-read body so the concurrent edit survives.",
904
+ relative_posix,
905
+ )
906
+
907
+ try:
908
+ abs_path.write_text(
909
+ dump_frontmatter(frontmatter, body_to_write), encoding="utf-8"
910
+ )
911
+ except OSError as e:
912
+ logger.error(
913
+ "vault sync: %s — DB row %s is committed but failed to write id "
914
+ "back to disk: %s. Next sync will detect the missing id and "
915
+ "rewrite the frontmatter.",
916
+ relative_posix,
917
+ document_id[:8],
918
+ e,
919
+ )
920
+
921
+
922
+ def _normalized_body(body: str) -> str:
923
+ """Return ``body`` with the fence + line-ending + trailing-whitespace normalization.
924
+
925
+ The DB stores the canonical form: LF line endings, no trailing whitespace,
926
+ no auto-generated derived-edges fence. Reading the value back and
927
+ comparing to a freshly-normalized disk read must produce identical
928
+ strings, so the export → sync round-trip stays a no-op even if the
929
+ file was saved with CRLF, has an extra trailing newline, or carries a
930
+ fenced "Related" section appended by the linker (Phase D).
931
+
932
+ The fence strip is what keeps ``documents.content`` clean — vector
933
+ search must not surface documents by their auto-generated "Related"
934
+ list, and the wiki-link parser (which runs over this same normalized
935
+ body in :func:`_materialize_links`) must not double-count fence-internal
936
+ references that already live in ``derived_links``.
937
+ """
938
+ fence_stripped = strip_fence(body)
939
+ return fence_stripped.replace("\r\n", "\n").replace("\r", "\n").strip()
940
+
941
+
942
+ def _legacy_body_hash(body: str) -> str:
943
+ """SHA-256 of ``body`` (fence stripped, otherwise unnormalized).
944
+
945
+ Phase 1 ingest computed ``documents.content_hash = sha256(doc.content)``
946
+ with no normalization, and Phase 1 export wrote files whose body bytes
947
+ matched ``doc.content`` exactly. This helper reproduces that legacy
948
+ digest so the sync engine can recognize "body unchanged under the
949
+ legacy hash" and skip a re-embed on the very first sync after a Phase
950
+ 1 export — preserving the round-trip-no-op contract.
951
+
952
+ Phase D adds the fence strip: a Phase 1 file that has since been
953
+ rendered with a derived-edges fence still hashes equal to its original
954
+ content under the legacy form, so the linker's first relink doesn't
955
+ accidentally invalidate the legacy-hash short-circuit and trigger a
956
+ re-embed. The fence is contractually not authored body.
957
+ """
958
+ return hashlib.sha256(strip_fence(body).encode("utf-8")).hexdigest()
959
+
960
+
961
+ def _build_metadata(
962
+ frontmatter: dict[str, Any],
963
+ aliases: list[str],
964
+ *,
965
+ tier: str,
966
+ existing_metadata: dict[str, Any] | None = None,
967
+ ) -> dict[str, Any]:
968
+ """Project the file's frontmatter into ``documents.metadata`` shape.
969
+
970
+ For vault-tier rows the file is authoritative: metadata is built fresh
971
+ from frontmatter (only fields that aren't first-class columns end up in
972
+ metadata: aliases, plus any user-supplied keys like ``priority``).
973
+
974
+ For ingested-tier rows the DB is authoritative for metadata: source
975
+ fields like ``date`` and ``duration_min`` are populated during the
976
+ original ingest and re-syncing the mirror file must not blow them
977
+ away. We merge the existing DB metadata with any new ``aliases`` from
978
+ the file (user-authored aliases on an ingested mirror are intentional)
979
+ but otherwise leave the DB metadata untouched.
980
+
981
+ Aliases live in ``metadata['aliases']`` per the spec; we never write an
982
+ empty list (keeps the JSONB tidy and matches how export omits the field).
983
+ """
984
+ if tier == "ingested":
985
+ merged = dict(existing_metadata or {})
986
+ if aliases:
987
+ merged["aliases"] = aliases
988
+ elif "aliases" in merged and not merged["aliases"]:
989
+ # An empty aliases list in the DB is noise; drop it.
990
+ del merged["aliases"]
991
+ return merged
992
+
993
+ meta: dict[str, Any] = {}
994
+ reserved = {
995
+ "id",
996
+ "title",
997
+ "tags",
998
+ "aliases",
999
+ "kind",
1000
+ "content_type",
1001
+ "source",
1002
+ "external_id",
1003
+ "created",
1004
+ "updated",
1005
+ "vault_path",
1006
+ }
1007
+ for key, value in frontmatter.items():
1008
+ if key in reserved:
1009
+ continue
1010
+ meta[key] = value
1011
+ if aliases:
1012
+ meta["aliases"] = aliases
1013
+ return meta
1014
+
1015
+
1016
+ def _coerce_tag_list(value: Any) -> list[str]:
1017
+ """Coerce a frontmatter ``tags`` value into a clean ``list[str]``.
1018
+
1019
+ Accepts ``None`` / missing → ``[]``, a list → filter to string elements,
1020
+ a single string → ``[string]`` (some users write ``tags: career`` as a
1021
+ scalar). Anything else returns an empty list — the file is still
1022
+ syncable, the user just sees no tags.
1023
+ """
1024
+ if value is None:
1025
+ return []
1026
+ if isinstance(value, str):
1027
+ return [value] if value.strip() else []
1028
+ if isinstance(value, list):
1029
+ return [t for t in value if isinstance(t, str) and t.strip()]
1030
+ return []
1031
+
1032
+
1033
+ def _coerce_alias_list(value: Any) -> list[str]:
1034
+ """Coerce a frontmatter ``aliases`` value into a clean ``list[str]``.
1035
+
1036
+ Mirrors :func:`_coerce_tag_list` — accepts a list of strings, a scalar
1037
+ string (rare but seen), or anything else → ``[]``.
1038
+ """
1039
+ return _coerce_tag_list(value)
1040
+
1041
+
1042
+ def _source_from_frontmatter(
1043
+ frontmatter: dict[str, Any], tier: str
1044
+ ) -> str | None:
1045
+ """Extract the ``source`` field for ingested-tier files.
1046
+
1047
+ Vault-tier files don't have a source, so we return ``None`` regardless
1048
+ of what the YAML says. For ingested-tier we trust the file's
1049
+ ``source:`` (krisp / slack / gmail / manual / ...). Returns ``None`` if
1050
+ the field is missing or not a string — the resulting row will have
1051
+ ``source_id=NULL`` which still lets ``brain show`` and ``brain search``
1052
+ work; only ``[[<source>:<external_id>]]`` resolution would miss it,
1053
+ which is a soft degradation rather than a failure.
1054
+ """
1055
+ if tier != "ingested":
1056
+ return None
1057
+ raw = frontmatter.get("source")
1058
+ if isinstance(raw, str) and raw.strip():
1059
+ return raw.strip()
1060
+ return None
1061
+
1062
+
1063
+ def _external_id_from_frontmatter(
1064
+ frontmatter: dict[str, Any], tier: str
1065
+ ) -> str | None:
1066
+ """Extract the ``external_id`` field for ingested-tier files."""
1067
+ if tier != "ingested":
1068
+ return None
1069
+ raw = frontmatter.get("external_id")
1070
+ if isinstance(raw, str) and raw.strip():
1071
+ return raw.strip()
1072
+ return None
1073
+
1074
+
1075
+ def _upsert_source_row(
1076
+ conn: psycopg.Connection[Any],
1077
+ *,
1078
+ kind: str,
1079
+ external_id: str | None,
1080
+ ) -> str | None:
1081
+ """Return an existing ``sources.id`` for ``(kind, external_id)``, or insert one.
1082
+
1083
+ Mirrors the ingest pipeline's ``_upsert_source`` helper but with no
1084
+ metadata payload — vault-authored ingested-tier files don't have
1085
+ upstream metadata to merge in. Returns ``None`` when ``external_id``
1086
+ is ``None`` (a sourceless row gets ``source_id=NULL``, same as a
1087
+ purely manual ingest).
1088
+ """
1089
+ if external_id is None:
1090
+ return None
1091
+ row = conn.execute(
1092
+ "SELECT id::text FROM sources "
1093
+ "WHERE kind = %s AND external_id IS NOT DISTINCT FROM %s",
1094
+ (kind, external_id),
1095
+ ).fetchone()
1096
+ if row is not None:
1097
+ return str(row[0])
1098
+ new = conn.execute(
1099
+ "INSERT INTO sources (kind, external_id, metadata) "
1100
+ "VALUES (%s, %s, '{}'::jsonb) RETURNING id::text",
1101
+ (kind, external_id),
1102
+ ).fetchone()
1103
+ assert new is not None # RETURNING id always yields a row
1104
+ return str(new[0])
1105
+
1106
+
1107
+ def _insert_document(
1108
+ conn: psycopg.Connection[Any],
1109
+ *,
1110
+ embedder: Embedder,
1111
+ document_id: str,
1112
+ title: str,
1113
+ content: str,
1114
+ content_hash: str,
1115
+ content_type: str,
1116
+ tags: list[str],
1117
+ metadata: dict[str, Any],
1118
+ kind: str,
1119
+ vault_path: str,
1120
+ source: str | None = None,
1121
+ external_id: str | None = None,
1122
+ ) -> None:
1123
+ """Insert a brand-new ``documents`` row + chunks.
1124
+
1125
+ Caller is responsible for the surrounding transaction — this helper
1126
+ runs INSERTs in the connection's current transaction.
1127
+
1128
+ For ingested-tier rows with both ``source`` and ``external_id``, we
1129
+ upsert a ``sources`` row first so ``[[<source>:<external_id>]]`` link
1130
+ resolution works against this row from day one. Vault-authored
1131
+ ingested-tier files (a user manually drops one into ``_ingested/``)
1132
+ therefore don't depend on a prior ``brain ingest`` having run.
1133
+ """
1134
+ source_id: str | None = None
1135
+ if source is not None and external_id is not None:
1136
+ source_id = _upsert_source_row(conn, kind=source, external_id=external_id)
1137
+ conn.execute(
1138
+ """
1139
+ INSERT INTO documents
1140
+ (id, title, content, content_hash, content_type, tags,
1141
+ metadata, kind, vault_path, source_id)
1142
+ VALUES (%s, %s, %s, %s, %s, %s, %s::jsonb, %s, %s, %s)
1143
+ """,
1144
+ (
1145
+ document_id,
1146
+ title,
1147
+ content,
1148
+ content_hash,
1149
+ content_type,
1150
+ tags,
1151
+ json.dumps(metadata),
1152
+ kind,
1153
+ vault_path,
1154
+ source_id,
1155
+ ),
1156
+ )
1157
+ _embed_and_insert_chunks(
1158
+ conn,
1159
+ embedder=embedder,
1160
+ document_id=document_id,
1161
+ content=content,
1162
+ title=title,
1163
+ tags=tags,
1164
+ )
1165
+
1166
+
1167
+ def _update_document(
1168
+ conn: psycopg.Connection[Any],
1169
+ *,
1170
+ embedder: Embedder,
1171
+ document_id: str,
1172
+ title: str,
1173
+ content: str,
1174
+ content_hash: str,
1175
+ content_type: str,
1176
+ tags: list[str],
1177
+ metadata: dict[str, Any],
1178
+ kind: str,
1179
+ vault_path: str,
1180
+ body_changed: bool,
1181
+ ) -> None:
1182
+ """Update an existing ``documents`` row + (when body changed) re-chunk."""
1183
+ conn.execute(
1184
+ """
1185
+ UPDATE documents SET
1186
+ title = %s,
1187
+ content = %s,
1188
+ content_hash = %s,
1189
+ content_type = %s,
1190
+ tags = %s,
1191
+ metadata = %s::jsonb,
1192
+ kind = %s,
1193
+ vault_path = %s,
1194
+ ingested_at = NOW()
1195
+ WHERE id = %s
1196
+ """,
1197
+ (
1198
+ title,
1199
+ content,
1200
+ content_hash,
1201
+ content_type,
1202
+ tags,
1203
+ json.dumps(metadata),
1204
+ kind,
1205
+ vault_path,
1206
+ document_id,
1207
+ ),
1208
+ )
1209
+ if body_changed:
1210
+ conn.execute("DELETE FROM chunks WHERE document_id = %s", (document_id,))
1211
+ _embed_and_insert_chunks(
1212
+ conn,
1213
+ embedder=embedder,
1214
+ document_id=document_id,
1215
+ content=content,
1216
+ title=title,
1217
+ tags=tags,
1218
+ )
1219
+ else:
1220
+ # Body unchanged but the parent doc's title / tags may have moved
1221
+ # (frontmatter-only edit). Migration 009 denormalizes both onto
1222
+ # every chunk for the weighted tsv, so propagate the change to the
1223
+ # existing chunks. The IS DISTINCT FROM guards inside the helper
1224
+ # make this a free no-op when neither column actually moved.
1225
+ sync_chunk_search_metadata(conn, document_id)
1226
+
1227
+
1228
+ def _embed_and_insert_chunks(
1229
+ conn: psycopg.Connection[Any],
1230
+ *,
1231
+ embedder: Embedder,
1232
+ document_id: str,
1233
+ content: str,
1234
+ title: str,
1235
+ tags: list[str],
1236
+ ) -> None:
1237
+ """Chunk + embed ``content`` and INSERT the resulting chunks.
1238
+
1239
+ Empty / whitespace-only bodies produce zero chunks; the document row
1240
+ still exists (so ``brain show`` works) but is unsearchable until the user
1241
+ adds content. Matches the existing ingest pipeline's behavior.
1242
+
1243
+ ``title`` and ``tags`` populate the migration-009 denormalized
1244
+ ``chunks.title_text`` / ``chunks.tags_text`` columns so the weighted
1245
+ FTS tsvector (title at weight A, tags at weight B) ranks vault-tier
1246
+ title/tag hits ahead of body hits — same contract as the ingest
1247
+ pipeline.
1248
+ """
1249
+ chunks = chunk_text(content, count_tokens=embedder.count_tokens)
1250
+ if not chunks:
1251
+ return
1252
+ embeddings = embedder.embed([c.content for c in chunks], input_type="document")
1253
+ title_text, tags_text = _chunk_search_metadata(title, tags)
1254
+ for c, vec in zip(chunks, embeddings, strict=True):
1255
+ conn.execute(
1256
+ "INSERT INTO chunks (document_id, chunk_index, content, embedding, "
1257
+ "title_text, tags_text, search_extras) "
1258
+ "VALUES (%s, %s, %s, %s, %s, %s, %s)",
1259
+ (
1260
+ document_id,
1261
+ c.index,
1262
+ c.content,
1263
+ vec,
1264
+ title_text,
1265
+ tags_text,
1266
+ extract_sub_tokens(c.content),
1267
+ ),
1268
+ )
1269
+
1270
+
1271
+ def _materialize_links(
1272
+ conn: psycopg.Connection[Any],
1273
+ *,
1274
+ document_id: str,
1275
+ body: str,
1276
+ title: str,
1277
+ report: SyncReport,
1278
+ ) -> None:
1279
+ """Drop + re-insert every link sourced from ``document_id``.
1280
+
1281
+ The drop-and-recreate pattern is simpler than diffing and runs in the
1282
+ same transaction as the document upsert, so a crash mid-link leaves the
1283
+ DB consistent. Each parsed link either lands in ``links`` (resolved) or
1284
+ ``unresolved_links`` (dangling); the unique constraints on both tables
1285
+ deduplicate when a body has multiple identical ``[[X]]`` references.
1286
+
1287
+ ``body`` is expected to already be fence-stripped — callers pass
1288
+ ``_normalized_body(...)``, which runs :func:`strip_fence` first.
1289
+ Wiki-links inside the auto-generated derived-edges fence (Phase D)
1290
+ therefore do NOT land in ``links``; those edges live in
1291
+ ``derived_links`` and would double-count if materialized here.
1292
+ """
1293
+ conn.execute(
1294
+ "DELETE FROM links WHERE src_document_id = %s", (document_id,)
1295
+ )
1296
+ conn.execute(
1297
+ "DELETE FROM unresolved_links WHERE src_document_id = %s", (document_id,)
1298
+ )
1299
+ for parsed in parse_wiki_links(body):
1300
+ target = resolve_link(conn, parsed, exclude_doc_id=document_id)
1301
+ if target is None:
1302
+ _record_unresolved(conn, document_id, parsed, title=title)
1303
+ report.links_unresolved += 1
1304
+ else:
1305
+ _record_resolved(conn, document_id, parsed, target.document_id)
1306
+ report.links_resolved += 1
1307
+
1308
+
1309
+ def _record_resolved(
1310
+ conn: psycopg.Connection[Any],
1311
+ src_document_id: str,
1312
+ parsed: ParsedLink,
1313
+ dst_document_id: str,
1314
+ ) -> None:
1315
+ """INSERT into ``links``; ON CONFLICT DO NOTHING for repeated text in body."""
1316
+ conn.execute(
1317
+ """
1318
+ INSERT INTO links
1319
+ (src_document_id, dst_document_id, link_text, link_kind, display_text)
1320
+ VALUES (%s, %s, %s, %s, %s)
1321
+ ON CONFLICT (src_document_id, dst_document_id, link_text, link_kind)
1322
+ DO NOTHING
1323
+ """,
1324
+ (
1325
+ src_document_id,
1326
+ dst_document_id,
1327
+ parsed.raw,
1328
+ parsed.kind,
1329
+ parsed.display_text,
1330
+ ),
1331
+ )
1332
+
1333
+
1334
+ def _record_unresolved(
1335
+ conn: psycopg.Connection[Any],
1336
+ src_document_id: str,
1337
+ parsed: ParsedLink,
1338
+ *,
1339
+ title: str,
1340
+ ) -> None:
1341
+ """INSERT into ``unresolved_links`` with ON CONFLICT DO NOTHING.
1342
+
1343
+ We log a one-line warning when the resolution failed because of a title
1344
+ collision — that's the single case where the user can fix it by adding
1345
+ an alias, and the diagnostic is the single most useful breadcrumb the
1346
+ sync produces.
1347
+ """
1348
+ if parsed.target_type == "title":
1349
+ collisions = title_collisions(
1350
+ conn, parsed.target_value, exclude_doc_id=src_document_id
1351
+ )
1352
+ if len(collisions) > 1:
1353
+ preview = ", ".join(c[:8] for c in collisions[:3])
1354
+ logger.warning(
1355
+ "vault sync: %r links to %r which matches %d documents (%s%s); "
1356
+ "use [[brain:<prefix>]] to disambiguate",
1357
+ title,
1358
+ parsed.target_value,
1359
+ len(collisions),
1360
+ preview,
1361
+ "…" if len(collisions) > 3 else "",
1362
+ )
1363
+ conn.execute(
1364
+ """
1365
+ INSERT INTO unresolved_links
1366
+ (src_document_id, link_text, link_kind, display_text)
1367
+ VALUES (%s, %s, %s, %s)
1368
+ ON CONFLICT (src_document_id, link_text, link_kind) DO NOTHING
1369
+ """,
1370
+ (src_document_id, parsed.raw, parsed.kind, parsed.display_text),
1371
+ )
1372
+
1373
+
1374
+ def _process_missing(
1375
+ conn: psycopg.Connection[Any],
1376
+ *,
1377
+ seen_relative: set[str],
1378
+ report: SyncReport,
1379
+ prune: bool,
1380
+ dry_run: bool,
1381
+ ) -> list[str]:
1382
+ """Find vault-tier rows whose ``vault_path`` was NOT seen and prune/warn.
1383
+
1384
+ Dry-run reports the planned action via the report counters but never
1385
+ writes — and never logs a warning either, since dry-run is contractually
1386
+ "read-only" (no DB writes, no FS writes, no log noise the user wasn't
1387
+ asking for). The counters in the returned report still reflect what
1388
+ *would* happen.
1389
+
1390
+ Returns the list of document ids that were ACTUALLY deleted this pass
1391
+ (``prune`` and not ``dry_run``) so the caller can drop them from the
1392
+ people graph (wave G1-c). Empty on warn-only / dry-run.
1393
+ """
1394
+ rows = conn.execute(
1395
+ "SELECT id::text, vault_path, title FROM documents "
1396
+ "WHERE kind = 'vault' AND vault_path IS NOT NULL"
1397
+ ).fetchall()
1398
+ pruned_ids: list[str] = []
1399
+ for doc_id, vault_path_value, title in rows:
1400
+ if vault_path_value in seen_relative:
1401
+ continue
1402
+ if prune:
1403
+ if not dry_run:
1404
+ conn.execute("DELETE FROM documents WHERE id = %s", (doc_id,))
1405
+ pruned_ids.append(str(doc_id))
1406
+ report.deleted += 1
1407
+ else:
1408
+ if not dry_run:
1409
+ logger.warning(
1410
+ "vault sync: %r (%s) has vault_path=%r but no file on disk; "
1411
+ "use --prune to delete",
1412
+ title,
1413
+ str(doc_id)[:8],
1414
+ vault_path_value,
1415
+ )
1416
+ report.warned += 1
1417
+ return pruned_ids
1418
+
1419
+
1420
+ def _retry_unresolved(
1421
+ conn: psycopg.Connection[Any],
1422
+ src_document_ids: set[str],
1423
+ report: SyncReport,
1424
+ ) -> None:
1425
+ """Try to resolve ``unresolved_links`` whose source was just processed.
1426
+
1427
+ Restricting the scan to ``src_document_id IN (just-processed-ids)``
1428
+ keeps it cheap on a large vault — we don't re-scan dangling refs from
1429
+ every doc, only the ones whose body might have just been changed (or
1430
+ whose target may have just been created when another file in this
1431
+ same run produced it).
1432
+
1433
+ Each newly-resolvable row moves from ``unresolved_links`` to ``links``
1434
+ in a single transaction (per row, to keep the transaction footprint
1435
+ small). The corresponding counters are adjusted so the final report
1436
+ reflects the post-pass state, not the per-file naive count.
1437
+ """
1438
+ if not src_document_ids:
1439
+ return
1440
+ # Two-step: collect the candidates (read-only), then for each one try a
1441
+ # resolve + move. Doing the move inline while iterating the cursor would
1442
+ # invalidate the cursor; the candidate set is small (just-processed
1443
+ # documents) so materializing it is cheap.
1444
+ rows = conn.execute(
1445
+ "SELECT id::text, src_document_id::text, link_text, link_kind, display_text "
1446
+ "FROM unresolved_links WHERE src_document_id = ANY(%s)",
1447
+ (list(src_document_ids),),
1448
+ ).fetchall()
1449
+ for row_id, src_id, link_text, link_kind, display_text in rows:
1450
+ parsed = _reparse_link_text(link_text, link_kind, display_text)
1451
+ if parsed is None:
1452
+ continue
1453
+ target = resolve_link(conn, parsed, exclude_doc_id=str(src_id))
1454
+ if target is None:
1455
+ continue
1456
+ with conn.transaction():
1457
+ conn.execute(
1458
+ """
1459
+ INSERT INTO links
1460
+ (src_document_id, dst_document_id, link_text, link_kind, display_text)
1461
+ VALUES (%s, %s, %s, %s, %s)
1462
+ ON CONFLICT (src_document_id, dst_document_id, link_text, link_kind)
1463
+ DO NOTHING
1464
+ """,
1465
+ (
1466
+ str(src_id),
1467
+ target.document_id,
1468
+ link_text,
1469
+ link_kind,
1470
+ display_text,
1471
+ ),
1472
+ )
1473
+ conn.execute(
1474
+ "DELETE FROM unresolved_links WHERE id = %s", (row_id,)
1475
+ )
1476
+ report.links_resolved += 1
1477
+ report.links_unresolved -= 1
1478
+
1479
+
1480
+ def _reparse_link_text(
1481
+ link_text: str, link_kind: str, display_text: str | None
1482
+ ) -> ParsedLink | None:
1483
+ """Re-parse a stored ``unresolved_links.link_text`` back into a ParsedLink.
1484
+
1485
+ The DB only stores the raw ``[[...]]`` form (we deliberately don't keep
1486
+ the parsed structure around — fewer columns to migrate later). To retry
1487
+ resolution we run the same parser over the stored text, which yields at
1488
+ most one ParsedLink (the entire text *is* one link). Returns ``None``
1489
+ for any malformed stored text — extremely unusual but the retry pass
1490
+ just skips it.
1491
+ """
1492
+ parsed = parse_wiki_links(link_text)
1493
+ if not parsed:
1494
+ return None
1495
+ only = parsed[0]
1496
+ # The retry pass uses the stored ``display_text`` rather than the
1497
+ # re-parsed one — the writer already extracted aliases, so they should
1498
+ # always agree. A divergence (parser drift after an upgrade, a manual DB
1499
+ # edit, a migration) must NOT abort a foreground ``brain vault sync``, and
1500
+ # a bare ``assert`` here would (and is silently stripped under ``python
1501
+ # -O``, making the behavior inconsistent). Log it and continue: the caller
1502
+ # inserts the STORED ``display_text``, so no alias is dropped either way.
1503
+ if only.display_text is not None and only.display_text != display_text:
1504
+ logger.warning(
1505
+ "vault sync: unresolved link %r re-parsed display text %r differs "
1506
+ "from stored %r; using the stored display text and continuing",
1507
+ link_text,
1508
+ only.display_text,
1509
+ display_text,
1510
+ )
1511
+ if link_kind not in {"wiki", "embed"}:
1512
+ return None
1513
+ return only