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/activity.py ADDED
@@ -0,0 +1,206 @@
1
+ """Time-windowed activity-reader queries (shared: brain brief + brain review weekly).
2
+
3
+ Single home for the read-only, time-bounded queries that both proactivity
4
+ features need: which documents were interacted with in a window
5
+ (:func:`iter_activity_docs`), which were ingested in a window
6
+ (:func:`iter_ingested_docs`), and which were captured in the last N hours
7
+ (:func:`recent_captures`). Plus :func:`week_bounds`, the ISO-week parser.
8
+
9
+ Import direction is one-way: ``brain.brief`` → ``brain.activity`` and
10
+ ``brain.review.weekly`` → ``brain.activity``, never the reverse. This module
11
+ has no dependency on the brief or weekly-report shapes — it only knows query
12
+ shapes — so it stays the neutral shared layer.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import re
17
+ from dataclasses import dataclass
18
+ from datetime import UTC, datetime
19
+ from typing import Any
20
+
21
+ import psycopg
22
+
23
+ from .queries import DocumentRow
24
+
25
+ # Strict ``YYYY-Www`` shape (e.g. ``2026-W23``). Anchored so a stray suffix or a
26
+ # bare year is rejected before reaching ``datetime.fromisocalendar`` (which
27
+ # would otherwise raise a less-actionable error).
28
+ _ISO_WEEK_RE = re.compile(r"^(\d{4})-W(\d{2})$")
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class ActivityDoc:
33
+ """One document interacted with inside an activity window.
34
+
35
+ ``interaction_count`` is the number of ``interactions`` rows for this doc in
36
+ the window; ``last_at`` is the most recent of them. ``tags`` is the doc's
37
+ tag array (may be empty).
38
+ """
39
+
40
+ document_id: str
41
+ title: str
42
+ interaction_count: int
43
+ last_at: datetime
44
+ tags: list[str]
45
+
46
+
47
+ @dataclass(frozen=True)
48
+ class IngestedDoc:
49
+ """One document ingested inside an activity window.
50
+
51
+ ``source_kind`` is the joined ``sources.kind`` (``None`` for sourceless
52
+ manual docs). ``tags`` is the doc's tag array (may be empty).
53
+ """
54
+
55
+ document_id: str
56
+ title: str
57
+ ingested_at: datetime
58
+ source_kind: str | None
59
+ tags: list[str]
60
+
61
+
62
+ def week_bounds(iso_week: str) -> tuple[datetime, datetime]:
63
+ """Parse ``"YYYY-Www"`` → ``(monday 00:00 UTC, sunday 23:59:59 UTC)``.
64
+
65
+ Uses :meth:`datetime.fromisocalendar`, so ISO year-boundaries are correct:
66
+ ``week_bounds("2026-W01")`` → ``2025-12-29 00:00`` .. ``2026-01-04 23:59:59``.
67
+
68
+ Raises:
69
+ ValueError: ``iso_week`` is not the strict ``YYYY-Www`` shape, or the
70
+ week number is out of range for the ISO year (e.g. ``2026-W53``).
71
+ """
72
+ match = _ISO_WEEK_RE.match(iso_week)
73
+ if match is None:
74
+ raise ValueError(
75
+ f"week must be in 'YYYY-Www' form (e.g. '2026-W23'); got {iso_week!r}"
76
+ )
77
+ year, week = int(match.group(1)), int(match.group(2))
78
+ # fromisocalendar raises ValueError on week 0 / out-of-range weeks.
79
+ start = datetime.fromisocalendar(year, week, 1).replace(tzinfo=UTC)
80
+ end = datetime.fromisocalendar(year, week, 7).replace(
81
+ hour=23, minute=59, second=59, tzinfo=UTC
82
+ )
83
+ return start, end
84
+
85
+
86
+ def current_iso_week() -> str:
87
+ """Return the current ISO week as ``"YYYY-Www"`` (UTC), e.g. ``"2026-W23"``.
88
+
89
+ The default ``--week`` for ``brain review weekly``. Uses ``%G-W%V`` so the
90
+ ISO year + zero-padded ISO week round-trip through :func:`week_bounds`.
91
+ """
92
+ return datetime.now(UTC).strftime("%G-W%V")
93
+
94
+
95
+ def iter_activity_docs(
96
+ conn: psycopg.Connection[Any],
97
+ *,
98
+ after: datetime,
99
+ before: datetime,
100
+ limit: int = 20,
101
+ ) -> list[ActivityDoc]:
102
+ """Return documents interacted with in ``[after, before]``, busiest first.
103
+
104
+ Joins ``interactions`` to ``documents`` over the time window, counts
105
+ interactions per doc, and orders by interaction count (then recency, then
106
+ id) for a deterministic result. Graph-target interaction rows
107
+ (``document_id IS NULL``) are excluded. Returns ``[]`` for an empty window.
108
+ """
109
+ rows = conn.execute(
110
+ """
111
+ SELECT d.id::text, d.title, d.tags,
112
+ COUNT(*) AS interaction_count,
113
+ MAX(i.at) AS last_at
114
+ FROM interactions i
115
+ JOIN documents d ON d.id = i.document_id
116
+ WHERE i.at BETWEEN %s AND %s
117
+ AND i.document_id IS NOT NULL
118
+ GROUP BY d.id, d.title, d.tags
119
+ ORDER BY interaction_count DESC, last_at DESC, d.id
120
+ LIMIT %s
121
+ """,
122
+ (after, before, limit),
123
+ ).fetchall()
124
+ return [
125
+ ActivityDoc(
126
+ document_id=r[0],
127
+ title=r[1],
128
+ tags=list(r[2] or []),
129
+ interaction_count=int(r[3]),
130
+ last_at=r[4],
131
+ )
132
+ for r in rows
133
+ ]
134
+
135
+
136
+ def iter_ingested_docs(
137
+ conn: psycopg.Connection[Any],
138
+ *,
139
+ after: datetime,
140
+ before: datetime,
141
+ limit: int = 10,
142
+ ) -> list[IngestedDoc]:
143
+ """Return documents ingested in ``[after, before]``, newest first.
144
+
145
+ Left-joins ``sources`` so ``source_kind`` is populated (``None`` for manual
146
+ docs without a source row). Ordered by ingest time then id for determinism.
147
+ Returns ``[]`` for an empty window.
148
+ """
149
+ rows = conn.execute(
150
+ """
151
+ SELECT d.id::text, d.title, d.ingested_at, d.tags, s.kind
152
+ FROM documents d
153
+ LEFT JOIN sources s ON s.id = d.source_id
154
+ WHERE d.ingested_at BETWEEN %s AND %s
155
+ ORDER BY d.ingested_at DESC, d.id
156
+ LIMIT %s
157
+ """,
158
+ (after, before, limit),
159
+ ).fetchall()
160
+ return [
161
+ IngestedDoc(
162
+ document_id=r[0],
163
+ title=r[1],
164
+ ingested_at=r[2],
165
+ tags=list(r[3] or []),
166
+ source_kind=r[4],
167
+ )
168
+ for r in rows
169
+ ]
170
+
171
+
172
+ def recent_captures(
173
+ conn: psycopg.Connection[Any],
174
+ *,
175
+ since_hours: int,
176
+ limit: int,
177
+ ) -> list[DocumentRow]:
178
+ """Return docs ingested in the last ``since_hours`` hours, newest first.
179
+
180
+ The ``brain brief`` "recent captures" section. Returns the light
181
+ :class:`brain.queries.DocumentRow` projection (no body / summary), mirroring
182
+ :func:`brain.queries.list_documents`. The window is computed in SQL relative
183
+ to ``NOW()`` so the caller need not pass a timestamp.
184
+ """
185
+ rows = conn.execute(
186
+ """
187
+ SELECT d.id::text, d.title, d.content_type, d.tags, s.kind, d.ingested_at
188
+ FROM documents d
189
+ LEFT JOIN sources s ON s.id = d.source_id
190
+ WHERE d.ingested_at >= NOW() - (%s * INTERVAL '1 hour')
191
+ ORDER BY d.ingested_at DESC, d.id
192
+ LIMIT %s
193
+ """,
194
+ (since_hours, limit),
195
+ ).fetchall()
196
+ return [
197
+ DocumentRow(
198
+ id=r[0],
199
+ title=r[1],
200
+ content_type=r[2],
201
+ tags=list(r[3] or []),
202
+ source_kind=r[4],
203
+ ingested_at=r[5],
204
+ )
205
+ for r in rows
206
+ ]