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/ingest/gmail.py ADDED
@@ -0,0 +1,621 @@
1
+ """Gmail ingester — shells out to the `gws` CLI.
2
+
3
+ Uses the real ``gws gmail users messages list/get`` surface. Each operation
4
+ accepts a JSON-encoded ``--params`` blob that mirrors the underlying Gmail
5
+ REST API. Message bodies returned by the ``get`` call are base64url-encoded
6
+ and may live either on ``payload.body.data`` or inside ``payload.parts[]``
7
+ (multi-part messages) — this module normalises both shapes into plain text.
8
+ """
9
+ import base64
10
+ import html
11
+ import json
12
+ import re
13
+ import shutil
14
+ import subprocess
15
+ from collections.abc import Callable
16
+ from datetime import UTC, datetime
17
+ from email.utils import parsedate_to_datetime
18
+ from typing import Any
19
+
20
+ from brain.config import BOILERPLATE_PATTERNS
21
+
22
+ # Re-using the shared Re/Fwd-prefix helper from vault.slug rather than
23
+ # duplicating the regex — it's marked private (leading underscore) but the
24
+ # strip rule is identical between the URL slug (``gmail_slug``) and the
25
+ # thread-doc title, and a divergent local copy would invite drift. Single
26
+ # source of truth wins over the soft visibility convention here.
27
+ from brain.vault.slug import _strip_re_fwd_prefixes
28
+
29
+ from . import ExtractedDoc
30
+
31
+ # Compile boilerplate patterns once at import time. Per-pattern ``(?s)``
32
+ # inline flags opt individual entries into DOTALL; see ``BOILERPLATE_PATTERNS``
33
+ # in ``brain.config`` for the rationale.
34
+ _BOILERPLATE_REGEXES: tuple[re.Pattern[str], ...] = tuple(
35
+ re.compile(p, re.MULTILINE | re.IGNORECASE) for p in BOILERPLATE_PATTERNS
36
+ )
37
+
38
+ # Run-collapse threshold: only collapse runs of consecutive identical lines
39
+ # longer than this (in characters). Short repeated lines (signoffs, blank
40
+ # separators, "ok") stay untouched.
41
+ _COLLAPSE_MIN_LINE_LEN = 40
42
+
43
+
44
+ class GmailError(RuntimeError):
45
+ """Raised when the `gws` CLI is missing or returns a non-zero exit."""
46
+
47
+
48
+ Runner = Callable[[list[str]], str]
49
+
50
+
51
+ def _build_query(
52
+ *,
53
+ query: str | None,
54
+ label: str | None,
55
+ since: str | None,
56
+ until: str | None,
57
+ from_addr: str | None,
58
+ ) -> str:
59
+ """Compose the Gmail search ``q`` string from the CLI scope flags.
60
+
61
+ Drafts are now **included** by default (wave Q1-A, 2026-05-11). The
62
+ per-message extractor (:func:`to_extracted_doc`) and per-thread
63
+ extractor (:func:`to_extracted_thread`) stamp
64
+ ``metadata["_is_draft"] = True`` on all-draft documents so the ingest
65
+ pipeline can set ``documents.draft = TRUE``. The P1.6 wiki quarantine
66
+ (``contentIndex.ts:397``) hides those rows from the Quartz build;
67
+ ``brain search`` / ``brain show`` still surface them so "what was I
68
+ going to email person-x about?" is answerable.
69
+ """
70
+ parts: list[str] = []
71
+ if query:
72
+ parts.append(query)
73
+ if label:
74
+ parts.append(f"label:{label}")
75
+ if from_addr:
76
+ parts.append(f"from:{from_addr}")
77
+ if since:
78
+ parts.append(f"after:{since}")
79
+ if until:
80
+ parts.append(f"before:{until}")
81
+ return " ".join(parts)
82
+
83
+
84
+ def list_messages(
85
+ *,
86
+ query: str | None = None,
87
+ label: str | None = None,
88
+ since: str | None = None,
89
+ until: str | None = None,
90
+ from_addr: str | None = None,
91
+ max_results: int = 50,
92
+ runner: Runner | None = None,
93
+ ) -> list[dict[str, Any]]:
94
+ """Return a list of message stubs: ``[{"id": ..., "threadId": ...}, ...]``.
95
+
96
+ Calls ``gws gmail users messages list --params <json> --format json`` and
97
+ returns the ``messages`` array. The Gmail API omits the ``messages`` key
98
+ entirely when there are zero matches, so we coalesce to an empty list.
99
+ """
100
+ q = _build_query(
101
+ query=query, label=label, since=since, until=until, from_addr=from_addr
102
+ )
103
+ params: dict[str, Any] = {"userId": "me", "maxResults": max_results}
104
+ if q:
105
+ params["q"] = q
106
+ out = _run(
107
+ [
108
+ "gws", "gmail", "users", "messages", "list",
109
+ "--params", json.dumps(params),
110
+ "--format", "json",
111
+ ],
112
+ runner,
113
+ )
114
+ parsed = json.loads(out) if out.strip() else {}
115
+ return list(parsed.get("messages") or [])
116
+
117
+
118
+ def read_message(message_id: str, *, runner: Runner | None = None) -> dict[str, Any]:
119
+ """Return the full Gmail Message resource for ``message_id``.
120
+
121
+ Calls ``gws gmail users messages get --params <json> --format json`` with
122
+ ``format=full`` so the response includes headers and the message body
123
+ payload (possibly multipart).
124
+ """
125
+ params = {"userId": "me", "id": message_id, "format": "full"}
126
+ out = _run(
127
+ [
128
+ "gws", "gmail", "users", "messages", "get",
129
+ "--params", json.dumps(params),
130
+ "--format", "json",
131
+ ],
132
+ runner,
133
+ )
134
+ parsed: dict[str, Any] = json.loads(out)
135
+ return parsed
136
+
137
+
138
+ def _headers_to_dict(headers: list[dict[str, str]]) -> dict[str, str]:
139
+ """Flatten a Gmail ``headers`` list to a ``{name.lower(): value}`` dict."""
140
+ return {h.get("name", "").lower(): h.get("value", "") for h in headers or []}
141
+
142
+
143
+ def _decode_body_data(data: str) -> str:
144
+ """Decode a Gmail API ``body.data`` base64url string into UTF-8 text."""
145
+ if not data:
146
+ return ""
147
+ padded = data + "=" * (-len(data) % 4)
148
+ raw = base64.urlsafe_b64decode(padded)
149
+ return raw.decode("utf-8", errors="replace")
150
+
151
+
152
+ def _strip_html(text: str) -> str:
153
+ """Naive HTML→text fallback: drop tags and collapse whitespace."""
154
+ text = re.sub(r"<style.*?</style>", "", text, flags=re.IGNORECASE | re.DOTALL)
155
+ text = re.sub(r"<script.*?</script>", "", text, flags=re.IGNORECASE | re.DOTALL)
156
+ text = re.sub(r"<[^>]+>", " ", text)
157
+ text = re.sub(r"\s+", " ", text)
158
+ return text.strip()
159
+
160
+
161
+ def _extract_body(payload: dict[str, Any]) -> str:
162
+ """Pull plain text out of a Gmail payload, recursing into parts.
163
+
164
+ Strategy: prefer ``text/plain`` parts (aggregated), fall back to
165
+ ``text/html`` (aggregated and HTML-stripped), and if neither is present,
166
+ use whatever data is on the root payload's ``body``.
167
+ """
168
+ plain_chunks: list[str] = []
169
+ html_chunks: list[str] = []
170
+
171
+ def visit(node: dict[str, Any]) -> None:
172
+ mime = (node.get("mimeType") or "").lower()
173
+ body = node.get("body") or {}
174
+ data = body.get("data") or ""
175
+ if mime == "text/plain" and data:
176
+ plain_chunks.append(_decode_body_data(data))
177
+ elif mime == "text/html" and data:
178
+ html_chunks.append(_decode_body_data(data))
179
+ for child in node.get("parts") or []:
180
+ visit(child)
181
+
182
+ visit(payload)
183
+ if plain_chunks:
184
+ return "\n\n".join(plain_chunks)
185
+ if html_chunks:
186
+ return _strip_html("\n\n".join(html_chunks))
187
+ root_data = (payload.get("body") or {}).get("data") or ""
188
+ return _decode_body_data(root_data) if root_data else ""
189
+
190
+
191
+ def _collapse_repeated_long_lines(body: str) -> str:
192
+ """Collapse runs of ≥2 consecutive identical lines longer than the threshold.
193
+
194
+ Operates on the line view of ``body`` (split on ``\\n`` so empty trailing
195
+ lines from a trailing newline are preserved). Only lines whose stripped
196
+ length exceeds :data:`_COLLAPSE_MIN_LINE_LEN` are eligible — short
197
+ repetitions (sign-offs, blank lines, single-word pleasantries) are left
198
+ alone.
199
+ """
200
+ if not body:
201
+ return body
202
+ lines = body.split("\n")
203
+ out: list[str] = []
204
+ i = 0
205
+ while i < len(lines):
206
+ line = lines[i]
207
+ # Look for a run of identical lines starting at i.
208
+ j = i + 1
209
+ while j < len(lines) and lines[j] == line:
210
+ j += 1
211
+ run_length = j - i
212
+ if run_length >= 2 and len(line.strip()) > _COLLAPSE_MIN_LINE_LEN:
213
+ out.append(line)
214
+ else:
215
+ out.extend(lines[i:j])
216
+ i = j
217
+ return "\n".join(out)
218
+
219
+
220
+ def strip_boilerplate(body: str) -> str:
221
+ """Remove repeated email signatures / disclaimers / mobile-app footers.
222
+
223
+ 1. Collapse runs of identical lines (>40 chars) to one occurrence.
224
+ 2. Strip patterns from ``BOILERPLATE_PATTERNS`` (config.py).
225
+ Quoted-reply markers below the most recent message are left alone for
226
+ single-message ingest; thread assembly (Phase 2) will revisit.
227
+ """
228
+ if not body:
229
+ return body
230
+ body = _collapse_repeated_long_lines(body)
231
+ for regex in _BOILERPLATE_REGEXES:
232
+ body = regex.sub("", body)
233
+ return body.strip()
234
+
235
+
236
+ def _parse_date_header_to_iso_utc(raw: str | None) -> str | None:
237
+ """Parse an RFC 2822 ``Date:`` header into an ISO-8601 UTC string.
238
+
239
+ Returns ``None`` when ``raw`` is missing/empty or unparseable so the
240
+ caller can omit the field rather than crash the ingest. Naive datetimes
241
+ (rare in real Gmail headers) are treated as UTC.
242
+ """
243
+ if not raw or not raw.strip():
244
+ return None
245
+ try:
246
+ parsed = parsedate_to_datetime(raw)
247
+ except (TypeError, ValueError):
248
+ return None
249
+ if parsed is None:
250
+ return None
251
+ if parsed.tzinfo is None:
252
+ parsed = parsed.replace(tzinfo=UTC)
253
+ return parsed.astimezone(UTC).isoformat()
254
+
255
+
256
+ def _is_draft(msg: dict[str, Any]) -> bool:
257
+ """Return True when the Gmail Message resource carries the ``DRAFT`` label.
258
+
259
+ Gmail's draft state is communicated via ``labelIds`` on the Message
260
+ resource. Drafts are unsent — the user typed them but never sent —
261
+ and ingesting them pollutes the searchable corpus. Ingest paths use
262
+ this helper to short-circuit on drafts before doing any work.
263
+ """
264
+ label_ids = msg.get("labelIds") or []
265
+ return "DRAFT" in label_ids
266
+
267
+
268
+ def to_extracted_doc(msg: dict[str, Any]) -> ExtractedDoc:
269
+ """Build an :class:`ExtractedDoc` from a Gmail ``users.messages.get`` response.
270
+
271
+ Draft messages (``labelIds`` containing ``DRAFT``) are now included
272
+ rather than rejected. ``metadata["_is_draft"]`` (leading underscore
273
+ signals a derived/internal key, mirroring ``_participant_keys``) is
274
+ set to ``True`` so the ingest pipeline can stamp
275
+ ``documents.draft = TRUE`` and the P1.6 wiki quarantine hides the
276
+ doc from Quartz while ``brain search`` / ``brain show`` still
277
+ surface it.
278
+ """
279
+ payload = msg.get("payload") or {}
280
+ headers = _headers_to_dict(payload.get("headers") or [])
281
+ title = headers.get("subject") or "(no subject)"
282
+ body = strip_boilerplate(_extract_body(payload).strip())
283
+ metadata: dict[str, Any] = {
284
+ "from": headers.get("from"),
285
+ "to": headers.get("to"),
286
+ "date": headers.get("date"),
287
+ "message_id": msg.get("id"),
288
+ "thread_id": msg.get("threadId"),
289
+ "label_ids": msg.get("labelIds") or [],
290
+ "_is_draft": _is_draft(msg),
291
+ }
292
+ # New typed-column feeders (P1.3). Each key is omitted when the source
293
+ # header is absent so downstream column-promotion stays NULL rather than
294
+ # storing empty strings.
295
+ rfc_id = headers.get("message-id")
296
+ if rfc_id:
297
+ metadata["rfc_message_id"] = rfc_id
298
+ in_reply_to = headers.get("in-reply-to")
299
+ if in_reply_to:
300
+ metadata["in_reply_to"] = in_reply_to
301
+ sent_at = _parse_date_header_to_iso_utc(headers.get("date"))
302
+ if sent_at is not None:
303
+ metadata["sent_at"] = sent_at
304
+ return ExtractedDoc(
305
+ title=title,
306
+ content=body,
307
+ content_type="email",
308
+ source_path=None,
309
+ metadata=metadata,
310
+ )
311
+
312
+
313
+ # ---------------------------------------------------------------------------
314
+ # Thread assembly (P2.1)
315
+ #
316
+ # A Gmail thread is N messages sharing the same ``threadId``. P2.1 collapses
317
+ # them into one ``ExtractedDoc`` so downstream search / vault export operates
318
+ # on a single conversational unit instead of N near-duplicate per-message
319
+ # rows. The function is pure: no DB, no I/O, no logging at INFO.
320
+ # ---------------------------------------------------------------------------
321
+
322
+ # Cap the assembled-thread title at 200 chars (after Re/Fwd strip) so a
323
+ # pathological subject line — e.g. an auto-mailer that crams a multi-line
324
+ # log into the subject — doesn't blow out the ``documents.title`` column or
325
+ # the wiki UI. Truncation is whole-word-aware; the URL slug uses a separate
326
+ # 64-char cap inside ``vault.slug.gmail_slug``.
327
+ _THREAD_TITLE_MAX = 200
328
+
329
+
330
+ def _message_sort_key(msg: dict[str, Any]) -> int:
331
+ """Return a milliseconds-since-epoch sort key for a Gmail message.
332
+
333
+ Prefer ``internalDate`` (Gmail's canonical receive timestamp, expressed
334
+ as a string of ms-since-epoch). When ``internalDate`` is missing or
335
+ unparseable, fall back to ``Date:`` header parsed via the standard
336
+ library. Returns ``0`` when neither is parseable — the call site keeps
337
+ such messages but their order is undefined; the spec only requires
338
+ defensive handling, not stable tie-breaking.
339
+ """
340
+ raw = msg.get("internalDate")
341
+ if isinstance(raw, int):
342
+ return raw
343
+ if isinstance(raw, str) and raw.strip():
344
+ try:
345
+ return int(raw)
346
+ except ValueError:
347
+ pass
348
+
349
+ payload = msg.get("payload") or {}
350
+ headers = _headers_to_dict(payload.get("headers") or [])
351
+ date_header = headers.get("date")
352
+ if date_header:
353
+ try:
354
+ parsed = parsedate_to_datetime(date_header)
355
+ except (TypeError, ValueError):
356
+ parsed = None
357
+ if parsed is not None:
358
+ if parsed.tzinfo is None:
359
+ parsed = parsed.replace(tzinfo=UTC)
360
+ return int(parsed.astimezone(UTC).timestamp() * 1000)
361
+ return 0
362
+
363
+
364
+ def _truncate_title_to_word(title: str, *, limit: int = _THREAD_TITLE_MAX) -> str:
365
+ """Truncate ``title`` to ``limit`` chars at a word boundary, append ``…``.
366
+
367
+ Returns the input unchanged when ``len(title) <= limit``. Otherwise:
368
+
369
+ 1. Take the first ``limit`` characters.
370
+ 2. Cut back to the last space within that window so we don't slice
371
+ through a word. If no space exists (single very long token), keep
372
+ the hard slice.
373
+ 3. ``rstrip`` trailing whitespace and append the U+2026 ellipsis.
374
+
375
+ Final length is at most ``limit + 1`` (one ellipsis char appended).
376
+ """
377
+ if len(title) <= limit:
378
+ return title
379
+ chunk = title[:limit]
380
+ last_space = chunk.rfind(" ")
381
+ if last_space > 0:
382
+ chunk = chunk[:last_space]
383
+ return f"{chunk.rstrip()}…"
384
+
385
+
386
+ def _split_addresses(value: str | None) -> list[str]:
387
+ """Split a comma-separated To/Cc header value into individual entries.
388
+
389
+ Simple comma split — does NOT handle the rare RFC 5322 quoted-pair case
390
+ (``"Last, First" <addr>``). Test fixtures and live Gmail traffic stay
391
+ unquoted in practice; revisit with ``email.utils.getaddresses`` only if
392
+ we hit a real-world false-split. Empty / whitespace-only entries are
393
+ dropped.
394
+ """
395
+ if not value:
396
+ return []
397
+ return [part.strip() for part in value.split(",") if part.strip()]
398
+
399
+
400
+ def _format_thread_section(msg: dict[str, Any], *, collapsed: bool) -> str:
401
+ """Render one message as a Markdown section for the assembled thread body.
402
+
403
+ The most recent message uses a plain ``## YYYY-MM-DD HH:MM — <from>``
404
+ H2 (always expanded). Older messages wrap in ``<details><summary>``
405
+ so they collapse by default — both Markdown processors and Quartz
406
+ pass HTML through, and ``<details>`` is supported natively by every
407
+ modern browser.
408
+
409
+ Date format is ``YYYY-MM-DD HH:MM`` in UTC. The ``from`` value is the
410
+ raw header (``"Name <email>"``) for fidelity. Each message body passes
411
+ through :func:`strip_boilerplate` first.
412
+ """
413
+ payload = msg.get("payload") or {}
414
+ headers = _headers_to_dict(payload.get("headers") or [])
415
+ raw_from = headers.get("from") or "(unknown sender)"
416
+
417
+ iso_utc = _parse_date_header_to_iso_utc(headers.get("date"))
418
+ if iso_utc is not None:
419
+ date_label = datetime.fromisoformat(iso_utc).astimezone(UTC).strftime(
420
+ "%Y-%m-%d %H:%M"
421
+ )
422
+ else:
423
+ date_label = "(unknown date)"
424
+
425
+ body = strip_boilerplate(_extract_body(payload).strip())
426
+ heading = f"{date_label} — {raw_from}"
427
+
428
+ if collapsed:
429
+ # P4.4 fix: HTML-escape the summary content. Gmail headers
430
+ # routinely carry the From in `Name <email@addr>` form, and
431
+ # markdown processors (Quartz / CommonMark) treat raw HTML
432
+ # blocks like ``<details>...</details>`` as opaque pass-
433
+ # through. The browser then parses the inner ``<summary>Name
434
+ # <email@addr></summary>`` and treats ``<email@addr>`` as an
435
+ # unknown HTML tag — silently stripping it from the rendered
436
+ # text. Escaping ``<`` / ``>`` / ``&`` keeps the address
437
+ # visible in the rendered summary AND keeps the email
438
+ # substring available to the P4.4 "Show only my replies"
439
+ # JS filter (which reads ``summary.textContent`` and matches
440
+ # against ``window.BRAIN_USER_EMAIL``). Without the escape,
441
+ # the user's own historical replies inside ``<details>``
442
+ # mismatch the filter and stay visible when the toggle is on.
443
+ # Apply ``quote=False`` so single/double quotes pass through
444
+ # — the surrounding HTML uses no quote-delimited attributes
445
+ # on ``<summary>``.
446
+ escaped_heading = html.escape(heading, quote=False)
447
+ # Blank lines around the body are required for markdown processors
448
+ # (and Quartz) to render the inner content as markdown rather than
449
+ # a single HTML block.
450
+ return (
451
+ f"<details>\n"
452
+ f"<summary>{escaped_heading}</summary>\n"
453
+ f"\n"
454
+ f"{body}\n"
455
+ f"\n"
456
+ f"</details>"
457
+ )
458
+ return f"## {heading}\n\n{body}"
459
+
460
+
461
+ def to_extracted_thread(messages: list[dict[str, Any]]) -> ExtractedDoc:
462
+ """Group N gmail messages from the same thread into one ExtractedDoc.
463
+
464
+ Pure function — no DB writes, no file I/O, no logging at INFO level.
465
+ Messages are sorted ascending by ``internalDate`` (defensive fallback to
466
+ ``Date:`` header) and assembled into a single Markdown document, one
467
+ H2 per message. The most recent message renders as a plain H2; older
468
+ ones wrap in ``<details><summary>`` so they collapse by default.
469
+
470
+ Title is the FIRST message's subject after stripping leading
471
+ ``Re:`` / ``Fwd:`` / ``Fw:`` prefixes (case-insensitive, repeated).
472
+ Empty subjects fall back to ``"(no subject)"``. Subjects longer than
473
+ 200 chars are word-boundary truncated with a trailing ``…``.
474
+
475
+ Metadata aggregation — first-vs-latest is asymmetric on purpose:
476
+
477
+ - ``thread_id`` is the FIRST message's ``threadId`` (stable across the
478
+ whole thread; first-vs-last is a no-op in practice but the spec
479
+ pins ``first`` so re-ingestion is deterministic if a future Gmail
480
+ bug ever splits a thread mid-conversation).
481
+ - ``rfc_message_id``, ``in_reply_to``, ``from``, ``to``, ``date``,
482
+ ``sent_at`` come from the LATEST message — the thread doc tracks
483
+ the most recent reply so an in-flight conversation surfaces with
484
+ its newest state.
485
+ - ``participants`` is the union of From + To + Cc across ALL
486
+ messages, deduped case-insensitively, sorted case-insensitively for
487
+ stability.
488
+ - ``label_ids`` is the sorted union of ``labelIds`` across all
489
+ messages — a thread inherits IMPORTANT / STARRED if any reply
490
+ carries it.
491
+ - ``message_count`` is N.
492
+
493
+ Tags are not produced here — the per-message extractor doesn't tag
494
+ either. Phase 2.4's destructive collapse will preserve tags by
495
+ unioning across the source per-message rows; new threaded ingests
496
+ pick tags up via ``brain tag <id> +foo`` post-ingest.
497
+
498
+ Draft handling is asymmetric on purpose:
499
+
500
+ - **All-draft thread**: every message carries the ``DRAFT`` label →
501
+ the full thread is assembled intact and
502
+ ``metadata["_is_draft"] = True`` is set. The ingest pipeline stamps
503
+ ``documents.draft = TRUE``; the P1.6 wiki quarantine hides the doc
504
+ from Quartz while ``brain search`` can still find it ("what was I
505
+ going to email person-x about?").
506
+ - **Mixed thread**: at least one sent message → drafts are dropped
507
+ from the rendered body (body, participants, label_ids, message_count
508
+ all reflect the post-filter sent set) and
509
+ ``metadata["_is_draft"] = False`` is set. This prevents a WIP
510
+ unsent reply from appearing as the visible H2 in a published thread.
511
+ - **Empty input**: raises ``ValueError`` (programmer error — callers
512
+ must supply at least one message).
513
+
514
+ Raises:
515
+ ValueError: ``messages`` is empty.
516
+ """
517
+ if not messages:
518
+ raise ValueError("to_extracted_thread requires at least one message")
519
+
520
+ all_drafts = all(_is_draft(m) for m in messages)
521
+ if all_drafts:
522
+ # All-draft thread: assemble the full list; the wiki quarantine
523
+ # hides the resulting doc; brain search still surfaces it.
524
+ sorted_msgs = sorted(messages, key=_message_sort_key)
525
+ else:
526
+ # Mixed or fully-sent thread: drop drafts from the rendered body
527
+ # so a WIP unsent reply doesn't appear as the visible H2.
528
+ sorted_msgs = sorted(
529
+ [m for m in messages if not _is_draft(m)], key=_message_sort_key
530
+ )
531
+ first = sorted_msgs[0]
532
+ latest = sorted_msgs[-1]
533
+
534
+ first_headers = _headers_to_dict(
535
+ (first.get("payload") or {}).get("headers") or []
536
+ )
537
+ latest_headers = _headers_to_dict(
538
+ (latest.get("payload") or {}).get("headers") or []
539
+ )
540
+
541
+ raw_subject = first_headers.get("subject") or ""
542
+ stripped_subject = _strip_re_fwd_prefixes(raw_subject).strip()
543
+ title = stripped_subject or "(no subject)"
544
+ title = _truncate_title_to_word(title)
545
+
546
+ last_idx = len(sorted_msgs) - 1
547
+ sections = [
548
+ _format_thread_section(msg, collapsed=(idx != last_idx))
549
+ for idx, msg in enumerate(sorted_msgs)
550
+ ]
551
+ body = "\n\n".join(sections)
552
+
553
+ # Participants: From + To + Cc across every message, deduped case-
554
+ # insensitively (first-seen form wins so a "Alice <a@x.com>" sighting
555
+ # is preferred over a later "ALICE <a@x.com>"), then sorted case-
556
+ # insensitively for stability across re-ingests.
557
+ seen: dict[str, str] = {}
558
+ for msg in sorted_msgs:
559
+ headers = _headers_to_dict((msg.get("payload") or {}).get("headers") or [])
560
+ candidates: list[str] = []
561
+ from_hdr = headers.get("from")
562
+ if from_hdr:
563
+ candidates.append(from_hdr)
564
+ candidates.extend(_split_addresses(headers.get("to")))
565
+ candidates.extend(_split_addresses(headers.get("cc")))
566
+ for addr in candidates:
567
+ key = addr.casefold()
568
+ if key not in seen:
569
+ seen[key] = addr
570
+ participants = sorted(seen.values(), key=str.casefold)
571
+
572
+ label_ids: set[str] = set()
573
+ for msg in sorted_msgs:
574
+ label_ids.update(msg.get("labelIds") or [])
575
+
576
+ metadata: dict[str, Any] = {
577
+ "thread_id": first.get("threadId"),
578
+ "from": latest_headers.get("from"),
579
+ "to": latest_headers.get("to"),
580
+ "date": latest_headers.get("date"),
581
+ "label_ids": sorted(label_ids),
582
+ "participants": participants,
583
+ "message_count": len(sorted_msgs),
584
+ # Internal flag for the ingest pipeline: True when every message
585
+ # in the original (unfiltered) input was a DRAFT. The pipeline
586
+ # uses this to stamp documents.draft = TRUE and route the doc
587
+ # through the P1.6 wiki quarantine. Leading underscore mirrors
588
+ # the ``_participant_keys`` convention for derived fields.
589
+ "_is_draft": all_drafts,
590
+ }
591
+ rfc_id = latest_headers.get("message-id")
592
+ if rfc_id:
593
+ metadata["rfc_message_id"] = rfc_id
594
+ in_reply_to = latest_headers.get("in-reply-to")
595
+ if in_reply_to:
596
+ metadata["in_reply_to"] = in_reply_to
597
+ sent_at = _parse_date_header_to_iso_utc(latest_headers.get("date"))
598
+ if sent_at is not None:
599
+ metadata["sent_at"] = sent_at
600
+
601
+ return ExtractedDoc(
602
+ title=title,
603
+ content=body,
604
+ content_type="email_thread",
605
+ source_path=None,
606
+ metadata=metadata,
607
+ )
608
+
609
+
610
+ def _run(cmd: list[str], runner: Runner | None = None) -> str:
611
+ """Execute ``cmd`` and return stdout; delegate to ``runner`` when supplied (tests)."""
612
+ if runner is not None:
613
+ return runner(cmd)
614
+ if not shutil.which(cmd[0]): # pragma: no cover - requires missing gws on PATH
615
+ raise GmailError(f"`{cmd[0]}` CLI not found on PATH")
616
+ proc = subprocess.run( # pragma: no cover - exercised only against the real gws CLI
617
+ cmd, capture_output=True, text=True, check=False
618
+ )
619
+ if proc.returncode != 0: # pragma: no cover - requires real gws failure
620
+ raise GmailError(f"{' '.join(cmd)} failed: {proc.stderr.strip()}")
621
+ return proc.stdout # pragma: no cover - requires real gws success
@@ -0,0 +1,37 @@
1
+ """Markdown extractor — preserves source syntax, surfaces heading list as metadata."""
2
+ import re
3
+ from pathlib import Path
4
+
5
+ from . import ExtractedDoc
6
+
7
+ _HEADING_RE = re.compile(r"^(#{1,6})\s+(.+)$", re.MULTILINE)
8
+
9
+
10
+ def extract_markdown(path: Path) -> ExtractedDoc:
11
+ """Extract an :class:`ExtractedDoc` from a Markdown file on disk.
12
+
13
+ The body is preserved verbatim — code fences (``` ``` ```), heading
14
+ markers (``#``), bullet markers (``-``/``*``/``+``), inline emphasis,
15
+ and link URLs all survive into ``ExtractedDoc.content``. Earlier
16
+ versions of this extractor ran a flatten-to-plaintext pass before
17
+ storing, which silently erased code blocks, headings, and list
18
+ structure from the corpus — callers that round-trip through
19
+ ``brain vault export`` lost rendering fidelity for any markdown that
20
+ wasn't pure prose.
21
+
22
+ Headings are still surfaced separately in ``metadata["headings"]`` for
23
+ callers that want a structured outline (search snippet builders,
24
+ etc.); the title is the first heading found, falling back to the
25
+ file's stem.
26
+ """
27
+ raw = Path(path).read_text(encoding="utf-8", errors="replace")
28
+ headings = [m.group(2).strip() for m in _HEADING_RE.finditer(raw)]
29
+ title = headings[0] if headings else Path(path).stem
30
+
31
+ return ExtractedDoc(
32
+ title=title,
33
+ content=raw.strip(),
34
+ content_type="markdown",
35
+ source_path=str(Path(path).resolve()),
36
+ metadata={"headings": headings},
37
+ )