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,264 @@
1
+ """Backfill `summary:` frontmatter into existing vault mirror files.
2
+
3
+ Wave Q2-SUMMARY-WIKI Item 3.
4
+
5
+ Q1-D ships ``documents.summary`` and exposes it via ``brain show`` /
6
+ MCP ``brain_show``. The vault mirror writer
7
+ (``brain.vault.export._build_frontmatter``) was extended in the same
8
+ wave to emit ``summary:`` into per-doc frontmatter, but every doc
9
+ ingested BEFORE that change has stale on-disk frontmatter — the DB
10
+ carries a summary, but the ``.md`` file under ``<vault>/_ingested/``
11
+ (or the user-authored vault tier) doesn't yet. This one-shot module
12
+ reconciles the on-disk frontmatter for those rows so the Quartz
13
+ ``SummaryLede`` component has something to render after the next
14
+ ``brain vault render`` pass.
15
+
16
+ Idempotent: rerunning on a synced vault is a fast NO-OP (every row
17
+ returns ``unchanged``). Non-destructive: only the ``summary:`` key is
18
+ mutated — every other frontmatter key (and the file body) round-trips
19
+ verbatim through :mod:`brain.vault.frontmatter`. Atomic per file via
20
+ :func:`brain.vault._atomic.atomic_write_text` (sibling tempfile +
21
+ ``os.replace``).
22
+
23
+ Discovery is the same shape as ``brain enrich --backfill``: keyset
24
+ pagination over ``documents.id``, batches yielded to keep the in-memory
25
+ footprint bounded. The driver loops over batches until exhaustion.
26
+ """
27
+ from __future__ import annotations
28
+
29
+ import logging
30
+ from collections.abc import Iterator
31
+ from dataclasses import dataclass, field
32
+ from pathlib import Path
33
+
34
+ import psycopg
35
+ import yaml
36
+
37
+ from ._atomic import atomic_write_text
38
+ from .frontmatter import dump_frontmatter, parse_frontmatter
39
+
40
+ _logger = logging.getLogger(__name__)
41
+
42
+ _BATCH_SIZE = 100
43
+
44
+
45
+ @dataclass
46
+ class SyncSummariesReport:
47
+ """Outcome counters for one :func:`sync_summaries` run.
48
+
49
+ ``inspected`` counts every row pulled from the DB (the universe of
50
+ ``summary IS NOT NULL AND vault_path IS NOT NULL`` docs);
51
+ ``updated`` / ``unchanged`` / ``missing_file`` / ``errored`` are the
52
+ four mutually-exclusive outcomes per row. ``errored`` carries one
53
+ string per failure so the CLI can surface them at the end of the
54
+ run without aborting the loop mid-corpus.
55
+ """
56
+
57
+ inspected: int = 0
58
+ updated: int = 0
59
+ unchanged: int = 0
60
+ missing_file: int = 0
61
+ errored: int = 0
62
+ errors: list[str] = field(default_factory=list)
63
+
64
+
65
+ @dataclass
66
+ class _SummaryRow:
67
+ """Internal projection of one document for the sync loop."""
68
+
69
+ id: str
70
+ summary: str
71
+ vault_path: str
72
+
73
+
74
+ def _iter_rows(
75
+ conn: psycopg.Connection,
76
+ *,
77
+ limit: int | None,
78
+ batch_size: int = _BATCH_SIZE,
79
+ ) -> Iterator[_SummaryRow]:
80
+ """Yield documents that have a summary AND an on-disk vault path.
81
+
82
+ Keyset pagination over ``documents.id`` (same shape as
83
+ :func:`brain.queries.iter_unenriched_documents`) keeps memory
84
+ bounded on a large corpus. ``limit`` caps the total emitted across
85
+ all batches — when set, the last batch is shrunk so we stop on the
86
+ exact requested row count.
87
+ """
88
+ last_id: str | None = None
89
+ emitted = 0
90
+ while True:
91
+ if limit is not None and emitted >= limit:
92
+ return
93
+ remaining = batch_size if limit is None else min(batch_size, limit - emitted)
94
+ if last_id is None:
95
+ rows = conn.execute(
96
+ "SELECT id::text, summary, vault_path FROM documents "
97
+ "WHERE summary IS NOT NULL AND vault_path IS NOT NULL "
98
+ "ORDER BY id LIMIT %s",
99
+ (remaining,),
100
+ ).fetchall()
101
+ else:
102
+ rows = conn.execute(
103
+ "SELECT id::text, summary, vault_path FROM documents "
104
+ "WHERE summary IS NOT NULL AND vault_path IS NOT NULL "
105
+ "AND id > %s::uuid "
106
+ "ORDER BY id LIMIT %s",
107
+ (last_id, remaining),
108
+ ).fetchall()
109
+ if not rows:
110
+ return
111
+ last_id = str(rows[-1][0])
112
+ for r in rows:
113
+ yield _SummaryRow(
114
+ id=str(r[0]),
115
+ summary=str(r[1]),
116
+ vault_path=str(r[2]),
117
+ )
118
+ emitted += 1
119
+
120
+
121
+ def _rewrite_with_summary(target: Path, summary: str) -> bool:
122
+ """Insert/refresh ``summary:`` on ``target``'s frontmatter atomically.
123
+
124
+ Returns ``True`` when the file was rewritten, ``False`` when the
125
+ existing frontmatter already matches (idempotent skip).
126
+
127
+ Field order preserved: ``summary`` is inserted immediately after
128
+ ``content_type`` when that key exists (mirrors the order
129
+ :func:`brain.vault.export._build_frontmatter` uses for fresh
130
+ writes), otherwise appended to the end. Existing user-authored keys
131
+ are not reordered.
132
+
133
+ Raises :class:`OSError` if reading or writing fails. Raises
134
+ :class:`yaml.YAMLError` or :class:`ValueError` if the existing
135
+ frontmatter is malformed — the caller treats both as "errored" so
136
+ a corrupt file doesn't kill the whole backfill loop.
137
+ """
138
+ text = target.read_text(encoding="utf-8")
139
+ fields, body = parse_frontmatter(text)
140
+
141
+ existing = fields.get("summary")
142
+ if isinstance(existing, str) and existing == summary:
143
+ return False
144
+
145
+ # Build a fresh ordered dict so the inserted key sits in the
146
+ # canonical slot (after ``content_type`` when present). Re-creating
147
+ # the dict is necessary because Python preserves insertion order —
148
+ # mutating in place would put ``summary`` at the END of the file,
149
+ # which works but reads oddly when compared against a freshly-
150
+ # exported mirror.
151
+ new_fields: dict[str, object] = {}
152
+ inserted = False
153
+ for key, value in fields.items():
154
+ if key == "summary":
155
+ # Drop the stale entry; we'll insert a fresh one below at
156
+ # the canonical position (or here if content_type already
157
+ # passed). If we already inserted, just skip — guards
158
+ # against a duplicate ``summary:`` line ending up in the
159
+ # output.
160
+ if not inserted:
161
+ new_fields["summary"] = summary
162
+ inserted = True
163
+ continue
164
+ new_fields[key] = value
165
+ if key == "content_type" and not inserted:
166
+ new_fields["summary"] = summary
167
+ inserted = True
168
+ if not inserted:
169
+ new_fields["summary"] = summary
170
+
171
+ atomic_write_text(target, dump_frontmatter(new_fields, body))
172
+ return True
173
+
174
+
175
+ def sync_summaries(
176
+ conn: psycopg.Connection,
177
+ *,
178
+ vault_root: Path,
179
+ dry_run: bool = False,
180
+ limit: int | None = None,
181
+ ) -> SyncSummariesReport:
182
+ """Reconcile ``summary:`` frontmatter for every enriched doc on disk.
183
+
184
+ Drives the backfill loop: iterate every ``documents`` row that has
185
+ both a ``summary`` and a ``vault_path``, read the corresponding
186
+ file under ``vault_root``, parse its frontmatter, and either
187
+ rewrite (with :func:`_rewrite_with_summary`) or skip when the
188
+ on-disk ``summary:`` already matches.
189
+
190
+ ``dry_run=True`` performs every read + comparison but writes
191
+ nothing — the report reflects what WOULD have happened. Useful for
192
+ sanity-checking a backfill before it touches the disk on a large
193
+ corpus.
194
+
195
+ ``limit`` caps the total number of rows inspected. The CLI exposes
196
+ this for testing (``--limit 5``) and for incremental drains in case
197
+ a backfill needs to be paced.
198
+
199
+ The report's ``missing_file`` counter is bumped when a DB row
200
+ references a ``vault_path`` whose ``.md`` file no longer exists on
201
+ disk (mirror was rm'd manually, vault wiped, etc.). ``errored``
202
+ captures parsing / OS errors per row with a one-line message —
203
+ the loop continues so a single bad file doesn't halt the run.
204
+ """
205
+ report = SyncSummariesReport()
206
+
207
+ for row in _iter_rows(conn, limit=limit):
208
+ report.inspected += 1
209
+ target = vault_root / row.vault_path
210
+ if not target.is_file():
211
+ report.missing_file += 1
212
+ _logger.warning(
213
+ "sync-summaries: vault_path missing for %s at %s; "
214
+ "run `brain vault export --force` to recreate",
215
+ row.id,
216
+ target,
217
+ )
218
+ continue
219
+
220
+ try:
221
+ # ``parse_frontmatter`` returns ``({}, text)`` on a file
222
+ # with no frontmatter fences — we re-emit with a fresh
223
+ # ``summary:`` block in that case, which adds the fences
224
+ # the file is missing. That's the right thing: the file
225
+ # has a corresponding DB row, so it SHOULD carry the
226
+ # canonical frontmatter shape.
227
+ existing_text = target.read_text(encoding="utf-8")
228
+ fields, _body = parse_frontmatter(existing_text)
229
+ except (OSError, yaml.YAMLError, ValueError) as exc:
230
+ report.errored += 1
231
+ msg = f"{row.vault_path}: parse failed ({exc})"
232
+ report.errors.append(msg)
233
+ _logger.warning("sync-summaries: %s", msg)
234
+ continue
235
+
236
+ on_disk_summary = fields.get("summary")
237
+ if isinstance(on_disk_summary, str) and on_disk_summary == row.summary:
238
+ report.unchanged += 1
239
+ continue
240
+
241
+ if dry_run:
242
+ report.updated += 1
243
+ continue
244
+
245
+ try:
246
+ wrote = _rewrite_with_summary(target, row.summary)
247
+ except (OSError, yaml.YAMLError, ValueError) as exc:
248
+ report.errored += 1
249
+ msg = f"{row.vault_path}: rewrite failed ({exc})"
250
+ report.errors.append(msg)
251
+ _logger.warning("sync-summaries: %s", msg)
252
+ continue
253
+
254
+ # ``wrote`` is False only when ``_rewrite_with_summary``'s own
255
+ # idempotency check (re-reading the frontmatter inside the
256
+ # helper) matched — defensive belt-and-suspenders against a
257
+ # race where another writer touched the file between the
258
+ # outer read and the rewrite call.
259
+ if wrote:
260
+ report.updated += 1
261
+ else:
262
+ report.unchanged += 1
263
+
264
+ return report
@@ -0,0 +1,145 @@
1
+ """Embedded template strings + the Phase 3 renderer.
2
+
3
+ The strings below are written by ``brain vault init`` into ``_templates/`` on
4
+ first run; the user owns them after that and we never overwrite them.
5
+
6
+ :func:`render_template` is the Phase 3 renderer used by ``brain note new`` and
7
+ ``brain daily``. It supports a tiny grammar — ``{{name}}`` placeholders only
8
+ — so user templates stay readable in any text editor and don't accidentally
9
+ acquire surface area we'd have to maintain (no conditionals, no loops, no
10
+ filters). Unknown placeholders pass through unchanged.
11
+
12
+ :func:`list_template_names` enumerates the templates in a vault's
13
+ ``_templates/`` directory so the CLI can validate ``--template T``.
14
+ """
15
+ import re
16
+ from pathlib import Path
17
+
18
+ DAILY_TEMPLATE = """\
19
+ ---
20
+ title: "{{date}}"
21
+ tags: [daily]
22
+ ---
23
+
24
+ # {{date}}
25
+
26
+ ## Notes
27
+
28
+ ## Tasks
29
+
30
+ ## Reflection
31
+ """
32
+
33
+ NOTE_TEMPLATE = """\
34
+ ---
35
+ title: "{{title}}"
36
+ tags: []
37
+ ---
38
+
39
+ # {{title}}
40
+ """
41
+
42
+ INGESTED_README = """\
43
+ # Ingested artifacts
44
+
45
+ Files in this folder are mirrors of documents in the brain DB whose source of
46
+ truth lives elsewhere (Krisp, Slack, Gmail, raw files). They are rewritten by
47
+ `brain vault sync` whenever their upstream source is re-ingested.
48
+
49
+ **Do not edit these files** — your edits will be overwritten on the next
50
+ re-ingest. To capture thoughts about an ingested artifact, create a vault-tier
51
+ note (anywhere outside `_ingested/`) and link to it with `[[brain:<id-prefix>]]`.
52
+ """
53
+
54
+ VAULT_README = """\
55
+ # Brain vault
56
+
57
+ This is your second brain's vault. Plain Markdown files are the source of truth
58
+ for vault-tier notes; the `brain` CLI keeps a Postgres index in sync.
59
+
60
+ ## Layout
61
+
62
+ - `_templates/` — note templates (`daily.md`, `note.md`)
63
+ - `_attachments/` — binary files referenced by notes
64
+ - `_ingested/` — read-only mirrors of DB-authoritative artifacts
65
+ - `capture/` — quick-capture inbox (`brain capture` lands authored thoughts here)
66
+ - `daily/<YYYY>/<YYYY-MM-DD>.md` — daily notes
67
+ - (anything else) — your authored notes
68
+
69
+ ## Frontmatter contract
70
+
71
+ Every `.md` file has a YAML frontmatter block with at minimum `id` and `title`.
72
+ `brain vault sync` auto-assigns `id` on first sight if missing.
73
+ """
74
+
75
+ CAPTURE_README = """\
76
+ # Capture inbox
77
+
78
+ Quick-capture inbox — `brain capture` lands authored thoughts here, tagged `inbox`.
79
+
80
+ Review and route captures with `brain capture review`.
81
+ """
82
+
83
+ # ``{{ name }}`` — surrounding whitespace inside the braces is tolerated so
84
+ # users who write ``{{ title }}`` get the same substitution as ``{{title}}``.
85
+ # We deliberately disallow nested braces, dots, and pipes to keep the grammar
86
+ # narrow (no risk of clashing with future template extensions).
87
+ _PLACEHOLDER_RE = re.compile(r"\{\{\s*(?P<name>[A-Za-z_][A-Za-z0-9_]*)\s*\}\}")
88
+
89
+ _TEMPLATES_DIRNAME = "_templates"
90
+
91
+
92
+ def render_template(template_text: str, vars: dict[str, str]) -> str:
93
+ """Substitute ``{{name}}`` placeholders in ``template_text`` from ``vars``.
94
+
95
+ The grammar is intentionally minimal:
96
+
97
+ - Only ``{{name}}`` (or ``{{ name }}`` with optional inner whitespace).
98
+ - ``name`` is a Python-style identifier (``[A-Za-z_][A-Za-z0-9_]*``).
99
+ - Unknown placeholders are left **as-is** in the output — they're not an
100
+ error. This lets a template keep ``{{some_future_var}}`` around without
101
+ forcing the renderer to know about every variable a future call site
102
+ might pass.
103
+ - No conditionals, no loops, no filters. If a template needs more, the
104
+ user is better served by a real templating engine outside the brain.
105
+
106
+ The function is pure: same inputs → same output, no I/O, no datetime
107
+ side effects (callers compute ``{{date}}`` / ``{{datetime}}`` themselves
108
+ and pass them in via ``vars``).
109
+ """
110
+
111
+ def _replace(match: re.Match[str]) -> str:
112
+ name = match.group("name")
113
+ if name in vars:
114
+ return vars[name]
115
+ # Preserve the original token verbatim — including any inner whitespace
116
+ # the user typed — so the template round-trips losslessly when no
117
+ # value is supplied.
118
+ return match.group(0)
119
+
120
+ return _PLACEHOLDER_RE.sub(_replace, template_text)
121
+
122
+
123
+ def list_template_names(vault_path: Path) -> list[str]:
124
+ """Return the ``.md`` template basenames (without extension) under ``_templates/``.
125
+
126
+ Used by the CLI to validate ``--template T`` and (eventually) to print a
127
+ helpful "available templates: …" diagnostic when the user asks for one
128
+ that doesn't exist.
129
+
130
+ Returns an empty list if the vault doesn't have a ``_templates/`` directory
131
+ yet (e.g. the user pointed ``--vault`` at an unmanaged folder). The caller
132
+ decides whether the empty case is an error — for ``brain note new`` it is
133
+ (we suggest ``brain vault init``); for diagnostics it isn't.
134
+
135
+ Iteration order is sorted so output is deterministic across platforms.
136
+ """
137
+ templates_dir = vault_path / _TEMPLATES_DIRNAME
138
+ if not templates_dir.is_dir():
139
+ return []
140
+ names = [
141
+ p.stem
142
+ for p in sorted(templates_dir.iterdir())
143
+ if p.is_file() and p.suffix == ".md"
144
+ ]
145
+ return names