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
brain/todo.py ADDED
@@ -0,0 +1,124 @@
1
+ """Parser for ``- [ ]`` / ``- [x]`` Markdown action-item lines.
2
+
3
+ Standalone so it can be unit-tested without a DB. The CLI calls
4
+ :func:`parse_action_items` on each ``krisp_action_items`` doc body fetched
5
+ in one SELECT (see :mod:`brain.cli` `todo` command).
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import re
10
+ from collections.abc import Iterator
11
+ from dataclasses import dataclass
12
+ from datetime import datetime
13
+ from typing import Any, Literal
14
+
15
+ import psycopg
16
+
17
+ # Match leading whitespace, ``-`` or ``*``, whitespace, ``[ ]`` or ``[x]``
18
+ # (case-insensitive), whitespace, rest-of-line. Anchored with ``^`` +
19
+ # ``re.MULTILINE`` so we walk every body line in one pass.
20
+ _TASK_RE = re.compile(
21
+ r"^\s*[-*]\s*\[([ xX])\]\s*(.+?)\s*$",
22
+ re.MULTILINE,
23
+ )
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class ActionItem:
28
+ """One parsed action-item line.
29
+
30
+ ``state`` is ``"open"`` for ``[ ]`` and ``"done"`` for ``[x]`` (case-
31
+ insensitive). ``text`` is the rest-of-line content after the checkbox,
32
+ stripped of trailing whitespace.
33
+ """
34
+
35
+ state: Literal["open", "done"]
36
+ text: str
37
+
38
+
39
+ def parse_action_items(body: str) -> list[ActionItem]:
40
+ """Return one :class:`ActionItem` per recognized task line.
41
+
42
+ Lines without the ``[ ]`` / ``[x]`` pattern are ignored — comments,
43
+ headers, the trailing ``Parent meeting:`` wikilink. The parser is
44
+ Markdown-aware enough to handle leading whitespace + ``-`` / ``*``
45
+ bullets but does NOT support nested checkboxes (a future wave can add
46
+ nesting if the action-items shape grows). Pure function; the CLI feeds
47
+ in pre-fetched bodies.
48
+ """
49
+ out: list[ActionItem] = []
50
+ for m in _TASK_RE.finditer(body):
51
+ ch = m.group(1)
52
+ state: Literal["open", "done"] = "done" if ch.lower() == "x" else "open"
53
+ out.append(ActionItem(state=state, text=m.group(2)))
54
+ return out
55
+
56
+
57
+ @dataclass(frozen=True)
58
+ class TodoRow:
59
+ """One flat row returned by :func:`iter_action_item_docs`.
60
+
61
+ Carries the parent document context (id, title, ingested_at) alongside
62
+ the parsed action item so the CLI ``brain todo`` view can render a flat
63
+ table without re-joining at print time.
64
+ """
65
+
66
+ document_id: str
67
+ document_title: str
68
+ ingested_at: datetime | None
69
+ state: Literal["open", "done"]
70
+ text: str
71
+
72
+
73
+ def iter_action_item_docs(
74
+ conn: psycopg.Connection[Any],
75
+ *,
76
+ source_kind: str | None = None,
77
+ since_days: int | None = None,
78
+ include_closed: bool = False,
79
+ ) -> Iterator[TodoRow]:
80
+ """Yield one :class:`TodoRow` per parsed item across the corpus.
81
+
82
+ Walks ``documents WHERE content_type='krisp_action_items'`` (optionally
83
+ filtered by source / recency), parses each body line by line, and
84
+ yields the parsed items in document-recency order. The caller decides
85
+ whether to render a table or emit JSON.
86
+
87
+ Filters:
88
+
89
+ - ``source_kind`` — restricts to docs whose joined ``sources.kind``
90
+ matches (today only ``"krisp"`` is meaningful, but the parameter
91
+ stays open for future source kinds emitting the same content_type).
92
+ - ``since_days`` — restricts to docs ingested in the last N days.
93
+ - ``include_closed`` — when False (default), drop ``[x]`` items from
94
+ the stream; when True, both states are returned.
95
+ """
96
+ sql = (
97
+ "SELECT d.id::text, d.title, d.content, d.ingested_at, s.kind "
98
+ "FROM documents d LEFT JOIN sources s ON s.id = d.source_id "
99
+ "WHERE d.content_type = 'krisp_action_items'"
100
+ )
101
+ params: list[Any] = []
102
+ if source_kind is not None:
103
+ sql += " AND s.kind = %s"
104
+ params.append(source_kind)
105
+ if since_days is not None:
106
+ sql += " AND d.ingested_at >= NOW() - (%s * INTERVAL '1 day')"
107
+ params.append(since_days)
108
+ sql += " ORDER BY d.ingested_at DESC"
109
+ rows = conn.execute(sql, params).fetchall()
110
+ for r in rows:
111
+ doc_id = str(r[0])
112
+ title = str(r[1])
113
+ body = str(r[2] or "")
114
+ ingested_at = r[3]
115
+ for item in parse_action_items(body):
116
+ if item.state == "done" and not include_closed:
117
+ continue
118
+ yield TodoRow(
119
+ document_id=doc_id,
120
+ document_title=title,
121
+ ingested_at=ingested_at,
122
+ state=item.state,
123
+ text=item.text,
124
+ )
brain/uninstall.py ADDED
@@ -0,0 +1,185 @@
1
+ """brain uninstall — removes launchd plists, $BRAIN_HOME runtime state, and optionally the vault.
2
+
3
+ Does NOT remove the pipx installation itself; a CLI cannot safely uninstall
4
+ its own running process. After running this command, the user should manually
5
+ run `pipx uninstall secondbrain-py`.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import shutil
10
+ import subprocess
11
+ import sys
12
+ from pathlib import Path
13
+
14
+ import typer
15
+
16
+ from ._compose import compose_cmd
17
+ from .config import DEFAULT_VAULT_PATH, _brain_home_root
18
+
19
+ # Files / dirs inside $BRAIN_HOME that are safe to remove even when --remove-db
20
+ # is NOT set (i.e. everything except the data/ subtree).
21
+ _BRAIN_HOME_SAFE_REMOVES = (
22
+ ".env",
23
+ ".shims",
24
+ "Caddyfile",
25
+ "docker-compose.yml",
26
+ "bin",
27
+ "logs",
28
+ "build",
29
+ )
30
+
31
+
32
+ def _step(label: str) -> None:
33
+ typer.echo(f" {label}")
34
+
35
+
36
+ def _ok(label: str) -> None:
37
+ typer.secho(f" [ok] {label}", fg="green")
38
+
39
+
40
+ def _skipped(label: str) -> None:
41
+ typer.echo(f" [skipped] {label}")
42
+
43
+
44
+ def run_uninstall(
45
+ *,
46
+ yes: bool = False,
47
+ remove_db: bool = False,
48
+ remove_vault: bool = False,
49
+ brain_home: Path | None = None,
50
+ vault_path: Path | None = None,
51
+ _launchd_uninstall: object | None = None,
52
+ ) -> None:
53
+ """Core uninstall logic — separated from the Typer command for testability.
54
+
55
+ Parameters
56
+ ----------
57
+ yes:
58
+ Skip the interactive confirmation prompt.
59
+ remove_db:
60
+ Also remove ``$BRAIN_HOME/data/postgres/`` (requires extra typed
61
+ confirmation regardless of ``yes``).
62
+ remove_vault:
63
+ Also remove the vault directory.
64
+ brain_home:
65
+ Override the resolved ``$BRAIN_HOME`` path (for tests).
66
+ vault_path:
67
+ Override the resolved ``$BRAIN_VAULT_PATH`` path (for tests).
68
+ _launchd_uninstall:
69
+ Dependency-injected callable that replaces ``brain.bin.launchd.uninstall_main``
70
+ (for tests that don't want real launchctl calls).
71
+ """
72
+ home = brain_home if brain_home is not None else _brain_home_root()
73
+ vault = vault_path if vault_path is not None else DEFAULT_VAULT_PATH
74
+
75
+ # -----------------------------------------------------------------------
76
+ # 1. Print summary of what will be removed.
77
+ # -----------------------------------------------------------------------
78
+ typer.echo("")
79
+ typer.echo("🧠 brain uninstall")
80
+ typer.echo("")
81
+ typer.echo("The following will be removed:")
82
+ typer.echo(" • launchd plists (macOS only)")
83
+ typer.echo(" • Docker compose containers / networks (brain project)")
84
+ for name in _BRAIN_HOME_SAFE_REMOVES:
85
+ p = home / name
86
+ if p.exists():
87
+ typer.echo(f" • {p}")
88
+ if remove_db:
89
+ typer.echo(f" • {home / 'data' / 'postgres'} ← ALL DATABASE DATA")
90
+ if remove_vault:
91
+ typer.echo(f" • {vault} ← YOUR NOTES")
92
+ typer.echo("")
93
+
94
+ # -----------------------------------------------------------------------
95
+ # 2. Interactive confirmation (unless --yes).
96
+ # -----------------------------------------------------------------------
97
+ if not yes:
98
+ typer.confirm("Proceed?", default=False, abort=True)
99
+
100
+ # -----------------------------------------------------------------------
101
+ # 3. Extra typed confirmation for --remove-db (NEVER bypassed by --yes).
102
+ # -----------------------------------------------------------------------
103
+ if remove_db:
104
+ typer.echo("")
105
+ typer.echo("WARNING: --remove-db will permanently delete all ingested documents.")
106
+ answer = typer.prompt('Type "yes, delete my data" to confirm')
107
+ if answer != "yes, delete my data":
108
+ typer.secho("Aborted — database data was NOT removed.", fg="yellow")
109
+ raise typer.Abort()
110
+
111
+ # -----------------------------------------------------------------------
112
+ # 4. Uninstall launchd (macOS only).
113
+ # -----------------------------------------------------------------------
114
+ if sys.platform == "darwin":
115
+ _step("Uninstalling launchd plists …")
116
+ try:
117
+ if _launchd_uninstall is not None:
118
+ _launchd_uninstall() # type: ignore[operator]
119
+ else:
120
+ from .bin.launchd import uninstall_main
121
+ uninstall_main()
122
+ _ok("launchd plists removed")
123
+ except Exception as exc: # noqa: BLE001
124
+ typer.secho(f" [warn] launchd uninstall: {exc}", fg="yellow")
125
+ else:
126
+ _skipped("launchd (not macOS)")
127
+
128
+ # -----------------------------------------------------------------------
129
+ # 5. Stop Docker compose (fail-silent — user may have stopped it already).
130
+ # -----------------------------------------------------------------------
131
+ _step("Stopping Docker compose …")
132
+ try:
133
+ subprocess.run(
134
+ compose_cmd("down", brain_home=home),
135
+ check=False,
136
+ capture_output=True,
137
+ timeout=30,
138
+ )
139
+ _ok("docker compose down")
140
+ except Exception as exc: # noqa: BLE001
141
+ typer.secho(f" [warn] docker compose down: {exc}", fg="yellow")
142
+
143
+ # -----------------------------------------------------------------------
144
+ # 6. Remove safe $BRAIN_HOME entries.
145
+ # -----------------------------------------------------------------------
146
+ _step(f"Removing $BRAIN_HOME runtime files from {home} …")
147
+ for name in _BRAIN_HOME_SAFE_REMOVES:
148
+ p = home / name
149
+ if p.is_dir():
150
+ shutil.rmtree(p, ignore_errors=True)
151
+ _ok(f"removed dir {p}")
152
+ elif p.is_file():
153
+ p.unlink(missing_ok=True)
154
+ _ok(f"removed file {p}")
155
+
156
+ # -----------------------------------------------------------------------
157
+ # 7. Optionally remove the database data directory.
158
+ # -----------------------------------------------------------------------
159
+ if remove_db:
160
+ pg_dir = home / "data" / "postgres"
161
+ if pg_dir.exists():
162
+ shutil.rmtree(pg_dir)
163
+ _ok(f"removed database {pg_dir}")
164
+ else:
165
+ _skipped(f"data/postgres not found at {pg_dir}")
166
+
167
+ # -----------------------------------------------------------------------
168
+ # 8. Optionally remove the vault.
169
+ # -----------------------------------------------------------------------
170
+ if remove_vault:
171
+ if vault.exists():
172
+ shutil.rmtree(vault)
173
+ _ok(f"removed vault {vault}")
174
+ else:
175
+ _skipped(f"vault not found at {vault}")
176
+ else:
177
+ _skipped(f"vault kept at {vault} (pass --remove-vault to delete)")
178
+
179
+ # -----------------------------------------------------------------------
180
+ # 9. Final hint.
181
+ # -----------------------------------------------------------------------
182
+ typer.echo("")
183
+ typer.secho("Done. To complete removal, run:", fg="cyan")
184
+ typer.secho(" pipx uninstall secondbrain-py", bold=True)
185
+ typer.echo("")
@@ -0,0 +1,115 @@
1
+ """Vault model — `.md` files on disk are the source of truth for vault notes.
2
+
3
+ Phase 1 ships :func:`init_vault` (one-shot directory + template scaffold) and
4
+ :func:`export_vault` (DB → vault dump). Later phases add sync, link parsing,
5
+ authoring commands, and a Quartz renderer on top of the same module layout.
6
+ """
7
+ from dataclasses import dataclass, field
8
+ from pathlib import Path
9
+
10
+ from .link_rewrite import rewrite_vault_links, rewrite_wiki_links
11
+ from .templates import (
12
+ CAPTURE_README,
13
+ DAILY_TEMPLATE,
14
+ INGESTED_README,
15
+ NOTE_TEMPLATE,
16
+ VAULT_README,
17
+ )
18
+
19
+ __all__ = [
20
+ "CAPTURE_README",
21
+ "DAILY_TEMPLATE",
22
+ "INGESTED_README",
23
+ "NOTE_TEMPLATE",
24
+ "VAULT_README",
25
+ "VAULT_SUBDIRS",
26
+ "VAULT_TEMPLATE_FILES",
27
+ "VaultInitSummary",
28
+ "create_vault_note",
29
+ "init_vault",
30
+ "rewrite_vault_links",
31
+ "rewrite_wiki_links",
32
+ ]
33
+
34
+ # Subdirectories created (or ensured) by ``brain vault init``. Order is
35
+ # preserved in the summary so output stays deterministic across runs.
36
+ VAULT_SUBDIRS: tuple[str, ...] = (
37
+ "_templates",
38
+ "_attachments",
39
+ "_ingested",
40
+ "_ingested/krisp",
41
+ "_ingested/slack",
42
+ "_ingested/gmail",
43
+ "_ingested/manual",
44
+ "capture",
45
+ "daily",
46
+ )
47
+
48
+ # (relative path, content) pairs written if the file does not already exist.
49
+ # Tuple-of-tuples (not dict) so iteration order is part of the contract.
50
+ VAULT_TEMPLATE_FILES: tuple[tuple[str, str], ...] = (
51
+ ("_templates/daily.md", DAILY_TEMPLATE),
52
+ ("_templates/note.md", NOTE_TEMPLATE),
53
+ ("_ingested/README.md", INGESTED_README),
54
+ ("capture/README.md", CAPTURE_README),
55
+ ("README.md", VAULT_README),
56
+ )
57
+
58
+
59
+ @dataclass
60
+ class VaultInitSummary:
61
+ """What ``init_vault`` actually did, for the CLI to print.
62
+
63
+ ``created_dirs`` lists subdirs that didn't exist before this call;
64
+ ``existing_dirs`` lists ones that did. ``written_files`` and
65
+ ``preserved_files`` are the analogous lists for templates: a template
66
+ is only ever written when absent (we never overwrite user edits).
67
+ """
68
+
69
+ vault_path: Path
70
+ created_dirs: list[str] = field(default_factory=list)
71
+ existing_dirs: list[str] = field(default_factory=list)
72
+ written_files: list[str] = field(default_factory=list)
73
+ preserved_files: list[str] = field(default_factory=list)
74
+
75
+
76
+ def init_vault(vault_path: Path) -> VaultInitSummary:
77
+ """Create / ensure the vault folder structure and default templates.
78
+
79
+ Idempotent — safe to re-run. Never overwrites existing files; user edits
80
+ to ``_templates/*.md`` or ``README.md`` survive every subsequent
81
+ ``brain vault init`` call.
82
+
83
+ Creates the vault root if absent. Creates each subdir under
84
+ :data:`VAULT_SUBDIRS` with ``mkdir -p`` semantics (no error if present).
85
+ Writes each template in :data:`VAULT_TEMPLATE_FILES` only if its target
86
+ path does not yet exist.
87
+ """
88
+ summary = VaultInitSummary(vault_path=vault_path)
89
+ vault_path.mkdir(parents=True, exist_ok=True)
90
+
91
+ for relative in VAULT_SUBDIRS:
92
+ target = vault_path / relative
93
+ if target.is_dir():
94
+ summary.existing_dirs.append(relative)
95
+ else:
96
+ target.mkdir(parents=True, exist_ok=True)
97
+ summary.created_dirs.append(relative)
98
+
99
+ for relative, content in VAULT_TEMPLATE_FILES:
100
+ target = vault_path / relative
101
+ if target.exists():
102
+ summary.preserved_files.append(relative)
103
+ else:
104
+ target.write_text(content, encoding="utf-8")
105
+ summary.written_files.append(relative)
106
+
107
+ return summary
108
+
109
+
110
+ # Imported at the BOTTOM on purpose: ``note_builder`` pulls in (lazily) the
111
+ # ingest pipeline, and ``brain.ingest`` imports ``brain.vault.export``, which
112
+ # does ``from . import init_vault``. Re-exporting here only after ``init_vault``
113
+ # is defined keeps that re-entrant import resolvable instead of hitting a
114
+ # partially-initialized package. See note_builder's module docstring.
115
+ from .note_builder import create_vault_note # noqa: E402
brain/vault/_atomic.py ADDED
@@ -0,0 +1,25 @@
1
+ """Atomic file-write helper shared by every body-rewriter under ``vault/``."""
2
+ import os
3
+ from pathlib import Path
4
+
5
+
6
+ def atomic_write_text(path: Path, text: str) -> None:
7
+ """Write ``text`` to ``path`` atomically (sibling tempfile + ``os.replace``).
8
+
9
+ The temp file is a sibling of ``path`` (same parent directory) so the
10
+ rename never crosses a filesystem boundary — ``os.replace`` is atomic
11
+ on POSIX in that case (it's ``rename(2)`` underneath). A crash between
12
+ the write and the rename leaves the original file intact.
13
+
14
+ On any failure during the write or rename, the temp file is removed so
15
+ we don't leave stray ``*.tmp`` siblings cluttering the vault. Tempfile
16
+ cleanup uses ``unlink(missing_ok=True)`` because the failure may have
17
+ happened before the file was created.
18
+ """
19
+ tmp = path.with_name(f"{path.name}.tmp")
20
+ try:
21
+ tmp.write_text(text, encoding="utf-8")
22
+ os.replace(tmp, path)
23
+ except BaseException:
24
+ tmp.unlink(missing_ok=True)
25
+ raise
@@ -0,0 +1,228 @@
1
+ """Auto-generate ``<vault>/daily/index.md`` from the dailies on disk.
2
+
3
+ Phase 4.1 of the Wiki UX Overhaul. The home note's "Daily notes" door
4
+ points at ``daily/`` — Quartz resolves a folder reference to
5
+ ``daily/index`` if and only if a markdown file exists at that path.
6
+ Without :func:`regenerate_daily_index`, the door is a 404.
7
+
8
+ Contract:
9
+
10
+ * Walks ``<vault>/daily/<YYYY>/<YYYY-MM-DD>.md`` recursively (any depth
11
+ is tolerated, but the brain CLI only writes year-folder children) and
12
+ collects every ``.md`` file whose stem matches ``YYYY-MM-DD``.
13
+ ``index.md`` itself is excluded so a re-run of the regen doesn't
14
+ feed itself.
15
+ * Sorted reverse-chronological by filename. The filename is the
16
+ canonical truth (matches the slug Quartz emits, matches what the
17
+ user expects to see) — frontmatter ``date`` would be a second
18
+ truth-source that could drift.
19
+ * Body = ``- [[YYYY-MM-DD]]`` per line, newest first. Wiki-link form
20
+ uses the bare date stem so Quartz's ``markdownLinkResolution:
21
+ shortest`` resolves to the right path under ``daily/<YYYY>/``
22
+ without the regen having to know about year folders.
23
+ * Frontmatter: ``id`` and ``created`` are preserved across re-runs
24
+ (read from the existing file when present); ``updated`` is bumped
25
+ only when the body changed; ``autogenerated: true`` flags the
26
+ file as a brain-managed artifact users shouldn't hand-edit.
27
+ * Idempotent: when the body matches what's already on disk, the file
28
+ is left untouched (no ``updated`` churn, no atomic write). A test
29
+ asserts byte-identical output across two consecutive regens.
30
+ * Empty case: when no daily notes exist, the function does NOT write
31
+ ``index.md``. Combined with the home-page hide-if-empty
32
+ transformer this gives a coherent "no dailies → no door" UX. A
33
+ pre-existing ``index.md`` is left in place — deleting it would
34
+ surprise users who hand-curated the file before running the regen.
35
+
36
+ The atomic write uses :func:`brain.vault._atomic.atomic_write_text`
37
+ so a crash mid-write can never leave a half-written index visible to
38
+ Quartz's watcher.
39
+ """
40
+ from __future__ import annotations
41
+
42
+ import re
43
+ import uuid
44
+ from datetime import UTC, datetime
45
+ from pathlib import Path
46
+
47
+ import yaml
48
+
49
+ from ._atomic import atomic_write_text
50
+ from .frontmatter import dump_frontmatter, parse_frontmatter
51
+ from .paths import strip_md_extension
52
+
53
+ # Filename stem of a daily note: ``YYYY-MM-DD``. Anchored on both ends
54
+ # so a stray ``2026-04-29-rough-thoughts.md`` (a manually-named note
55
+ # under ``daily/``) doesn't get hoisted into the index.
56
+ _DAILY_STEM_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
57
+
58
+ _INDEX_FILENAME = "index.md"
59
+
60
+
61
+ def _list_daily_notes(daily_dir: Path) -> list[Path]:
62
+ """Return every ``YYYY-MM-DD.md`` daily note under ``daily_dir``.
63
+
64
+ Recursive — the brain CLI nests daily notes under
65
+ ``daily/<YYYY>/`` but a user who's flattened the structure
66
+ (or never year-folded their pre-2026 notes) is also supported. The
67
+ filename ``index.md`` is excluded so a stale auto-generated index
68
+ can't recursively feed itself.
69
+
70
+ Returns the empty list when ``daily_dir`` doesn't exist (the brain
71
+ vault scaffolds it via ``init_vault`` but a user pointing at an
72
+ unmanaged folder might not have one yet).
73
+ """
74
+ if not daily_dir.is_dir():
75
+ return []
76
+ out: list[Path] = []
77
+ for path in daily_dir.rglob("*.md"):
78
+ if path.name == _INDEX_FILENAME:
79
+ continue
80
+ if not path.is_file():
81
+ continue
82
+ if _DAILY_STEM_RE.match(path.stem):
83
+ out.append(path)
84
+ return out
85
+
86
+
87
+ def _build_index_body(daily_paths: list[Path], *, vault_path: Path) -> str:
88
+ """Return the body text for ``daily/index.md`` from ``daily_paths``.
89
+
90
+ Reverse-chronological by filename stem (``YYYY-MM-DD`` lex-sorts
91
+ correctly as date order). Each line is a path-form-aliased wiki
92
+ link — ``[[daily/<year>/<stem>|<stem>]]`` — matching the canonical
93
+ shape that :func:`brain.vault.link_rewrite.rewrite_wiki_links`
94
+ produces for resolved vault links.
95
+
96
+ Why path-form, not bare stem? Earlier prototype used
97
+ ``[[<stem>]]`` and relied on Quartz's ``markdownLinkResolution:
98
+ shortest`` to resolve the date-shaped target. That worked at the
99
+ Quartz level, but the post-write ``brain vault sync`` ran the link
100
+ rewriter over the body and replaced every bare stem with the path-
101
+ form alias. The next ``brain daily`` invocation then saw a body
102
+ mismatch (regen wanted bare stem, disk had path form), triggered
103
+ a rewrite, and bumped ``updated:`` — losing idempotency and
104
+ sending the watcher into a rebuild loop on every invocation.
105
+ Emitting the path-form here makes the regen output already
106
+ canonical, so the sync step is a no-op and consecutive runs
107
+ leave the file byte-identical.
108
+
109
+ The body opens with a ``# Daily notes`` heading and a one-line
110
+ preamble — Quartz uses the heading as the page title and the
111
+ preamble as the breadcrumb-friendly first paragraph. A trailing
112
+ newline closes the file in the conventional Unix way.
113
+ """
114
+ # Sort by stem desc — but keep the resolved path alongside so the
115
+ # path-form alias can be emitted without a second filesystem walk.
116
+ rels: list[tuple[str, str]] = []
117
+ for p in daily_paths:
118
+ try:
119
+ rel = p.resolve().relative_to(vault_path.resolve())
120
+ except ValueError:
121
+ # Defensive: ``rglob`` shouldn't surface a path outside
122
+ # ``daily_dir``, but a symlinked vault could in theory.
123
+ # Skip rather than crash.
124
+ continue
125
+ # POSIX form so the wiki link round-trips on Windows too. Strip
126
+ # ``.md`` to match what the link rewriter emits — shared helper
127
+ # in :mod:`brain.vault.paths` so the homepage / people / daily
128
+ # renderers cannot drift on the canonical link-target shape.
129
+ rels.append((p.stem, strip_md_extension(rel.as_posix())))
130
+ rels.sort(key=lambda t: t[0], reverse=True)
131
+
132
+ lines = [
133
+ "# Daily notes",
134
+ "",
135
+ (
136
+ "All daily notes, newest first. "
137
+ "This page is auto-generated — run `brain daily` to refresh it."
138
+ ),
139
+ "",
140
+ ]
141
+ for stem, posix in rels:
142
+ lines.append(f"- [[{posix}|{stem}]]")
143
+ lines.append("")
144
+ return "\n".join(lines)
145
+
146
+
147
+ def _read_existing_metadata(target: Path) -> tuple[str | None, str | None, str | None]:
148
+ """Return ``(id, created, body)`` from an existing index.md, or ``None``s.
149
+
150
+ Used to preserve ``id`` and ``created`` across re-runs (so the
151
+ auto-generated row keeps a stable DB identity even when the body
152
+ changes), and to short-circuit the rewrite when the body hasn't
153
+ changed (so the on-disk file stays byte-identical and the watcher
154
+ doesn't fire a needless rebuild).
155
+
156
+ Failure modes — missing file, malformed YAML, structurally invalid
157
+ frontmatter — return ``(None, None, None)`` so the caller treats
158
+ the situation as "regenerate from scratch." This is safe: a
159
+ corrupt index re-emerges with a fresh id; the user can always
160
+ rebind it via ``brain edit`` if they had hand-edits worth saving
161
+ (auto-generated files explicitly carry ``autogenerated: true`` to
162
+ discourage that workflow).
163
+ """
164
+ if not target.is_file():
165
+ return (None, None, None)
166
+ try:
167
+ text = target.read_text(encoding="utf-8")
168
+ except OSError:
169
+ return (None, None, None)
170
+ try:
171
+ fields, body = parse_frontmatter(text)
172
+ except (ValueError, yaml.YAMLError):
173
+ return (None, None, None)
174
+ raw_id = fields.get("id")
175
+ raw_created = fields.get("created")
176
+ return (
177
+ raw_id if isinstance(raw_id, str) else None,
178
+ raw_created if isinstance(raw_created, str) else None,
179
+ body,
180
+ )
181
+
182
+
183
+ def regenerate_daily_index(vault_path: Path) -> bool:
184
+ """Regenerate ``<vault>/daily/index.md`` from the dailies on disk.
185
+
186
+ Returns ``True`` if the file was written (or rewritten), ``False``
187
+ if the regen was a no-op (body unchanged, or no dailies to index).
188
+
189
+ Atomic — a crash mid-write leaves the previous ``index.md`` (if
190
+ any) intact. Idempotent — two consecutive calls against an
191
+ unchanged ``daily/`` produce a byte-identical file on disk.
192
+
193
+ Empty case: no daily notes ⇒ no write. A pre-existing
194
+ ``index.md`` is left in place rather than deleted; the user's
195
+ hand-curated file (if any) survives. The home-page hide-if-empty
196
+ transformer is the layer that reflects "no dailies" in the UI.
197
+ """
198
+ daily_dir = vault_path / "daily"
199
+ target = daily_dir / _INDEX_FILENAME
200
+
201
+ daily_paths = _list_daily_notes(daily_dir)
202
+ if not daily_paths:
203
+ return False
204
+
205
+ body = _build_index_body(daily_paths, vault_path=vault_path)
206
+
207
+ existing_id, existing_created, existing_body = _read_existing_metadata(target)
208
+ if existing_body is not None and existing_body == body:
209
+ # No content drift — skip rewrite to keep the file byte-identical
210
+ # and the watcher quiet.
211
+ return False
212
+
213
+ now_iso = datetime.now(UTC).isoformat(timespec="seconds")
214
+ doc_id = existing_id if existing_id else str(uuid.uuid4())
215
+ created = existing_created if existing_created else now_iso
216
+ fields: dict[str, object] = {
217
+ "id": doc_id,
218
+ "title": "Daily notes",
219
+ "created": created,
220
+ "updated": now_iso,
221
+ "tags": ["daily", "index"],
222
+ "kind": "vault",
223
+ "content_type": "note",
224
+ "autogenerated": True,
225
+ }
226
+ target.parent.mkdir(parents=True, exist_ok=True)
227
+ atomic_write_text(target, dump_frontmatter(fields, body))
228
+ return True