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,541 @@
1
+ """Server-side render of the home-page "Recently captured" rail (P4.7).
2
+
3
+ Phase 4.7 of the Wiki UX Overhaul. Surfaces the 12 most-recently-ingested
4
+ documents on the home page so the user lands on something fresh after every
5
+ ingest cycle (Krisp call dump, Slack thread pull, Gmail batch, …) instead of
6
+ the static "Doors / Topic clusters" copy.
7
+
8
+ Two write surfaces, both fed from the same DB query:
9
+
10
+ - ``<vault>/_partials/recent.md`` — the rendered bullet list, written
11
+ byte-stable for inspection / debugging. The Quartz workspace ignores the
12
+ ``_partials/`` directory (see ``ignorePatterns`` in ``quartz.config.ts``)
13
+ so this file never becomes a public page.
14
+ - ``<vault>/index.md`` — the live home note. Its body has a fenced region
15
+ marked by ``<!-- BRAIN_RECENT_START -->`` / ``<!-- BRAIN_RECENT_END -->``;
16
+ this module rewrites the content between markers in-place. Mirrors the
17
+ Phase D derived-edges fence pattern (``brain.vault.derived_links.fence``)
18
+ exactly: stable markers, atomic write, idempotent on byte-identical input.
19
+
20
+ Why two files? The partial is the source-of-truth artifact (a future tooling
21
+ layer — e.g. a "What's new" RSS feed — can read it without parsing index.md).
22
+ The fence in index.md is what readers actually see in the rendered wiki. The
23
+ public function :func:`refresh_homepage` writes both in lockstep so they never
24
+ disagree.
25
+
26
+ Failure modes are silent-but-loud: a missing fence in index.md logs a
27
+ warning and skips the rewrite (don't auto-insert — the user might be
28
+ deliberately omitting the rail); a DB error inside :func:`refresh_homepage`
29
+ logs an error and returns ``(False, False)`` so the surrounding build never
30
+ fails *because* the rail couldn't be regenerated. The build is the customer;
31
+ the rail is a courtesy.
32
+ """
33
+ from __future__ import annotations
34
+
35
+ import datetime
36
+ import logging
37
+ from collections.abc import Sequence
38
+ from dataclasses import dataclass
39
+ from pathlib import Path
40
+ from typing import Any
41
+
42
+ import psycopg
43
+
44
+ from ..config import Config
45
+ from ..db import connect
46
+ from ..vault._atomic import atomic_write_text
47
+ from ..vault.frontmatter import dump_frontmatter, parse_frontmatter
48
+ from ..vault.paths import safe_wikilink_alias, strip_md_extension
49
+
50
+ # Stable HTML-comment markers — universal CommonMark passthroughs that
51
+ # Obsidian, Quartz, and GFM all leave alone. Public so tests + future
52
+ # render surfaces can detect the fence without re-importing internal regex
53
+ # state. Mirrors :data:`brain.vault.derived_links.fence.FENCE_START_MARKER`
54
+ # pattern but in its own namespace so tweaking one surface doesn't tug the
55
+ # other.
56
+ FENCE_START_MARKER: str = "<!-- BRAIN_RECENT_START -->"
57
+ FENCE_END_MARKER: str = "<!-- BRAIN_RECENT_END -->"
58
+
59
+ # Fixed window. The plan says "12 newest"; we don't make this configurable
60
+ # because the visual rail is laid out for exactly this count and adding a
61
+ # knob invites drift between the partial, the fence, and any future RSS
62
+ # consumer.
63
+ RECENT_LIMIT: int = 12
64
+
65
+ # Source-icon vocabulary mirrors `brain/quartz_overrides/quartz/util/sourceIcons.ts`
66
+ # (the SOURCE_ICONS map there is the canonical client-side copy). Keeping a
67
+ # server-side duplicate here is intentional: the partial is rendered by Python
68
+ # *before* Quartz is invoked, so we can't import the .ts module. The two
69
+ # copies must stay aligned — a future change to the client glyph table needs
70
+ # this dict bumped too. Tests in ``test_brain_recent_homepage.py`` pin every
71
+ # key/value pair to lock that contract.
72
+ _SOURCE_ICONS: dict[str, str] = {
73
+ "gmail": "📧",
74
+ "krisp": "🎙️",
75
+ "slack": "💬",
76
+ "manual": "✍️",
77
+ "vault": "🌱",
78
+ }
79
+
80
+ # Unknown source kinds fall back to the generic "vault" glyph so a row never
81
+ # renders icon-less. Same default as ``sourceIconFor`` on the client.
82
+ _DEFAULT_SOURCE_ICON: str = _SOURCE_ICONS["vault"]
83
+
84
+ _logger = logging.getLogger(__name__)
85
+
86
+
87
+ @dataclass(frozen=True)
88
+ class RecentDoc:
89
+ """One row of the recent-rail query, projected for rendering.
90
+
91
+ ``source_kind`` may be ``None`` for vault-tier docs (no ``sources`` row);
92
+ callers map ``None`` → ``"vault"`` before icon lookup.
93
+
94
+ ``vault_path`` is guaranteed non-empty by the SQL filter — the renderer
95
+ relies on it to build the wiki-link target. Rows without a vault_path
96
+ are excluded at the SQL layer (they aren't browseable yet).
97
+
98
+ ``display_date`` carries the doc's CONTENT/EVENT date, not its raw ingest
99
+ time: the SQL projects ``COALESCE(d.doc_date, d.ingested_at)`` so a Krisp
100
+ meeting held last week but ingested today ranks (and renders) by the
101
+ meeting date, not the processing timestamp. ``documents.doc_date`` is the
102
+ generated ``COALESCE(sent_at, ingested_at)`` column (migration 021); the
103
+ outer ``COALESCE`` guards corpora predating that migration. Both the
104
+ ordering and the rendered ``data-date`` span read this field, so the rail
105
+ stops mis-ranking on ``ingested_at`` (which the pipeline bulk-bumps when it
106
+ regenerates derived pages).
107
+ """
108
+
109
+ title: str
110
+ source_kind: str | None
111
+ display_date: datetime.datetime
112
+ vault_path: str
113
+
114
+
115
+ # ---------------------------------------------------------------------------
116
+ # Public API
117
+ # ---------------------------------------------------------------------------
118
+
119
+
120
+ def refresh_homepage(cfg: Config) -> tuple[bool, bool]:
121
+ """Refresh the partial + the fence in lockstep using ``cfg``'s DB + vault.
122
+
123
+ Convenience entry point used by the build pipeline (CLI in
124
+ ``brain.wiki.build_swap.main`` and any caller that already has a
125
+ :class:`Config` in hand). Opens a short-lived DB connection, runs the
126
+ recent-docs query once, and writes both surfaces from the same row set
127
+ so the partial and the fence can never drift.
128
+
129
+ Returns ``(partial_changed, fence_changed)`` — each ``True`` if the
130
+ corresponding file was written (content drifted), ``False`` if the
131
+ write was a no-op (byte-identical input) or skipped (no fence in
132
+ index.md, no docs to render). Tests assert specific tuples here to
133
+ prove idempotency.
134
+
135
+ Failure handling is intentionally permissive: any
136
+ :class:`psycopg.Error` (DB unreachable, schema drift, …) is logged at
137
+ ``WARNING`` and swallowed — the rail is a nice-to-have, not a build
138
+ gate. A failed refresh leaves both files untouched; the next
139
+ successful build retries.
140
+ """
141
+ try:
142
+ with connect(cfg.database_url) as conn:
143
+ docs = _fetch_recent_docs(conn, limit=RECENT_LIMIT)
144
+ except psycopg.Error as exc:
145
+ _logger.warning(
146
+ "wiki recent rail: DB query failed (%s) — skipping refresh", exc
147
+ )
148
+ return (False, False)
149
+
150
+ partial_changed = regenerate_recent_partial(cfg.vault_path, docs=docs)
151
+ fence_changed = regenerate_recent_fence(cfg.vault_path, docs=docs)
152
+ return (partial_changed, fence_changed)
153
+
154
+
155
+ def regenerate_recent_partial(
156
+ vault_path: Path, *, docs: Sequence[RecentDoc]
157
+ ) -> bool:
158
+ """Write the rendered bullet list to ``<vault>/_partials/recent.md``.
159
+
160
+ Returns ``True`` iff the file was actually written (content drifted
161
+ from what's already on disk). A no-op return preserves the file's
162
+ mtime so the watcher doesn't fire a needless rebuild.
163
+
164
+ The partial directory is created on demand (``mkdir -p``) — first
165
+ call on a fresh vault doesn't require a separate scaffold step.
166
+
167
+ The atomic write goes through
168
+ :func:`brain.vault._atomic.atomic_write_text` so a crash mid-write
169
+ can never leave a half-written partial visible to consumers.
170
+ """
171
+ rendered = _render_bullets(docs)
172
+ target = vault_path / "_partials" / "recent.md"
173
+
174
+ # Idempotency: read-then-compare before write. A re-run with the same
175
+ # DB state must produce a byte-identical file — that's the property the
176
+ # `Phase 4.1 daily index` test pattern proved valuable; we mirror it.
177
+ if target.is_file():
178
+ try:
179
+ existing = target.read_text(encoding="utf-8")
180
+ except OSError:
181
+ existing = None
182
+ if existing == rendered:
183
+ return False
184
+
185
+ target.parent.mkdir(parents=True, exist_ok=True)
186
+ atomic_write_text(target, rendered)
187
+ return True
188
+
189
+
190
+ def regenerate_recent_fence(
191
+ vault_path: Path, *, docs: Sequence[RecentDoc]
192
+ ) -> bool:
193
+ """Replace the recent-rail fence in ``<vault>/index.md`` in place.
194
+
195
+ Looks for the ``<!-- BRAIN_RECENT_START -->`` / ``<!-- BRAIN_RECENT_END -->``
196
+ markers in the home note's body. If found, the content between them is
197
+ swapped for the freshly-rendered bullet list (preserving the markers
198
+ themselves). If the markers are missing, logs a warning and returns
199
+ ``False`` — the renderer never auto-inserts the fence because the user
200
+ might have deliberately removed it from the home note.
201
+
202
+ Returns ``True`` iff the file was actually rewritten. A re-run with
203
+ the same DB state and the same on-disk fence content returns ``False``
204
+ without touching the file (preserves mtime, keeps the watcher quiet).
205
+
206
+ The home note retains its existing frontmatter (id / created /
207
+ title / tags / …) — only the body changes. ``updated`` is left
208
+ intact so the home note doesn't bump on every rail refresh; the
209
+ fence is a low-signal change and we don't want it polluting the
210
+ "modified" timestamp Quartz surfaces in the page header.
211
+
212
+ Atomic — sibling tempfile + ``os.replace`` via
213
+ :func:`atomic_write_text`. A crash mid-write leaves the previous
214
+ home note intact.
215
+ """
216
+ target = vault_path / "index.md"
217
+ if not target.is_file():
218
+ _logger.warning(
219
+ "wiki recent rail: home note %s does not exist — skipping fence",
220
+ target,
221
+ )
222
+ return False
223
+
224
+ text = target.read_text(encoding="utf-8")
225
+ try:
226
+ frontmatter, body = parse_frontmatter(text)
227
+ except Exception as exc: # noqa: BLE001 — parse_frontmatter raises broadly
228
+ _logger.warning(
229
+ "wiki recent rail: malformed frontmatter in %s (%s) — skipping",
230
+ target, exc,
231
+ )
232
+ return False
233
+
234
+ new_body = _replace_fence(body, _render_bullets(docs))
235
+ if new_body is None:
236
+ _logger.warning(
237
+ "wiki recent rail: %s missing %s/%s markers — add them by hand to "
238
+ "enable the recent rail (skipping)",
239
+ target, FENCE_START_MARKER, FENCE_END_MARKER,
240
+ )
241
+ return False
242
+
243
+ if new_body == body:
244
+ return False
245
+
246
+ new_text = dump_frontmatter(frontmatter, new_body)
247
+ atomic_write_text(target, new_text)
248
+ return True
249
+
250
+
251
+ # ---------------------------------------------------------------------------
252
+ # Internals — query + rendering helpers.
253
+ # ---------------------------------------------------------------------------
254
+
255
+
256
+ def _fetch_recent_docs(
257
+ conn: psycopg.Connection[Any], *, limit: int
258
+ ) -> list[RecentDoc]:
259
+ """Return the ``limit`` most-recent docs (by event date) eligible for the rail.
260
+
261
+ Filters applied at the SQL layer:
262
+
263
+ - ``draft = FALSE`` — drafts are quarantined from every public surface
264
+ (P1.6). The recent rail must not surface them either.
265
+ - ``vault_path IS NOT NULL`` — without a vault path the row isn't
266
+ browseable from the wiki, so emitting a wiki-link would 404.
267
+ - ``ingested_at IS NOT NULL`` — defensive; the column is ``NOT NULL``
268
+ in 001_init.sql but the predicate guards a future schema relax.
269
+ - ``vault_path <> 'index.md'`` — the home note itself. Without this the
270
+ rail would list itself (the rail lives inside index.md), and because the
271
+ pipeline re-stamps the home note's ``ingested_at`` on every derived-page
272
+ regeneration it would otherwise sit permanently at the top.
273
+ - ``vault_path NOT LIKE 'people/%%'`` — the People-Hub auto-page namespace.
274
+ ``brain.wiki.build_people.emit_people_pages`` writes EVERY page it emits
275
+ under ``<vault>/people/`` (the per-person ``people/<slug>.md`` roster
276
+ pages + ``people/index.md``); those are machine-generated derived pages,
277
+ re-stamped ``ingested_at = now()`` on each People-Hub regeneration, so
278
+ after any Krisp/Slack batch they would swamp the rail. Path-based (not a
279
+ jsonb ``?`` lookup) so it never collides with psycopg ``%s`` placeholders;
280
+ the literal ``%`` is doubled because psycopg treats the SQL string as a
281
+ format template.
282
+
283
+ Ranking + display both use ``COALESCE(d.doc_date, d.ingested_at)`` (the
284
+ doc's content/event date — see :class:`RecentDoc`), NOT raw ``ingested_at``,
285
+ so a meeting held last week but ingested today ranks by the meeting date.
286
+ Sort is that expression ``DESC``. LEFT JOIN against ``sources`` so
287
+ vault-tier docs (no ``sources`` row) come back with ``source_kind=NULL``
288
+ rather than being silently dropped.
289
+
290
+ Read-only — never INSERT/UPDATE/DELETE. Safe to call from any
291
+ autocommit-or-not context.
292
+ """
293
+ rows = conn.execute(
294
+ """
295
+ SELECT d.title, s.kind, COALESCE(d.doc_date, d.ingested_at) AS display_date,
296
+ d.vault_path
297
+ FROM documents d
298
+ LEFT JOIN sources s ON s.id = d.source_id
299
+ WHERE d.draft = FALSE
300
+ AND d.vault_path IS NOT NULL
301
+ AND d.ingested_at IS NOT NULL
302
+ AND d.vault_path <> 'index.md'
303
+ AND d.vault_path NOT LIKE 'people/%%'
304
+ ORDER BY COALESCE(d.doc_date, d.ingested_at) DESC
305
+ LIMIT %s
306
+ """,
307
+ (limit,),
308
+ ).fetchall()
309
+ return [
310
+ RecentDoc(
311
+ title=str(title),
312
+ source_kind=str(kind) if kind is not None else None,
313
+ display_date=display_date,
314
+ vault_path=str(vault_path),
315
+ )
316
+ for (title, kind, display_date, vault_path) in rows
317
+ ]
318
+
319
+
320
+ def _render_bullets(docs: Sequence[RecentDoc]) -> str:
321
+ """Render the recent-rail markdown body (bullets, no fence markers).
322
+
323
+ Empty corpus → a single italic placeholder line so the rail has *some*
324
+ visible content rather than collapsing to a blank gap. The placeholder
325
+ uses ``*…*`` (markdown italic) which renders as muted text in the
326
+ Linear-style theme — gentler than a hard "(no docs)" string.
327
+
328
+ Each non-empty line is shaped:
329
+
330
+ ``- {icon} [[<vault_path-without-md>|<safe_title>]] · <span ...>{abs}</span>``
331
+
332
+ where the trailing token is a machine-readable relative-date span:
333
+
334
+ ``<span class="brain-rel-date" data-date="YYYY-MM-DD">{absolute}</span>``
335
+
336
+ and:
337
+
338
+ - ``{icon}`` comes from :data:`_SOURCE_ICONS` (with the ``"vault"``
339
+ fallback for unknown kinds).
340
+ - ``<vault_path-without-md>`` is the doc's ``vault_path`` with a
341
+ trailing ``.md`` stripped — matches the canonical wiki-link target
342
+ shape the link rewriter emits, so a future ``brain vault sync`` pass
343
+ over the home note doesn't churn this body.
344
+ - ``<safe_title>`` strips wiki-link-breaking ``[`` / ``]`` from the
345
+ alias slot. Quartz's wiki-link regex defines aliases as
346
+ ``[^\\[\\]\\#]``; bracketed prefixes like ``Re: [External] Re: …``
347
+ from Gmail subjects would otherwise emit raw text. Same trick the
348
+ derived-edges fence uses.
349
+ - ``{cal_date}`` is :func:`_recent_calendar_date` of ``display_date`` —
350
+ a plain ``YYYY-MM-DD`` calendar date (NOT a full ISO timestamp), the
351
+ machine-readable source of truth the client script
352
+ (``/static/relativeDate.js``) reads to recompute the relative text
353
+ ("today" / "3d ago" / …) live on every page load. ``display_date`` is
354
+ the doc's content/event date (``COALESCE(doc_date, ingested_at)``), so a
355
+ meeting held last week but ingested today renders "1w ago", not "today".
356
+ Emitting a plain calendar date (parsed client-side as a LOCAL naive
357
+ date) avoids the off-by-one timezone drift that a full ISO timestamp
358
+ caused for UTC-midnight date-only docs (Krisp meetings, ``--date``
359
+ ingests). Baking a relative string here would decay: a doc 3 days old at
360
+ build time still reads "3d ago" weeks later because the home note isn't
361
+ re-rendered daily. Emitting the calendar date + recomputing client-side
362
+ keeps the rail honest.
363
+ - ``{absolute}`` is :func:`_format_absolute_date` of the computed
364
+ calendar date — a NON-decaying fallback (e.g. ``"Jun 11"``) shown
365
+ verbatim if the client script never runs (JS disabled, parse failure).
366
+ Both the ``data-date`` attribute and the absolute fallback are
367
+ machine-generated (no user content), so neither needs HTML-escaping.
368
+
369
+ Trailing newline so the body always ends Unix-cleanly.
370
+ """
371
+ if not docs:
372
+ return "*No documents ingested yet — try `brain ingest <file>`.*\n"
373
+
374
+ lines: list[str] = []
375
+ for doc in docs:
376
+ icon = _SOURCE_ICONS.get(doc.source_kind or "vault", _DEFAULT_SOURCE_ICON)
377
+ target = strip_md_extension(doc.vault_path)
378
+ alias = safe_wikilink_alias(doc.title)
379
+ cal_date = _recent_calendar_date(doc.display_date)
380
+ # ``date.isoformat()`` yields ``YYYY-MM-DD`` — a plain calendar date,
381
+ # no time component, no tz offset. The client parses it as a local
382
+ # naive date, so no timezone shift occurs on either side.
383
+ iso = cal_date.isoformat()
384
+ absolute = _format_absolute_date(cal_date)
385
+ span = f'<span class="brain-rel-date" data-date="{iso}">{absolute}</span>'
386
+ lines.append(f"- {icon} [[{target}|{alias}]] · {span}")
387
+ return "\n".join(lines) + "\n"
388
+
389
+
390
+ def _replace_fence(body: str, new_inner: str) -> str | None:
391
+ """Return ``body`` with the fence's inner content swapped for ``new_inner``.
392
+
393
+ Mirrors the contract of
394
+ :func:`brain.vault.derived_links.fence.extract_fence` but reduced to a
395
+ single-shot replace: we don't need the partial-extract API surface
396
+ here (the partial file is the inspection surface; the fence is purely
397
+ in-place).
398
+
399
+ Behavior:
400
+
401
+ - Both markers present and well-ordered (START before END) → return
402
+ ``<prefix><START>\\n<new_inner><END><suffix>`` (note the END marker
403
+ is appended directly after the inner content; ``new_inner`` already
404
+ ends with ``\\n`` so the END line lands on its own line).
405
+ - Markers missing or inverted (END before START) → return ``None``.
406
+ The caller logs a warning and skips the rewrite.
407
+ - Multiple START or END markers — only the first START and the
408
+ first END after it are anchors. Strays stay in the surrounding
409
+ body as text; corruption recovery is then a re-render away.
410
+
411
+ Idempotency is the caller's job (compare returned body to input).
412
+ """
413
+ start_idx = body.find(FENCE_START_MARKER)
414
+ if start_idx == -1:
415
+ return None
416
+ end_search_from = start_idx + len(FENCE_START_MARKER)
417
+ end_idx = body.find(FENCE_END_MARKER, end_search_from)
418
+ if end_idx == -1:
419
+ return None
420
+
421
+ prefix = body[: start_idx + len(FENCE_START_MARKER)]
422
+ suffix = body[end_idx:]
423
+ # ``new_inner`` always ends with ``\n`` (per :func:`_render_bullets`),
424
+ # so the END marker lands on its own line. The start marker is
425
+ # followed by exactly one newline so the bullets begin on the next
426
+ # row, matching how the user would hand-author the fence.
427
+ return f"{prefix}\n{new_inner}{suffix}"
428
+
429
+
430
+ def _format_relative_date(
431
+ when: datetime.datetime, *, today: datetime.date
432
+ ) -> str:
433
+ """Render ``when`` as a coarse human-friendly relative date.
434
+
435
+ PARITY REFERENCE — NOT dead code. As of the live-relative-date change
436
+ the renderer no longer calls this: :func:`_render_bullets` emits an
437
+ absolute date + a machine-readable ``data-date`` span, and the browser
438
+ (``quartz/static/relativeDate.js``) recomputes the relative bucket on
439
+ every page load. This function is now exercised only by the parity
440
+ tests in ``tests/test_brain_recent_homepage.py`` and MUST stay in
441
+ lock-step with ``relativeDate.js``'s bucket logic — keep it as the
442
+ canonical Python reference; do NOT delete it as "unused."
443
+
444
+ Buckets, in order:
445
+
446
+ - same calendar day → ``"today"``
447
+ - 1–6 days ago → ``"1d ago"`` … ``"6d ago"``
448
+ - 1–4 weeks ago → ``"1w ago"`` … ``"4w ago"``
449
+ - 5+ weeks ago → ``"Apr 27"`` style (locale-independent ``%b %-d``)
450
+
451
+ Calendar-day comparison (not 24h windows) so a doc ingested at 23:59
452
+ yesterday and another at 00:01 today both render as expected — naive
453
+ elapsed-seconds bucketing would mis-classify the boundary.
454
+
455
+ Future dates (clock skew, manual ``ingested_at`` overrides) bucket as
456
+ ``"today"`` — a recent rail with a pretend-future bullet shouldn't
457
+ surface ``"-3d ago"``.
458
+
459
+ The calendar-day delta is computed from :func:`_recent_calendar_date`
460
+ (NOT a plain local projection) so this parity reference applies the same
461
+ UTC-midnight-vs-local rule the rendered span carries — keeping it in
462
+ lock-step with what ``relativeDate.js`` recomputes from the emitted
463
+ ``data-date``.
464
+ """
465
+ when_date = _recent_calendar_date(when)
466
+ delta = (today - when_date).days
467
+ if delta <= 0:
468
+ return "today"
469
+ if delta < 7:
470
+ return f"{delta}d ago"
471
+ if delta < 35:
472
+ return f"{delta // 7}w ago"
473
+ # 35+ days ago → the same absolute "Mon D" form the recent-rail span
474
+ # falls back to. Delegated to :func:`_format_absolute_date` so the two
475
+ # surfaces (this >= 35-day branch and the client-side span fallback)
476
+ # stay byte-identical without a second copy of the portable day-build.
477
+ return _format_absolute_date(when_date)
478
+
479
+
480
+ def _format_absolute_date(cal_date: datetime.date) -> str:
481
+ """Render an already-computed calendar date as a portable ``"Mon D"``.
482
+
483
+ Example: ``date(2026, 6, 11)`` → ``"Jun 11"`` (no leading zero on the
484
+ day). This is the NON-decaying fallback baked into the recent-rail span's
485
+ text content — the client script overwrites it with a live relative
486
+ string, but if JS never runs (disabled, parse failure) the absolute date
487
+ is what the reader sees.
488
+
489
+ ``%-d`` is GNU/BSD-specific (no leading zero); on Windows the right
490
+ spelling is ``%#d``. Both are absent from POSIX. Build the day ourselves
491
+ from ``.day`` (an ``int``) to stay portable across hosts.
492
+
493
+ Takes the calendar ``date`` directly (computed by
494
+ :func:`_recent_calendar_date`) so the absolute fallback and the relative
495
+ text agree on which calendar day a doc lands on — no second projection,
496
+ no chance of drift.
497
+ """
498
+ month = cal_date.strftime("%b")
499
+ return f"{month} {cal_date.day}"
500
+
501
+
502
+ def _recent_calendar_date(when: datetime.datetime) -> datetime.date:
503
+ """Project a doc's ``display_date`` onto the calendar date to display.
504
+
505
+ Two kinds of timestamp flow through the recent rail and they want
506
+ different projections:
507
+
508
+ - DATE-ONLY content — Krisp meeting dates and ``--date`` ingests are
509
+ stored as UTC-midnight date-only values (e.g.
510
+ ``2026-06-11T00:00:00+00:00``). These carry no real time-of-day; the
511
+ intent is purely "June 11". Projecting them to LOCAL time shifts the
512
+ date back a day in any negative-offset zone (UTC−7 → "Jun 10"), the
513
+ off-by-one bug this fixes. For these, use the **UTC** calendar date
514
+ verbatim — no local shift.
515
+ - REAL timestamps — Gmail ``sent_at`` and note ``ingested_at`` are true
516
+ wall-clock instants. A doc sent at 23:30 UTC genuinely belongs to the
517
+ reader's local "tomorrow" in a positive-offset zone, so these keep the
518
+ LOCAL projection (``astimezone().date()``), the original behavior.
519
+
520
+ The heuristic — "exactly 00:00:00.000000 in UTC ⇒ date-only" — is safe
521
+ because real timestamps essentially never land on exact UTC midnight:
522
+ ``ingested_at`` is a microsecond-resolution clock and Gmail send-times
523
+ carry seconds. The worst-case misfire is a 1-day display difference on an
524
+ astronomically rare exact-UTC-midnight email; the upside is correct dates
525
+ for every Krisp call and ``--date`` ingest.
526
+
527
+ Naive (tz-less) datetimes are treated as already-local and projected via
528
+ ``.date()`` directly — they predate the TIMESTAMPTZ era and the UTC
529
+ heuristic can't apply without a tzinfo.
530
+ """
531
+ if when.tzinfo is None:
532
+ return when.date()
533
+ as_utc = when.astimezone(datetime.UTC)
534
+ if as_utc.hour == 0 and as_utc.minute == 0 and as_utc.second == 0 and (
535
+ as_utc.microsecond == 0
536
+ ):
537
+ # Date-only content: trust the UTC calendar date, no local shift.
538
+ return as_utc.date()
539
+ # Real timestamp: project onto the reader's local calendar date.
540
+ return when.astimezone().date()
541
+