secondbrain-py 0.2.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (273) hide show
  1. brain/__init__.py +0 -0
  2. brain/__main__.py +18 -0
  3. brain/_capture_command.py +445 -0
  4. brain/_compose.py +52 -0
  5. brain/activity.py +206 -0
  6. brain/ask.py +631 -0
  7. brain/audio.py +591 -0
  8. brain/backfill/__init__.py +12 -0
  9. brain/backfill/search_extras.py +141 -0
  10. brain/backfill/source_rows.py +101 -0
  11. brain/bin/__init__.py +1 -0
  12. brain/bin/_launcher.py +107 -0
  13. brain/bin/down.py +8 -0
  14. brain/bin/launchd.py +268 -0
  15. brain/bin/monitor.py +570 -0
  16. brain/bin/rebuild.py +8 -0
  17. brain/bin/status.py +8 -0
  18. brain/bin/up.py +8 -0
  19. brain/brief.py +272 -0
  20. brain/capture.py +49 -0
  21. brain/chat.py +293 -0
  22. brain/cli.py +9760 -0
  23. brain/cli_claude.py +81 -0
  24. brain/cli_connect.py +285 -0
  25. brain/cli_demo.py +266 -0
  26. brain/config.py +1949 -0
  27. brain/connect.py +925 -0
  28. brain/db.py +540 -0
  29. brain/demo/__init__.py +452 -0
  30. brain/demo/corpus/manifest.json +403 -0
  31. brain/demo/embedder.py +74 -0
  32. brain/durations.py +84 -0
  33. brain/edit_session.py +156 -0
  34. brain/editor.py +67 -0
  35. brain/elicit/__init__.py +16 -0
  36. brain/elicit/detectors.py +250 -0
  37. brain/elicit/drafter.py +70 -0
  38. brain/elicit/queue.py +220 -0
  39. brain/elicit/schema.py +48 -0
  40. brain/elicit/session.py +445 -0
  41. brain/embedding_targets.py +54 -0
  42. brain/embeddings.py +424 -0
  43. brain/enrichment.py +808 -0
  44. brain/errors.py +357 -0
  45. brain/eval/__init__.py +129 -0
  46. brain/eval/answer_eval.py +281 -0
  47. brain/eval/baseline.py +265 -0
  48. brain/eval/concept_extraction.py +378 -0
  49. brain/eval/corpus.py +152 -0
  50. brain/eval/errors.py +19 -0
  51. brain/eval/graph_baseline.py +226 -0
  52. brain/eval/graph_retrieval.py +202 -0
  53. brain/eval/graph_runner.py +319 -0
  54. brain/eval/metrics.py +101 -0
  55. brain/eval/runner.py +223 -0
  56. brain/format.py +783 -0
  57. brain/gaps.py +390 -0
  58. brain/graph_rag/__init__.py +94 -0
  59. brain/graph_rag/_retrieval_common.py +113 -0
  60. brain/graph_rag/aggregates.py +303 -0
  61. brain/graph_rag/aliases/__init__.py +583 -0
  62. brain/graph_rag/backends/__init__.py +10 -0
  63. brain/graph_rag/backends/_age_helpers.py +473 -0
  64. brain/graph_rag/backends/age.py +782 -0
  65. brain/graph_rag/backends/base.py +272 -0
  66. brain/graph_rag/build.py +344 -0
  67. brain/graph_rag/communities.py +644 -0
  68. brain/graph_rag/communities_summary.py +437 -0
  69. brain/graph_rag/concepts.py +202 -0
  70. brain/graph_rag/cooccur.py +193 -0
  71. brain/graph_rag/cross_type.py +312 -0
  72. brain/graph_rag/extract.py +885 -0
  73. brain/graph_rag/fuse.py +371 -0
  74. brain/graph_rag/global_.py +412 -0
  75. brain/graph_rag/grouping.py +372 -0
  76. brain/graph_rag/person_resolver.py +167 -0
  77. brain/graph_rag/reconcile.py +792 -0
  78. brain/graph_rag/relational.py +353 -0
  79. brain/graph_rag/retrieve.py +526 -0
  80. brain/graph_rag/router.py +288 -0
  81. brain/graph_rag/schema.py +320 -0
  82. brain/graph_rag/sync.py +237 -0
  83. brain/graph_rag/tenancy.py +43 -0
  84. brain/graph_rag/themes.py +501 -0
  85. brain/graph_rag/weighting.py +202 -0
  86. brain/ingest/__init__.py +1926 -0
  87. brain/ingest/chunker.py +249 -0
  88. brain/ingest/docx.py +40 -0
  89. brain/ingest/gmail.py +621 -0
  90. brain/ingest/markdown.py +37 -0
  91. brain/ingest/pdf.py +61 -0
  92. brain/ingest/stdin.py +22 -0
  93. brain/ingest/sub_tokens.py +91 -0
  94. brain/ingest/text.py +16 -0
  95. brain/interactions.py +205 -0
  96. brain/maintenance.py +355 -0
  97. brain/mcp_server.py +3405 -0
  98. brain/migrations/001_init.sql +43 -0
  99. brain/migrations/002_qwen3_embedding.sql +17 -0
  100. brain/migrations/003_vault_model.sql +41 -0
  101. brain/migrations/004_relax_content_hash_uniqueness.sql +18 -0
  102. brain/migrations/005_derived_links.sql +67 -0
  103. brain/migrations/006_dedup_file_by_source_path.sql +25 -0
  104. brain/migrations/007_email_thread_and_draft.sql +15 -0
  105. brain/migrations/008_gmail_thread_unique.sql +11 -0
  106. brain/migrations/009_chunks_weighted_tsv.sql +28 -0
  107. brain/migrations/010_interactions.sql +30 -0
  108. brain/migrations/011_documents_summary.sql +23 -0
  109. brain/migrations/012_graphrag.sql +171 -0
  110. brain/migrations/013_graphrag_communities.sql +125 -0
  111. brain/migrations/014_graphrag_community_summary_hash.sql +33 -0
  112. brain/migrations/015_interactions_graph_targets.sql +89 -0
  113. brain/migrations/016_index_hygiene.sql +61 -0
  114. brain/migrations/017_elicit.sql +30 -0
  115. brain/migrations/018_review_gap_signal_kinds.sql +40 -0
  116. brain/migrations/019_search_queries.sql +35 -0
  117. brain/migrations/020_link_suggestions.sql +40 -0
  118. brain/migrations/021_timeline_doc_date.sql +34 -0
  119. brain/migrations/022_link_suggestions_undirected.sql +84 -0
  120. brain/migrations/023_search_queries_fts_count.sql +28 -0
  121. brain/quartz_overrides/__init__.py +8 -0
  122. brain/quartz_overrides/quartz/bootstrap-cli.mjs +65 -0
  123. brain/quartz_overrides/quartz/build.ts +568 -0
  124. brain/quartz_overrides/quartz/cli/args.js +152 -0
  125. brain/quartz_overrides/quartz/cli/build_partial_handler.js +544 -0
  126. brain/quartz_overrides/quartz/cli/handlers.js +636 -0
  127. brain/quartz_overrides/quartz/components/CommandPalette.tsx +172 -0
  128. brain/quartz_overrides/quartz/components/Explorer.tsx +198 -0
  129. brain/quartz_overrides/quartz/components/Footer.tsx +27 -0
  130. brain/quartz_overrides/quartz/components/Graph.tsx +468 -0
  131. brain/quartz_overrides/quartz/components/PageTitle.tsx +72 -0
  132. brain/quartz_overrides/quartz/components/RelatedDocs.tsx +38 -0
  133. brain/quartz_overrides/quartz/components/Search.tsx +161 -0
  134. brain/quartz_overrides/quartz/components/SummaryLede.tsx +72 -0
  135. brain/quartz_overrides/quartz/components/index.ts +92 -0
  136. brain/quartz_overrides/quartz/components/pages/TagContent.tsx +272 -0
  137. brain/quartz_overrides/quartz/components/scripts/commandPalette.inline.ts +665 -0
  138. brain/quartz_overrides/quartz/components/scripts/explorer.inline.ts +768 -0
  139. brain/quartz_overrides/quartz/components/scripts/graph.inline.ts +2302 -0
  140. brain/quartz_overrides/quartz/components/scripts/relatedDocs.inline.ts +163 -0
  141. brain/quartz_overrides/quartz/components/scripts/search.inline.ts +1011 -0
  142. brain/quartz_overrides/quartz/plugins/emitters/contentIndex.ts +546 -0
  143. brain/quartz_overrides/quartz/plugins/transformers/codeCopy.ts +94 -0
  144. brain/quartz_overrides/quartz/plugins/transformers/derivedFenceMark.ts +302 -0
  145. brain/quartz_overrides/quartz/plugins/transformers/emailThread.ts +148 -0
  146. brain/quartz_overrides/quartz/plugins/transformers/emptyDoorFilter.ts +213 -0
  147. brain/quartz_overrides/quartz/plugins/transformers/index.ts +114 -0
  148. brain/quartz_overrides/quartz/plugins/transformers/linkKindMark.ts +205 -0
  149. brain/quartz_overrides/quartz/plugins/transformers/linkSourceTag.ts +104 -0
  150. brain/quartz_overrides/quartz/plugins/transformers/relativeDate.ts +100 -0
  151. brain/quartz_overrides/quartz/plugins/transformers/reloadSignal.ts +131 -0
  152. brain/quartz_overrides/quartz/processors/parse.ts +371 -0
  153. brain/quartz_overrides/quartz/processors/parser_cache.ts +78 -0
  154. brain/quartz_overrides/quartz/static/brain-logo-dark.png +0 -0
  155. brain/quartz_overrides/quartz/static/brain-logo-light.png +0 -0
  156. brain/quartz_overrides/quartz/static/codeCopy.js +196 -0
  157. brain/quartz_overrides/quartz/static/emailThread.js +334 -0
  158. brain/quartz_overrides/quartz/static/favicon.ico +0 -0
  159. brain/quartz_overrides/quartz/static/icon.png +0 -0
  160. brain/quartz_overrides/quartz/static/linkSourceTag.js +104 -0
  161. brain/quartz_overrides/quartz/static/relativeDate.js +142 -0
  162. brain/quartz_overrides/quartz/static/reload.js +168 -0
  163. brain/quartz_overrides/quartz/styles/brain/_article.scss +252 -0
  164. brain/quartz_overrides/quartz/styles/brain/_atmosphere.scss +113 -0
  165. brain/quartz_overrides/quartz/styles/brain/_callouts.scss +180 -0
  166. brain/quartz_overrides/quartz/styles/brain/_cmdk.scss +7 -0
  167. brain/quartz_overrides/quartz/styles/brain/_code.scss +208 -0
  168. brain/quartz_overrides/quartz/styles/brain/_command_palette.scss +369 -0
  169. brain/quartz_overrides/quartz/styles/brain/_email_thread.scss +228 -0
  170. brain/quartz_overrides/quartz/styles/brain/_explorer.scss +142 -0
  171. brain/quartz_overrides/quartz/styles/brain/_home.scss +182 -0
  172. brain/quartz_overrides/quartz/styles/brain/_links.scss +322 -0
  173. brain/quartz_overrides/quartz/styles/brain/_marginalia.scss +117 -0
  174. brain/quartz_overrides/quartz/styles/brain/_motion.scss +175 -0
  175. brain/quartz_overrides/quartz/styles/brain/_people_hub.scss +100 -0
  176. brain/quartz_overrides/quartz/styles/brain/_related_docs.scss +137 -0
  177. brain/quartz_overrides/quartz/styles/brain/_search.scss +252 -0
  178. brain/quartz_overrides/quartz/styles/brain/_sidebar.scss +468 -0
  179. brain/quartz_overrides/quartz/styles/brain/_summary_lede.scss +56 -0
  180. brain/quartz_overrides/quartz/styles/brain/_surface.scss +43 -0
  181. brain/quartz_overrides/quartz/styles/brain/_tag_content.scss +118 -0
  182. brain/quartz_overrides/quartz/styles/brain/_tokens.scss +197 -0
  183. brain/quartz_overrides/quartz/styles/brain/_typography.scss +92 -0
  184. brain/quartz_overrides/quartz/styles/custom.scss +89 -0
  185. brain/quartz_overrides/quartz/styles/graph.scss +505 -0
  186. brain/quartz_overrides/quartz/util/ctx.ts +92 -0
  187. brain/quartz_overrides/quartz/util/fastpath_manifest.ts +608 -0
  188. brain/quartz_overrides/quartz/util/path.ts +358 -0
  189. brain/quartz_overrides/quartz/util/sourceIcons.ts +55 -0
  190. brain/quartz_overrides/quartz.config.ts +270 -0
  191. brain/quartz_overrides/quartz.layout.ts +314 -0
  192. brain/queries.py +1188 -0
  193. brain/rank_fusion.py +8 -0
  194. brain/resurface.py +210 -0
  195. brain/review/__init__.py +26 -0
  196. brain/review/emit.py +27 -0
  197. brain/review/queries.py +436 -0
  198. brain/review/render.py +196 -0
  199. brain/review/scans.py +355 -0
  200. brain/review/weekly.py +413 -0
  201. brain/search.py +704 -0
  202. brain/set_similarity.py +15 -0
  203. brain/setup.py +1205 -0
  204. brain/tags.py +56 -0
  205. brain/templates/Caddyfile.j2 +9 -0
  206. brain/templates/__init__.py +1 -0
  207. brain/templates/bin/__init__.py +1 -0
  208. brain/templates/bin/_brain-brief-fg.sh +25 -0
  209. brain/templates/bin/_brain-build-fg.sh +53 -0
  210. brain/templates/bin/_brain-watcher-fg.sh +65 -0
  211. brain/templates/bin/brain-down.sh +89 -0
  212. brain/templates/bin/brain-status.sh +83 -0
  213. brain/templates/bin/brain-up.sh +221 -0
  214. brain/templates/docker/age/Dockerfile +79 -0
  215. brain/templates/docker-compose.stock.yml.j2 +26 -0
  216. brain/templates/docker-compose.yml.j2 +34 -0
  217. brain/templates/env.example +190 -0
  218. brain/templates/launchd/__init__.py +1 -0
  219. brain/templates/launchd/com.brain.brief.plist.j2 +45 -0
  220. brain/templates/launchd/com.brain.build.plist.j2 +46 -0
  221. brain/templates/launchd/com.brain.watcher.plist.j2 +46 -0
  222. brain/templates/skill/SKILL.md +63 -0
  223. brain/templates/skill/__init__.py +1 -0
  224. brain/timeline.py +834 -0
  225. brain/todo.py +124 -0
  226. brain/uninstall.py +185 -0
  227. brain/vault/__init__.py +115 -0
  228. brain/vault/_atomic.py +25 -0
  229. brain/vault/daily_index.py +228 -0
  230. brain/vault/derived_links/__init__.py +50 -0
  231. brain/vault/derived_links/directory.py +683 -0
  232. brain/vault/derived_links/fence.py +408 -0
  233. brain/vault/derived_links/gws.py +64 -0
  234. brain/vault/derived_links/participants.py +143 -0
  235. brain/vault/derived_links/pass_runner.py +362 -0
  236. brain/vault/derived_links/rules.py +137 -0
  237. brain/vault/export.py +683 -0
  238. brain/vault/frontmatter.py +165 -0
  239. brain/vault/graph.py +620 -0
  240. brain/vault/graph_format.py +388 -0
  241. brain/vault/link_rewrite.py +235 -0
  242. brain/vault/links.py +260 -0
  243. brain/vault/note_builder.py +211 -0
  244. brain/vault/paths.py +55 -0
  245. brain/vault/quartz_overlay.py +236 -0
  246. brain/vault/rename.py +591 -0
  247. brain/vault/resolver.py +304 -0
  248. brain/vault/slug.py +127 -0
  249. brain/vault/sync.py +1513 -0
  250. brain/vault/sync_summaries.py +264 -0
  251. brain/vault/templates.py +145 -0
  252. brain/vault/watch.py +1052 -0
  253. brain/wiki/__init__.py +6 -0
  254. brain/wiki/_github_slugger.py +76 -0
  255. brain/wiki/_person_name.py +314 -0
  256. brain/wiki/build_homepage.py +541 -0
  257. brain/wiki/build_partial.py +273 -0
  258. brain/wiki/build_people.py +934 -0
  259. brain/wiki/build_related.py +758 -0
  260. brain/wiki/build_swap.py +585 -0
  261. brain/wiki/build_watcher.py +975 -0
  262. brain/wiki/edit_classifier.py +215 -0
  263. brain/wiki/errors.py +10 -0
  264. brain/wiki/fastpath_manifest.py +475 -0
  265. brain/wiki/fastpath_state.py +174 -0
  266. brain/wiki/install.py +296 -0
  267. brain/wiki/slug.py +111 -0
  268. secondbrain_py-0.2.1.dist-info/METADATA +195 -0
  269. secondbrain_py-0.2.1.dist-info/RECORD +273 -0
  270. secondbrain_py-0.2.1.dist-info/WHEEL +5 -0
  271. secondbrain_py-0.2.1.dist-info/entry_points.txt +11 -0
  272. secondbrain_py-0.2.1.dist-info/licenses/LICENSE +21 -0
  273. secondbrain_py-0.2.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,408 @@
