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,273 @@
1
+ """Python subprocess wrapper for the Quartz ``build-partial`` CLI subcommand.
2
+
3
+ Drives the Node.js ``build-partial`` handler (T4) as a one-shot subprocess
4
+ and surfaces structured failure information via :class:`BrainWikiPartialBuildError`
5
+ so the watcher (T6) can route recovery decisions (e.g. force full build on exit 6
6
+ vs. increment a retry counter on exit 5) without string-matching stderr.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+ import os
12
+ import re
13
+ import shutil
14
+ import subprocess
15
+ from dataclasses import dataclass
16
+ from enum import Enum
17
+ from pathlib import Path
18
+
19
+ from brain.wiki.errors import BrainWikiBuildError
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+ # Maximum number of stderr characters surfaced in error messages.
24
+ # Long emitter stack traces are trimmed to avoid log bloat.
25
+ _STDERR_TAIL_LIMIT = 500
26
+
27
+ # Regex for the success stdout line emitted by build_partial_handler.js:
28
+ # "wiki: build-partial slug=<slug> elapsed=<N>ms"
29
+ _ELAPSED_RE = re.compile(r"elapsed=(\d+)ms")
30
+
31
+
32
+ # ---------------------------------------------------------------------------
33
+ # Failure taxonomy
34
+ # ---------------------------------------------------------------------------
35
+
36
+
37
+ class PartialBuildFailureKind(Enum):
38
+ """Distinct failure categories for telemetry and recovery routing.
39
+
40
+ T6 uses this enum to decide whether to fall back to a full build
41
+ immediately (e.g. ``UNSUPPORTED_SLUG_SCOPE``) or to increment a
42
+ partial-failure counter first (e.g. ``EMITTER_FAILED``).
43
+ """
44
+
45
+ MISSING_NODE = "missing-node"
46
+ """``node`` binary not found on PATH."""
47
+
48
+ MISSING_BOOTSTRAP = "missing-bootstrap"
49
+ """``bootstrap-cli.mjs`` absent in the workspace directory."""
50
+
51
+ MANIFEST_OR_CONTENTMAP_UNREADABLE = "manifest-or-contentmap-unreadable"
52
+ """Exit 1: manifest or contentmap JSON missing or unparseable."""
53
+
54
+ ENVELOPE_MISMATCH = "envelope-mismatch"
55
+ """Exit 2: ``version`` or ``parent_build_id`` differs between manifest and contentmap."""
56
+
57
+ SLUG_NOT_IN_MANIFEST = "slug-not-in-manifest"
58
+ """Exit 3: the requested slug is absent from ``manifest.slugs``."""
59
+
60
+ SLUG_NOT_IN_CONTENTMAP = "slug-not-in-contentmap"
61
+ """Exit 4: the slug is in the manifest but absent from ``contentmap.entries``."""
62
+
63
+ EMITTER_FAILED = "emitter-failed"
64
+ """Exit 5: an uncaught exception during the emitter walk (fail-fast)."""
65
+
66
+ UNSUPPORTED_SLUG_SCOPE = "unsupported-slug-scope"
67
+ """Exit 6: slug is a tag/folder/index page that requires a full build."""
68
+
69
+ UNKNOWN_NONZERO = "unknown-nonzero"
70
+ """Any nonzero exit code not covered by exit codes 1-6."""
71
+
72
+ SUBPROCESS_TIMEOUT = "subprocess-timeout"
73
+ """The build-partial subprocess exceeded the configured timeout."""
74
+
75
+
76
+ # Mapping from Node process exit codes (1-6) to failure kinds.
77
+ _EXIT_CODE_TO_KIND: dict[int, PartialBuildFailureKind] = {
78
+ 1: PartialBuildFailureKind.MANIFEST_OR_CONTENTMAP_UNREADABLE,
79
+ 2: PartialBuildFailureKind.ENVELOPE_MISMATCH,
80
+ 3: PartialBuildFailureKind.SLUG_NOT_IN_MANIFEST,
81
+ 4: PartialBuildFailureKind.SLUG_NOT_IN_CONTENTMAP,
82
+ 5: PartialBuildFailureKind.EMITTER_FAILED,
83
+ 6: PartialBuildFailureKind.UNSUPPORTED_SLUG_SCOPE,
84
+ }
85
+
86
+
87
+ # ---------------------------------------------------------------------------
88
+ # Exception
89
+ # ---------------------------------------------------------------------------
90
+
91
+
92
+ class BrainWikiPartialBuildError(BrainWikiBuildError):
93
+ """Raised on any ``build-partial`` failure.
94
+
95
+ Carries a structured :class:`PartialBuildFailureKind` for routing and
96
+ the originating ``slug`` for diagnostics. The human-readable message
97
+ always includes the slug, exit code (where applicable), and the tail of
98
+ stderr so an operator can diagnose without needing the raw subprocess
99
+ output.
100
+ """
101
+
102
+ def __init__(
103
+ self,
104
+ message: str,
105
+ *,
106
+ kind: PartialBuildFailureKind,
107
+ slug: str | None = None,
108
+ ) -> None:
109
+ super().__init__(message)
110
+ self.kind = kind
111
+ self.slug = slug
112
+
113
+
114
+ # ---------------------------------------------------------------------------
115
+ # Result
116
+ # ---------------------------------------------------------------------------
117
+
118
+
119
+ @dataclass(frozen=True, slots=True)
120
+ class PartialBuildResult:
121
+ """Summary of a successful ``build-partial`` invocation.
122
+
123
+ ``elapsed_ms`` is parsed from the Node process stdout line
124
+ ``"wiki: build-partial slug=<slug> elapsed=<N>ms"``. If the pattern
125
+ is not matched (e.g. the handler changed its output format), ``elapsed_ms``
126
+ is ``-1`` and a warning is logged; the result is still considered
127
+ successful because exit code 0 was returned.
128
+ """
129
+
130
+ slug: str
131
+ elapsed_ms: int
132
+ stdout: str
133
+ stderr: str
134
+
135
+
136
+ # ---------------------------------------------------------------------------
137
+ # Public API
138
+ # ---------------------------------------------------------------------------
139
+
140
+
141
+ def run_build_partial(
142
+ *,
143
+ slug: str,
144
+ vault_dir: Path,
145
+ build_dir: Path,
146
+ workspace_dir: Path,
147
+ timeout_s: float = 30.0,
148
+ ) -> PartialBuildResult:
149
+ """Run the Quartz ``build-partial`` CLI subcommand for a single slug.
150
+
151
+ Invokes::
152
+
153
+ node <workspace_dir>/quartz/bootstrap-cli.mjs build-partial \\
154
+ --directory <vault_dir> \\
155
+ --output <build_dir> \\
156
+ --slug <slug>
157
+
158
+ with ``cwd=workspace_dir`` (the workspace ROOT) so Quartz can resolve
159
+ ``./package.json`` and ``./quartz/*`` relative paths correctly.
160
+
161
+ Args:
162
+ slug: The Quartz slug to partially rebuild (vault-relative, no ``.md``).
163
+ vault_dir: Vault root directory (``--directory`` argument to Quartz).
164
+ build_dir: Quartz output directory for the active color
165
+ (``--output`` argument; ``.build-id`` is written here on success).
166
+ workspace_dir: **Quartz workspace root** (e.g. ``<vault>/.quartz``).
167
+ Must contain ``quartz/bootstrap-cli.mjs`` and ``package.json``.
168
+ The subprocess runs with ``cwd=workspace_dir`` so Quartz's relative
169
+ paths (``./package.json``, ``./quartz/build.ts``, etc.) resolve
170
+ correctly. Relative paths are resolved to absolute before use.
171
+ timeout_s: Hard wall-clock limit for the subprocess (default: 30 s).
172
+
173
+ Returns:
174
+ :class:`PartialBuildResult` on exit code 0.
175
+
176
+ Raises:
177
+ :class:`BrainWikiPartialBuildError`: On any failure. The ``kind``
178
+ attribute is a :class:`PartialBuildFailureKind` for routing;
179
+ the message includes the slug, exit code, and stderr tail.
180
+ """
181
+ # Resolve workspace_dir to an absolute path up-front so that relative
182
+ # inputs (e.g. Path(".quartz")) work correctly as subprocess cwd.
183
+ workspace = workspace_dir.expanduser().resolve()
184
+
185
+ # Step 1: Resolve node binary — hard-fail rather than falling back to npx.
186
+ node = shutil.which("node")
187
+ if node is None:
188
+ raise BrainWikiPartialBuildError(
189
+ "node binary not found on PATH; install Node.js"
190
+ " (Homebrew: `brew install node`;"
191
+ " Linux: nodejs.org or your distro's package manager)",
192
+ kind=PartialBuildFailureKind.MISSING_NODE,
193
+ slug=slug,
194
+ )
195
+
196
+ # Step 2: Verify bootstrap-cli.mjs exists at <workspace>/quartz/bootstrap-cli.mjs.
197
+ bootstrap = workspace / "quartz" / "bootstrap-cli.mjs"
198
+ if not bootstrap.is_file():
199
+ raise BrainWikiPartialBuildError(
200
+ f"Quartz bootstrap CLI not found at {bootstrap};"
201
+ " run `brain vault render --overlay` to reinstall the workspace overlay",
202
+ kind=PartialBuildFailureKind.MISSING_BOOTSTRAP,
203
+ slug=slug,
204
+ )
205
+
206
+ # Step 3: Build subprocess argv.
207
+ args: list[str] = [
208
+ node,
209
+ str(bootstrap),
210
+ "build-partial",
211
+ "--directory",
212
+ str(vault_dir),
213
+ "--output",
214
+ str(build_dir),
215
+ "--slug",
216
+ slug,
217
+ ]
218
+
219
+ # Step 4: Run subprocess — capture output, never raise on exit code.
220
+ # cwd = workspace ROOT so Quartz's ./package.json + ./quartz/* paths resolve.
221
+ try:
222
+ proc = subprocess.run( # noqa: S603 — list-form args, no shell injection
223
+ args,
224
+ cwd=str(workspace),
225
+ capture_output=True,
226
+ text=True,
227
+ timeout=timeout_s,
228
+ env=os.environ,
229
+ check=False,
230
+ )
231
+ except subprocess.TimeoutExpired as exc:
232
+ raise BrainWikiPartialBuildError(
233
+ f"build-partial slug={slug} timed out after {timeout_s}s",
234
+ kind=PartialBuildFailureKind.SUBPROCESS_TIMEOUT,
235
+ slug=slug,
236
+ ) from exc
237
+ except OSError as exc:
238
+ raise BrainWikiPartialBuildError(
239
+ f"build-partial slug={slug} failed to launch: {exc}",
240
+ kind=PartialBuildFailureKind.UNKNOWN_NONZERO,
241
+ slug=slug,
242
+ ) from exc
243
+
244
+ rc = proc.returncode
245
+
246
+ # Step 5 (success): parse elapsed_ms from stdout and return.
247
+ if rc == 0:
248
+ elapsed_ms = -1
249
+ m = _ELAPSED_RE.search(proc.stdout)
250
+ if m:
251
+ elapsed_ms = int(m.group(1))
252
+ else:
253
+ logger.warning(
254
+ "build-partial slug=%s: could not parse elapsed_ms from stdout: %r",
255
+ slug,
256
+ proc.stdout[:200],
257
+ )
258
+ return PartialBuildResult(
259
+ slug=slug,
260
+ elapsed_ms=elapsed_ms,
261
+ stdout=proc.stdout,
262
+ stderr=proc.stderr,
263
+ )
264
+
265
+ # Steps 6-7: Non-zero exit — map to failure kind and raise.
266
+ kind = _EXIT_CODE_TO_KIND.get(rc, PartialBuildFailureKind.UNKNOWN_NONZERO)
267
+ # Trim stderr to avoid bloating structured logs with multi-KB stack traces.
268
+ stderr_tail = proc.stderr[-_STDERR_TAIL_LIMIT:] if proc.stderr else ""
269
+ raise BrainWikiPartialBuildError(
270
+ f"build-partial slug={slug} exited {rc}: {stderr_tail}",
271
+ kind=kind,
272
+ slug=slug,
273
+ )