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,302 @@
1
+ // Sample Quartz v4 markdown transformer for brain vaults.
2
+ //
3
+ // This file is a TEMPLATE. It is installed at
4
+ // `<vault>/.quartz/quartz/plugins/transformers/derivedFenceMark.ts`
5
+ // by `brain vault render --overlay`. It does NOT compile or run from
6
+ // the brain repo itself; the imports below resolve against the
7
+ // dependencies Quartz pulls into the cloned workspace via
8
+ // `npm install`, not against any package brain ships.
9
+ //
10
+ // Tested against Quartz v4.5.x (April 2026). If a future Quartz
11
+ // version restructures the transformer plugin shape or the mdast
12
+ // types it uses, pull the latest transformer reference from
13
+ // https://github.com/jackyzha0/quartz/tree/v4/quartz/plugins/transformers
14
+ // and re-apply the brain tweaks flagged below — `// brain:` for
15
+ // value/structural choices on upstream-supported fields, and
16
+ // `// brain-extension:` for keys/types that don't exist in stock
17
+ // Quartz.
18
+ //
19
+ // What this transformer does, and why: Phase D
20
+ // (`src/brain/vault/derived_links/fence.py`) writes a fenced section
21
+ // at the bottom of every `_ingested/` mirror file:
22
+ //
23
+ // <!-- BRAIN_DERIVED_START -->
24
+ // ## Related (auto-generated, do not edit)
25
+ // - [[partner-stem|Partner Title]] *(same_day_participant)*
26
+ // - [[partner-stem|Partner Title]] *(shared_thread)*
27
+ // <!-- BRAIN_DERIVED_END -->
28
+ //
29
+ // The brain graph renderer needs to distinguish these "evidence"
30
+ // edges from authored wiki-links so it can apply dashed/translucent
31
+ // styling and surface the rule in hover tooltips. This transformer
32
+ // scans the mdast tree for the BRAIN_DERIVED_START / END html-comment
33
+ // markers and stamps each `<a>` link inside the fenced range with
34
+ // `data-brain-derived="true"` + `data-brain-rule="<rule>"` (and
35
+ // `data-brain-weight` if a weight is encoded — see parser below).
36
+ // The custom contentIndex emitter
37
+ // (`quartz_overrides/plugins/emitters/contentIndex.ts`) reads these
38
+ // HTML attributes back when emitting `static/contentIndex.json` so
39
+ // each link record carries `kind: "derived"` + structured rule /
40
+ // weight metadata.
41
+ //
42
+ // brain (Lane B 2026-05-02): also writes a `title` attribute on each
43
+ // derived link so the `cursor: help` styling Lane B's `_links.scss`
44
+ // applies to derived links surfaces a real tooltip on hover. Format:
45
+ // `"Derived: <rule>"` when the bullet emphasis parsed cleanly,
46
+ // `"Derived (related)"` as a fallback when no rule was extracted.
47
+ // Cross-lane edit (Lane B's CSS depends on this transformer's data).
48
+ //
49
+ // Registration: this transformer must run AFTER
50
+ // `Plugin.ObsidianFlavoredMarkdown()` so that `[[wiki-link]]` syntax
51
+ // has already been converted into mdast `link` nodes before we walk
52
+ // the tree. The brain `quartz.config.ts` template wires it up in the
53
+ // correct slot — see the `transformers: [...]` list there.
54
+ //
55
+ // brain: marker constants are mirrored verbatim from
56
+ // `src/brain/vault/derived_links/fence.py` (the canonical Phase D
57
+ // source). TypeScript can't import from Python, so they live in two
58
+ // places — keep them in sync. If Phase D ever changes the marker
59
+ // shape, update both files together and re-run the overlay's
60
+ // parse-smoke test against a freshly-rendered fixture vault.
61
+
62
+ import type { Root, RootContent, Link, Paragraph, Emphasis } from "mdast"
63
+
64
+ import { QuartzTransformerPlugin } from "../types"
65
+
66
+ // brain: kept identical to ``FENCE_START_MARKER`` /
67
+ // ``FENCE_END_MARKER`` in ``src/brain/vault/derived_links/fence.py``.
68
+ // Detection uses ``includes`` (not equality) so leading/trailing
69
+ // whitespace introduced by markdown parsers around the html-comment
70
+ // node doesn't break the match.
71
+ const FENCE_START_MARKER = "<!-- BRAIN_DERIVED_START -->"
72
+ const FENCE_END_MARKER = "<!-- BRAIN_DERIVED_END -->"
73
+
74
+ // brain-extension: the structured shape we extract from each bullet's
75
+ // trailing emphasis. ``rule`` is what fence.py writes today
76
+ // (``same_day_participant`` / ``shared_thread``). It defaults to
77
+ // undefined so the emitter's classifier can omit the
78
+ // ``data-brain-rule`` attribute when the parser couldn't pin a value
79
+ // down. Note: a missing ``rule`` does NOT downgrade the link's
80
+ // classification — see ``stampDerived`` for the fence-membership
81
+ // truth-source rule.
82
+ interface BulletMeta {
83
+ rule?: string
84
+ }
85
+
86
+ // brain-extension: parses the trailing-emphasis text of a Phase D
87
+ // bullet. Strict — only the shape fence.py actually writes today
88
+ // (``*(<rule>)*`` where ``<rule>`` is an identifier) is accepted;
89
+ // anything else returns ``{}``. Identifier-shape guard prevents
90
+ // matching whole sentences if a future fence accidentally mixes
91
+ // prose with the metadata convention.
92
+ //
93
+ // brain: the fence membership — not this parser's success — is the
94
+ // truth-source for ``kind: "derived"``. Links inside the fence whose
95
+ // emphasis fails to parse (missing, malformed, mixed prose) still
96
+ // get ``data-brain-derived="true"`` stamped on them by
97
+ // ``stampDerived``; only ``data-brain-rule`` is omitted. If Phase E
98
+ // ever adds a structured ``weight`` to the bullet shape, extend
99
+ // this parser and ``BulletMeta`` together — don't pre-build the
100
+ // hook.
101
+ function parseBulletEmphasis(text: string): BulletMeta {
102
+ const match = text.trim().match(/^\(([A-Za-z_][A-Za-z0-9_-]*)\)$/)
103
+ if (!match) return {}
104
+ return { rule: match[1] }
105
+ }
106
+
107
+ // brain: recursive text collector. mdast emphasis/strong nodes can
108
+ // contain mixed text + nested formatting; flattening to a plain
109
+ // string is safer than reading ``.children[0].value`` blindly when
110
+ // fence.py's output is hand-edited or rendered through alternate
111
+ // markdown processors.
112
+ function collectText(node: RootContent): string {
113
+ if (node.type === "text") return node.value
114
+ if ("children" in node && Array.isArray(node.children)) {
115
+ return (node.children as RootContent[]).map(collectText).join("")
116
+ }
117
+ return ""
118
+ }
119
+
120
+ // brain: in-place attribute stamper. mdast's standard extension
121
+ // point for "attach HTML attributes that survive into hast" is the
122
+ // ``data.hProperties`` object — remark-rehype copies its keys onto
123
+ // the corresponding hast element's properties, which then become
124
+ // real HTML attributes after stringification. The brain emitter
125
+ // reads these attributes back via ``classifyLink`` to flag derived
126
+ // links in ``contentIndex.json``.
127
+ //
128
+ // brain: ``data-brain-derived`` is stamped unconditionally for
129
+ // every link inside the fence range — fence membership is the
130
+ // truth-source for "this is a derived edge," NOT the bullet
131
+ // emphasis parse. ``data-brain-rule`` is added only when the parser
132
+ // pinned a rule down; bullets with missing/malformed metadata still
133
+ // produce ``kind: "derived"`` records (just without a rule field).
134
+ //
135
+ // brain (Lane B 2026-05-02): also stamp ``title`` so the
136
+ // ``cursor: help`` styling on derived links in
137
+ // ``brain/styles/_links.scss`` actually surfaces a tooltip on hover.
138
+ // Without the title write, the SCSS rule promised an affordance the
139
+ // DOM didn't deliver. The fallback string for the rule-less case is
140
+ // the literal word "Derived (related)" so screen readers and
141
+ // browser tooltips both read sensibly even when the bullet emphasis
142
+ // failed to parse.
143
+ function stampDerived(link: Link, meta: BulletMeta): void {
144
+ const data = (link.data ??= {}) as Record<string, unknown>
145
+ const props = (data.hProperties ??= {}) as Record<string, string>
146
+ props["data-brain-derived"] = "true"
147
+ if (meta.rule !== undefined) {
148
+ props["data-brain-rule"] = meta.rule
149
+ props["title"] = `Derived: ${meta.rule}`
150
+ } else {
151
+ props["title"] = "Derived (related)"
152
+ }
153
+ }
154
+
155
+ // brain: walks a Phase D bullet's paragraph children to find the
156
+ // (link, trailing-emphasis) pair fence.py writes. The bullet shape
157
+ // after Obsidian-flavored-markdown processing is:
158
+ //
159
+ // listItem
160
+ // └─ paragraph
161
+ // ├─ link ← [[stem|title]]
162
+ // ├─ text " "
163
+ // └─ emphasis ← *(<rule>)*
164
+ //
165
+ // We walk left-to-right so a paragraph that mistakenly contains
166
+ // multiple links gets paired correctly: each link inherits the
167
+ // emphasis that immediately follows it, falling back to "no rule"
168
+ // if none does. Returns the map keyed by mdast link node identity.
169
+ function pairLinksAndRules(paragraph: Paragraph): Map<Link, BulletMeta> {
170
+ const result = new Map<Link, BulletMeta>()
171
+ let pendingLink: Link | null = null
172
+ for (const child of paragraph.children) {
173
+ if (child.type === "link") {
174
+ if (pendingLink !== null && !result.has(pendingLink)) {
175
+ // Previous link had no trailing emphasis; commit it as
176
+ // "derived but rule unknown" so the emitter still flags it.
177
+ result.set(pendingLink, {})
178
+ }
179
+ pendingLink = child
180
+ continue
181
+ }
182
+ if (child.type === "emphasis" && pendingLink !== null) {
183
+ const text = collectText(child as Emphasis)
184
+ result.set(pendingLink, parseBulletEmphasis(text))
185
+ pendingLink = null
186
+ }
187
+ }
188
+ if (pendingLink !== null && !result.has(pendingLink)) {
189
+ result.set(pendingLink, {})
190
+ }
191
+ return result
192
+ }
193
+
194
+ // brain: top-level mdast walker. Tracks ``inFence`` state across the
195
+ // root's direct children (where the BRAIN_DERIVED_START / END html
196
+ // comments live as siblings of the bullet list). Within the fence
197
+ // range, recurses into descendants and:
198
+ //
199
+ // 1. Records every link node so we know which links are derived
200
+ // (kind="derived") regardless of whether their bullet's
201
+ // emphasis was parseable.
202
+ // 2. Detects paragraph nodes (the bullet's content node) and pairs
203
+ // each link with its trailing emphasis to extract rule/weight
204
+ // metadata.
205
+ //
206
+ // Two-pass shape (collect, then stamp) keeps the recursion side-
207
+ // effect-free and lets us merge rule/weight metadata onto the
208
+ // minimal ``derived=true`` baseline in one place.
209
+ function annotateFence(tree: Root): void {
210
+ let inFence = false
211
+ const linksInFence = new Set<Link>()
212
+ const ruleByLink = new Map<Link, BulletMeta>()
213
+
214
+ const visit = (node: RootContent | Root): void => {
215
+ if (node.type === "html" && typeof node.value === "string") {
216
+ // brain: html-comment markers are root-level siblings of the
217
+ // bullet list, so toggling ``inFence`` here (rather than on
218
+ // descent) gives the correct enclosing-range semantics. The
219
+ // markers themselves don't carry links, so there's nothing to
220
+ // miss by skipping the recursion below.
221
+ //
222
+ // brain: literal ``<!-- BRAIN_DERIVED_START -->`` text in body
223
+ // prose would also trigger this — vault docs almost never
224
+ // self-document the fence format (the canonical doc lives in
225
+ // ``docs/specs/`` outside the vault), so we accept the
226
+ // false-positive risk rather than wrap the marker in a unique
227
+ // sentinel that fence.py would also need to emit.
228
+ if (!inFence && node.value.includes(FENCE_START_MARKER)) {
229
+ inFence = true
230
+ return
231
+ }
232
+ if (inFence && node.value.includes(FENCE_END_MARKER)) {
233
+ inFence = false
234
+ return
235
+ }
236
+ }
237
+
238
+ if (inFence) {
239
+ if (node.type === "link") {
240
+ linksInFence.add(node)
241
+ }
242
+ // brain: paragraph-only — fence.py emits bullets, which become
243
+ // ``listItem > paragraph`` after mdast parsing. If a future
244
+ // fence shape ever inserts a heading or blockquote inside the
245
+ // marker range, links there would still get
246
+ // ``kind: "derived"`` (via ``linksInFence`` above) but without
247
+ // a ``rule`` field. Revisit the pairing logic at that point.
248
+ if (node.type === "paragraph") {
249
+ for (const [link, meta] of pairLinksAndRules(node)) {
250
+ ruleByLink.set(link, meta)
251
+ }
252
+ }
253
+ }
254
+
255
+ if ("children" in node && Array.isArray(node.children)) {
256
+ for (const child of node.children as RootContent[]) {
257
+ visit(child)
258
+ }
259
+ }
260
+ }
261
+
262
+ visit(tree)
263
+
264
+ if (inFence) {
265
+ // brain: defensive — fence.py always emits matched START/END
266
+ // pairs (and ``extract_fence`` in fence.py guards against the
267
+ // same corruption from the other side), but a hand-edited file
268
+ // could leave a doc in-fence forever. Drop everything we
269
+ // collected and surface a console warning rather than silently
270
+ // mis-tag every link below the orphaned START marker.
271
+ console.warn(
272
+ "[derivedFenceMark] unmatched BRAIN_DERIVED_START — discarding fence annotations for this document",
273
+ )
274
+ return
275
+ }
276
+
277
+ for (const link of linksInFence) {
278
+ stampDerived(link, ruleByLink.get(link) ?? {})
279
+ }
280
+ }
281
+
282
+ // brain-extension: the transformer plugin itself. Empty options
283
+ // object is intentional — the only knobs would be the marker strings
284
+ // (which are sourced from fence.py) and the matched-rule allowlist
285
+ // (which is enforced upstream by fence.py's ``FENCE_RULES``).
286
+ // Keeping the signature ``(opts?: never)`` documents that no
287
+ // configuration is expected; if a knob is genuinely needed later,
288
+ // widen the type at that point rather than guessing now.
289
+ export const DerivedFenceMark: QuartzTransformerPlugin = () => {
290
+ return {
291
+ name: "DerivedFenceMark",
292
+ markdownPlugins() {
293
+ return [
294
+ () => {
295
+ return (tree: Root) => {
296
+ annotateFence(tree)
297
+ }
298
+ },
299
+ ]
300
+ },
301
+ }
302
+ }
@@ -0,0 +1,148 @@
1
+ // Brain wiki redesign — email-thread reading-mode transformer plugin
2
+ // (P4.4 of the Wiki UX Overhaul).
3
+ //
4
+ // This file is a TEMPLATE. It is installed at
5
+ // `<vault>/.quartz/quartz/plugins/transformers/emailThread.ts`
6
+ // by `brain vault render --overlay`. It does NOT compile or run from
7
+ // the brain repo itself; the imports below resolve against the
8
+ // dependencies Quartz pulls into the cloned workspace via
9
+ // `npm install`, not against any package brain ships.
10
+ //
11
+ // Tested against Quartz v4.5.x (April 2026). If a future Quartz
12
+ // version restructures the transformer plugin shape or the
13
+ // `externalResources()` hook contract, pull the latest transformer
14
+ // reference from
15
+ // https://github.com/jackyzha0/quartz/tree/v4/quartz/plugins/transformers
16
+ // and re-apply the brain tweaks flagged below — `// brain:` for
17
+ // value/structural choices on upstream-supported fields, and
18
+ // `// brain-extension:` for keys/types that don't exist in stock
19
+ // Quartz.
20
+ //
21
+ // What this transformer does, and why: P4.4 ships an "Email thread
22
+ // reading mode" — every per-message section in an `email_thread`
23
+ // body becomes a collapsible `<details>` block (older messages
24
+ // closed, latest expanded), and a "Show only my replies" filter
25
+ // button hides every section whose `From:` header doesn't equal the
26
+ // owner's email. The runtime that wires these affordances lives in
27
+ // `quartz/static/emailThread.js`; this transformer's job is to (a)
28
+ // inject that script into every page and (b) bake the owner's email
29
+ // (read from `process.env.BRAIN_USER_EMAIL` at build time) into a
30
+ // tiny inline `<script>window.BRAIN_USER_EMAIL = "..."</script>`
31
+ // snippet so the runtime knows whose `From:` to match against.
32
+ //
33
+ // brain: this transformer ALWAYS injects the runtime — same pattern
34
+ // as `LinkSourceTag` and `CodeCopy`, both of which are part of the
35
+ // production redesign and don't gate on env vars. The user-email
36
+ // global is emitted only when `BRAIN_USER_EMAIL` is set; without it
37
+ // the runtime renders the toggle button anyway but the filter
38
+ // matches no message (correct "you forgot to set the env var" cue).
39
+ //
40
+ // brain: cross-references for the email-thread reading-mode contract:
41
+ // * `quartz_overrides/quartz/static/emailThread.js` — the runtime
42
+ // this transformer points page `<head>` at.
43
+ // * `quartz_overrides/quartz/styles/brain/_email_thread.scss` — the
44
+ // SCSS partial that paints `<details>` and the filter button,
45
+ // and reads the body class (`brain-replies-only`) plus per-
46
+ // section attributes (`data-brain-is-mine`) the runtime stamps.
47
+ // * `src/brain/ingest/gmail.py` — `to_extracted_thread()`, which
48
+ // emits the `## YYYY-MM-DD HH:MM — <from>` headings + per-message
49
+ // `<details>` markup the runtime parses.
50
+ // * `src/brain/config.py` — the `Config.user_email` field and
51
+ // `BRAIN_USER_EMAIL` env-var contract this transformer mirrors.
52
+ //
53
+ // Registration: this transformer has NO ordering requirement among
54
+ // the other transformers — it doesn't touch the mdast tree, only
55
+ // contributes resources via `externalResources()`. Place it anywhere
56
+ // in the `transformers: [...]` list; the brain `quartz.config.ts`
57
+ // template wires it next to `LinkSourceTag` / `CodeCopy` so the
58
+ // brain script-only transformers stay grouped.
59
+
60
+ import { QuartzTransformerPlugin } from "../types"
61
+
62
+ // brain-extension: the path the page `<script src=...>` tag points
63
+ // at. Leading slash is intentional — Quartz emits pages at arbitrary
64
+ // slug depths (e.g. `/_ingested/gmail/<thread>/index.html`) and a
65
+ // relative `static/emailThread.js` would break for any non-root
66
+ // page. Server-rooted `/static/emailThread.js` resolves consistently
67
+ // regardless of which page the user landed on. This works because
68
+ // Caddy's `file_server` (and Quartz's stock `file_server` in dev)
69
+ // serves the build dir as the site root
70
+ // (`<vault>/.quartz/current/static/emailThread.js`).
71
+ const SCRIPT_SRC = "/static/emailThread.js"
72
+
73
+ // brain-extension: build-time env var carrying the owner's email.
74
+ // Read inside `externalResources()` so each plugin instance evaluates
75
+ // the value fresh per build (matters for tests + for `bin/brain-up`
76
+ // re-invocations where the env may differ between runs).
77
+ const USER_EMAIL_ENV_VAR = "BRAIN_USER_EMAIL"
78
+
79
+ // brain: minimal escape pass for the user-email value before it gets
80
+ // interpolated into the inline `<script>` body. The email comes from
81
+ // the user's own `.env` so it's not adversarial input, but a stray
82
+ // `</script>` (malformed entry) or `\` would break the script tag
83
+ // boundary. Escaping `<`, `>`, `\`, and `"` keeps the inline-script
84
+ // stream parseable. Symmetric with how stock Quartz escapes inline
85
+ // JSON in its `description.ts` transformer.
86
+ function escapeForJsString(value: string): string {
87
+ return value
88
+ .replace(/\\/g, "\\\\")
89
+ .replace(/"/g, '\\"')
90
+ .replace(/</g, "\\u003c")
91
+ .replace(/>/g, "\\u003e")
92
+ .replace(/\r/g, "\\r")
93
+ .replace(/\n/g, "\\n")
94
+ }
95
+
96
+ // brain-extension: the transformer plugin itself. Empty options
97
+ // shape — the only knobs would be the script path and the env var
98
+ // name, both of which are part of the contract documented above.
99
+ // Keeping the signature `(opts?: never)` documents that no
100
+ // configuration is expected; if a knob is genuinely needed later
101
+ // (e.g. a per-vault user-allowlist override), widen the type at that
102
+ // point rather than guessing now.
103
+ export const EmailThreadReader: QuartzTransformerPlugin = (_opts?: never) => {
104
+ return {
105
+ name: "EmailThreadReader",
106
+ externalResources() {
107
+ // brain: read the env var fresh per build. `.trim()` so a
108
+ // trailing newline from a `.env` quirk doesn't bleed into the
109
+ // JS global.
110
+ const rawEmail = (process.env[USER_EMAIL_ENV_VAR] ?? "").trim()
111
+ const escapedEmail = escapeForJsString(rawEmail)
112
+
113
+ // brain: emit two JS resources:
114
+ //
115
+ // 1. inline — sets `window.BRAIN_USER_EMAIL` BEFORE the runtime
116
+ // script reads it. `loadTime: "beforeDOMReady"` ensures the
117
+ // global is in place before the runtime's
118
+ // `DOMContentLoaded` listener fires. Always emitted (even
119
+ // when the env var is unset) so the runtime always sees
120
+ // `window.BRAIN_USER_EMAIL` defined as `""` rather than
121
+ // `undefined` — simpler null handling on the runtime side.
122
+ //
123
+ // 2. external — the runtime itself, served from
124
+ // `/static/emailThread.js`. `loadTime: "afterDOMReady"`
125
+ // because the runtime walks the article DOM at boot, and
126
+ // `afterDOMReady` guarantees the article is in place.
127
+ //
128
+ // `spaPreserve` left unset on both — Quartz's SPA reuses the
129
+ // existing `<script>` between navigations only when set; we want
130
+ // both scripts to re-run on each `nav` event so the runtime can
131
+ // re-detect the page and re-inject the toggle.
132
+ return {
133
+ js: [
134
+ {
135
+ loadTime: "beforeDOMReady",
136
+ contentType: "inline",
137
+ script: `;window.BRAIN_USER_EMAIL = "${escapedEmail}";`,
138
+ },
139
+ {
140
+ loadTime: "afterDOMReady",
141
+ contentType: "external",
142
+ src: SCRIPT_SRC,
143
+ },
144
+ ],
145
+ }
146
+ },
147
+ }
148
+ }
@@ -0,0 +1,213 @@
1
+ // Brain hide-if-empty door filter — Phase 4.1 of the Wiki UX Overhaul.
2
+ //
3
+ // This file is a TEMPLATE. It is installed at
4
+ // `<vault>/.quartz/quartz/plugins/transformers/emptyDoorFilter.ts` by
5
+ // `brain vault render --overlay`. It does NOT compile or run from the
6
+ // brain repo itself; the imports below resolve against the
7
+ // dependencies Quartz pulls into the cloned workspace via `npm
8
+ // install`.
9
+ //
10
+ // What this transformer does, and why: the home note (`index.md`) ships
11
+ // a hand-curated set of "doors" — folder-style links like
12
+ // `[Daily notes](daily/)` that take the user into a top-level subdir
13
+ // of the vault. When the destination subdir is empty, the door
14
+ // resolves to a 404. The fix has two layers:
15
+ //
16
+ // 1. A brain CLI step that auto-generates `<subdir>/index.md` when
17
+ // the subdir has notes (see `src/brain/vault/daily_index.py` for
18
+ // the daily case). When the subdir is non-empty the door
19
+ // resolves cleanly.
20
+ //
21
+ // 2. This transformer. When the subdir is empty (no `*.md` files
22
+ // other than a stale `index.md` itself), strip the door's
23
+ // enclosing `<li>` from the rendered HTML so the user never
24
+ // sees a link to a 404. Generic — a single `folders` option
25
+ // controls which subdirs to check, so a future "Workbench"
26
+ // door (or any other future top-level subdir) gets the same
27
+ // treatment by appending its name to the option.
28
+ //
29
+ // Restricted to the home page by default (`pageSlugs: ["index"]`) —
30
+ // stripping random `<li>`s on every page would be too aggressive.
31
+ // The brief case is the home navigation; if a future PR needs the
32
+ // behaviour elsewhere, extend the option.
33
+ //
34
+ // Tested against Quartz v4.5.x (April 2026). The rehype tree shape
35
+ // (hast `Element` with `tagName` / `properties` / `children`) is
36
+ // stable across versions; the `Plugin.CrawlLinks` integration this
37
+ // depends on (resolving folder refs to relative URLs) has been
38
+ // in place since v4.0.
39
+
40
+ import * as fs from "node:fs"
41
+ import * as path from "node:path"
42
+
43
+ import type { Element, Root, RootContent } from "hast"
44
+
45
+ import { QuartzTransformerPlugin } from "../types"
46
+
47
+ export interface EmptyDoorFilterOptions {
48
+ // brain: list of folder names (relative to the vault root) to check
49
+ // for emptiness. The transformer compares each link's resolved href
50
+ // against this list and strips the enclosing `<li>` if the folder
51
+ // has zero matchable `.md` files. `daily` is the only case that
52
+ // motivated this transformer; future top-level subdirs (e.g. a
53
+ // hypothetical `workbench/`) get the same treatment by appending
54
+ // their name here.
55
+ folders: string[]
56
+ // brain: page slugs the transformer fires on. Default `["index"]`
57
+ // — the home note. Restricting prevents accidentally stripping
58
+ // legitimate navigation `<li>`s from other pages.
59
+ pageSlugs: string[]
60
+ }
61
+
62
+ const defaultOptions: EmptyDoorFilterOptions = {
63
+ folders: ["daily"],
64
+ pageSlugs: ["index"],
65
+ }
66
+
67
+ // brain: a folder counts as "non-empty" if it contains at least one
68
+ // markdown file whose name is NOT `index.md`. The `index.md` exclusion
69
+ // stops a stale auto-generated index from masking an otherwise-empty
70
+ // folder. Recursive walk so a year-folded ``daily/<YYYY>/<note>.md``
71
+ // layout is handled (the brain CLI nests dailies under year folders).
72
+ function folderHasNotes(folderPath: string): boolean {
73
+ if (!fs.existsSync(folderPath)) return false
74
+ let stack: string[] = [folderPath]
75
+ while (stack.length > 0) {
76
+ const dir = stack.pop()
77
+ if (dir === undefined) break
78
+ let entries: fs.Dirent[]
79
+ try {
80
+ entries = fs.readdirSync(dir, { withFileTypes: true })
81
+ } catch {
82
+ // Permission errors / race deletes — treat as empty for the
83
+ // affected subtree rather than crashing the build.
84
+ continue
85
+ }
86
+ for (const entry of entries) {
87
+ const full = path.join(dir, entry.name)
88
+ if (entry.isDirectory()) {
89
+ stack.push(full)
90
+ continue
91
+ }
92
+ if (!entry.isFile()) continue
93
+ if (!entry.name.endsWith(".md")) continue
94
+ if (entry.name === "index.md") continue
95
+ return true
96
+ }
97
+ }
98
+ return false
99
+ }
100
+
101
+ // brain: tolerant href→folder match. `Plugin.CrawlLinks` rewrites
102
+ // internal links to relative URLs; the home note's `daily/` link
103
+ // might resolve to `./daily/`, `daily/`, or `daily/index` depending
104
+ // on `markdownLinkResolution`. We strip a leading `./`, drop a
105
+ // trailing `/`, and trim a trailing `/index` so the comparator
106
+ // against the bare folder name catches every shape.
107
+ function hrefMatchesFolder(href: string, emptyFolders: Set<string>): boolean {
108
+ if (href.length === 0) return false
109
+ // Skip absolute / external URLs cheaply — folder refs are always
110
+ // relative.
111
+ if (/^[a-z]+:/i.test(href)) return false
112
+ let cleaned = href.split("#")[0]
113
+ cleaned = cleaned.replace(/^\.\//, "")
114
+ cleaned = cleaned.replace(/\/$/, "")
115
+ cleaned = cleaned.replace(/\/index$/, "")
116
+ // Folder name is the last path segment for nested doors (none today,
117
+ // but a future `tools/scratch/` would need this).
118
+ return emptyFolders.has(cleaned)
119
+ }
120
+
121
+ // brain: walks an `<li>` subtree to determine if any descendant `<a>`
122
+ // points at one of the empty folders. Hast doesn't ship a visitor
123
+ // helper this targeted (`hast-util-visit` would work but adds an
124
+ // import for what's a 12-line walk), so we recurse manually.
125
+ function listItemPointsAtEmptyFolder(
126
+ li: Element,
127
+ emptyFolders: Set<string>,
128
+ ): boolean {
129
+ const stack: RootContent[] = [...li.children]
130
+ while (stack.length > 0) {
131
+ const node = stack.pop()
132
+ if (node === undefined) break
133
+ if (node.type !== "element") continue
134
+ const el = node as Element
135
+ if (el.tagName === "a") {
136
+ const href = (el.properties ?? {})["href"]
137
+ if (typeof href === "string" && hrefMatchesFolder(href, emptyFolders)) {
138
+ return true
139
+ }
140
+ }
141
+ if (Array.isArray(el.children)) {
142
+ for (const child of el.children) {
143
+ stack.push(child)
144
+ }
145
+ }
146
+ }
147
+ return false
148
+ }
149
+
150
+ // brain: in-place tree edit — splice every `<li>` that points at an
151
+ // empty folder. Walks recursively so a `<li>` nested inside a `<ul>`
152
+ // inside a `<details>` (or any wrapper) is still reachable. We mutate
153
+ // `node.children` to a filtered list; preact reconciles fine with
154
+ // hast tree mutation since rehype-stringify reads the final shape
155
+ // after every transformer plugin runs.
156
+ function stripEmptyDoorListItems(
157
+ tree: Root | Element,
158
+ emptyFolders: Set<string>,
159
+ ): void {
160
+ const children = tree.children
161
+ if (!Array.isArray(children)) return
162
+ const filtered: typeof children = []
163
+ for (const child of children) {
164
+ if (child.type === "element") {
165
+ const el = child as Element
166
+ if (el.tagName === "li" && listItemPointsAtEmptyFolder(el, emptyFolders)) {
167
+ // Drop the entire `<li>` — including any nested children we
168
+ // would otherwise have recursed into. The whole door (link +
169
+ // surrounding context — emoji, em-dash subtitle, etc.) is
170
+ // expected to live inside the same `<li>`, which matches the
171
+ // home note's authored shape (`- 📅 [Daily notes](daily/) — …`).
172
+ continue
173
+ }
174
+ stripEmptyDoorListItems(el, emptyFolders)
175
+ }
176
+ filtered.push(child)
177
+ }
178
+ tree.children = filtered as typeof tree.children
179
+ }
180
+
181
+ export const EmptyDoorFilter: QuartzTransformerPlugin<
182
+ Partial<EmptyDoorFilterOptions>
183
+ > = (userOpts) => {
184
+ const opts: EmptyDoorFilterOptions = { ...defaultOptions, ...userOpts }
185
+ return {
186
+ name: "EmptyDoorFilter",
187
+ htmlPlugins(ctx) {
188
+ // brain: cache empty-folder set per build. The vault filesystem
189
+ // is read once per `htmlPlugins` invocation (Quartz calls it once
190
+ // per build); rendering 1000+ pages doesn't re-stat the same
191
+ // dirs. The `Set` is the comparator the per-page walker hits.
192
+ const vaultRoot = ctx.argv.directory
193
+ const emptyFolders = new Set<string>()
194
+ for (const folder of opts.folders) {
195
+ if (!folderHasNotes(path.join(vaultRoot, folder))) {
196
+ emptyFolders.add(folder)
197
+ }
198
+ }
199
+ const pageSlugSet = new Set(opts.pageSlugs)
200
+ return [
201
+ () => async (tree: Root, file) => {
202
+ // brain: short-circuit when nothing's empty — the vast
203
+ // majority of builds will land here, since the home note's
204
+ // doors point at populated dirs.
205
+ if (emptyFolders.size === 0) return
206
+ const slug = (file.data as { slug?: string }).slug
207
+ if (typeof slug !== "string" || !pageSlugSet.has(slug)) return
208
+ stripEmptyDoorListItems(tree, emptyFolders)
209
+ },
210
+ ]
211
+ },
212
+ }
213
+ }