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,236 @@
1
+ """Quartz overlay step — copies ``brain/quartz_overrides/`` into a Quartz workspace.
2
+
3
+ The brain package ships a small set of overrides under ``brain/quartz_overrides/``
4
+ that customize how Quartz renders the vault (custom Graph component,
5
+ extended contentIndex emitter, derived-fence transformer, layout, scss).
6
+ ``brain vault render --overlay`` copies these files over the user's
7
+ Quartz workspace right before invoking ``npx quartz build``.
8
+
9
+ Special case — `_upstreamContentIndex.tsx` rename:
10
+ Our overlay's ``contentIndex.ts`` is a thin wrapper that imports
11
+ Quartz's stock emitter via ``./_upstreamContentIndex``. To make
12
+ that import resolve, this module renames the stock
13
+ ``contentIndex.tsx`` shipped by Quartz out of the way before
14
+ copying. The rename is idempotent — on a workspace that has
15
+ already been overlaid, only ``_upstreamContentIndex.tsx`` is
16
+ present and no rename is needed.
17
+
18
+ The module exposes a pure :func:`plan_overlay` (no filesystem
19
+ mutations) plus :func:`apply_overlay` (performs rename + copy). The
20
+ two-step shape lets the CLI implement ``--print-overlay`` (plan
21
+ without applying) and lets unit tests exercise planning and applying
22
+ separately.
23
+ """
24
+ import os
25
+ import shutil
26
+ from dataclasses import dataclass
27
+ from importlib.resources import files as resource_files
28
+ from pathlib import Path
29
+ from typing import Literal
30
+
31
+ from ..errors import BrainError
32
+
33
+ # brain: special case — see brain/quartz_overrides/quartz/plugins/emitters/contentIndex.ts
34
+ # header for the why. The wrapper imports from `./_upstreamContentIndex`,
35
+ # so we rename Quartz's stock emitter out of the way before the copy.
36
+ _UPSTREAM_RENAME_FROM = Path("quartz/plugins/emitters/contentIndex.tsx")
37
+ _UPSTREAM_RENAME_TO = Path("quartz/plugins/emitters/_upstreamContentIndex.tsx")
38
+
39
+
40
+ RenameState = Literal["needed", "already_applied", "missing_both"]
41
+
42
+
43
+ class OverlayError(BrainError):
44
+ """Raised when the overlay step cannot proceed safely.
45
+
46
+ Two cases trigger this: a missing ``brain/quartz_overrides/`` source dir
47
+ (brain package is broken), or an inconsistent Quartz workspace
48
+ where BOTH the upstream ``contentIndex.tsx`` AND the renamed
49
+ ``_upstreamContentIndex.tsx`` are present at the same time. The
50
+ second case we refuse to auto-resolve — picking one would silently
51
+ discard the other, which may be a user customization.
52
+ """
53
+
54
+
55
+ @dataclass(frozen=True)
56
+ class OverlayPlan:
57
+ """A computed-but-not-applied overlay snapshot.
58
+
59
+ ``pairs`` is the ordered list of (source, destination) absolute
60
+ paths the copy step would write, in deterministic (sorted-by-src)
61
+ order. ``rename`` is the upstream-rename pair if it should fire
62
+ on this workspace, else ``None``. ``rename_state`` distinguishes
63
+ the two ``rename is None`` sub-cases (already applied vs. neither
64
+ file present) so ``--print-overlay`` can surface the difference.
65
+ """
66
+
67
+ quartz_dir: Path
68
+ pairs: tuple[tuple[Path, Path], ...]
69
+ rename: tuple[Path, Path] | None
70
+ rename_state: RenameState
71
+
72
+
73
+ def _overlay_source_root() -> Path:
74
+ """Resolve the ``quartz_overrides/`` tree from inside the installed brain package.
75
+
76
+ Uses ``importlib.resources.files("brain.quartz_overrides")`` so the path is
77
+ valid in both editable installs (``pip install -e``) and wheel-installed pipx
78
+ environments — importlib.resources handles both cases.
79
+
80
+ Raises :class:`OverlayError` if the package resource is not backed by a regular
81
+ filesystem directory (e.g. if the package were installed inside a zip archive,
82
+ which is not expected for brain).
83
+ """
84
+ root = resource_files("brain.quartz_overrides")
85
+ if not isinstance(root, os.PathLike):
86
+ raise OverlayError(
87
+ "brain.quartz_overrides must be installed as a directory, not inside a "
88
+ "zip archive. Re-install brain with 'pip install brain' (not as a zipapp)."
89
+ )
90
+ return Path(root)
91
+
92
+
93
+ def plan_overlay(quartz_dir: Path) -> OverlayPlan:
94
+ """Enumerate every overlay file + figure out the upstream rename.
95
+
96
+ Pure planning step — no filesystem mutations. Raises
97
+ :class:`OverlayError` if the brain package's ``quartz_overrides/``
98
+ directory is missing or unreadable, or if the Quartz workspace is in
99
+ an inconsistent state we won't auto-resolve.
100
+ """
101
+ overrides_root = _overlay_source_root()
102
+ quartz_dir_resolved = quartz_dir.resolve()
103
+ if not overrides_root.is_dir():
104
+ raise OverlayError(
105
+ f"overlay source directory not found: {overrides_root}\n"
106
+ f" Expected the brain package's quartz_overrides/ tree to be installed "
107
+ f"at that path. Try reinstalling brain."
108
+ )
109
+
110
+ pairs: list[tuple[Path, Path]] = []
111
+ for src in sorted(overrides_root.rglob("*")):
112
+ if not src.is_file():
113
+ continue
114
+ # Skip macOS metadata + dotfiles defensively; never legitimate
115
+ # overlay content.
116
+ if src.name.startswith("."):
117
+ continue
118
+ # Skip Python package metadata files and bytecode caches — they are
119
+ # not overlay content. __pycache__/ is generated by the Python
120
+ # interpreter when the package is imported and must not be copied.
121
+ if src.suffix in {".py", ".pyc"}:
122
+ continue
123
+ if "__pycache__" in src.parts:
124
+ continue
125
+ # Defense in depth: confirm src resolves inside overrides_root
126
+ # before we honor it (in case a future symlink ever points out).
127
+ try:
128
+ relative = src.resolve().relative_to(overrides_root)
129
+ except ValueError as e:
130
+ raise OverlayError(
131
+ f"overlay source escaped {overrides_root}: {src}"
132
+ ) from e
133
+ dest = quartz_dir_resolved / relative
134
+ pairs.append((src, dest))
135
+
136
+ rename, rename_state = _plan_upstream_rename(quartz_dir_resolved)
137
+ return OverlayPlan(
138
+ quartz_dir=quartz_dir_resolved,
139
+ pairs=tuple(pairs),
140
+ rename=rename,
141
+ rename_state=rename_state,
142
+ )
143
+
144
+
145
+ def _plan_upstream_rename(
146
+ quartz_dir: Path,
147
+ ) -> tuple[tuple[Path, Path] | None, RenameState]:
148
+ """Inspect the workspace and decide if the upstream rename should fire.
149
+
150
+ Three states:
151
+ * ``needed`` — only the original ``contentIndex.tsx`` is present;
152
+ rename it out of the way.
153
+ * ``already_applied`` — only ``_upstreamContentIndex.tsx`` is
154
+ present; rename has already happened, no-op.
155
+ * ``missing_both`` — neither is present; workspace is in an
156
+ unexpected state but we don't pre-empt it. The wrapper's
157
+ defensive load-time guard will surface a clear error when the
158
+ build runs.
159
+
160
+ Raises :class:`OverlayError` if BOTH files exist at once — that's
161
+ an inconsistent state we refuse to auto-resolve.
162
+ """
163
+ src = quartz_dir / _UPSTREAM_RENAME_FROM
164
+ dest = quartz_dir / _UPSTREAM_RENAME_TO
165
+ src_exists = src.is_file()
166
+ dest_exists = dest.is_file()
167
+ if src_exists and dest_exists:
168
+ raise OverlayError(
169
+ f"both upstream and renamed contentIndex files exist:\n"
170
+ f" {src}\n"
171
+ f" {dest}\n"
172
+ f" Delete whichever is stale and re-run. Keep "
173
+ f"`_upstreamContentIndex.tsx` if you want the brain wrapper; "
174
+ f"keep `contentIndex.tsx` if you want stock Quartz."
175
+ )
176
+ if src_exists:
177
+ return ((src, dest), "needed")
178
+ if dest_exists:
179
+ return (None, "already_applied")
180
+ return (None, "missing_both")
181
+
182
+
183
+ def apply_overlay(plan: OverlayPlan) -> list[tuple[Path, Path]]:
184
+ """Apply an overlay plan: rename upstream (if needed), then copy files.
185
+
186
+ Returns the list of (src, dest) pairs actually copied. The rename
187
+ runs first so the wrapper's ``./_upstreamContentIndex`` import
188
+ resolves once the build runs. Existing destinations are
189
+ overwritten via ``shutil.copy2`` — that's the whole point of the
190
+ overlay.
191
+
192
+ Re-runnable after a partial failure: the rename step is idempotent
193
+ via the three-state detection in :func:`_plan_upstream_rename`
194
+ (already-applied state is a no-op), and copies always overwrite —
195
+ so a fresh ``plan_overlay`` + ``apply_overlay`` pass converges
196
+ without manual cleanup.
197
+
198
+ Raises :class:`OverlayError` if any filesystem operation fails;
199
+ callers (CLI, MCP) need only catch ``OverlayError`` to convert
200
+ every failure mode into a friendly user-facing error.
201
+ """
202
+ if plan.rename is not None:
203
+ src, dest = plan.rename
204
+ try:
205
+ src.rename(dest)
206
+ except OSError as e:
207
+ raise OverlayError(
208
+ f"overlay rename failed: {src} → {dest}: {e}"
209
+ ) from e
210
+ for src, dest in plan.pairs:
211
+ try:
212
+ dest.parent.mkdir(parents=True, exist_ok=True)
213
+ shutil.copy2(src, dest)
214
+ # Shebanged files must be executable. shutil.copy2 preserves
215
+ # mode bits, so an overlay source checked in without +x
216
+ # produces a non-executable workspace copy (and an npx-cache
217
+ # install with the same mode), which makes `npx <bin>` fail
218
+ # with "Permission denied" at the shell layer. Restoring +x
219
+ # here guarantees the destination is runnable regardless of
220
+ # the source-file's tracked mode.
221
+ if _has_shebang(dest):
222
+ dest.chmod(dest.stat().st_mode | 0o111)
223
+ except OSError as e:
224
+ raise OverlayError(
225
+ f"overlay copy failed: {src} → {dest}: {e}"
226
+ ) from e
227
+ return list(plan.pairs)
228
+
229
+
230
+ def _has_shebang(path: Path) -> bool:
231
+ """Return True when ``path`` starts with ``#!`` (Unix shebang)."""
232
+ try:
233
+ with path.open("rb") as f:
234
+ return f.read(2) == b"#!"
235
+ except OSError:
236
+ return False