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,215 @@
1
+ """Edit classifier: classify a vault file edit as trivial or non-trivial.
2
+
3
+ A **trivial** edit is one whose canonical structural fingerprint did NOT
4
+ change — only body prose / whitespace / HTML comments / ignored frontmatter
5
+ fields were touched. A **non-trivial** edit requires a full Quartz build
6
+ (fingerprint changed, slug unknown, manifest missing / unreadable, source
7
+ path diverged from manifest record, etc.).
8
+
9
+ Used by the watcher (T6) to route edits through the fast path or the full
10
+ build path.
11
+
12
+ Public surface:
13
+ classify_edit(*, fastpath_dir, source_path, vault_root) → ClassificationResult
14
+
15
+ Slug helpers live in ``brain.wiki.slug``; import them from there.
16
+ """
17
+ from __future__ import annotations
18
+
19
+ from dataclasses import dataclass
20
+ from enum import Enum
21
+ from pathlib import Path
22
+
23
+ from brain.wiki.fastpath_manifest import (
24
+ ManifestError,
25
+ compute_fingerprint,
26
+ read_manifest,
27
+ )
28
+ from brain.wiki.slug import slugify_source_path
29
+
30
+ # ---------------------------------------------------------------------------
31
+ # Public types
32
+ # ---------------------------------------------------------------------------
33
+
34
+
35
+ class EditClassification(Enum):
36
+ """Result of classify_edit."""
37
+
38
+ TRIVIAL = "trivial"
39
+ NON_TRIVIAL = "non-trivial"
40
+
41
+
42
+ @dataclass(frozen=True, slots=True)
43
+ class ClassificationResult:
44
+ """Full result of a single-file edit classification."""
45
+
46
+ classification: EditClassification
47
+ reason: str # human-readable, for logs / telemetry
48
+ slug: str | None # None only if slug could not be determined
49
+ old_fingerprint: str | None # None if manifest missing / slug unknown
50
+ new_fingerprint: str | None # None if file unreadable / compute failed
51
+
52
+
53
+ # ---------------------------------------------------------------------------
54
+ # Public classifier
55
+ # ---------------------------------------------------------------------------
56
+
57
+
58
+ def classify_edit(
59
+ *,
60
+ fastpath_dir: Path,
61
+ source_path: Path,
62
+ vault_root: Path,
63
+ ) -> ClassificationResult:
64
+ """Classify a single vault file edit as trivial or non-trivial.
65
+
66
+ A trivial edit is one where the canonical structural fingerprint is
67
+ unchanged — only prose / whitespace / ignored frontmatter fields were
68
+ modified. A non-trivial edit (or any error condition) falls back to a
69
+ full Quartz build.
70
+
71
+ Algorithm:
72
+ 1. Validate ``source_path`` is inside ``vault_root`` (raises ValueError
73
+ on programmer error — T6 must pre-validate event paths).
74
+ 2. Compute slug from ``source_path`` relative to ``vault_root``.
75
+ 3. Check ``source_path`` still exists (delete/move → non-trivial).
76
+ 4. Read manifest from ``fastpath_dir``; catch ManifestError → non-trivial.
77
+ 5. Look up slug in manifest; missing → non-trivial.
78
+ 5a. **Rename guard:** compare current vault-relative path against
79
+ ``entry.source_path`` from the manifest. If they differ, the slug
80
+ collision is spurious (e.g. "a b.md" and "a-b.md" both slugify to
81
+ ``a-b``); force non-trivial so the full build reconciles the rename.
82
+ 6. Read file bytes; OS error → non-trivial.
83
+ 7. After step 5a verifies the current vault-relative path equals
84
+ ``entry.source_path``, compute the new fingerprint using the manifest's
85
+ recorded ``source_path`` / ``output_path`` (sanity guard against
86
+ drift between the manifest writer's and reader's path conventions).
87
+ 8. Compare fingerprints; equal → trivial, unequal → non-trivial.
88
+
89
+ Args:
90
+ fastpath_dir: Path to ``<vault>/.quartz/.cache/fastpath/``.
91
+ source_path: Absolute path to the edited file.
92
+ vault_root: Absolute path to the vault root directory.
93
+
94
+ Returns:
95
+ :class:`ClassificationResult` with all fields populated.
96
+
97
+ Raises:
98
+ ValueError: If ``source_path`` is not inside ``vault_root``.
99
+ """
100
+ # Step 1 — validate source_path inside vault_root (programmer-error guard).
101
+ try:
102
+ source_path.relative_to(vault_root)
103
+ except ValueError as exc:
104
+ raise ValueError(
105
+ f"source_path {source_path!r} is not inside vault_root {vault_root!r}"
106
+ ) from exc
107
+
108
+ # Step 2 — compute slug.
109
+ slug = slugify_source_path(source_path, vault_root)
110
+
111
+ # Step 3 — source file must exist (delete / move → non-trivial).
112
+ if not source_path.exists():
113
+ return ClassificationResult(
114
+ classification=EditClassification.NON_TRIVIAL,
115
+ reason="source file missing",
116
+ slug=slug,
117
+ old_fingerprint=None,
118
+ new_fingerprint=None,
119
+ )
120
+
121
+ # Step 4 — read manifest; any ManifestError → non-trivial.
122
+ try:
123
+ manifest = read_manifest(fastpath_dir)
124
+ except ManifestError as exc:
125
+ raw = str(exc)
126
+ # Produce a user-friendly reason while preserving the error detail.
127
+ if "cannot read manifest" in raw:
128
+ reason = "manifest missing — first build pending or cleared"
129
+ elif "malformed manifest JSON" in raw:
130
+ reason = f"manifest unreadable: {exc}"
131
+ elif "version" in raw:
132
+ reason = raw # already contains version numbers from read_manifest
133
+ else:
134
+ reason = f"manifest error: {exc}"
135
+ return ClassificationResult(
136
+ classification=EditClassification.NON_TRIVIAL,
137
+ reason=reason,
138
+ slug=slug,
139
+ old_fingerprint=None,
140
+ new_fingerprint=None,
141
+ )
142
+
143
+ # Step 5 — look up slug entry.
144
+ entry = manifest.slugs.get(slug)
145
+ if entry is None:
146
+ return ClassificationResult(
147
+ classification=EditClassification.NON_TRIVIAL,
148
+ reason=f"slug not in manifest: {slug}",
149
+ slug=slug,
150
+ old_fingerprint=None,
151
+ new_fingerprint=None,
152
+ )
153
+
154
+ # Step 5a — rename guard: verify current path matches manifest's record.
155
+ # Two distinct filenames can produce the same slug (e.g. "a b.md" and
156
+ # "a-b.md" both → "a-b"). If the paths differ the slug match is spurious;
157
+ # force full build so the rename is properly handled.
158
+ current_source_path = source_path.relative_to(vault_root).as_posix()
159
+ if entry.source_path != current_source_path:
160
+ return ClassificationResult(
161
+ classification=EditClassification.NON_TRIVIAL,
162
+ reason=(
163
+ f"source path changed: manifest={entry.source_path!r} "
164
+ f"current={current_source_path!r}"
165
+ ),
166
+ slug=slug,
167
+ old_fingerprint=entry.fingerprint,
168
+ new_fingerprint=None,
169
+ )
170
+
171
+ # Step 6 — read current file bytes.
172
+ try:
173
+ source_bytes = source_path.read_bytes()
174
+ except OSError as exc:
175
+ return ClassificationResult(
176
+ classification=EditClassification.NON_TRIVIAL,
177
+ reason=f"source unreadable: {exc}",
178
+ slug=slug,
179
+ old_fingerprint=entry.fingerprint,
180
+ new_fingerprint=None,
181
+ )
182
+
183
+ # Step 7 — compute new fingerprint using manifest's recorded paths.
184
+ try:
185
+ new_fp = compute_fingerprint(
186
+ source_bytes=source_bytes,
187
+ slug=slug,
188
+ source_path=entry.source_path,
189
+ output_path=entry.output_path,
190
+ )
191
+ except ManifestError as exc:
192
+ return ClassificationResult(
193
+ classification=EditClassification.NON_TRIVIAL,
194
+ reason=f"fingerprint computation failed: {exc}",
195
+ slug=slug,
196
+ old_fingerprint=entry.fingerprint,
197
+ new_fingerprint=None,
198
+ )
199
+
200
+ # Step 8 — compare fingerprints.
201
+ if new_fp == entry.fingerprint:
202
+ return ClassificationResult(
203
+ classification=EditClassification.TRIVIAL,
204
+ reason="fingerprint unchanged",
205
+ slug=slug,
206
+ old_fingerprint=entry.fingerprint,
207
+ new_fingerprint=new_fp,
208
+ )
209
+ return ClassificationResult(
210
+ classification=EditClassification.NON_TRIVIAL,
211
+ reason="fingerprint changed",
212
+ slug=slug,
213
+ old_fingerprint=entry.fingerprint,
214
+ new_fingerprint=new_fp,
215
+ )
brain/wiki/errors.py ADDED
@@ -0,0 +1,10 @@
1
+ """Wiki-pipeline exceptions."""
2
+ from brain.errors import BrainError
3
+
4
+
5
+ class BrainWikiError(BrainError):
6
+ """Base class for wiki-pipeline failures (build, swap, watch)."""
7
+
8
+
9
+ class BrainWikiBuildError(BrainWikiError):
10
+ """The Quartz build subprocess exited non-zero, timed out, or the workspace is broken."""
@@ -0,0 +1,475 @@
1
+ """Fast-path manifest: per-slug structural fingerprint + manifest I/O.
2
+
3
+ Python counterpart to ``brain/quartz_overrides/quartz/util/fastpath_manifest.ts``.
4
+ Both implementations MUST produce byte-identical canonical blobs for the same
5
+ input — verified by ``tests/wiki/test_fastpath_fingerprint_parity.py``.
6
+
7
+ Canonical-blob format: ``docs/specs/2026-05-09-fastpath-fingerprint.md``.
8
+
9
+ Design constraints:
10
+ - Pure Python; no Node/subprocess.
11
+ - Reads manifest.json written by the TS full-build hook.
12
+ - Computes fingerprint at classifier time (T3) for the current file on disk.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import hashlib
17
+ import json
18
+ import re
19
+ import struct
20
+ from dataclasses import dataclass
21
+ from datetime import date, datetime
22
+ from pathlib import Path
23
+ from typing import Any
24
+
25
+ import yaml
26
+
27
+ from brain.errors import BrainError
28
+ from brain.vault.frontmatter import parse_frontmatter
29
+
30
+ from ._github_slugger import Slugger
31
+
32
+ FINGERPRINT_VERSION: int = 1
33
+
34
+ # ---------------------------------------------------------------------------
35
+ # Structural frontmatter fields — canonical key order (spec §Frontmatter blob).
36
+ # Changes to this list must bump FINGERPRINT_VERSION.
37
+ # ---------------------------------------------------------------------------
38
+
39
+ _STRUCTURAL_FIELD_ORDER: tuple[str, ...] = (
40
+ "title", "draft", "publish", "tags", "aliases", "permalink", "slug",
41
+ "lang", "cssclasses", "socialImage", "enableToc", "comments", "kind",
42
+ "description", "socialDescription", "date", "created", "modified",
43
+ "updated", "published",
44
+ )
45
+
46
+ _STRUCTURAL_FIELDS: frozenset[str] = frozenset(_STRUCTURAL_FIELD_ORDER)
47
+
48
+ # Array fields: JSON value is a sorted string array (or null if absent).
49
+ _ARRAY_FIELDS: frozenset[str] = frozenset(("tags", "aliases", "cssclasses"))
50
+
51
+ # Boolean fields: JSON value is bool (or null if absent).
52
+ _BOOL_FIELDS: frozenset[str] = frozenset(("draft", "publish", "enableToc", "comments"))
53
+
54
+ # Date fields: JSON value is iso8601 string (or null if absent).
55
+ _DATE_FIELDS: frozenset[str] = frozenset(
56
+ ("date", "created", "modified", "updated", "published")
57
+ )
58
+
59
+ # ---------------------------------------------------------------------------
60
+ # Ignored frontmatter fields (Appendix A) — changes do NOT affect rendering.
61
+ # Changes to this list must bump FINGERPRINT_VERSION.
62
+ # ---------------------------------------------------------------------------
63
+
64
+ _IGNORED_FIELDS: frozenset[str] = frozenset({
65
+ # Identity / dedup metadata
66
+ "id", "external_id", "content_type", "source", "source_path",
67
+ "source_template_id", "source_template_name", "vault_path", "canonical", "hash",
68
+ # Ingest provenance
69
+ "ai_assisted", "autogenerated", "last_reviewed", "collected_by", "owner",
70
+ "organization_id", "organization_name",
71
+ # Brain workflow tracking
72
+ "autopilot_deferred_actions", "autopilot_digest", "autopilot_inventory",
73
+ "autopilot_subscription_renewal", "autopilot_sweep_compliance",
74
+ "autopilot_sweep_equipment", "autopilot_sweep_escalation",
75
+ "autopilot_sweep_payments", "autopilot_sweep_payroll",
76
+ "autopilot_sweep_proposals", "autopilot_sweep_routes",
77
+ "autopilot_sweep_seasonal", "autopilot_sweep_timesheets",
78
+ "autopilot_sweep_workload",
79
+ "billcom", "billcom_auto_sync", "check_grace_period", "hits",
80
+ "hours_saved_by_workflow", "hours_saved_today", "hours_saved_totals",
81
+ "komply_iq_sweep_deficiency", "komply_iq_sweep_invoice_delivery",
82
+ "komply_iq_sweep_invoice_generation", "komply_iq_sweep_payment_reminders",
83
+ "komply_iq_sweep_proposal_conversion", "komply_iq_sweep_quality_assurance",
84
+ "komply_iq_sweep_regulatory_filing",
85
+ "mandate", "proposal_expiration", "proposal_follow_up_reminders",
86
+ "quota", "report_usage", "reset_ai_quotas", "sidebar_position", "status",
87
+ "trial_expiration_check",
88
+ # Krisp/Slack/Gmail metadata
89
+ "participants", "duration_min", "thread_id", "channel", "attendees",
90
+ "to", "from", "subject", "schedule",
91
+ # Free-form metadata not rendered
92
+ "context", "keywords", "notes", "predecessor", "principle", "purview",
93
+ "render", "seo", "sweep", "where", "with", "wrote",
94
+ })
95
+
96
+ # ---------------------------------------------------------------------------
97
+ # Regex patterns for body parsing
98
+ # ---------------------------------------------------------------------------
99
+
100
+ # Non-transclusion wikilinks: [[target]] or [[target|alias]].
101
+ # Negative lookbehind for ! excludes transclusions.
102
+ # Group 1 = raw target text (including #anchor if present).
103
+ _WIKILINK_RE: re.Pattern[str] = re.compile(r"(?<!!)\[\[([^\[\]|]+?)(?:\|[^\[\]]*)?\]\]")
104
+
105
+ # Transclusions: ![[target]] including anchors (#Heading, #^block).
106
+ # Group 1 = raw target text.
107
+ _TRANSCLUSION_RE: re.Pattern[str] = re.compile(r"!\[\[([^\[\]]+?)\]\]")
108
+
109
+ # Block-ref IDs DEFINED in body: ^blockid at end of line.
110
+ # Negative lookbehind for [ and # to avoid matching inside wikilinks.
111
+ _BLOCKREF_DEF_RE: re.Pattern[str] = re.compile(
112
+ r"(?<![\[#])\^([A-Za-z0-9][A-Za-z0-9-]*)(?=\s*$)", re.MULTILINE
113
+ )
114
+
115
+ # ATX headings: # text (with optional trailing ##).
116
+ _HEADING_RE: re.Pattern[str] = re.compile(
117
+ r"^#{1,6}[ \t]+(.+?)(?:[ \t]+#+)?$", re.MULTILINE
118
+ )
119
+
120
+ # Inline body tags: #tagname preceded by whitespace or start of line.
121
+ # Excludes headings (which also start with #) because headings have space AFTER #.
122
+ # This matches Obsidian-style inline tags.
123
+ _INLINE_TAG_RE: re.Pattern[str] = re.compile(
124
+ r"(?:^|(?<=\s))#([A-Za-zÀ-ɏͰ-Ͽ一-龥_]"
125
+ r"[^\s#,;!@$%^&*()\[\]{}'\"<>?/\\|`]*)",
126
+ re.MULTILINE,
127
+ )
128
+
129
+
130
+ # ---------------------------------------------------------------------------
131
+ # Public exceptions + types
132
+ # ---------------------------------------------------------------------------
133
+
134
+
135
+ class ManifestError(BrainError):
136
+ """Raised on manifest read/parse failures or version mismatches."""
137
+
138
+
139
+ @dataclass(frozen=True)
140
+ class SlugEntry:
141
+ """Per-slug record stored in the manifest."""
142
+
143
+ fingerprint: str
144
+ output_path: str
145
+ source_path: str
146
+
147
+
148
+ @dataclass(frozen=True)
149
+ class Manifest:
150
+ """Deserialised fastpath ``manifest.json``."""
151
+
152
+ version: int
153
+ parent_build_id: str
154
+ built_at_ms: int
155
+ slugs: dict[str, SlugEntry]
156
+
157
+
158
+ # ---------------------------------------------------------------------------
159
+ # Public API
160
+ # ---------------------------------------------------------------------------
161
+
162
+
163
+ def read_manifest(dir: Path) -> Manifest:
164
+ """Read + parse ``manifest.json`` from ``dir``.
165
+
166
+ Raises :class:`ManifestError` on missing file, JSON parse error, or
167
+ ``version`` mismatch.
168
+ """
169
+ path = dir / "manifest.json"
170
+ try:
171
+ raw = path.read_text(encoding="utf-8")
172
+ except OSError as exc:
173
+ raise ManifestError(f"cannot read manifest at {path}: {exc}") from exc
174
+ try:
175
+ data: Any = json.loads(raw)
176
+ except json.JSONDecodeError as exc:
177
+ raise ManifestError(f"malformed manifest JSON at {path}: {exc}") from exc
178
+ if not isinstance(data, dict):
179
+ raise ManifestError(
180
+ f"manifest root must be a JSON object, got {type(data).__name__}"
181
+ )
182
+ version = data.get("version")
183
+ if version != FINGERPRINT_VERSION:
184
+ raise ManifestError(
185
+ f"manifest version {version!r} != current FINGERPRINT_VERSION "
186
+ f"{FINGERPRINT_VERSION}"
187
+ )
188
+ try:
189
+ slugs: dict[str, SlugEntry] = {
190
+ k: SlugEntry(
191
+ fingerprint=str(v["fingerprint"]),
192
+ output_path=str(v["output_path"]),
193
+ source_path=str(v["source_path"]),
194
+ )
195
+ for k, v in data.get("slugs", {}).items()
196
+ }
197
+ except (KeyError, TypeError) as exc:
198
+ raise ManifestError(f"malformed manifest slugs at {path}: {exc}") from exc
199
+ return Manifest(
200
+ version=int(data["version"]),
201
+ parent_build_id=str(data.get("parent_build_id", "")),
202
+ built_at_ms=int(data.get("built_at_ms", 0)),
203
+ slugs=slugs,
204
+ )
205
+
206
+
207
+ def compute_fingerprint(
208
+ *,
209
+ source_bytes: bytes,
210
+ slug: str,
211
+ source_path: str,
212
+ output_path: str,
213
+ ) -> str:
214
+ """Compute the structural fingerprint for a vault file.
215
+
216
+ Decodes ``source_bytes`` (UTF-8), parses YAML frontmatter, validates all
217
+ keys against the structural and ignored lists, extracts wikilinks /
218
+ transclusions / block-refs / heading anchors from the body, builds the
219
+ canonical blob per the fingerprint spec, and returns sha256 hex.
220
+
221
+ ``slug`` and ``output_path`` are caller-supplied because the classifier
222
+ (T3) reads them directly from the manifest entry it is validating — the
223
+ manifest already stores both values from the full build, so the classifier
224
+ never needs to re-implement Quartz's ``slugifyFilePath()`` or
225
+ output-path derivation.
226
+
227
+ Raises :class:`ManifestError` on:
228
+ - ``source_bytes`` not valid UTF-8
229
+ - YAML frontmatter parse error
230
+ - Unknown frontmatter field (not in structural or Appendix A ignored list)
231
+ - Any other unrecoverable input issue
232
+ """
233
+ blob = _compute_canonical_blob(
234
+ source_bytes=source_bytes,
235
+ slug=slug,
236
+ source_path=source_path,
237
+ output_path=output_path,
238
+ )
239
+ return hashlib.sha256(blob).hexdigest()
240
+
241
+
242
+ def compute_fingerprint_with_blob(
243
+ *,
244
+ source_bytes: bytes,
245
+ slug: str,
246
+ source_path: str,
247
+ output_path: str,
248
+ ) -> tuple[str, bytes]:
249
+ """Like :func:`compute_fingerprint` but also returns the raw canonical blob.
250
+
251
+ Intended for debugging parity failures — the blob hex lets maintainers
252
+ diff TS and Python byte-by-byte to identify which section diverges.
253
+ """
254
+ blob = _compute_canonical_blob(
255
+ source_bytes=source_bytes,
256
+ slug=slug,
257
+ source_path=source_path,
258
+ output_path=output_path,
259
+ )
260
+ return hashlib.sha256(blob).hexdigest(), blob
261
+
262
+
263
+ # ---------------------------------------------------------------------------
264
+ # Private helpers — shared computation core
265
+ # ---------------------------------------------------------------------------
266
+
267
+
268
+ def _compute_canonical_blob(
269
+ *,
270
+ source_bytes: bytes,
271
+ slug: str,
272
+ source_path: str,
273
+ output_path: str,
274
+ ) -> bytes:
275
+ """Parse ``source_bytes``, validate frontmatter, build and return the blob.
276
+
277
+ Shared by :func:`compute_fingerprint` and :func:`compute_fingerprint_with_blob`.
278
+ """
279
+ try:
280
+ source = source_bytes.decode("utf-8")
281
+ except UnicodeDecodeError as exc:
282
+ raise ManifestError(f"source bytes are not valid UTF-8: {exc}") from exc
283
+
284
+ try:
285
+ fields, body = parse_frontmatter(source)
286
+ except (yaml.YAMLError, ValueError) as exc:
287
+ raise ManifestError(f"YAML frontmatter parse error: {exc}") from exc
288
+
289
+ # Validate all frontmatter keys — unknown keys force non-trivial.
290
+ for key in fields:
291
+ if key not in _STRUCTURAL_FIELDS and key not in _IGNORED_FIELDS:
292
+ raise ManifestError(
293
+ f"unknown frontmatter field {key!r} — not in structural list or "
294
+ "Appendix A ignored list; add to spec and bump FINGERPRINT_VERSION"
295
+ )
296
+
297
+ # SECTION_FRONTMATTER: structural fields + YAML-only tags.
298
+ fm_json = _build_frontmatter_json(fields)
299
+
300
+ # SECTION_TAGS: merged YAML tags + inline body tags, sorted, deduped.
301
+ yaml_tags = _get_str_list(fields.get("tags"))
302
+ inline_tags = _extract_inline_tags(body)
303
+ merged_tags = sorted(set(yaml_tags) | set(inline_tags))
304
+ tags_str = "\n".join(merged_tags)
305
+
306
+ # SECTION_WIKILINKS
307
+ wikilinks_str = "\n".join(sorted(set(_extract_wikilinks(body))))
308
+
309
+ # SECTION_TRANSCLUSIONS
310
+ transclusions_str = "\n".join(sorted(set(_extract_transclusions(body))))
311
+
312
+ # SECTION_BLOCK_REFS
313
+ block_refs_str = "\n".join(sorted(set(_extract_block_refs(body))))
314
+
315
+ # SECTION_HEADING_ANCHORS (document order — NOT sorted)
316
+ headings_str = "\n".join(_extract_heading_anchors(body))
317
+
318
+ return _build_canonical_blob(
319
+ slug=slug,
320
+ source_path=source_path,
321
+ output_path=output_path,
322
+ frontmatter_json=fm_json,
323
+ tags_str=tags_str,
324
+ wikilinks_str=wikilinks_str,
325
+ transclusions_str=transclusions_str,
326
+ block_refs_str=block_refs_str,
327
+ headings_str=headings_str,
328
+ )
329
+
330
+
331
+ # ---------------------------------------------------------------------------
332
+ # Private helpers — canonical blob encoding
333
+ # ---------------------------------------------------------------------------
334
+
335
+
336
+ def _u32be(n: int) -> bytes:
337
+ return struct.pack(">I", n)
338
+
339
+
340
+ def _encode_section(s: str) -> bytes:
341
+ encoded = s.encode("utf-8")
342
+ return _u32be(len(encoded)) + encoded
343
+
344
+
345
+ def _build_canonical_blob(
346
+ *,
347
+ slug: str,
348
+ source_path: str,
349
+ output_path: str,
350
+ frontmatter_json: str,
351
+ tags_str: str,
352
+ wikilinks_str: str,
353
+ transclusions_str: str,
354
+ block_refs_str: str,
355
+ headings_str: str,
356
+ ) -> bytes:
357
+ """Assemble the 10-section canonical blob per the fingerprint spec."""
358
+ return b"".join([
359
+ _u32be(FINGERPRINT_VERSION), # SECTION_VERSION: just u32be, no length prefix
360
+ _encode_section(slug), # SECTION_SLUG
361
+ _encode_section(source_path), # SECTION_SOURCE_PATH
362
+ _encode_section(output_path), # SECTION_OUTPUT_PATH
363
+ _encode_section(frontmatter_json), # SECTION_FRONTMATTER
364
+ _encode_section(tags_str), # SECTION_TAGS
365
+ _encode_section(wikilinks_str), # SECTION_WIKILINKS
366
+ _encode_section(transclusions_str), # SECTION_TRANSCLUSIONS
367
+ _encode_section(block_refs_str), # SECTION_BLOCK_REFS
368
+ _encode_section(headings_str), # SECTION_HEADING_ANCHORS
369
+ ])
370
+
371
+
372
+ # ---------------------------------------------------------------------------
373
+ # Private helpers — frontmatter JSON blob
374
+ # ---------------------------------------------------------------------------
375
+
376
+
377
+ def _normalize_date_val(val: Any) -> str | None:
378
+ """Serialize a YAML date/datetime/string to ISO 8601, or None.
379
+
380
+ Midnight ``datetime.datetime`` objects are truncated to date-only to match
381
+ TS ``_normalizeDateVal`` behaviour: js-yaml parses bare YAML date scalars
382
+ (e.g. ``date: 2024-03-15``) as UTC-midnight ``Date`` objects, which the TS
383
+ side normalises back to a ``"YYYY-MM-DD"`` string. Python pyyaml produces
384
+ ``datetime.date`` for bare dates (already date-only) and
385
+ ``datetime.datetime`` for bare date-times; truncating the midnight case
386
+ keeps both sides byte-identical.
387
+
388
+ Examples::
389
+
390
+ datetime.date(2024, 3, 15) → "2024-03-15"
391
+ datetime.datetime(2024, 3, 15, 0, 0, 0) → "2024-03-15" # midnight truncated
392
+ datetime.datetime(2024, 3, 15, 12, 0, 0)→ "2024-03-15T12:00:00"
393
+ """
394
+ if val is None:
395
+ return None
396
+ if isinstance(val, datetime):
397
+ # Truncate midnight datetimes to date-only, matching TS _normalizeDateVal.
398
+ if (
399
+ val.hour == 0
400
+ and val.minute == 0
401
+ and val.second == 0
402
+ and val.microsecond == 0
403
+ ):
404
+ return val.date().isoformat()
405
+ return val.isoformat()
406
+ if isinstance(val, date):
407
+ return val.isoformat()
408
+ if isinstance(val, str):
409
+ return val
410
+ return str(val)
411
+
412
+
413
+ def _get_str_list(val: Any) -> list[str]:
414
+ """Coerce a YAML value to a list of strings."""
415
+ if val is None:
416
+ return []
417
+ if isinstance(val, list):
418
+ return [str(x) for x in val if x is not None]
419
+ if isinstance(val, str):
420
+ return [val]
421
+ return []
422
+
423
+
424
+ def _build_frontmatter_json(fields: dict[str, Any]) -> str:
425
+ """Build deterministic structural frontmatter JSON (no whitespace, exact key order).
426
+
427
+ Encodes YAML-only tags (not merged with inline body tags) per the spec.
428
+ """
429
+ obj: dict[str, Any] = {}
430
+ for key in _STRUCTURAL_FIELD_ORDER:
431
+ if key in _ARRAY_FIELDS:
432
+ if key not in fields or fields[key] is None:
433
+ obj[key] = None
434
+ else:
435
+ obj[key] = sorted(_get_str_list(fields[key]))
436
+ elif key in _BOOL_FIELDS:
437
+ val = fields.get(key)
438
+ obj[key] = bool(val) if val is not None else None
439
+ elif key in _DATE_FIELDS:
440
+ obj[key] = _normalize_date_val(fields.get(key))
441
+ else:
442
+ val = fields.get(key)
443
+ obj[key] = str(val) if val is not None else None
444
+ return json.dumps(obj, separators=(",", ":"), ensure_ascii=False)
445
+
446
+
447
+ # ---------------------------------------------------------------------------
448
+ # Private helpers — body parsing
449
+ # ---------------------------------------------------------------------------
450
+
451
+
452
+ def _extract_inline_tags(body: str) -> list[str]:
453
+ """Extract inline ``#tags`` from body text (Obsidian-style)."""
454
+ return [m.group(1) for m in _INLINE_TAG_RE.finditer(body)]
455
+
456
+
457
+ def _extract_wikilinks(body: str) -> list[str]:
458
+ """Extract non-transclusion wikilink targets (raw text, including anchors)."""
459
+ return [m.group(1).strip() for m in _WIKILINK_RE.finditer(body) if m.group(1).strip()]
460
+
461
+
462
+ def _extract_transclusions(body: str) -> list[str]:
463
+ """Extract transclusion targets (``![[...]]``, including anchors)."""
464
+ return [m.group(1).strip() for m in _TRANSCLUSION_RE.finditer(body) if m.group(1).strip()]
465
+
466
+
467
+ def _extract_block_refs(body: str) -> list[str]:
468
+ """Extract block-ref IDs defined in body (``^blockid`` at end of line)."""
469
+ return [m.group(1) for m in _BLOCKREF_DEF_RE.finditer(body)]
470
+
471
+
472
+ def _extract_heading_anchors(body: str) -> list[str]:
473
+ """Return heading anchors in document order using github-slugger semantics."""
474
+ slugger = Slugger()
475
+ return [slugger.slug(m.group(1).strip()) for m in _HEADING_RE.finditer(body)]