1
+ """Fenced auto-section for derived edges in `_ingested/` bodies.
2
+
3
+ Each ingested-tier vault file gets a single trailing section, bracketed by
4
+ HTML-comment markers, that materializes the document's metadata-derived
5
+ partners as native Obsidian/Quartz wiki-links so they're visible in
6
+ Quartz's `/graph` view (see
7
+ ``docs/specs/2026-04-30-derived-edges-in-bodies-design.md``).
8
+
9
+ The fence is what keeps this safe across the rest of the pipeline:
10
+
11
+ - Sync's ``body_hash`` strips the fence before hashing → freshly-rendered
12
+ fences don't trigger the re-embed cascade.
13
+ - Sync's normalized-body-for-DB strips the fence → ``documents.content``
14
+ stays clean, so vector search isn't polluted by "Related" lists.
15
+ - Wiki-link parser skips the fence → ``[[stems]]`` inside the fence don't
16
+ double-count edges that already live in ``derived_links``.
17
+
18
+ This module ships:
19
+
20
+ - Pure-string helpers: :func:`extract_fence`, :func:`strip_fence`,
21
+ :func:`replace_fence`. No DB.
22
+ - DB-reading renderer: :func:`render_fenced_section` — produces the
23
+ fenced markdown for a doc id from current ``derived_links`` rows.
24
+ - File-rewriting renderer: :func:`rewrite_derived_fences` — for each
25
+ affected ingested-tier doc, regenerates the fence on disk via an
26
+ atomic write. Skips vault-tier docs (Q3=a) and docs without an
27
+ ``_ingested/`` mirror (covered by export, not the fence renderer).
28
+ """
29
+ import datetime
30
+ import logging
31
+ from email.utils import parsedate_to_datetime
32
+ from pathlib import Path, PurePosixPath
33
+ from typing import Any
34
+
35
+ import psycopg
36
+ import yaml
37
+
38
+ from .._atomic import atomic_write_text
39
+ from ..paths import safe_wikilink_alias
40
+
41
+ # Stable HTML-comment markers. Universal CommonMark — Obsidian, Quartz, GFM,
42
+ # and standard Markdown all treat the line as a passthrough HTML comment.
43
+ # Public so the sync engine and watcher can detect fence regions without
44
+ # re-importing internal regex state.
45
+ FENCE_START_MARKER: str = "<!-- BRAIN_DERIVED_START -->"
46
+ FENCE_END_MARKER: str = "<!-- BRAIN_DERIVED_END -->"
47
+
48
+ # Rules surfaced in the Quartz-visible fence. ``shared_participant`` (R2) is
49
+ # excluded because it's high-recall / low-precision: at corpus scale it
50
+ # produces an N×N hairball that breaks Quartz's force-directed graph layout
51
+ # (the user is a participant in nearly every doc, so almost every doc shares
52
+ # a participant with almost every other doc). R2 edges remain queryable via
53
+ # ``brain backlinks`` / ``brain graph`` / MCP — only the Quartz surface is
54
+ # narrowed, not the underlying ``derived_links`` table.
55
+ #
56
+ # Sorted to give psycopg a stable parameter binding shape.
57
+ FENCE_RULES: tuple[str, ...] = ("same_day_participant", "shared_thread")
58
+
59
+ # Section heading inside the fence. Stable so users (and `git diff`) can grep
60
+ # for it.
61
+ _SECTION_HEADING: str = "## Related (auto-generated, do not edit)"
62
+
63
+ _logger = logging.getLogger(__name__)
64
+
65
+
66
+
67
+ def extract_fence(body: str) -> tuple[str, str | None]:
68
+ """Split ``body`` into ``(body_without_fence, fence_text_or_None)``.
69
+
70
+ The returned ``fence_text`` includes the start/end markers; the body
71
+ slice excludes the entire fence region (markers + everything between).
72
+
73
+ If multiple ``BRAIN_DERIVED_START`` markers appear in the body
74
+ (corruption — e.g. two stacked fences from a buggy renderer), only
75
+ the first is treated as the fence anchor; later markers stay in
76
+ ``body_without_fence`` as plain text. Recovery is then a re-render
77
+ away.
78
+
79
+ Bodies without a fence return ``(body, None)`` unchanged.
80
+ """
81
+ start_idx = body.find(FENCE_START_MARKER)
82
+ if start_idx == -1:
83
+ return body, None
84
+
85
+ # Search for the matching END marker AFTER the START. A stray END before
86
+ # the first START is treated as content (no fence detected — earlier
87
+ # ``find`` returned -1 — so we never reach this branch in that case).
88
+ end_search_from = start_idx + len(FENCE_START_MARKER)
89
+ end_idx = body.find(FENCE_END_MARKER, end_search_from)
90
+ if end_idx == -1:
91
+ # START with no matching END — treat as malformed and leave body
92
+ # untouched so the user sees the corruption rather than us silently
93
+ # eating the rest of the file.
94
+ return body, None
95
+
96
+ fence_close = end_idx + len(FENCE_END_MARKER)
97
+ fence_text = body[start_idx:fence_close]
98
+ body_without = body[:start_idx] + body[fence_close:]
99
+ return body_without, fence_text
100
+
101
+
102
+ def strip_fence(body: str) -> str:
103
+ """Return ``body`` with the fenced region removed.
104
+
105
+ Idempotent — calling on a body that has no fence returns it unchanged,
106
+ so ``strip_fence(strip_fence(x)) == strip_fence(x)``. This is what the
107
+ sync engine uses to compute a fence-stable ``body_hash`` (and a
108
+ fence-free ``documents.content`` projection).
109
+
110
+ When a fence is removed, trailing whitespace it introduced (the blank
111
+ line that separated user content from the fence, plus any newline
112
+ after the END marker) is collapsed and replaced with a single trailing
113
+ ``\\n``. Two fenced bodies that only differ in their fence content
114
+ therefore strip to byte-identical strings — that's the property the
115
+ ``body_hash`` strip relies on to avoid re-embed loops.
116
+
117
+ Bodies without a fence are returned unchanged so the function is a
118
+ pure no-op for files the renderer has never touched.
119
+ """
120
+ body_without, fence = extract_fence(body)
121
+ if fence is None:
122
+ return body
123
+ trimmed = body_without.rstrip()
124
+ return trimmed + "\n" if trimmed else ""
125
+
126
+
127
+ def replace_fence(body: str, new_fence: str) -> str:
128
+ """Return ``body`` with its fence swapped for ``new_fence`` (appending if absent).
129
+
130
+ ``new_fence`` is the fully-rendered fenced section — including the
131
+ start/end markers — typically produced by :func:`render_fenced_section`.
132
+ The function strips any pre-existing fence first, then appends
133
+ ``new_fence`` after exactly one blank-line separator and finishes with a
134
+ single trailing newline.
135
+
136
+ Edge cases handled:
137
+
138
+ - Body without trailing newline → fence appended cleanly with separator.
139
+ - Body with one or more trailing newlines → trailing whitespace is
140
+ normalized so the result ends with exactly one ``\\n`` after the END
141
+ marker (no double-blank, no missing-newline).
142
+ - Body already containing a fence → that fence is removed before the
143
+ new one is appended (so callers can pass the renderer's latest output
144
+ directly without manual orchestration).
145
+ """
146
+ base, _existing = extract_fence(body)
147
+ base = base.rstrip()
148
+ if not base:
149
+ return new_fence + "\n"
150
+ return f"{base}\n\n{new_fence}\n"
151
+
152
+
153
+ def render_fenced_section(
154
+ conn: psycopg.Connection[Any], doc_id: str
155
+ ) -> str | None:
156
+ """Build the fenced section for ``doc_id`` from the current ``derived_links``.
157
+
158
+ Queries every derived edge whose ``src`` or ``dst`` is ``doc_id`` AND
159
+ whose ``rule`` is in :data:`FENCE_RULES` (i.e. R1/R3 — ``shared_thread``
160
+ and ``same_day_participant``). R2 (``shared_participant``) is filtered
161
+ out at the SQL layer because it's high-recall / low-precision and at
162
+ corpus scale produces a graph hairball that breaks Quartz's
163
+ force-directed layout. R2 edges remain queryable via every other read
164
+ surface (``brain backlinks`` / ``brain graph`` / MCP); only the
165
+ Quartz-visible fence is narrowed.
166
+
167
+ Joins ``documents`` for each partner's title / vault_path / metadata
168
+ and emits one bullet per edge:
169
+
170
+ - [[<partner-filename-stem>|<partner-title>]] *(<rule>)*
171
+
172
+ Sort order (deterministic, primary→tertiary):
173
+
174
+ 1. ``rule weight DESC`` — highest-weight rules surface first (R1
175
+ ``shared_thread`` weight 1.0 before R3 ``same_day_participant``
176
+ weight 0.7).
177
+ 2. Partner ``metadata->>'date'`` DESC — within a weight tier, newer
178
+ partners win. Partners with a missing or unparseable date sort
179
+ AFTER partners with a date.
180
+ 3. Partner document ``id`` ASC — final tie-breaker so two partners
181
+ with identical weight + identical date (or both undated) always
182
+ emit in the same order across runs. Without this every relink →
183
+ sync round-trip risked reshuffling the bullet list and bumping
184
+ file mtimes unnecessarily.
185
+
186
+ Both the SQL query and the in-memory sort are stable: SQL
187
+ ``ORDER BY`` pins the row delivery order, and Python's ``list.sort``
188
+ is guaranteed stable, so the tertiary id key dominates only when the
189
+ weight + date keys tie.
190
+
191
+ Partners without a ``vault_path`` (not exported yet) are skipped — the
192
+ wiki-link wouldn't resolve in Quartz anyway. If skipping leaves the
193
+ bullet list empty (or the rule filter dropped every edge for this
194
+ doc), returns ``None`` (caller should remove the fence rather than
195
+ emit an empty section).
196
+ """
197
+ rows = conn.execute(
198
+ """
199
+ SELECT
200
+ partner.id::text,
201
+ partner.title,
202
+ partner.vault_path,
203
+ partner.metadata,
204
+ dl.rule,
205
+ dl.weight
206
+ FROM derived_links dl
207
+ JOIN documents partner ON partner.id = (
208
+ CASE WHEN dl.src_document_id = %s::uuid
209
+ THEN dl.dst_document_id
210
+ ELSE dl.src_document_id
211
+ END
212
+ )
213
+ WHERE (dl.src_document_id = %s::uuid
214
+ OR dl.dst_document_id = %s::uuid)
215
+ AND dl.rule = ANY(%s)
216
+ ORDER BY dl.weight DESC,
217
+ partner.metadata->>'date' DESC NULLS LAST,
218
+ partner.id ASC
219
+ """,
220
+ (doc_id, doc_id, doc_id, list(FENCE_RULES)),
221
+ ).fetchall()
222
+
223
+ bullets: list[tuple[float, bool, int, str, str]] = []
224
+ # Sort key tuple meaning:
225
+ # index 0: -weight → ascending sort puts highest weight first
226
+ # index 1: date_missing (False<True) → with-date partners first within tier
227
+ # index 2: -date.toordinal() → ascending puts newest date first
228
+ # index 3: partner_id ASC → deterministic tertiary tie-breaker so
229
+ # partners with identical weight + identical date emit in
230
+ # the same order across runs (no mtime churn from
231
+ # relink → sync cycles re-shuffling tied bullets).
232
+ for partner_id, title, vault_path, metadata, rule, weight in rows:
233
+ if not vault_path:
234
+ _logger.debug(
235
+ "fence: skipping partner %s (no vault_path yet)", partner_id
236
+ )
237
+ continue
238
+ stem = PurePosixPath(str(vault_path)).stem
239
+ partner_date = _parse_metadata_date(dict(metadata or {}))
240
+ date_missing = partner_date is None
241
+ date_key = -partner_date.toordinal() if partner_date else 0
242
+ bullet = f"- [[{stem}|{safe_wikilink_alias(title)}]] *({rule})*"
243
+ bullets.append(
244
+ (-float(weight), date_missing, date_key, str(partner_id), bullet)
245
+ )
246
+
247
+ if not bullets:
248
+ return None
249
+
250
+ bullets.sort(key=lambda b: (b[0], b[1], b[2], b[3]))
251
+ body_lines = [FENCE_START_MARKER, _SECTION_HEADING]
252
+ body_lines.extend(b[4] for b in bullets)
253
+ body_lines.append(FENCE_END_MARKER)
254
+ return "\n".join(body_lines)
255
+
256
+
257
+ def _parse_metadata_date(metadata: dict[str, Any]) -> datetime.date | None:
258
+ """Best-effort parse of ``metadata['date']`` into a :class:`datetime.date`.
259
+
260
+ Mirrors the shapes ``brain.vault.derived_links.pass_runner._parse_date``
261
+ accepts (ISO ``YYYY-MM-DD`` for Krisp, RFC 5322 for Gmail) but doesn't
262
+ require knowing the source kind — the renderer is sort-only and tolerant
263
+ of either form. Returns ``None`` for missing, non-string, or
264
+ unparseable values; partners with a missing date sort last within the
265
+ same weight tier.
266
+ """
267
+ raw = metadata.get("date")
268
+ if not isinstance(raw, str):
269
+ return None
270
+ text = raw.strip()
271
+ if not text:
272
+ return None
273
+
274
+ # Try ISO YYYY-MM-DD first (Krisp). Slicing tolerates "YYYY-MM-DDTHH:MM"
275
+ # without pulling in the wider ``datetime.fromisoformat`` grammar.
276
+ try:
277
+ return datetime.date.fromisoformat(text[:10])
278
+ except ValueError:
279
+ pass
280
+
281
+ # Fall back to RFC 5322 (Gmail).
282
+ try:
283
+ parsed = parsedate_to_datetime(text)
284
+ except (TypeError, ValueError):
285
+ return None
286
+ if parsed is None:
287
+ return None
288
+ return parsed.date()
289
+
290
+
291
+ def rewrite_derived_fences(
292
+ conn: psycopg.Connection[Any],
293
+ doc_ids: set[str],
294
+ *,
295
+ vault_path: Path,
296
+ ) -> int:
297
+ """Regenerate the derived-edges fence for every affected ``_ingested/`` file.
298
+
299
+ Driven by ``rebuild_derived_for``'s ``affected_ids`` return value:
300
+ every doc whose edges changed (gained or lost) gets its fence rewritten
301
+ on disk, so Quartz's ``/graph`` view stays consistent with the latest
302
+ ``derived_links`` rows.
303
+
304
+ Behavior per the user's Q3–Q5 decisions (with the 2026-05-08 idempotency fix):
305
+
306
+ - **Q3=a** vault-tier files are silently skipped — user-authored notes
307
+ stay untouched in v1. Only docs with ``kind='ingested'`` are
308
+ candidates.
309
+ - **Skip on byte-identical** (supersedes the original Q4=b "always
310
+ rewrite"): if the freshly-rendered file text is byte-identical to
311
+ what's already on disk, the file is NOT written and is NOT counted.
312
+ The returned ``written`` count reflects actual disk writes, so
313
+ ``relink-derived → sync`` is a true no-op for unchanged docs (no
314
+ mtime bumps, no Quartz rebuild churn) and the caller's reported
315
+ counter cannot lie about disk effect.
316
+ - **Q5=b** + Q2a fence content uses ``[[<filename-stem>|<title>]]
317
+ *(<rule>)*`` (rendered by :func:`render_fenced_section`).
318
+
319
+ Returns the count of files actually written. Docs in ``doc_ids`` that
320
+ map to a vault-tier row, have no ``vault_path`` set, whose mirror
321
+ file is missing on disk, OR whose freshly-rendered text matches the
322
+ on-disk text byte-for-byte are silently dropped from the count —
323
+ those skips aren't failures, they're "the renderer has no work to do
324
+ here."
325
+
326
+ Atomicity: each write goes through a sibling temp file plus
327
+ :func:`os.replace`, which is atomic on POSIX (rename(2)). A crash
328
+ mid-write leaves the original file intact; the temp file is cleaned
329
+ up on the next pass over the same doc id.
330
+
331
+ Empty input short-circuits to ``0`` — no DB round-trip, no FS scan.
332
+ """
333
+ if not doc_ids:
334
+ return 0
335
+
336
+ # Local imports break a cycle: ``brain.vault.frontmatter`` imports
337
+ # :func:`strip_fence` from this module so its ``body_hash`` ignores the
338
+ # fence content; we in turn need its parse/dump helpers to round-trip
339
+ # the YAML frontmatter while editing the body. Lazy-loading at call
340
+ # time keeps both modules' top-level imports cycle-free.
341
+ from ..frontmatter import dump_frontmatter, parse_frontmatter
342
+
343
+ # Single batch lookup: kind + vault_path for every affected doc id. At
344
+ # production scale (~500 ingested docs) the IN-list fits comfortably in
345
+ # one query and saves N round-trips on a full corpus relink.
346
+ rows = conn.execute(
347
+ "SELECT id::text, kind, vault_path FROM documents "
348
+ "WHERE id = ANY(%s)",
349
+ (sorted(doc_ids),),
350
+ ).fetchall()
351
+
352
+ written = 0
353
+ for doc_id, kind, vp in rows:
354
+ # Q3=a: vault-tier files stay untouched in v1.
355
+ if kind != "ingested":
356
+ continue
357
+ # No mirror exported yet — the fence renderer has no file to
358
+ # rewrite. Export will produce one on the next pass; this is not
359
+ # an error.
360
+ if not vp:
361
+ continue
362
+ target = vault_path / str(vp)
363
+ if not target.is_file():
364
+ _logger.debug(
365
+ "fence: skipping %s — vault_path %r has no file on disk",
366
+ doc_id, vp,
367
+ )
368
+ continue
369
+ try:
370
+ text = target.read_text(encoding="utf-8")
371
+ except OSError as e:
372
+ _logger.warning(
373
+ "fence: could not read %s: %s — skipping", target, e
374
+ )
375
+ continue
376
+ try:
377
+ frontmatter, body = parse_frontmatter(text)
378
+ except (ValueError, yaml.YAMLError) as e:
379
+ _logger.warning(
380
+ "fence: malformed frontmatter in %s: %s — skipping",
381
+ target, e,
382
+ )
383
+ continue
384
+
385
+ new_fence = render_fenced_section(conn, doc_id)
386
+ new_body = (
387
+ strip_fence(body) if new_fence is None
388
+ else replace_fence(body, new_fence)
389
+ )
390
+ new_text = dump_frontmatter(frontmatter, new_body)
391
+
392
+ # Idempotency: skip the write entirely when the rendered text is
393
+ # byte-identical to what's already on disk. This is the contract
394
+ # that lets ``relink-derived → sync`` round-trip without bumping
395
+ # mtimes — counters reflect real disk effect, not "we ran the
396
+ # renderer." See ``docs/plans/2026-05-08-vault-sync-fence-strip-bug.md``.
397
+ if new_text == text:
398
+ continue
399
+
400
+ try:
401
+ atomic_write_text(target, new_text)
402
+ except OSError as e:
403
+ _logger.warning(
404
+ "fence: could not rewrite %s: %s — skipping", target, e
405
+ )
406
+ continue
407
+ written += 1
408
+ return written
@@ -0,0 +1,64 @@
1
+ """Production ``gws`` subprocess runner conforming to the GwsRunner Protocol.
2
+
3
+ Mirrors the shell-out shape used by ``brain.ingest.gmail._run`` but routes
4
+ all expected subprocess failures (missing binary, non-zero exit, timeout,
5
+ unexpected ``OSError``) through :class:`brain.errors.DirectoryRefreshError`
6
+ so the refresh helpers in ``directory.py`` can downgrade them to soft
7
+ warnings (``refresh_calendar`` / ``refresh_contacts`` already catch
8
+ ``DirectoryRefreshError`` from the runner).
9
+
10
+ CLAUDE.md security rules: explicit timeout (30s), narrow exception catch,
11
+ no ``shell=True``, output truncation in error messages so logs stay
12
+ readable when ``gws`` chatters on stderr.
13
+ """
14
+ import shutil
15
+ import subprocess
16
+
17
+ from brain.errors import DirectoryRefreshError
18
+
19
+ # Hard cap on stderr included in the translated error message — keeps logs
20
+ # readable when ``gws`` panics with a long Python traceback.
21
+ _STDERR_SNIPPET_LIMIT = 200
22
+
23
+ # Default subprocess timeout. ``gws`` calendar / people calls are I/O bound
24
+ # against Google APIs; 30s is generous for a single page.
25
+ _DEFAULT_TIMEOUT_SECONDS = 30
26
+
27
+
28
+ def real_gws_runner(args: list[str]) -> str:
29
+ """Shell out to the ``gws`` CLI; return stdout, translate failures.
30
+
31
+ Translates ``FileNotFoundError`` (gws missing), ``CalledProcessError``
32
+ (non-zero exit), ``TimeoutExpired`` (timeout), and any other
33
+ ``OSError`` into :class:`DirectoryRefreshError` so the refresh helpers
34
+ in ``directory.py`` log them as warnings and return 0.
35
+
36
+ Conforms to the :class:`brain.vault.derived_links.directory.GwsRunner`
37
+ Protocol so it can be passed straight into
38
+ ``refresh_calendar`` / ``refresh_contacts``.
39
+ """
40
+ if not args:
41
+ raise DirectoryRefreshError("gws runner invoked with empty args")
42
+ if not shutil.which(args[0]):
43
+ raise DirectoryRefreshError(f"`{args[0]}` CLI not found on PATH")
44
+ try:
45
+ proc = subprocess.run(
46
+ args,
47
+ capture_output=True,
48
+ text=True,
49
+ check=True,
50
+ timeout=_DEFAULT_TIMEOUT_SECONDS,
51
+ )
52
+ except subprocess.CalledProcessError as exc:
53
+ stderr_snippet = (exc.stderr or "").strip()[:_STDERR_SNIPPET_LIMIT]
54
+ raise DirectoryRefreshError(
55
+ f"gws command failed (exit {exc.returncode}): {stderr_snippet}"
56
+ ) from exc
57
+ except subprocess.TimeoutExpired as exc:
58
+ raise DirectoryRefreshError(
59
+ f"gws command timed out after {_DEFAULT_TIMEOUT_SECONDS}s: "
60
+ f"{' '.join(args)}"
61
+ ) from exc
62
+ except OSError as exc:
63
+ raise DirectoryRefreshError(f"gws command failed: {exc}") from exc
64
+ return proc.stdout
@@ -0,0 +1,143 @@
1
+ """Pure participant-extraction helpers — Krisp speaker labels + Gmail headers."""
2
+ import re
3
+ from email.utils import getaddresses
4
+ from typing import Any
5
+
6
+ # Krisp writes unidentified speakers as ``Speaker <digits>`` (real transcripts,
7
+ # space separator) or ``Speaker_<digits>`` (legacy / synthetic test data,
8
+ # underscore). Either form is dropped because it would over-link unrelated
9
+ # calls — every transcript starts numbering at 1, so ``Speaker 2`` from one
10
+ # call has nothing to do with ``Speaker 2`` from another.
11
+ _SPEAKER_PLACEHOLDER_RE = re.compile(r"^speaker[ _]\d+$")
12
+
13
+ # Krisp inline speaker label: ``**<name-or-email> | mm:ss**`` (or ``H:MM:SS``
14
+ # for calls over an hour). Capture the label text only. The optional
15
+ # ``(?:\d{1,2}:)?`` head allows the H prefix without forcing it.
16
+ _KRISP_SPEAKER_RE = re.compile(
17
+ r"\*\*([^|*]+?)\s*\|\s*(?:\d{1,2}:)?\d{1,2}:\d{2}\s*\*\*",
18
+ )
19
+
20
+ # Internal-whitespace collapse used by name normalization.
21
+ _WHITESPACE_RE = re.compile(r"\s+")
22
+
23
+ # Single-letter "names" (A, J) are too noisy to link on; require at least two
24
+ # characters after normalization.
25
+ _MIN_NAME_LENGTH = 2
26
+
27
+
28
+ def is_email_like(addr: str) -> bool:
29
+ """Heuristic: exactly one ``@``, both halves non-empty, RHS has a ``.``, no whitespace."""
30
+ if any(ch.isspace() for ch in addr):
31
+ return False
32
+ if addr.count("@") != 1:
33
+ return False
34
+ local, _, domain = addr.partition("@")
35
+ if not local or not domain:
36
+ return False
37
+ return "." in domain
38
+
39
+
40
+ def _normalize_email(addr: str) -> str | None:
41
+ """Strip surrounding angle brackets + whitespace, lowercase, validate.
42
+
43
+ Brackets are stripped independently — a token with only a leading ``<`` or
44
+ only a trailing ``>`` still has the stray bracket removed before
45
+ validation. ``is_email_like`` is the final gate.
46
+ """
47
+ cleaned = addr.strip().lower()
48
+ if cleaned.startswith("<"):
49
+ cleaned = cleaned[1:]
50
+ if cleaned.endswith(">"):
51
+ cleaned = cleaned[:-1]
52
+ cleaned = cleaned.strip()
53
+ if not is_email_like(cleaned):
54
+ return None
55
+ return cleaned
56
+
57
+
58
+ def normalize_participant(token: str) -> str | None:
59
+ """Lowercase + strip; return email if `@` present, normalized name otherwise.
60
+
61
+ Returns None for empty / Speaker_N / clearly-malformed tokens.
62
+ """
63
+ stripped = token.strip()
64
+ if not stripped:
65
+ return None
66
+
67
+ # Drop unidentified Krisp speakers (case-insensitive).
68
+ if _SPEAKER_PLACEHOLDER_RE.match(stripped.lower()):
69
+ return None
70
+
71
+ # Email branch: any token containing ``@`` is treated as email.
72
+ if "@" in stripped:
73
+ return _normalize_email(stripped)
74
+
75
+ # Name branch: lowercase, collapse whitespace, strip outer punctuation.
76
+ lowered = stripped.lower()
77
+ collapsed = _WHITESPACE_RE.sub(" ", lowered)
78
+ # Strip leading/trailing punctuation (anything that's not alphanumeric or
79
+ # an internal space). We re-strip whitespace afterwards because removing
80
+ # punctuation can leave dangling spaces (``", Pat."`` → ``" pat "``).
81
+ name = collapsed.strip(" \t\n\r\f\v.,;:!?\"'()[]{}<>-_/\\|")
82
+ name = name.strip()
83
+ if len(name) < _MIN_NAME_LENGTH:
84
+ return None
85
+ return name
86
+
87
+
88
+ def extract_krisp_speakers(body: str) -> set[str]:
89
+ """Parse `**name-or-email | mm:ss**` labels from a Krisp transcript body.
90
+
91
+ Drops Speaker_N placeholders. Returns the set of normalized participant
92
+ keys (emails preferred, names where no email is present).
93
+ """
94
+ if not body:
95
+ return set()
96
+
97
+ speakers: set[str] = set()
98
+ for match in _KRISP_SPEAKER_RE.finditer(body):
99
+ normalized = normalize_participant(match.group(1))
100
+ if normalized is not None:
101
+ speakers.add(normalized)
102
+ return speakers
103
+
104
+
105
+ def extract_gmail_addresses(metadata: dict[str, Any]) -> list[tuple[str | None, str]]:
106
+ """Use `email.utils.getaddresses` over metadata['from'] + metadata['to'].
107
+
108
+ Returns list of (display_name, email) tuples. display_name is None when
109
+ absent. Both elements are normalized (lowercased, stripped). Display
110
+ names are run through `normalize_participant` for cross-source matching
111
+ consistency — that drops outer punctuation and rejects sub-2-char or
112
+ Speaker_N values that would never resolve through the directory anyway.
113
+ """
114
+ raw_strings: list[str] = []
115
+ for key in ("from", "to"):
116
+ value = metadata.get(key)
117
+ if isinstance(value, str) and value.strip():
118
+ raw_strings.append(value)
119
+
120
+ if not raw_strings:
121
+ return []
122
+
123
+ seen_emails: set[str] = set()
124
+ pairs: list[tuple[str | None, str]] = []
125
+ for realname, addr in getaddresses(raw_strings):
126
+ email = (addr or "").strip().lower()
127
+ if not email or not is_email_like(email):
128
+ continue
129
+ if email in seen_emails:
130
+ continue
131
+
132
+ display_raw = (realname or "").strip()
133
+ display: str | None
134
+ if display_raw:
135
+ collapsed = _WHITESPACE_RE.sub(" ", display_raw.lower())
136
+ display = normalize_participant(collapsed)
137
+ else:
138
+ display = None
139
+
140
+ seen_emails.add(email)
141
+ pairs.append((display, email))
142
+
143
+ return pairs