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/brief.py ADDED
@@ -0,0 +1,272 @@
1
+ """Proactive daily-digest assembler (``brain brief``).
2
+
3
+ Single responsibility: assemble :class:`BriefData` from the DB and optionally
4
+ synthesize next-step suggestions. No CLI, no MCP, no terminal formatting — the
5
+ callers (CLI + MCP) format the result. Reads recent captures (via
6
+ :mod:`brain.activity`), open action items (via :mod:`brain.todo`), and pinned
7
+ docs from the interactions log.
8
+
9
+ The LLM leg goes through :func:`brain.chat.chat_json` — NOT
10
+ :mod:`brain.enrichment` — so this module never imports the enricher (which would
11
+ create an ``enrichment → brief → enrichment`` import cycle). Only titles + todo
12
+ texts are forwarded to the model; document bodies never leave the DB.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import logging
17
+ from dataclasses import dataclass
18
+ from datetime import date, datetime
19
+ from pathlib import Path
20
+ from typing import Any
21
+
22
+ import psycopg
23
+
24
+ from . import chat
25
+ from .activity import recent_captures
26
+ from .config import Config
27
+ from .errors import EnrichmentError
28
+ from .queries import DocumentRow
29
+ from .todo import TodoRow, iter_action_item_docs
30
+ from .vault.frontmatter import dump_frontmatter
31
+
32
+ _logger = logging.getLogger(__name__)
33
+
34
+ # Cap on suggestions requested from the model.
35
+ _MAX_SUGGESTIONS = 5
36
+ # Cap on prompt context lines (titles / todo texts) to keep the prompt bounded.
37
+ _MAX_PROMPT_ITEMS = 20
38
+
39
+ _SUGGEST_SYSTEM = (
40
+ "You are a focused assistant proposing the user's next concrete steps for "
41
+ "the day. You are given recent capture titles and open action items (titles "
42
+ "and text only — no document bodies). Propose up to 5 short, specific, "
43
+ "actionable next steps grounded in that context. Return ONLY valid JSON:\n"
44
+ '{"suggestions": ["step 1", "step 2"]}\n'
45
+ "Each suggestion is one imperative sentence. Never invent facts not implied "
46
+ "by the inputs; return fewer suggestions (or an empty list) rather than "
47
+ "padding."
48
+ )
49
+
50
+
51
+ @dataclass(frozen=True)
52
+ class PinnedDoc:
53
+ """One pinned document (most-recent ``action='pinned'`` interaction)."""
54
+
55
+ document_id: str
56
+ title: str
57
+ pinned_at: datetime
58
+
59
+
60
+ @dataclass(frozen=True)
61
+ class BriefData:
62
+ """Assembled daily-brief payload. Pure value object; callers format it."""
63
+
64
+ date: date
65
+ captures: list[DocumentRow]
66
+ open_todos: list[TodoRow]
67
+ pinned: list[PinnedDoc]
68
+ suggestions: list[str]
69
+
70
+ def to_dict(self) -> dict[str, Any]:
71
+ """Serialize to the wire shape shared by ``brain brief --json`` + MCP."""
72
+ return {
73
+ "date": self.date.isoformat(),
74
+ "captures": [
75
+ {
76
+ "id": doc.id,
77
+ "title": doc.title,
78
+ "source": doc.source_kind,
79
+ "ingested_at": (
80
+ doc.ingested_at.isoformat() if doc.ingested_at else None
81
+ ),
82
+ }
83
+ for doc in self.captures
84
+ ],
85
+ "open_todos": [
86
+ {
87
+ "document_id": row.document_id,
88
+ "text": row.text,
89
+ "document_title": row.document_title,
90
+ "ingested_at": (
91
+ row.ingested_at.isoformat() if row.ingested_at else None
92
+ ),
93
+ }
94
+ for row in self.open_todos
95
+ ],
96
+ "pinned": [
97
+ {
98
+ "id": pin.document_id,
99
+ "title": pin.title,
100
+ "pinned_at": pin.pinned_at.isoformat(),
101
+ }
102
+ for pin in self.pinned
103
+ ],
104
+ "suggestions": self.suggestions,
105
+ }
106
+
107
+
108
+ def assemble_brief(
109
+ conn: psycopg.Connection[Any],
110
+ cfg: Config,
111
+ *,
112
+ since_hours: int,
113
+ todo_since_days: int,
114
+ on_date: date,
115
+ ) -> BriefData:
116
+ """Assemble the brief for ``on_date`` (no LLM — ``suggestions`` is empty).
117
+
118
+ ``since_hours`` bounds the recent-captures window; ``todo_since_days`` bounds
119
+ the open-action-item window; ``on_date`` is the header date (the caller
120
+ passes today so this stays deterministic under test). Suggestions are filled
121
+ separately by :func:`suggest_next_steps`.
122
+ """
123
+ captures = recent_captures(
124
+ conn, since_hours=since_hours, limit=cfg.brief_capture_limit
125
+ )
126
+ open_todos = list(
127
+ iter_action_item_docs(
128
+ conn, since_days=todo_since_days, include_closed=False
129
+ )
130
+ )
131
+ pinned = _pinned_docs(conn, limit=cfg.brief_pin_limit)
132
+ return BriefData(
133
+ date=on_date,
134
+ captures=captures,
135
+ open_todos=open_todos,
136
+ pinned=pinned,
137
+ suggestions=[],
138
+ )
139
+
140
+
141
+ def _pinned_docs(conn: psycopg.Connection[Any], *, limit: int) -> list[PinnedDoc]:
142
+ """Return the most-recently pinned docs (one row per doc, newest first).
143
+
144
+ A doc may be pinned more than once; ``MAX(at)`` collapses to the latest pin
145
+ timestamp per doc. ``DISTINCT ON`` is invalid alongside ``GROUP BY``, so the
146
+ grouped subquery feeds the title join.
147
+ """
148
+ rows = conn.execute(
149
+ """
150
+ SELECT d.id::text, d.title, p.pinned_at
151
+ FROM (
152
+ SELECT document_id, MAX(at) AS pinned_at
153
+ FROM interactions
154
+ WHERE action = 'pinned' AND document_id IS NOT NULL
155
+ GROUP BY document_id
156
+ ORDER BY MAX(at) DESC
157
+ LIMIT %s
158
+ ) p
159
+ JOIN documents d ON d.id = p.document_id
160
+ ORDER BY p.pinned_at DESC, d.id
161
+ """,
162
+ (limit,),
163
+ ).fetchall()
164
+ return [
165
+ PinnedDoc(document_id=r[0], title=r[1], pinned_at=r[2]) for r in rows
166
+ ]
167
+
168
+
169
+ def _build_suggest_prompt(brief: BriefData) -> str:
170
+ """Compose the suggestion prompt from titles + todo texts only (no bodies)."""
171
+ parts = [_SUGGEST_SYSTEM, "", "RECENT CAPTURES:"]
172
+ if brief.captures:
173
+ for doc in brief.captures[:_MAX_PROMPT_ITEMS]:
174
+ parts.append(f"- {doc.title}")
175
+ else:
176
+ parts.append("- (none)")
177
+ parts.append("")
178
+ parts.append("OPEN ACTION ITEMS:")
179
+ if brief.open_todos:
180
+ for row in brief.open_todos[:_MAX_PROMPT_ITEMS]:
181
+ parts.append(f"- {row.text}")
182
+ else:
183
+ parts.append("- (none)")
184
+ return "\n".join(parts)
185
+
186
+
187
+ def suggest_next_steps(brief: BriefData, cfg: Config) -> list[str]:
188
+ """Return up to 5 LLM-proposed next steps, or ``[]`` if Ollama is unavailable.
189
+
190
+ Assembles the prompt entirely from capture titles + todo texts and calls
191
+ :func:`brain.chat.chat_json`. Best-effort: on any
192
+ :class:`~brain.errors.EnrichmentError` (Ollama unavailable OR two malformed
193
+ JSON / schema-violating responses — ``OllamaUnavailable`` is a subclass) it
194
+ logs a warning and returns ``[]`` so the brief always renders. Non-string
195
+ entries from the model are dropped; the list is capped at five.
196
+ """
197
+ prompt = _build_suggest_prompt(brief)
198
+ try:
199
+ body = chat.chat_json(prompt, schema={"suggestions": "list"}, cfg=cfg)
200
+ except EnrichmentError as exc:
201
+ # OllamaUnavailable is an EnrichmentError subclass, so this single clause
202
+ # covers transport failure AND malformed-JSON-twice — never fail the brief.
203
+ _logger.warning(
204
+ "suggest_next_steps: synthesis failed (%s); returning no suggestions",
205
+ exc,
206
+ )
207
+ return []
208
+ raw = body.get("suggestions")
209
+ if not isinstance(raw, list):
210
+ return []
211
+ suggestions = [s.strip() for s in raw if isinstance(s, str) and s.strip()]
212
+ return suggestions[:_MAX_SUGGESTIONS]
213
+
214
+
215
+ def write_brief_to_vault(vault_path: Path, on_date: date, brief: BriefData) -> Path:
216
+ """Write the brief to ``<vault>/daily/<YYYY>/<date>-brief.md`` (read-only note).
217
+
218
+ A separate filename from ``brain daily``'s ``<date>.md`` so the two never
219
+ collide. The file is NOT ingested into Postgres — it is a rendered digest,
220
+ not a corpus document, so it creates no embedding churn. Surfaces titles +
221
+ todo texts only; never document bodies.
222
+ """
223
+ year_folder = f"{on_date.year:04d}"
224
+ target = vault_path / "daily" / year_folder / f"{on_date.isoformat()}-brief.md"
225
+ target.parent.mkdir(parents=True, exist_ok=True)
226
+ fields = {
227
+ "title": f"Brain Brief · {on_date.isoformat()}",
228
+ "date": on_date.isoformat(),
229
+ "kind": "brief",
230
+ "tags": ["brief", "daily"],
231
+ }
232
+ target.write_text(
233
+ dump_frontmatter(fields, _render_brief_body(brief)), encoding="utf-8"
234
+ )
235
+ return target
236
+
237
+
238
+ def _render_brief_body(brief: BriefData) -> str:
239
+ """Render the brief's Markdown body (titles + todo texts only)."""
240
+ lines = [
241
+ f"# Brain Brief · {brief.date.isoformat()}",
242
+ "",
243
+ "## Recent captures",
244
+ "",
245
+ ]
246
+ if brief.captures:
247
+ for doc in brief.captures:
248
+ kind = doc.source_kind or "manual"
249
+ lines.append(f"- [{kind}] {doc.title}")
250
+ else:
251
+ lines.append("_No recent captures._")
252
+
253
+ lines += ["", "## Open action items", ""]
254
+ if brief.open_todos:
255
+ for row in brief.open_todos:
256
+ lines.append(f"- [ ] {row.text}")
257
+ else:
258
+ lines.append("_No open action items._")
259
+
260
+ lines += ["", "## Pinned / follow-up docs", ""]
261
+ if brief.pinned:
262
+ for pin in brief.pinned:
263
+ lines.append(f"- {pin.title}")
264
+ else:
265
+ lines.append("_No pinned docs._")
266
+
267
+ if brief.suggestions:
268
+ lines += ["", "## Suggested next steps", ""]
269
+ for i, suggestion in enumerate(brief.suggestions, 1):
270
+ lines.append(f"{i}. {suggestion}")
271
+
272
+ return "\n".join(lines) + "\n"
brain/capture.py ADDED
@@ -0,0 +1,49 @@
1
+ """Pure helpers for the quick-capture inbox (`brain capture`): slug + title build."""
2
+ from __future__ import annotations
3
+
4
+ import re
5
+ import unicodedata
6
+ from datetime import date
7
+
8
+ # Runs of non-word characters separate slug segments WITHIN a single word.
9
+ # ``\w`` (with re.UNICODE) keeps unicode letters/digits/underscore so accented
10
+ # captures survive instead of being stripped to ASCII.
11
+ _NON_WORD_RUN = re.compile(r"[^\w]+", flags=re.UNICODE)
12
+
13
+
14
+ def slugify_title(text: str, max_words: int) -> str:
15
+ """Casefold ``text``, keep the first ``max_words`` whitespace words, hyphenate.
16
+
17
+ Words are split on whitespace, so a hyphenated token like ``project-ko``
18
+ counts as one word and survives intact. Within each word, any run of
19
+ non-word characters collapses to a single ``-`` and leading/trailing ``-``
20
+ are trimmed; empty words are dropped. The cleaned words are then joined
21
+ with ``-``.
22
+
23
+ Example::
24
+
25
+ slugify_title("follow up with person-a re project-ko", max_words=6)
26
+ # -> "follow-up-with-person-a-re-project-ko"
27
+
28
+ Raises:
29
+ ValueError: when ``max_words`` is less than 1.
30
+ """
31
+ if max_words < 1:
32
+ raise ValueError(f"max_words must be >= 1 (got {max_words})")
33
+ folded = unicodedata.normalize("NFKC", text).strip().casefold()
34
+ cleaned: list[str] = []
35
+ for raw_word in folded.split()[:max_words]:
36
+ slug_word = _NON_WORD_RUN.sub("-", raw_word).strip("-")
37
+ if slug_word:
38
+ cleaned.append(slug_word)
39
+ return "-".join(cleaned)
40
+
41
+
42
+ def make_capture_title(content: str, *, today: date, max_words: int) -> str:
43
+ """Build the deterministic auto-title for a capture.
44
+
45
+ Shape: ``<ISO date>-capture-<slug>`` (e.g. ``2026-06-04-capture-follow-up``).
46
+ ``today`` is injected by the caller so this stays pure and frozen-date
47
+ testable — never call ``date.today()`` here.
48
+ """
49
+ return f"{today.isoformat()}-capture-{slugify_title(content, max_words)}"
brain/chat.py ADDED
@@ -0,0 +1,293 @@
1
+ """Public JSON-mode Ollama chat helper shared across feature modules.
2
+
3
+ Extracted from :meth:`brain.enrichment.OllamaEnricher._chat_with_retry` so
4
+ feature modules (Plan 01 ``brief``, Plan 04 audio, Plan 06 ``ask``) can issue
5
+ JSON-mode ``/api/chat`` round-trips WITHOUT importing ``enrichment`` — which
6
+ would create import cycles (``enrichment`` → feature → ``enrichment``).
7
+ ``chat`` is the neutral home: it depends only on :class:`~brain.config.Config`
8
+ and the project error types.
9
+
10
+ Single implementation of the round-trip. :func:`chat_json` is the public
11
+ ``Config``-based convenience entry; :func:`chat_json_with_client` is the
12
+ lower-level core (caller supplies the long-lived ``httpx.Client`` and an
13
+ explicit message list). ``OllamaEnricher`` delegates its transport + retry to
14
+ :func:`chat_json_with_client` so the enricher keeps its persistent injected
15
+ client and exact two-message (system + user) wire shape, with zero duplicated
16
+ retry logic.
17
+ """
18
+ from __future__ import annotations
19
+
20
+ import json
21
+ import logging
22
+ from typing import Any
23
+
24
+ import httpx
25
+
26
+ from .config import Config, keep_alive_wire_value
27
+ from .errors import EnrichmentError, OllamaUnavailable
28
+
29
+ _logger = logging.getLogger(__name__)
30
+
31
+ # Default completion-length cap. Matches the historical enricher default so the
32
+ # delegated summary/tag calls keep their exact prior budget.
33
+ DEFAULT_NUM_PREDICT = 256
34
+
35
+ # One chat message: ``{"role": ..., "content": ...}``.
36
+ ChatMessage = dict[str, str]
37
+
38
+ # Stringified boolean tokens local models commonly emit instead of a JSON bool.
39
+ # Shared by :func:`coerce_bool`; kept in lockstep with the (raise-on-unknown)
40
+ # variant in :meth:`brain.enrichment.OllamaEnricher._coerce_contradicts`.
41
+ _TRUE_TOKENS = frozenset({"true", "yes", "1"})
42
+ _FALSE_TOKENS = frozenset({"false", "no", "0"})
43
+
44
+
45
+ def coerce_bool(value: Any, *, default: bool = False) -> bool:
46
+ """Coerce a model's JSON boolean-ish field into a real ``bool``.
47
+
48
+ Local models frequently emit a *stringified* boolean (``"true"`` /
49
+ ``"false"``) or an int (``1`` / ``0``) where a JSON boolean was asked for.
50
+ Passing such a value straight through ``bool(...)`` is a trap: ``bool("false")``
51
+ is ``True`` (any non-empty string is truthy), so a control-flow flag like
52
+ ``ask``'s ``sufficient`` verdict silently flips.
53
+
54
+ Normalisation:
55
+
56
+ * a real ``bool`` returns as-is;
57
+ * an ``int`` (``1`` → ``True``, ``0`` → ``False``; any other int → ``default``);
58
+ * a ``str`` matched case-insensitively / trimmed against ``"true"/"yes"/"1"``
59
+ (→ ``True``) and ``"false"/"no"/"0"`` (→ ``False``);
60
+ * anything else (an unrecognised string, a float, a dict, ``None``) falls
61
+ back to ``default``.
62
+
63
+ Unlike :meth:`brain.enrichment.OllamaEnricher._coerce_contradicts` this never
64
+ raises: it is meant for control-flow flags where a safe default beats an
65
+ abort. Callers pick the conservative fallback (``ask`` passes ``default=False``
66
+ so an unparseable sufficiency verdict keeps the retrieval loop going).
67
+ """
68
+ if isinstance(value, bool):
69
+ return value
70
+ if isinstance(value, int): # bool already handled above; plain 1/0 only.
71
+ if value == 1:
72
+ return True
73
+ if value == 0:
74
+ return False
75
+ return default
76
+ if isinstance(value, str):
77
+ token = value.strip().lower()
78
+ if token in _TRUE_TOKENS:
79
+ return True
80
+ if token in _FALSE_TOKENS:
81
+ return False
82
+ return default
83
+
84
+
85
+ def _build_client(host: str, timeout: float) -> httpx.Client:
86
+ """Construct the Ollama HTTP client (single seam tests patch).
87
+
88
+ Kept as a tiny factory so unit tests can swap in an ``httpx.Client`` backed
89
+ by a :class:`httpx.MockTransport` without monkey-patching production
90
+ transport internals.
91
+ """
92
+ return httpx.Client(base_url=host, timeout=httpx.Timeout(timeout))
93
+
94
+
95
+ def chat_json(
96
+ prompt: str,
97
+ *,
98
+ schema: dict[str, Any],
99
+ cfg: Config,
100
+ model: str | None = None,
101
+ num_predict: int | None = None,
102
+ timeout: float | None = None,
103
+ ) -> dict[str, Any]:
104
+ """Issue a JSON-mode ``/api/chat`` call and return the parsed object.
105
+
106
+ Canonical signature shared across Plans 01 / 04 / 06 (locked in the
107
+ roadmap README cross-plan decisions).
108
+
109
+ Args:
110
+ prompt: The full user-turn instruction. Callers fold any system
111
+ framing directly into this string — ``chat_json`` sends a single
112
+ ``user`` message so feature modules own their whole prompt.
113
+ schema: A mapping whose KEYS are the required top-level keys of the
114
+ returned JSON object. Every key must be present in the model's
115
+ response or the call retries (and ultimately raises). Values are
116
+ ignored here (they exist for the caller's own documentation of the
117
+ expected shape).
118
+ cfg: Supplies the Ollama host, default model (``cfg.enrich_model``),
119
+ ``keep_alive``, and default timeout (``cfg.enrich_timeout_seconds``).
120
+ model: Per-call model override; defaults to ``cfg.enrich_model``.
121
+ num_predict: Per-call completion-length cap; defaults to
122
+ :data:`DEFAULT_NUM_PREDICT`.
123
+ timeout: Per-call HTTP timeout (seconds); defaults to
124
+ ``cfg.enrich_timeout_seconds``.
125
+
126
+ Returns:
127
+ The parsed JSON object (a ``dict``) with every key in ``schema`` present.
128
+
129
+ Raises:
130
+ OllamaUnavailable: Transport failure / 5xx (no retry — fail fast).
131
+ EnrichmentError: Two consecutive malformed-JSON / schema-violating
132
+ responses, or a 4xx permanent error.
133
+ """
134
+ resolved_model = model or cfg.enrich_model
135
+ resolved_timeout = (
136
+ timeout if timeout is not None else cfg.enrich_timeout_seconds
137
+ )
138
+ resolved_num_predict = (
139
+ num_predict if num_predict is not None else DEFAULT_NUM_PREDICT
140
+ )
141
+ required_keys = tuple(schema.keys())
142
+ messages: list[ChatMessage] = [{"role": "user", "content": prompt}]
143
+ with _build_client(cfg.ollama_host, resolved_timeout) as client:
144
+ return chat_json_with_client(
145
+ client,
146
+ model=resolved_model,
147
+ messages=messages,
148
+ required_keys=required_keys,
149
+ keep_alive=cfg.ollama_keep_alive,
150
+ num_predict=resolved_num_predict,
151
+ )
152
+
153
+
154
+ def chat_json_with_client(
155
+ client: httpx.Client,
156
+ *,
157
+ model: str,
158
+ messages: list[ChatMessage],
159
+ required_keys: tuple[str, ...],
160
+ keep_alive: str,
161
+ num_predict: int = DEFAULT_NUM_PREDICT,
162
+ ) -> dict[str, Any]:
163
+ """Issue an ``/api/chat`` JSON-mode call with one retry on parse failure.
164
+
165
+ The shared retry core. The caller supplies the (possibly long-lived)
166
+ ``client`` and an explicit ``messages`` list so both the ``Config``-based
167
+ :func:`chat_json` (single user message, per-call client) and
168
+ :class:`brain.enrichment.OllamaEnricher` (system + user messages, persistent
169
+ injected client) route through one implementation.
170
+
171
+ Returns the parsed JSON object on success. Raises
172
+ :class:`OllamaUnavailable` on transient transport errors (no retry — fail
173
+ fast so callers can skip). Raises :class:`EnrichmentError` when two
174
+ consecutive attempts fail JSON parsing / schema validation.
175
+ """
176
+ last_error: Exception | None = None
177
+ current_num_predict = num_predict
178
+ for attempt in (1, 2):
179
+ try:
180
+ response_text = _chat_once(
181
+ client,
182
+ model=model,
183
+ messages=messages,
184
+ keep_alive=keep_alive,
185
+ num_predict=current_num_predict,
186
+ )
187
+ except OllamaUnavailable:
188
+ # Transient — propagate immediately, no retry.
189
+ raise
190
+ try:
191
+ parsed = json.loads(response_text)
192
+ except json.JSONDecodeError as exc:
193
+ last_error = exc
194
+ # Truncation: the model exhausted ``num_predict`` before closing the
195
+ # JSON. Two signals, both meaning "the response was cut off, not
196
+ # malformed from the start": an unterminated string (cut mid-value),
197
+ # OR a decode failure that lands at/after the end of the received text
198
+ # (cut mid-object — e.g. "Expecting ',' delimiter" / "Expecting
199
+ # property name …" at EOF). Either way the SAME budget would
200
+ # re-truncate deterministically at temperature 0.0, so double
201
+ # ``num_predict`` for the retry to give the next call room to finish.
202
+ # A genuinely malformed response (garbage decoded well before EOF, e.g.
203
+ # "Expecting value" at position 0) is NOT truncation and doesn't bump —
204
+ # the retry there is a deliberate best-effort second try. Schema /
205
+ # non-dict failures don't reach this branch.
206
+ if "Unterminated" in exc.msg or exc.pos >= len(response_text):
207
+ current_num_predict *= 2
208
+ _logger.debug(
209
+ "chat attempt %d: JSON decode failed (%s); response=%r",
210
+ attempt,
211
+ exc,
212
+ response_text[:200],
213
+ )
214
+ continue
215
+ if not isinstance(parsed, dict):
216
+ last_error = EnrichmentError(
217
+ f"chat returned non-object JSON: {parsed!r}"
218
+ )
219
+ continue
220
+ missing = [k for k in required_keys if k not in parsed]
221
+ if missing:
222
+ last_error = EnrichmentError(
223
+ f"chat response missing required keys {missing}: {parsed!r}"
224
+ )
225
+ continue
226
+ return parsed
227
+ # Both attempts failed.
228
+ assert last_error is not None
229
+ raise EnrichmentError(
230
+ f"chat failed after 2 attempts: {last_error}"
231
+ ) from last_error
232
+
233
+
234
+ def _chat_once(
235
+ client: httpx.Client,
236
+ *,
237
+ model: str,
238
+ messages: list[ChatMessage],
239
+ keep_alive: str,
240
+ num_predict: int = DEFAULT_NUM_PREDICT,
241
+ ) -> str:
242
+ """Single ``/api/chat`` round-trip returning the inner content string.
243
+
244
+ Returns the ``message.content`` field verbatim so the caller can
245
+ ``json.loads`` it. Maps transport-layer failures to
246
+ :class:`OllamaUnavailable` (CLAUDE.md "no bare except" — only
247
+ ``httpx.HTTPError`` and ``ValueError`` are caught here, matching the
248
+ embedder's pattern). ``num_predict`` caps the completion length.
249
+ """
250
+ request_body = {
251
+ "model": model,
252
+ "stream": False,
253
+ "format": "json",
254
+ "keep_alive": keep_alive_wire_value(keep_alive),
255
+ "messages": messages,
256
+ "options": {"temperature": 0.0, "num_predict": num_predict},
257
+ }
258
+ try:
259
+ response = client.post("/api/chat", json=request_body)
260
+ response.raise_for_status()
261
+ payload = response.json()
262
+ except httpx.HTTPStatusError as exc:
263
+ status = exc.response.status_code if exc.response is not None else 0
264
+ body_preview = (
265
+ exc.response.text[:200] if exc.response is not None else "<no body>"
266
+ )
267
+ if status >= 500:
268
+ raise OllamaUnavailable(
269
+ f"Ollama returned HTTP {status}: {body_preview}"
270
+ ) from exc
271
+ # 4xx — permanent (bad model name, malformed request).
272
+ raise EnrichmentError(
273
+ f"Ollama returned HTTP {status}: {body_preview}"
274
+ ) from exc
275
+ except (httpx.ConnectError, httpx.ConnectTimeout, httpx.ReadTimeout) as exc:
276
+ raise OllamaUnavailable(f"Ollama unreachable: {exc}") from exc
277
+ except httpx.HTTPError as exc:
278
+ raise OllamaUnavailable(f"Ollama transport error: {exc}") from exc
279
+ except ValueError as exc:
280
+ # json.JSONDecodeError is a ValueError — a 200 OK with non-JSON body
281
+ # leaks as a raw decode error otherwise.
282
+ raise EnrichmentError(
283
+ f"Ollama returned non-JSON envelope: {exc}"
284
+ ) from exc
285
+ message = payload.get("message") if isinstance(payload, dict) else None
286
+ if not isinstance(message, dict):
287
+ raise EnrichmentError(f"Ollama response missing 'message': {payload!r}")
288
+ content = message.get("content")
289
+ if not isinstance(content, str):
290
+ raise EnrichmentError(
291
+ f"Ollama message.content is not a string: {message!r}"
292
+ )
293
+ return content