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,585 @@
1
+ """Atomic blue/green Quartz build + symlink swap.
2
+
3
+ Each call to :func:`build_and_swap` runs
4
+ ``node <quartz_dir>/quartz/bootstrap-cli.mjs build`` into a fresh
5
+ ``<quartz_dir>/builds/<ts>-<rand>/`` directory and then atomically retargets
6
+ the ``<quartz_dir>/current`` symlink at the new build via a temp-symlink +
7
+ ``rename(2)`` dance. Caddy serves ``current/`` directly, so readers only
8
+ ever see a fully-written tree — even mid-build, the previous build remains
9
+ the one on the wire.
10
+
11
+ The local Quartz workspace (``<quartz_dir>/quartz/bootstrap-cli.mjs``) is
12
+ invoked directly via ``node`` rather than ``npx quartz`` to eliminate the
13
+ ~100 s of npm-exec/package-resolution overhead that ``npx`` can introduce
14
+ before Quartz's own build timer starts. If ``bootstrap-cli.mjs`` is absent
15
+ or ``node`` is not on PATH, :func:`build_and_swap` hard-fails with a clear
16
+ repair-path message — there is **no automatic npx fallback**.
17
+
18
+ Quartz 4.5.x (pinned in ``package.json``) reliably supports ``--output``,
19
+ so ``_run_build`` uses that flag directly without any version probing.
20
+
21
+ After a successful swap, old build directories are garbage-collected: the
22
+ N most recent (by mtime) survive plus whichever directory ``current`` now
23
+ points at, even if it would otherwise be evicted. This guarantees the
24
+ active build is never deleted out from under Caddy.
25
+
26
+ This module also exposes a one-shot CLI entry point — ``python -m
27
+ brain.wiki.build_swap --vault PATH [--quartz-dir PATH] [--keep N]`` —
28
+ used by ``bin/brain-up`` (cold-start build) and ``bin/brain-rebuild``
29
+ (forced rebuild) so those scripts never have to import Python directly.
30
+ Exits 0 on success, 1 on a wrapped :class:`BrainWikiError`, and 2 (via
31
+ argparse) on bad arguments.
32
+ """
33
+ from __future__ import annotations
34
+
35
+ import argparse
36
+ import dataclasses
37
+ import logging
38
+ import secrets
39
+ import shutil
40
+ import subprocess
41
+ import sys
42
+ import time
43
+ import warnings
44
+ from dataclasses import dataclass
45
+ from datetime import datetime
46
+ from pathlib import Path
47
+ from typing import TYPE_CHECKING, Literal
48
+
49
+ if TYPE_CHECKING: # pragma: no cover — type-only import to avoid runtime cycle
50
+ from ..config import Config
51
+
52
+ from .errors import BrainWikiBuildError, BrainWikiError
53
+
54
+ logger = logging.getLogger(__name__)
55
+
56
+
57
+ @dataclass(frozen=True)
58
+ class BuildResult:
59
+ """Summary of a single :func:`build_and_swap` invocation.
60
+
61
+ ``build_dir`` is the absolute path to the new build's tree; ``build_id``
62
+ is its basename and the value written into ``build_dir/.build-id`` for
63
+ the reload-poller to read. ``elapsed_seconds`` covers the whole call
64
+ (build + swap + GC). ``pruned`` lists every build directory deleted by
65
+ garbage collection — empty on early runs, non-empty once we exceed
66
+ ``keep``. ``method`` is always ``"output-flag"`` (Quartz 4.5.x is
67
+ pinned and reliably supports ``--output``); it is preserved on the
68
+ result for observability and test assertions.
69
+ """
70
+
71
+ build_dir: Path
72
+ build_id: str
73
+ elapsed_seconds: float
74
+ pruned: list[Path]
75
+ method: Literal["output-flag"]
76
+
77
+
78
+ def build_and_swap(
79
+ vault: Path,
80
+ *,
81
+ quartz_dir: Path | None = None,
82
+ keep: int = 3,
83
+ node_path: str | None = None,
84
+ timeout_seconds: float = 600.0,
85
+ env: dict[str, str] | None = None,
86
+ refresh_related_inline: bool = True,
87
+ npx_path: str | None = None, # Deprecated and ignored. Kept for API compatibility.
88
+ ) -> BuildResult:
89
+ """Build the vault into a fresh dir and atomically retarget ``current``.
90
+
91
+ ``vault`` is the absolute path to the markdown root; Quartz is invoked
92
+ with ``--directory <vault>``. ``quartz_dir`` defaults to
93
+ ``<vault>/.quartz`` — that's where ``package.json`` and
94
+ ``quartz.config.ts`` must live (the dir created by ``npx quartz create``).
95
+
96
+ ``keep`` controls how many old build directories survive the post-swap
97
+ garbage-collect step (separately from the active ``current`` target,
98
+ which is *always* preserved — see :func:`_garbage_collect`).
99
+
100
+ ``node_path`` is the Node.js binary to invoke for the build subprocess.
101
+ When ``None`` (the default), it is resolved via
102
+ :func:`shutil.which`\\ ``("node")`` at call time. If ``node`` is not on
103
+ PATH a :class:`BrainWikiBuildError` is raised with a clear install hint.
104
+ Tests may pass an explicit path to a stub node script.
105
+
106
+ ``npx_path`` is **deprecated and ignored** — accepted for backwards API
107
+ compatibility only. Passing any non-``None`` value emits a
108
+ :class:`DeprecationWarning`. The build subprocess now invokes ``node``
109
+ directly via the pinned local Quartz workspace
110
+ (``<quartz_dir>/quartz/bootstrap-cli.mjs``) to eliminate ~100 s of
111
+ npm-exec overhead from the watcher hot path.
112
+
113
+ ``timeout_seconds`` is a hard wall-clock ceiling on the build
114
+ subprocess. The default (10 min) accommodates large vaults with
115
+ expensive Quartz transformers without ever leaving a runaway build
116
+ holding the process forever.
117
+
118
+ ``env`` is an optional extra-env dict merged into ``os.environ`` for
119
+ the build subprocess — used by tests to opt the stub script into
120
+ different output modes via env vars; production passes ``None``.
121
+
122
+ Raises :class:`BrainWikiError` if the workspace is missing or
123
+ ``bootstrap-cli.mjs`` is absent (with a repair-path hint); raises
124
+ :class:`BrainWikiBuildError` if ``node`` is not on PATH or if the build
125
+ subprocess fails. Partial output (a half-written ``build_dir`` left
126
+ behind by a failing build) is cleaned up before the exception propagates
127
+ so retries land on a clean slate.
128
+ """
129
+ if npx_path is not None:
130
+ warnings.warn(
131
+ "npx_path is deprecated and ignored; build now invokes node directly",
132
+ DeprecationWarning,
133
+ stacklevel=2,
134
+ )
135
+
136
+ started = time.monotonic()
137
+ workspace = quartz_dir if quartz_dir is not None else vault / ".quartz"
138
+ _check_workspace(workspace) # Also asserts bootstrap-cli.mjs is present.
139
+
140
+ # Resolve the node binary before any other work — hard-fail loud rather
141
+ # than falling back to npx (which adds ~100 s of variance per Codex
142
+ # measurement).
143
+ resolved_node: str
144
+ if node_path is not None:
145
+ resolved_node = node_path
146
+ else:
147
+ found = shutil.which("node")
148
+ if found is None:
149
+ raise BrainWikiBuildError(
150
+ "node binary not found on PATH; install Node.js"
151
+ " (Homebrew: `brew install node`;"
152
+ " Linux: nodejs.org or your distro's package manager)"
153
+ )
154
+ resolved_node = found
155
+
156
+ _refresh_pre_build_adornments(vault, refresh_related_inline=refresh_related_inline)
157
+
158
+ # Quartz 4.5.x is pinned in package.json and reliably supports --output.
159
+ # No version probe needed — always use the output-flag path.
160
+ method: Literal["output-flag"] = "output-flag"
161
+
162
+ builds_root = workspace / "builds"
163
+ builds_root.mkdir(parents=True, exist_ok=True)
164
+
165
+ build_id = _generate_build_id()
166
+ build_dir = builds_root / build_id
167
+
168
+ try:
169
+ _run_build(
170
+ node_path=resolved_node,
171
+ workspace=workspace,
172
+ vault=vault,
173
+ build_dir=build_dir,
174
+ build_id=build_id,
175
+ timeout_seconds=timeout_seconds,
176
+ env=env,
177
+ )
178
+ except (subprocess.CalledProcessError, subprocess.TimeoutExpired, OSError) as exc:
179
+ # Clean up any partial output before re-raising so the next attempt
180
+ # doesn't trip over a half-written tree.
181
+ if build_dir.exists():
182
+ shutil.rmtree(build_dir, ignore_errors=True)
183
+ # Invalidate fastpath artifacts written by the Quartz subprocess before
184
+ # it failed. writeFastpathArtifacts() in build.ts fires after
185
+ # emitContent() and is wrapped in try/catch, so it CAN succeed even
186
+ # when the overall build exits non-zero. If those artifacts are left on
187
+ # disk, classify_edit will read fingerprints from the failed (never-
188
+ # swapped) build and may route the next edit to build-partial, which
189
+ # would emit HTML based on a stale/inconsistent contentmap. Deleting
190
+ # manifest.json forces classify_edit to return NON_TRIVIAL, so the next
191
+ # watcher-triggered build starts fresh and writes consistent artifacts.
192
+ # Best-effort: _invalidate_fastpath_manifest never raises.
193
+ _invalidate_fastpath_manifest(workspace)
194
+ if isinstance(exc, subprocess.CalledProcessError):
195
+ raise BrainWikiBuildError(
196
+ f"quartz build failed (exit {exc.returncode}) for vault {vault}"
197
+ ) from exc
198
+ if isinstance(exc, subprocess.TimeoutExpired):
199
+ raise BrainWikiBuildError(
200
+ f"quartz build exceeded {timeout_seconds}s for vault {vault}"
201
+ ) from exc
202
+ raise BrainWikiBuildError(
203
+ f"quartz build raised {type(exc).__name__} for vault {vault}: {exc}"
204
+ ) from exc
205
+
206
+ # The two steps below — writing .build-id into the new build dir and
207
+ # atomically retargeting the ``current`` symlink — are both "post-build,
208
+ # pre-committed" operations: the subprocess already wrote fastpath
209
+ # artifacts (manifest.json + contentmap.json) tagged with ``build_id``
210
+ # to fastpath_dir. If EITHER step raises, ``current`` still points at
211
+ # the previous build while the manifest carries fingerprints from the new
212
+ # (uncommitted) one. We must invalidate the stale manifest on ANY
213
+ # OSError here — not only on the swap itself — so the classifier routes
214
+ # the next edit to a full build rather than a partial one on stale data.
215
+ try:
216
+ # Mark the build with its id. The reload-poller reads this file via
217
+ # /.build-id (Caddy serves the build root directly).
218
+ (build_dir / ".build-id").write_text(f"{build_id}\n", encoding="utf-8")
219
+ _atomic_swap(workspace, build_id)
220
+ except OSError as exc:
221
+ # Deleting manifest.json forces classify_edit to return NON_TRIVIAL on
222
+ # the next edit, routing to a full build that writes a fresh + consistent
223
+ # manifest once the commit succeeds.
224
+ _invalidate_fastpath_manifest(workspace)
225
+ raise BrainWikiBuildError(
226
+ f"quartz build swap failed for vault {vault}: {exc}"
227
+ ) from exc
228
+
229
+ pruned = _garbage_collect(workspace, keep=keep)
230
+
231
+ elapsed = time.monotonic() - started
232
+ return BuildResult(
233
+ build_dir=build_dir,
234
+ build_id=build_id,
235
+ elapsed_seconds=elapsed,
236
+ pruned=pruned,
237
+ method=method,
238
+ )
239
+
240
+
241
+ # ---------------------------------------------------------------------------
242
+ # Internal helpers.
243
+ # ---------------------------------------------------------------------------
244
+
245
+
246
+ def _invalidate_fastpath_manifest(workspace: Path) -> None:
247
+ """Delete ``fastpath_dir/manifest.json`` after a failed symlink swap.
248
+
249
+ When :func:`_run_build` succeeds, the Quartz subprocess writes
250
+ ``<workspace>/.cache/fastpath/manifest.json`` (and ``contentmap.json``)
251
+ tagged with the new ``build_id``. If the subsequent :func:`_atomic_swap`
252
+ then fails, ``current`` still points at the *previous* build while
253
+ ``manifest.json`` carries fingerprints from the *new* (unswapped) build.
254
+
255
+ Leaving the stale manifest on disk is dangerous: :func:`classify_edit`
256
+ would read the new fingerprints and might route the next edit as
257
+ ``TRIVIAL`` — launching ``build-partial`` against a live build dir whose
258
+ HTML is from an earlier state. Deleting the manifest file forces
259
+ ``classify_edit`` to return ``NON_TRIVIAL`` (``ManifestError`` → full
260
+ build), so the next watcher-driven build writes a fresh, consistent
261
+ manifest once the swap succeeds.
262
+
263
+ This function is **best-effort**: it never raises. A missing file is
264
+ silently ignored; any other ``OSError`` is logged at WARNING level so the
265
+ operator knows but the caller's exception path is unaffected.
266
+ """
267
+ manifest_path = workspace / ".cache" / "fastpath" / "manifest.json"
268
+ try:
269
+ manifest_path.unlink()
270
+ logger.debug(
271
+ "wiki build: invalidated stale fastpath manifest after swap failure (%s)",
272
+ manifest_path,
273
+ )
274
+ except FileNotFoundError:
275
+ pass # already gone — nothing to do
276
+ except OSError as exc:
277
+ logger.warning(
278
+ "wiki build: could not invalidate stale fastpath manifest %s: %s",
279
+ manifest_path,
280
+ exc,
281
+ )
282
+
283
+
284
+ def _check_workspace(workspace: Path) -> None:
285
+ """Validate that ``workspace`` looks like a Quartz workspace.
286
+
287
+ Performs two checks:
288
+
289
+ 1. ``quartz.config.ts`` — written by ``npx quartz create``, required by
290
+ our overlay step. Guards against pointing the builder at an empty dir.
291
+
292
+ 2. ``quartz/bootstrap-cli.mjs`` — the Node.js entry point that
293
+ :func:`_run_build` invokes directly (instead of ``npx quartz``). If
294
+ the file is absent the workspace is incomplete — the repair path is
295
+ ``cd <workspace> && npm install``.
296
+
297
+ We do *not* validate ``package.json`` or ``node_modules`` beyond the
298
+ bootstrap-cli check; the caller (brain-up cold start) has its own
299
+ friendlier preflight for those.
300
+ """
301
+ if not workspace.is_dir():
302
+ raise BrainWikiError(f"quartz workspace not found at {workspace}")
303
+ if not (workspace / "quartz.config.ts").is_file():
304
+ raise BrainWikiError(
305
+ f"quartz workspace at {workspace} is missing quartz.config.ts"
306
+ )
307
+ bootstrap = workspace / "quartz" / "bootstrap-cli.mjs"
308
+ if not bootstrap.is_file():
309
+ raise BrainWikiBuildError(
310
+ f"Quartz bootstrap CLI not found at {bootstrap};"
311
+ f' run `cd "{workspace}" && npm install` to repair the workspace'
312
+ )
313
+
314
+
315
+ def _generate_build_id() -> str:
316
+ """Return a fresh ``YYYYMMDD-HHMMSS-<6 hex>`` id.
317
+
318
+ Timestamp + 24 bits of randomness keeps ids monotonic-by-time (so
319
+ sort-by-name in ``ls`` lines up with sort-by-mtime) but still
320
+ collision-free if two builds race within the same second.
321
+ """
322
+ stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
323
+ return f"{stamp}-{secrets.token_hex(3)}"
324
+
325
+
326
+ def _run_build(
327
+ *,
328
+ node_path: str,
329
+ workspace: Path,
330
+ vault: Path,
331
+ build_dir: Path,
332
+ build_id: str,
333
+ timeout_seconds: float,
334
+ env: dict[str, str] | None,
335
+ ) -> None:
336
+ """Invoke ``node <workspace>/quartz/bootstrap-cli.mjs build --output <build_dir>``.
337
+
338
+ Implementation is split off :func:`build_and_swap` so the parent can
339
+ own the cleanup-on-error semantics in one place — this function just
340
+ runs the subprocess and lets every exception type bubble up untouched.
341
+
342
+ ``node_path`` is the resolved Node.js binary (pre-validated by the
343
+ caller); ``workspace / "quartz" / "bootstrap-cli.mjs"`` is the Quartz
344
+ entry point (pre-validated by :func:`_check_workspace`).
345
+
346
+ ``build_id`` is injected into the subprocess environment as
347
+ ``QUARTZ_PARENT_BUILD_ID`` so the Quartz overlay can write the
348
+ fastpath manifest (``manifest.json`` + ``contentmap.json``) tagged to
349
+ this build. Without it the overlay logs "skipping fastpath artifact
350
+ write" and every subsequent :func:`build_partial` call hits an
351
+ envelope mismatch and falls back to a full rebuild.
352
+
353
+ Quartz 4.5.x is pinned in ``package.json`` and reliably supports
354
+ ``--output``, so no method probe or fallback is needed.
355
+ """
356
+ import os # local import — only needed inside the build path
357
+
358
+ # Always build a merged env so QUARTZ_PARENT_BUILD_ID reaches the
359
+ # Node subprocess. Callers that pass env=None (production) still get
360
+ # the ID injected; callers that pass a custom dict (tests) get their
361
+ # overrides merged on top.
362
+ merged_env = dict(os.environ)
363
+ if env is not None:
364
+ merged_env.update(env)
365
+ merged_env["QUARTZ_PARENT_BUILD_ID"] = build_id
366
+ # Suppress Node deprecation noise (e.g. DEP0040 punycode from a transitive
367
+ # Quartz dep). Cosmetic only; setdefault preserves a caller override.
368
+ merged_env.setdefault("NODE_OPTIONS", "--no-deprecation")
369
+
370
+ args = [
371
+ node_path,
372
+ str(workspace / "quartz" / "bootstrap-cli.mjs"),
373
+ "build",
374
+ "--directory",
375
+ str(vault),
376
+ "--output",
377
+ str(build_dir),
378
+ ]
379
+ subprocess.run( # noqa: S603 — list-form args, no shell
380
+ args,
381
+ cwd=str(workspace),
382
+ check=True,
383
+ timeout=timeout_seconds,
384
+ env=merged_env, # always a dict (never None) — QUARTZ_PARENT_BUILD_ID injected above
385
+ )
386
+
387
+
388
+ def _atomic_swap(workspace: Path, build_id: str) -> None:
389
+ """Retarget ``<workspace>/current`` at ``builds/<build_id>`` atomically.
390
+
391
+ Uses a temp-symlink + ``Path.replace`` (POSIX ``rename(2)``) so a
392
+ reader that opens ``current/.build-id`` mid-swap either sees the old
393
+ target or the new one — never a missing file. APFS, ext4, and ZFS
394
+ all guarantee rename atomicity for symlinks.
395
+
396
+ The relative target (``builds/<id>``) keeps the symlink portable
397
+ if the workspace is moved; absolute targets would need rewriting.
398
+ """
399
+ target = Path("builds") / build_id
400
+ current = workspace / "current"
401
+ tmp = workspace / "current.tmp"
402
+ if tmp.is_symlink() or tmp.exists():
403
+ tmp.unlink()
404
+ tmp.symlink_to(target)
405
+ tmp.replace(current)
406
+
407
+
408
+ def _garbage_collect(workspace: Path, *, keep: int) -> list[Path]:
409
+ """Delete build dirs beyond the ``keep`` most-recent + the active one.
410
+
411
+ Sorting by mtime descending preserves the most recent ``keep`` even
412
+ if their names sort differently (clock skew / manual touches).
413
+ Whatever the symlink resolves to is *always* spared — without that
414
+ rule a ``keep=1`` config could delete the live build out from under
415
+ Caddy. Returns the list of pruned dirs so the CLI can log them.
416
+ """
417
+ builds_root = workspace / "builds"
418
+ if not builds_root.is_dir():
419
+ return []
420
+ candidates = [p for p in builds_root.iterdir() if p.is_dir()]
421
+ candidates.sort(key=lambda p: p.stat().st_mtime, reverse=True)
422
+
423
+ active = _resolve_current_target(workspace)
424
+ keepers: set[Path] = set(candidates[:keep])
425
+ if active is not None:
426
+ keepers.add(active)
427
+
428
+ pruned: list[Path] = []
429
+ for p in candidates:
430
+ if p in keepers:
431
+ continue
432
+ try:
433
+ shutil.rmtree(p)
434
+ except OSError as exc:
435
+ # GC is best-effort — a stuck dir (open file handle on
436
+ # Windows, exotic permissions) shouldn't fail the swap. Log
437
+ # and move on; the next run gets another shot at it.
438
+ logger.warning("wiki build: failed to prune %s: %s", p, exc)
439
+ continue
440
+ pruned.append(p)
441
+ return pruned
442
+
443
+
444
+ def _resolve_current_target(workspace: Path) -> Path | None:
445
+ """Return the absolute build dir ``current`` points at, or None.
446
+
447
+ None covers two cases: the symlink doesn't exist (first build), or
448
+ it points at a path that's already been deleted (rare race between
449
+ GC and a manual ``rm``). The caller treats both as "no active
450
+ build to spare from GC".
451
+ """
452
+ current = workspace / "current"
453
+ if not current.is_symlink() and not current.exists():
454
+ return None
455
+ try:
456
+ resolved = current.resolve(strict=True)
457
+ except (OSError, RuntimeError):
458
+ return None
459
+ if not resolved.is_dir():
460
+ return None
461
+ return resolved
462
+
463
+
464
+ # ---------------------------------------------------------------------------
465
+ # CLI entry point — ``python -m brain.wiki.build_swap``.
466
+ # ---------------------------------------------------------------------------
467
+
468
+
469
+ def _replace_vault_path(cfg: Config, vault_path: Path) -> Config:
470
+ """Return a copy of ``cfg`` with ``vault_path`` overridden.
471
+
472
+ ``Config`` is frozen, so we use :func:`dataclasses.replace`. Lifted
473
+ into a helper so the call site in :func:`main` reads cleanly and so
474
+ a future Config field addition (which would otherwise break the
475
+ inline ``replace(...)`` call by omission) flags here in one spot.
476
+ """
477
+ return dataclasses.replace(cfg, vault_path=vault_path)
478
+
479
+
480
+ def _refresh_pre_build_adornments(
481
+ vault: Path, *, refresh_related_inline: bool = True
482
+ ) -> None:
483
+ """Best-effort generated wiki adornment refresh before any Quartz build.
484
+
485
+ ``refresh_related_inline=False`` skips ``refresh_related`` (the heavy
486
+ hybrid-search recompute, ~70s on a 1100-doc vault). The build watcher
487
+ passes ``False`` and runs ``refresh_related`` on a background daemon
488
+ thread post-build to keep edit-to-UI latency down. ``bin/brain-rebuild``
489
+ keeps the default ``True`` so manual rebuilds always emit fresh
490
+ related-docs JSON synchronously.
491
+ """
492
+ # P4.7/P5.1 — refresh generated wiki adornments before the build
493
+ # so the new build picks up the freshest recent rail and related-doc
494
+ # JSON. Failures here are logged-and-swallowed by the refresh helpers
495
+ # themselves (DB unreachable, missing fence, …) — these adornments are
496
+ # a courtesy, the build is the customer.
497
+ # Config loading is opt-in: a Config-load failure (no DATABASE_URL on
498
+ # PATH) shouldn't block the build either, so we wrap the import too.
499
+ try:
500
+ from ..config import Config, ConfigError
501
+ from .build_homepage import refresh_homepage
502
+ from .build_related import refresh_related
503
+
504
+ try:
505
+ cfg = Config.load()
506
+ except ConfigError as exc:
507
+ logger.warning(
508
+ "wiki pre-build refresh: Config.load failed (%s) — skipping refresh",
509
+ exc,
510
+ )
511
+ else:
512
+ target_vault = vault.expanduser().resolve()
513
+ # Honor explicit build vault overrides: the generated adornments
514
+ # must follow the tree being built, not the env default.
515
+ cfg_for_build = (
516
+ cfg
517
+ if cfg.vault_path == target_vault
518
+ else _replace_vault_path(cfg, target_vault)
519
+ )
520
+ refresh_homepage(cfg_for_build)
521
+ if refresh_related_inline:
522
+ refresh_related(cfg_for_build)
523
+ except Exception as exc: # noqa: BLE001 — refresh is best-effort
524
+ logger.warning("wiki pre-build refresh: unexpected failure: %s", exc)
525
+
526
+
527
+ def main(argv: list[str] | None = None) -> int:
528
+ """Run a single ``build_and_swap`` from the command line.
529
+
530
+ Used by ``bin/brain-up`` (cold-start build, when ``current/`` doesn't
531
+ exist yet) and ``bin/brain-rebuild`` (forced rebuild) so those shell
532
+ scripts can stay shell-only — no Python import needed at the call
533
+ site.
534
+
535
+ Mirrors :func:`build_watcher.main` argument shape (``--vault``,
536
+ ``--quartz-dir``, ``--keep``) so the two CLIs feel like siblings.
537
+ Returns 0 on success and 1 on any :class:`BrainWikiError` (build
538
+ failure or workspace problem). Bad arguments exit 2 via argparse.
539
+ """
540
+ parser = argparse.ArgumentParser(
541
+ prog="brain.wiki.build_swap",
542
+ description="Run a single atomic Quartz build + symlink swap.",
543
+ )
544
+ parser.add_argument("--vault", required=True, type=Path, help="Vault root path.")
545
+ parser.add_argument(
546
+ "--quartz-dir",
547
+ type=Path,
548
+ default=None,
549
+ help="Quartz workspace dir (default: <vault>/.quartz).",
550
+ )
551
+ parser.add_argument(
552
+ "--keep",
553
+ type=int,
554
+ default=3,
555
+ help="How many old build dirs to retain after the swap (default: 3).",
556
+ )
557
+ args = parser.parse_args(argv)
558
+
559
+ logging.basicConfig(
560
+ level=logging.INFO,
561
+ format="%(asctime)s %(levelname)s %(name)s: %(message)s",
562
+ )
563
+
564
+ vault: Path = args.vault.expanduser().resolve()
565
+ quartz_dir: Path | None = (
566
+ args.quartz_dir.expanduser().resolve() if args.quartz_dir is not None else None
567
+ )
568
+
569
+ try:
570
+ result = build_and_swap(vault, quartz_dir=quartz_dir, keep=args.keep)
571
+ except BrainWikiError as exc:
572
+ # Wrapped wiki errors — workspace missing, build subprocess
573
+ # exited non-zero, or timed out. Print a one-line message and
574
+ # exit 1 so callers (brain-up, brain-rebuild) can `|| exit 1`.
575
+ sys.stderr.write(f"build failed: {exc}\n")
576
+ return 1
577
+
578
+ sys.stdout.write(
579
+ f"{result.build_id} ({result.elapsed_seconds:.2f}s, method={result.method})\n"
580
+ )
581
+ return 0
582
+
583
+
584
+ if __name__ == "__main__": # pragma: no cover — exercised via subprocess in CLI
585
+ raise SystemExit(main(sys.argv[1:]))