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,388 @@
1
+ """Byte-deterministic exporters for :class:`brain.vault.graph.GraphData`.
2
+
3
+ Three target formats:
4
+
5
+ - JSON — stable shape with ``sort_keys=True``; ``json.loads`` round-trips.
6
+ - Graphviz DOT — single ``digraph G {}``; pipe to ``dot -Tsvg`` for an SVG.
7
+ - Mermaid — ``graph TD`` (top-down); paste into a Mermaid renderer.
8
+
9
+ Determinism contract: every output is a pure function of the
10
+ :class:`GraphData` input. No timestamps, no random sorts, no environment
11
+ reads. Tests pin exact byte strings; any change to a formatter will
12
+ break them — that's the point.
13
+
14
+ Derived-edge tier styling (per spec §10 Q3):
15
+
16
+ - Wiki-link edges keep the existing solid black look — they are the
17
+ user's authoritative thinking surface.
18
+ - ``derived``/``shared_thread`` (R1, weight 1.0) — strongest derived
19
+ signal, rendered bold (Mermaid ``==>``, DOT ``style=bold``).
20
+ - ``derived``/``same_day_participant`` (R3, weight 0.7) — medium
21
+ confidence, plain solid arrow but in gray (Mermaid ``-->`` with a
22
+ ``linkStyle`` directive, DOT ``color=gray``).
23
+ - ``derived``/``shared_participant`` (R2, weight 0.4) — noisiest tier,
24
+ visually subordinated as dotted/light gray (Mermaid ``-.->``, DOT
25
+ ``style=dotted, color="#cccccc"``).
26
+
27
+ JSON output passes ``rule`` / ``weight`` / ``evidence`` straight through
28
+ on every edge regardless of tier; wiki edges carry ``null`` for all three
29
+ to keep the schema stable.
30
+ """
31
+ import hashlib
32
+ import json
33
+ from typing import Any
34
+
35
+ from .graph import GraphData, GraphEdge, GraphNode
36
+
37
+ # Long titles get truncated in DOT/Mermaid labels for readability — graphs
38
+ # with even moderately verbose titles produce unreadable SVG otherwise.
39
+ # JSON keeps the full title (machine-readable; renderers can wrap).
40
+ _LABEL_TRUNCATE_AT = 60
41
+ _TRUNCATION_SUFFIX = "…" # `…`
42
+
43
+ # Mermaid node ids must be alphanumeric or underscore. We strip hyphens
44
+ # from the UUID prefix; if that produces a collision we add a stable hash
45
+ # suffix (4 hex chars) computed from the full document_id.
46
+ _MERMAID_ID_PREFIX_LEN = 8
47
+ _MERMAID_HASH_SUFFIX_LEN = 4
48
+
49
+ # Vault-tier nodes get the "primary" color; ingested-tier nodes get a
50
+ # muted gray so the user's authored notes pop visually.
51
+ _DOT_FILL_VAULT = "lightblue"
52
+ _DOT_FILL_INGESTED = "lightgray"
53
+ # Mermaid uses class definitions; the body of these is byte-stable.
54
+ _MERMAID_CLASS_VAULT = (
55
+ "classDef vault fill:#cfe5f5,stroke:#3a7ab8,color:#000;"
56
+ )
57
+ _MERMAID_CLASS_INGESTED = (
58
+ "classDef ingested fill:#dddddd,stroke:#888,color:#000;"
59
+ )
60
+
61
+ # Mermaid arrow shape per derived rule. R1 (`shared_thread`) and R2
62
+ # (`shared_participant`) use Mermaid's built-in bold/dotted arrows; R3
63
+ # (`same_day_participant`) renders as a plain arrow whose color is
64
+ # overridden via a `linkStyle` directive emitted after the edge block.
65
+ _MERMAID_DERIVED_ARROWS: dict[str, str] = {
66
+ "shared_thread": "==>", # R1 — bold built-in
67
+ "shared_participant": "-.->", # R2 — dotted built-in
68
+ "same_day_participant": "-->", # R3 — plain arrow, gray via linkStyle
69
+ }
70
+ # Rules that need a per-edge `linkStyle` line to override the default
71
+ # stroke. Currently only R3 — R1 and R2 carry their styling in the arrow
72
+ # shape itself.
73
+ _MERMAID_LINKSTYLE: dict[str, str] = {
74
+ "same_day_participant": "stroke:gray", # R3
75
+ }
76
+
77
+ # DOT per-edge attributes for derived rules. Stored as a tuple of raw
78
+ # `key=value` fragments that are appended to the per-edge attribute list
79
+ # alongside the existing `label="..."`. Color values that aren't bare
80
+ # Graphviz keywords (e.g. hex codes) are quoted so the parser doesn't
81
+ # choke; bare keywords (`bold`, `solid`, `gray`, `black`, `dotted`) stay
82
+ # unquoted to match the spec table verbatim.
83
+ _DOT_DERIVED_ATTRS: dict[str, tuple[str, ...]] = {
84
+ "shared_thread": ("style=bold", "color=black"), # R1
85
+ "same_day_participant": ("style=solid", "color=gray"), # R3
86
+ "shared_participant": ("style=dotted", 'color="#cccccc"'), # R2
87
+ }
88
+
89
+
90
+ def to_json(graph: GraphData) -> str:
91
+ """Stable JSON: ``{"nodes": [...], "edges": [...]}``.
92
+
93
+ Nodes are sorted by ``(title.lower(), id)`` — case-insensitive title
94
+ primary, id tiebreaker. Edges are sorted by
95
+ ``(src, dst, link_text, link_kind)`` so edges between the same pair
96
+ of nodes still order consistently (a note with multiple ``[[refs]]``
97
+ to the same target produces multiple deterministic rows).
98
+
99
+ ``json.dumps`` is called with ``sort_keys=True`` so the per-object
100
+ key order is also stable. ``ensure_ascii=False`` keeps non-ASCII
101
+ titles readable instead of escaping them as ``\\uXXXX`` sequences;
102
+ determinism is preserved because the input was already a Python
103
+ string.
104
+
105
+ Every edge carries ``rule``, ``weight``, ``evidence`` keys so the
106
+ JSON shape is uniform across wiki and derived edges. Wiki edges have
107
+ ``null`` for all three; derived edges carry the metadata-rule
108
+ provenance pulled straight from :class:`GraphEdge`.
109
+ """
110
+ payload: dict[str, list[dict[str, Any]]] = {
111
+ "nodes": [
112
+ {
113
+ "id": n.document_id,
114
+ "title": n.title,
115
+ "kind": n.kind,
116
+ }
117
+ for n in sorted(
118
+ graph.nodes,
119
+ key=lambda n: (n.title.lower(), n.document_id),
120
+ )
121
+ ],
122
+ "edges": [
123
+ {
124
+ "src": e.src_document_id,
125
+ "dst": e.dst_document_id,
126
+ "kind": e.link_kind,
127
+ "text": e.link_text,
128
+ "display": e.display_text,
129
+ "rule": e.rule,
130
+ "weight": e.weight,
131
+ "evidence": e.evidence,
132
+ }
133
+ for e in sorted(
134
+ graph.edges,
135
+ key=lambda e: (
136
+ e.src_document_id,
137
+ e.dst_document_id,
138
+ e.link_text,
139
+ e.link_kind,
140
+ ),
141
+ )
142
+ ],
143
+ }
144
+ return json.dumps(payload, sort_keys=True, ensure_ascii=False, indent=2)
145
+
146
+
147
+ def to_dot(graph: GraphData) -> str:
148
+ """Graphviz DOT (digraph). Pipe to ``dot -Tsvg`` for visualization.
149
+
150
+ Empty graph returns a valid (but empty-bodied) digraph — tools like
151
+ ``dot`` accept it without warning.
152
+
153
+ Conventions:
154
+
155
+ - Node ids = first 8 chars of the document UUID with hyphens
156
+ stripped; collision-safe via a 4-hex hash suffix.
157
+ - Vault-tier nodes get ``fillcolor=lightblue``; ingested-tier nodes
158
+ get ``fillcolor=lightgray``.
159
+ - Embed edges (``link_kind='embed'``) get ``style=dashed``.
160
+ - Derived edges pick up tier-specific ``style`` + ``color`` from
161
+ :data:`_DOT_DERIVED_ATTRS` (see module docstring).
162
+ - Labels: title truncated to 60 chars; all backslashes / quotes /
163
+ newlines escaped per Graphviz quoted-string rules.
164
+ """
165
+ sorted_nodes = sorted(
166
+ graph.nodes, key=lambda n: (n.title.lower(), n.document_id)
167
+ )
168
+ sorted_edges = sorted(
169
+ graph.edges,
170
+ key=lambda e: (
171
+ e.src_document_id,
172
+ e.dst_document_id,
173
+ e.link_text,
174
+ e.link_kind,
175
+ ),
176
+ )
177
+
178
+ lines = [
179
+ "digraph G {",
180
+ " rankdir=LR;",
181
+ " node [shape=box, style=\"rounded,filled\"];",
182
+ ]
183
+ if not sorted_nodes and not sorted_edges:
184
+ lines.append("}")
185
+ return "\n".join(lines) + "\n"
186
+
187
+ id_map = _build_id_map(sorted_nodes)
188
+ for n in sorted_nodes:
189
+ node_id = id_map[n.document_id]
190
+ label = _dot_escape(_truncate_label(n.title))
191
+ fill = _DOT_FILL_VAULT if n.kind == "vault" else _DOT_FILL_INGESTED
192
+ lines.append(
193
+ f' {node_id} [label="{label}", fillcolor="{fill}"];'
194
+ )
195
+ for e in sorted_edges:
196
+ src = id_map.get(e.src_document_id)
197
+ dst = id_map.get(e.dst_document_id)
198
+ if src is None or dst is None:
199
+ # Shouldn't happen — graph_data filters edges to nodes in the
200
+ # same snapshot. Skip rather than emit a dangling edge.
201
+ continue # pragma: no cover
202
+ label = _dot_escape(_truncate_label(_edge_label(e)))
203
+ attrs = [f'label="{label}"']
204
+ if e.link_kind == "embed":
205
+ attrs.append('style="dashed"')
206
+ elif e.link_kind == "derived" and e.rule in _DOT_DERIVED_ATTRS:
207
+ attrs.extend(_DOT_DERIVED_ATTRS[e.rule])
208
+ lines.append(f" {src} -> {dst} [{', '.join(attrs)}];")
209
+ lines.append("}")
210
+ return "\n".join(lines) + "\n"
211
+
212
+
213
+ def to_mermaid(graph: GraphData) -> str:
214
+ """Mermaid ``graph TD`` (top-down). Paste into any Mermaid renderer.
215
+
216
+ Empty graph emits ``graph TD\\n`` — valid Mermaid syntax with no
217
+ nodes or edges (the renderer shows an empty canvas).
218
+
219
+ Conventions match :func:`to_dot`:
220
+
221
+ - Node ids: alphanumeric + underscore (Mermaid syntax requirement),
222
+ built from the UUID prefix + collision hash.
223
+ - Vault-tier nodes get class ``vault``; ingested-tier get
224
+ ``ingested``. Class definitions are emitted at the top so the
225
+ output is one self-contained string.
226
+ - Embed edges use ``-.->`` (dashed). Wiki edges use ``-->``.
227
+ - Derived edges pick their arrow shape from
228
+ :data:`_MERMAID_DERIVED_ARROWS` (R1 ``==>``, R2 ``-.->``, R3
229
+ ``-->``). Rules listed in :data:`_MERMAID_LINKSTYLE` (R3) emit a
230
+ trailing ``linkStyle <index> stroke:gray;`` directive targeting
231
+ the edge by its 0-indexed position in the rendered output.
232
+ - Labels: title truncated to 60 chars, with Mermaid-special chars
233
+ escaped (``"`` → ``#quot;`` per Mermaid's HTML-entity convention).
234
+ """
235
+ sorted_nodes = sorted(
236
+ graph.nodes, key=lambda n: (n.title.lower(), n.document_id)
237
+ )
238
+ sorted_edges = sorted(
239
+ graph.edges,
240
+ key=lambda e: (
241
+ e.src_document_id,
242
+ e.dst_document_id,
243
+ e.link_text,
244
+ e.link_kind,
245
+ ),
246
+ )
247
+
248
+ if not sorted_nodes and not sorted_edges:
249
+ return "graph TD\n"
250
+
251
+ lines = ["graph TD"]
252
+ # Class definitions kept out of the empty-graph branch — Mermaid
253
+ # rejects standalone class definitions without nodes in some
254
+ # renderer versions.
255
+ lines.append(f" {_MERMAID_CLASS_VAULT}")
256
+ lines.append(f" {_MERMAID_CLASS_INGESTED}")
257
+
258
+ id_map = _build_id_map(sorted_nodes)
259
+ for n in sorted_nodes:
260
+ node_id = id_map[n.document_id]
261
+ label = _mermaid_escape(_truncate_label(n.title))
262
+ # ``id["label"]`` is Mermaid's "rectangle with text" form — works
263
+ # for vault and ingested alike since we differentiate by class.
264
+ lines.append(f' {node_id}["{label}"]')
265
+ cls = "vault" if n.kind == "vault" else "ingested"
266
+ lines.append(f" class {node_id} {cls};")
267
+ # `linkStyle` directives must reference the 0-indexed position of an
268
+ # edge in the rendered Mermaid output. We track the index as we emit
269
+ # edges and accumulate directives in a sidecar list, then append them
270
+ # after the edge block — keeping every edge line contiguous (some
271
+ # Mermaid renderers stumble on linkStyle interleaved with edges).
272
+ linkstyle_lines: list[str] = []
273
+ edge_index = 0
274
+ for e in sorted_edges:
275
+ src = id_map.get(e.src_document_id)
276
+ dst = id_map.get(e.dst_document_id)
277
+ if src is None or dst is None:
278
+ continue # pragma: no cover
279
+ arrow = _mermaid_arrow_for(e)
280
+ label = _mermaid_escape(_truncate_label(_edge_label(e)))
281
+ if label:
282
+ lines.append(f' {src} {arrow}|"{label}"| {dst}')
283
+ else:
284
+ lines.append(f" {src} {arrow} {dst}")
285
+ if e.link_kind == "derived" and e.rule in _MERMAID_LINKSTYLE:
286
+ linkstyle_lines.append(
287
+ f" linkStyle {edge_index} {_MERMAID_LINKSTYLE[e.rule]};"
288
+ )
289
+ edge_index += 1
290
+ lines.extend(linkstyle_lines)
291
+ return "\n".join(lines) + "\n"
292
+
293
+
294
+ # ---------------------------------------------------------------------------
295
+ # Helpers — kept private (formatter-internal). Stable across releases as long
296
+ # as the byte-for-byte output of the public functions is stable.
297
+ # ---------------------------------------------------------------------------
298
+
299
+
300
+ def _build_id_map(nodes: list[GraphNode]) -> dict[str, str]:
301
+ """Map document_id → safe id string used by DOT/Mermaid.
302
+
303
+ Strategy: first 8 chars of the UUID with hyphens stripped (UUIDs use
304
+ only hex + hyphens, so the result is always alphanumeric). On
305
+ collision (same 8-hex prefix shared by two docs) append a 4-char
306
+ hash of the full id. Mermaid requires the prefix to be a letter or
307
+ underscore — UUIDs with leading digits are common, so we always
308
+ prepend ``n_``.
309
+ """
310
+ out: dict[str, str] = {}
311
+ used: dict[str, str] = {} # safe_id → document_id
312
+ for node in nodes:
313
+ candidate = "n_" + node.document_id.replace("-", "")[:_MERMAID_ID_PREFIX_LEN]
314
+ if candidate in used and used[candidate] != node.document_id:
315
+ suffix = hashlib.sha256(
316
+ node.document_id.encode("utf-8")
317
+ ).hexdigest()[:_MERMAID_HASH_SUFFIX_LEN]
318
+ candidate = f"{candidate}_{suffix}"
319
+ used[candidate] = node.document_id
320
+ out[node.document_id] = candidate
321
+ return out
322
+
323
+
324
+ def _truncate_label(text: str) -> str:
325
+ """Shorten ``text`` to :data:`_LABEL_TRUNCATE_AT` chars with an ellipsis."""
326
+ if len(text) <= _LABEL_TRUNCATE_AT:
327
+ return text
328
+ return text[: _LABEL_TRUNCATE_AT - 1] + _TRUNCATION_SUFFIX
329
+
330
+
331
+ def _mermaid_arrow_for(edge: GraphEdge) -> str:
332
+ """Pick the Mermaid arrow shape for an edge.
333
+
334
+ Embed edges keep the existing dashed arrow. Derived edges use the
335
+ per-rule arrow from :data:`_MERMAID_DERIVED_ARROWS`. Wiki edges and
336
+ any unknown derived rule fall through to the plain ``-->`` arrow —
337
+ that way an unrecognized rule still renders as a valid Mermaid
338
+ edge instead of crashing the formatter.
339
+ """
340
+ if edge.link_kind == "embed":
341
+ return "-.->"
342
+ if edge.link_kind == "derived" and edge.rule in _MERMAID_DERIVED_ARROWS:
343
+ return _MERMAID_DERIVED_ARROWS[edge.rule]
344
+ return "-->"
345
+
346
+
347
+ def _edge_label(edge: GraphEdge) -> str:
348
+ """Pick the human label for an edge.
349
+
350
+ Display text wins when set (``[[X|person-x]]`` shows "person-x"); otherwise
351
+ the raw ``link_text`` ("[[person-x conversation]]"). Renderers usually
352
+ want the same thing the user typed — ambiguity ("did they mean
353
+ display or raw?") is resolved here once instead of every consumer.
354
+ """
355
+ return edge.display_text or edge.link_text
356
+
357
+
358
+ def _dot_escape(text: str) -> str:
359
+ """Escape ``text`` for inclusion in a DOT quoted string.
360
+
361
+ Per Graphviz: backslash escapes itself, double-quote, and newline.
362
+ Other characters pass through. Length-preserving except for the
363
+ inserted backslashes.
364
+ """
365
+ return (
366
+ text.replace("\\", "\\\\")
367
+ .replace('"', '\\"')
368
+ .replace("\n", "\\n")
369
+ )
370
+
371
+
372
+ def _mermaid_escape(text: str) -> str:
373
+ """Escape ``text`` for inclusion inside a double-quoted Mermaid label.
374
+
375
+ Mermaid uses HTML entities for special chars inside ``"..."``:
376
+
377
+ - ``"`` → ``#quot;``
378
+ - ``#`` → ``#35;`` (so a literal ``#`` doesn't become an entity)
379
+
380
+ Newlines are converted to ``<br/>`` (Mermaid's only multi-line
381
+ convention inside labels). Backslashes are left alone — Mermaid
382
+ doesn't process them as escapes inside quoted labels.
383
+ """
384
+ return (
385
+ text.replace("#", "#35;")
386
+ .replace('"', "#quot;")
387
+ .replace("\n", "<br/>")
388
+ )
@@ -0,0 +1,235 @@
1
+ """Rewrite vault-tier wiki-links to vault-root-relative path form.
2
+
3
+ Quartz's ``ObsidianFlavoredMarkdown`` plugin treats the inner text of a
4
+ ``[[X]]`` marker as a literal filepath/slug — it does no frontmatter
5
+ lookup. So a vault note that contains ``[[company-mc <> COMPANY_REDACTED - Recap]]``
6
+ emits ``href="./company-mc-<>-COMPANY_REDACTED---Recap"`` and 404s in the rendered
7
+ site, even though the brain DB resolved the target to
8
+ ``_ingested/gmail/Mon, 20 Ap-19dacef6-re-company-mc-company-ko-recap.md``.
9
+
10
+ This module fixes that by rewriting every resolvable ``[[X]]`` in a
11
+ vault-tier file to ``[[<vault-root-relative-path>|<display>]]`` form
12
+ during ``brain vault sync``. The path is the target document's
13
+ ``vault_path`` (sans ``.md``); the display preserves whatever the user
14
+ wrote (title, alias, or the resolved title for the ``[[brain:<id>]]``
15
+ no-display case).
16
+
17
+ Mirrors the precedent set by
18
+ :func:`brain.vault.derived_links.fence.rewrite_derived_fences` — same
19
+ in-place body rewriting during sync, same atomic-write pattern, same
20
+ "counter-on-write" reporting via :class:`brain.vault.sync.SyncReport`.
21
+
22
+ Public API:
23
+
24
+ - :func:`rewrite_wiki_links` — pure: take a body string + connection,
25
+ return ``(rewritten_body, replacements_made)``. No filesystem access.
26
+ - :func:`rewrite_vault_links` — I/O wrapper: read a file, rewrite its
27
+ body, write it back atomically when changed. Returns whether the file
28
+ was rewritten.
29
+ """
30
+ import logging
31
+ from pathlib import Path
32
+ from typing import Any
33
+
34
+ import psycopg
35
+ import yaml
36
+
37
+ from ._atomic import atomic_write_text
38
+ from .frontmatter import dump_frontmatter, parse_frontmatter
39
+ from .links import ParsedLink, iter_wiki_links_with_spans
40
+ from .paths import strip_md_extension
41
+ from .resolver import resolve_link
42
+
43
+ logger = logging.getLogger(__name__)
44
+
45
+
46
+ def rewrite_wiki_links(
47
+ body: str,
48
+ *,
49
+ document_id: str,
50
+ conn: psycopg.Connection[Any],
51
+ ) -> tuple[str, int]:
52
+ """Rewrite resolvable ``[[…]]`` markers in ``body`` to path-form inner text.
53
+
54
+ For every wiki-link or embed:
55
+
56
+ 1. Parse via :func:`brain.vault.links.iter_wiki_links_with_spans` so
57
+ we get exact byte spans (length-preserving over fenced code +
58
+ inline code, which the parser already masks).
59
+ 2. Resolve via :func:`brain.vault.resolver.resolve_link`, excluding
60
+ ``document_id`` from candidates so a self-link is left alone.
61
+ 3. If the target has a ``vault_path``, build the new inner as
62
+ ``<path><#heading?>|<display>``:
63
+
64
+ - ``<path>`` is the target's ``vault_path`` with the trailing
65
+ ``.md`` stripped (POSIX form).
66
+ - ``<heading>`` is preserved from the original link if present.
67
+ - ``<display>`` is the original ``parsed.display_text`` if set,
68
+ else the original ``parsed.target_value`` (which preserves the
69
+ user's chosen label — title text, source-external string, etc.).
70
+ The one exception: ``[[brain:<id>]]`` with no display reads the
71
+ resolved target's ``documents.title`` so the rendered site shows
72
+ a human-friendly label instead of a UUID prefix.
73
+
74
+ 4. Compare the candidate inner to the link's existing inner; if
75
+ byte-equal, no rewrite (idempotent fast path — the second sync
76
+ pass over an already-rewritten body produces zero replacements).
77
+ 5. Replace each affected span in reverse order so earlier indices
78
+ stay valid as later substitutions change the body length.
79
+
80
+ Unresolved links, links whose target has ``vault_path IS NULL``, and
81
+ self-links are left untouched. The rewriter is contractually
82
+ non-destructive: it only ever writes path-form for links the resolver
83
+ confirmed.
84
+
85
+ Returns a ``(new_body, replacements)`` tuple. ``replacements`` is the
86
+ number of spans rewritten — a body with three resolvable links and
87
+ one already-in-path-form yields ``replacements == 2``.
88
+ """
89
+ spans = iter_wiki_links_with_spans(body)
90
+ if not spans:
91
+ return body, 0
92
+
93
+ # Walk the spans once, collecting ``(start, end, new_raw)`` tuples for
94
+ # each rewrite we want to apply. We iterate forward (document order)
95
+ # for deterministic logging, then apply substitutions in reverse to
96
+ # preserve byte offsets.
97
+ edits: list[tuple[int, int, str]] = []
98
+ for parsed, start, end in spans:
99
+ try:
100
+ target = resolve_link(conn, parsed, exclude_doc_id=document_id)
101
+ except psycopg.Error as e:
102
+ logger.warning(
103
+ "link rewrite: resolver failed on %s — leaving link alone: %s",
104
+ parsed.raw,
105
+ e,
106
+ )
107
+ continue
108
+ if target is None:
109
+ continue
110
+ # Per-link SELECT (not batched): personal-corpus scale (≤10K docs);
111
+ # batching would require a second pass over edits and add complexity
112
+ # for negligible gain.
113
+ target_row = conn.execute(
114
+ "SELECT vault_path, title FROM documents WHERE id = %s",
115
+ (target.document_id,),
116
+ ).fetchone()
117
+ if target_row is None:
118
+ # Race: target was resolved a moment ago but vanished. Leave
119
+ # the link untouched; the user (or a follow-up sync) will see
120
+ # the unresolved row in the DB and surface the issue.
121
+ continue
122
+ target_vault_path, target_title = target_row
123
+ if not target_vault_path:
124
+ logger.debug(
125
+ "link rewrite: target %s has no vault_path — leaving %s alone",
126
+ target.document_id[:8],
127
+ parsed.raw,
128
+ )
129
+ continue
130
+
131
+ path_no_ext = strip_md_extension(str(target_vault_path))
132
+ display = _choose_display(parsed, resolved_title=str(target_title))
133
+ new_inner = _build_inner(
134
+ path_no_ext, heading=parsed.heading, display=display
135
+ )
136
+ new_raw = ("![[" if parsed.kind == "embed" else "[[") + new_inner + "]]"
137
+
138
+ if new_raw == parsed.raw:
139
+ # Already in canonical path form — idempotent fast path.
140
+ continue
141
+ edits.append((start, end, new_raw))
142
+
143
+ if not edits:
144
+ return body, 0
145
+
146
+ new_body = body
147
+ for start, end, new_raw in reversed(edits):
148
+ new_body = new_body[:start] + new_raw + new_body[end:]
149
+ return new_body, len(edits)
150
+
151
+
152
+ def rewrite_vault_links(
153
+ file_path: Path,
154
+ *,
155
+ document_id: str,
156
+ conn: psycopg.Connection[Any],
157
+ ) -> bool:
158
+ """Read ``file_path``, rewrite its wiki-links, write back atomically.
159
+
160
+ Returns ``True`` iff the file's bytes changed (i.e. at least one link
161
+ was rewritten and the new body differs). On any I/O or YAML error the
162
+ function logs a warning and returns ``False`` — the rewrite is a
163
+ best-effort polish step; a failure here MUST NOT fail the sync run.
164
+ """
165
+ try:
166
+ text = file_path.read_text(encoding="utf-8")
167
+ except OSError as e:
168
+ logger.warning(
169
+ "link rewrite: could not read %s: %s — skipping", file_path, e
170
+ )
171
+ return False
172
+ try:
173
+ frontmatter, body = parse_frontmatter(text)
174
+ except (ValueError, yaml.YAMLError) as e:
175
+ logger.warning(
176
+ "link rewrite: malformed frontmatter in %s: %s — skipping",
177
+ file_path,
178
+ e,
179
+ )
180
+ return False
181
+
182
+ try:
183
+ new_body, replacements = rewrite_wiki_links(
184
+ body, document_id=document_id, conn=conn
185
+ )
186
+ except psycopg.Error as e:
187
+ # Defense in depth — ``rewrite_wiki_links`` already swallows
188
+ # per-link resolver errors; this branch catches a connection-level
189
+ # problem that escaped the inner handler. Log and move on.
190
+ logger.warning(
191
+ "link rewrite: DB error rewriting %s — skipping: %s",
192
+ file_path,
193
+ e,
194
+ )
195
+ return False
196
+
197
+ if replacements == 0 or new_body == body:
198
+ return False
199
+
200
+ new_text = dump_frontmatter(frontmatter, new_body)
201
+ try:
202
+ atomic_write_text(file_path, new_text)
203
+ except OSError as e:
204
+ logger.warning(
205
+ "link rewrite: could not write %s: %s — skipping", file_path, e
206
+ )
207
+ return False
208
+ return True
209
+
210
+
211
+ def _choose_display(parsed: ParsedLink, *, resolved_title: str) -> str:
212
+ """Pick the display label for the rewritten link.
213
+
214
+ Precedence:
215
+
216
+ 1. Original ``parsed.display_text`` if the user wrote one
217
+ (``[[X|alias]]`` or ``[[brain:<id>|alias]]``).
218
+ 2. For ``[[brain:<id>]]`` (no display), use the resolved target's
219
+ ``documents.title`` — the user clearly wanted a human label, not a
220
+ raw UUID prefix.
221
+ 3. Otherwise fall back to ``parsed.target_value`` (the original
222
+ title text, source-external string, etc.) so what the user typed
223
+ remains the visible label.
224
+ """
225
+ if parsed.display_text:
226
+ return str(parsed.display_text)
227
+ if parsed.target_type == "doc-id":
228
+ return resolved_title
229
+ return str(parsed.target_value)
230
+
231
+
232
+ def _build_inner(path: str, *, heading: str | None, display: str) -> str:
233
+ """Concatenate the rewritten link inner: ``<path>[#heading]|<display>``."""
234
+ head = f"#{heading}" if heading else ""
235
+ return f"{path}{head}|{display}"