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/rank_fusion.py ADDED
@@ -0,0 +1,8 @@
1
+ """Pure Reciprocal Rank Fusion (RRF) scoring helper shared across rankers."""
2
+
3
+ __all__ = ["rrf_contribution"]
4
+
5
+
6
+ def rrf_contribution(rank: int, *, k: int = 60) -> float:
7
+ """RRF contribution ``1 / (k + rank + 1)`` for a 0-indexed ``rank`` (default ``k=60``)."""
8
+ return 1.0 / (k + rank + 1)
brain/resurface.py ADDED
@@ -0,0 +1,210 @@
1
+ """Spaced-repetition resurfacing: score older, unreviewed docs for revisit.
2
+
3
+ Pure on-the-fly scoring over existing signals (Plan 02) -- no new table, no
4
+ state drift. Each document is scored from three signals already in the schema:
5
+
6
+ 1. **Age** -- ``coalesce(documents.sent_at, documents.ingested_at)``.
7
+ 2. **Last access** -- ``MAX(interactions.at)`` over ``opened`` / ``clicked``
8
+ rows (``NULL`` => never reviewed).
9
+ 3. **Importance** -- tag count + summary presence.
10
+
11
+ ``resurface_docs`` runs one read-only SQL query, computes ages in the DB (so
12
+ doc timestamps, last-access timestamps, and "now" share one clock), then scores
13
+ and sorts in Python. ``score_document`` is the pure-logic core, isolated so it
14
+ can be unit-tested without a database.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ from dataclasses import dataclass
19
+ from typing import Any
20
+
21
+ import psycopg
22
+ from psycopg import sql
23
+
24
+ from .config import Config
25
+
26
+ # Content types never worth resurfacing: action-item digests are operational,
27
+ # not knowledge to revisit. Excluded by the SQL guard.
28
+ _EXCLUDED_CONTENT_TYPES: tuple[str, ...] = ("krisp_action_items",)
29
+
30
+ # Length of the body preview returned with each item. Matches the search
31
+ # snippet convention; never emits the full body (privacy -- spec §6).
32
+ _SNIPPET_CHARS = 200
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class ResurfaceItem:
37
+ """One scored document due for review.
38
+
39
+ ``last_access_days`` is ``None`` when the document has never been opened or
40
+ clicked (treated as "never reviewed" by the scorer). ``snippet`` is the
41
+ first :data:`_SNIPPET_CHARS` characters of the body -- the full ``content``
42
+ column is never emitted.
43
+ """
44
+
45
+ id: str
46
+ title: str
47
+ source_kind: str | None
48
+ content_type: str
49
+ tags: list[str]
50
+ age_days: float
51
+ last_access_days: float | None
52
+ score: float
53
+ snippet: str
54
+
55
+
56
+ def score_document(
57
+ *,
58
+ age_days: float,
59
+ last_access_days: float | None,
60
+ tag_count: int | None,
61
+ has_summary: bool,
62
+ age_halflife_days: float,
63
+ access_halflife_days: float,
64
+ ) -> float:
65
+ """Compute the resurface score for one document.
66
+
67
+ ``resurface_score = age_factor * access_staleness * importance_factor`` where
68
+
69
+ - ``age_factor = 1 - 0.5^(age_days / age_halflife_days)`` -- the inverted
70
+ ``search.py`` recency decay, so older docs score higher (saturates toward
71
+ 1.0 well beyond the half-life).
72
+ - ``access_staleness = 1 - 0.5^(effective_access / access_halflife_days)``
73
+ where ``effective_access`` is ``last_access_days`` when the doc has been
74
+ opened, else ``age_days`` (a never-opened doc is treated as stale for its
75
+ whole life). A doc opened today scores near 0; never-opened scores near 1.
76
+ - ``importance_factor = 1.0 + 0.1*tag_count + 0.2*has_summary`` -- a mild
77
+ additive boost for richer docs. No LLM call.
78
+
79
+ All time inputs are in days. ``tag_count`` may be ``None`` (treated as 0).
80
+ The half-lives are required positive floats (validated upstream by
81
+ :class:`brain.config.Config`); this function does not re-validate them.
82
+ """
83
+ effective_access = last_access_days if last_access_days is not None else age_days
84
+ age_factor = 1.0 - 0.5 ** (age_days / age_halflife_days)
85
+ access_staleness = 1.0 - 0.5 ** (effective_access / access_halflife_days)
86
+ importance_factor = 1.0 + 0.1 * (tag_count or 0) + (0.2 if has_summary else 0.0)
87
+ return float(age_factor * access_staleness * importance_factor)
88
+
89
+
90
+ def resurface_docs(
91
+ conn: psycopg.Connection[Any],
92
+ *,
93
+ cfg: Config,
94
+ limit: int | None = None,
95
+ min_age_days: int | None = None,
96
+ source_kind: str | None = None,
97
+ ) -> list[ResurfaceItem]:
98
+ """Return the top scored documents due for review, highest score first.
99
+
100
+ Pulls every non-draft, non-action-item document older than
101
+ ``min_age_days`` (default :attr:`Config.resurface_min_age_days`), computes
102
+ each document's age and last-access age in the DB, scores them with
103
+ :func:`score_document`, and returns the top ``limit``
104
+ (default :attr:`Config.resurface_limit`) by descending score. Ties break on
105
+ older-first then id, for a deterministic order.
106
+
107
+ Args:
108
+ conn: Live Postgres connection (read-only query).
109
+ cfg: Active config -- supplies the limit / min-age / half-life defaults.
110
+ limit: Max items to return; ``None`` uses ``cfg.resurface_limit``.
111
+ min_age_days: Exclude docs younger than this; ``None`` uses
112
+ ``cfg.resurface_min_age_days``.
113
+ source_kind: Optional ``sources.kind`` filter (e.g. ``"manual"``).
114
+
115
+ Returns:
116
+ Up to ``limit`` :class:`ResurfaceItem` rows, descending score. Empty
117
+ when the corpus is empty or every doc is younger than ``min_age_days``.
118
+ """
119
+ effective_limit = cfg.resurface_limit if limit is None else limit
120
+ effective_min_age = (
121
+ cfg.resurface_min_age_days if min_age_days is None else min_age_days
122
+ )
123
+ # Validate the explicit override path. ``cfg`` values are already validated
124
+ # by Config.load(), but a direct CLI/MCP/API caller can still pass a bad
125
+ # value — and a bad one is silently wrong, not loud: limit=0 returns
126
+ # nothing, limit<0 slices "all but the last N" via ``items[:limit]``, and a
127
+ # negative min_age shifts the cutoff into the future (admitting brand-new /
128
+ # future-dated docs). Reject both with a clear ValueError; the CLI maps it
129
+ # to BadParameter and the MCP tool to INVALID_PARAMS.
130
+ if effective_limit < 1:
131
+ raise ValueError(f"limit must be an integer >= 1 (got {effective_limit})")
132
+ if effective_min_age < 0:
133
+ raise ValueError(
134
+ f"min_age_days must be a non-negative integer (got {effective_min_age})"
135
+ )
136
+
137
+ # WHERE composition mirrors queries.list_documents: static SQL fragments
138
+ # joined with " AND ", every user value bound via a %s placeholder.
139
+ where = [
140
+ "d.draft IS NOT TRUE",
141
+ "d.content_type <> ALL(%s)",
142
+ "coalesce(d.sent_at, d.ingested_at) IS NOT NULL",
143
+ "coalesce(d.sent_at, d.ingested_at) < now() - make_interval(days => %s)",
144
+ ]
145
+ params: list[Any] = [list(_EXCLUDED_CONTENT_TYPES), effective_min_age]
146
+ if source_kind:
147
+ where.append("s.kind = %s")
148
+ params.append(source_kind)
149
+
150
+ query = sql.SQL(
151
+ """
152
+ SELECT
153
+ d.id::text,
154
+ d.title,
155
+ d.content_type,
156
+ d.tags,
157
+ s.kind AS source_kind,
158
+ left(d.content, {snippet_chars}) AS snippet,
159
+ array_length(d.tags, 1) AS tag_count,
160
+ (d.summary IS NOT NULL) AS has_summary,
161
+ EXTRACT(
162
+ EPOCH FROM (now() - coalesce(d.sent_at, d.ingested_at))
163
+ ) / 86400.0 AS age_days,
164
+ EXTRACT(EPOCH FROM (now() - MAX(i.at))) / 86400.0 AS last_access_days
165
+ FROM documents d
166
+ LEFT JOIN sources s ON s.id = d.source_id
167
+ LEFT JOIN interactions i
168
+ ON i.document_id = d.id
169
+ AND i.action IN ('opened', 'clicked')
170
+ WHERE {where}
171
+ GROUP BY d.id, s.kind
172
+ """
173
+ ).format(
174
+ snippet_chars=sql.Literal(_SNIPPET_CHARS),
175
+ where=sql.SQL(" AND ").join(sql.SQL(w) for w in where),
176
+ )
177
+
178
+ rows = conn.execute(query, params).fetchall()
179
+
180
+ items: list[ResurfaceItem] = []
181
+ for r in rows:
182
+ age_days = float(r[8])
183
+ last_access_days = float(r[9]) if r[9] is not None else None
184
+ tag_count = int(r[6]) if r[6] is not None else None
185
+ has_summary = bool(r[7])
186
+ score = score_document(
187
+ age_days=age_days,
188
+ last_access_days=last_access_days,
189
+ tag_count=tag_count,
190
+ has_summary=has_summary,
191
+ age_halflife_days=cfg.resurface_age_halflife_days,
192
+ access_halflife_days=cfg.resurface_access_halflife_days,
193
+ )
194
+ items.append(
195
+ ResurfaceItem(
196
+ id=r[0],
197
+ title=r[1],
198
+ source_kind=r[4],
199
+ content_type=r[2],
200
+ tags=list(r[3] or []),
201
+ age_days=age_days,
202
+ last_access_days=last_access_days,
203
+ score=score,
204
+ snippet=r[5] or "",
205
+ )
206
+ )
207
+
208
+ # Highest score first; ties resolve to older-first then id for determinism.
209
+ items.sort(key=lambda it: (-it.score, -it.age_days, it.id))
210
+ return items[:effective_limit]
@@ -0,0 +1,26 @@
1
+ """``brain review`` — periodic synthesis over the corpus.
2
+
3
+ Plan 10 contributes the ``weekly`` leg (this package's :mod:`weekly` /
4
+ :mod:`render` / :mod:`emit`). Plan 03 adds the ``scan`` / ``list`` / ``dismiss``
5
+ leg (:mod:`scans` + :mod:`queries`) for contradiction + staleness detection.
6
+ The package is the shared home for the unified ``brain review`` subcommand tree.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from .emit import emit_weekly_page
11
+ from .render import render_weekly_json, render_weekly_md, render_weekly_rich
12
+ from .scans import ReviewFinding, run_conflict_scan, run_staleness_scan
13
+ from .weekly import ThemeBlock, WeeklyReport, build_weekly_report
14
+
15
+ __all__ = [
16
+ "ReviewFinding",
17
+ "ThemeBlock",
18
+ "WeeklyReport",
19
+ "build_weekly_report",
20
+ "emit_weekly_page",
21
+ "render_weekly_json",
22
+ "render_weekly_md",
23
+ "render_weekly_rich",
24
+ "run_conflict_scan",
25
+ "run_staleness_scan",
26
+ ]
brain/review/emit.py ADDED
@@ -0,0 +1,27 @@
1
+ """Vault I/O for weekly review pages.
2
+
3
+ One responsibility: where the page lands on disk and how it is written. The
4
+ page is rendered by :func:`brain.review.render.render_weekly_md` and written
5
+ atomically (sibling tempfile + ``os.replace``), so a re-run for the same week
6
+ overwrites byte-for-byte — idempotent.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from pathlib import Path
11
+
12
+ from ..vault._atomic import atomic_write_text
13
+ from .render import render_weekly_md
14
+ from .weekly import WeeklyReport
15
+
16
+
17
+ def emit_weekly_page(vault_root: Path, report: WeeklyReport) -> Path:
18
+ """Write the weekly review page to ``<vault_root>/reviews/<week>.md``.
19
+
20
+ Creates the ``reviews/`` directory if absent. Returns the written path.
21
+ Re-running with an equal report overwrites atomically with identical bytes
22
+ (idempotent), because :func:`render_weekly_md` is deterministic.
23
+ """
24
+ target = vault_root / "reviews" / f"{report.week}.md"
25
+ target.parent.mkdir(parents=True, exist_ok=True)
26
+ atomic_write_text(target, render_weekly_md(report))
27
+ return target