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/vault/graph.py ADDED
@@ -0,0 +1,620 @@
1
+ """Read-only link graph queries over the ``links`` / ``unresolved_links`` /
2
+ ``derived_links`` tables.
3
+
4
+ Phase 4 read API: backlinks, outgoing links, orphans, and a
5
+ :class:`GraphData` snapshot suitable for export. Plain SELECTs over
6
+ Phase 2's wiki-link materialization plus Phase 5's metadata-derived edges —
7
+ no schema changes, no writes, no embedder dependency. Callers pass a
8
+ ``psycopg.Connection``; this module never opens or closes one itself.
9
+
10
+ Resolution conventions:
11
+
12
+ - ``document_id`` is always a full UUID (string). Callers needing to
13
+ resolve a user-supplied prefix should hand off to
14
+ :func:`brain.queries.resolve_document_prefix` first.
15
+ - ``vault_only=True`` filters by ``documents.kind='vault'`` — the spec's
16
+ default for orphan / graph views since ingested-tier nodes (Krisp,
17
+ Slack, Gmail mirrors) usually have no ``[[refs]]`` and clutter the graph.
18
+
19
+ Derived-edge semantics:
20
+
21
+ - ``derived_links`` rows are stored in canonical ``(LEAST, GREATEST)``
22
+ order and carry an undirected meaning ("these two docs share a
23
+ thread / participant"). Read paths therefore treat a derived edge as
24
+ symmetric: both :func:`backlinks_for` and :func:`outgoing_links_for`
25
+ return the partner regardless of which side of the storage row the
26
+ caller's document sits on.
27
+ - Edges materialized in Python carry ``link_kind='derived'``. The schema
28
+ CHECK in migration 003 still restricts ``links.link_kind`` to
29
+ ``'wiki' | 'embed'`` — derived edges live in a sibling table, so the
30
+ enum extension is a Python-only convention.
31
+ - Wiki-link edges keep ``rule=None``, ``weight=None``,
32
+ ``evidence=None``; derived edges populate all three.
33
+ """
34
+ from collections import deque
35
+ from collections.abc import Iterable
36
+ from dataclasses import dataclass
37
+ from typing import Any
38
+
39
+ import psycopg
40
+
41
+
42
+ @dataclass(frozen=True)
43
+ class GraphNode:
44
+ """A document node in the link graph.
45
+
46
+ ``kind`` mirrors ``documents.kind`` (``'vault'`` or ``'ingested'``);
47
+ formatters use it to color/style nodes (vault = primary, ingested =
48
+ muted).
49
+ """
50
+
51
+ document_id: str
52
+ title: str
53
+ kind: str
54
+
55
+
56
+ @dataclass(frozen=True)
57
+ class GraphEdge:
58
+ """An edge in the link graph — wiki-link, embed, or metadata-derived.
59
+
60
+ ``link_kind`` is one of:
61
+
62
+ - ``'wiki'`` / ``'embed'`` — wiki-link materialization (per the
63
+ ``links.link_kind`` CHECK constraint). ``link_text`` carries the
64
+ raw ``[[X]]`` exactly as it appeared in the source body;
65
+ ``display_text`` carries the pipe alias when the user wrote
66
+ ``[[X|alias]]``. ``rule`` / ``weight`` / ``evidence`` are all
67
+ ``None``.
68
+ - ``'derived'`` — Python-level convention (NOT a value in the schema
69
+ CHECK) for an edge sourced from ``derived_links``. ``link_text``
70
+ is empty; ``display_text`` is ``None``. ``rule`` is the rule that
71
+ fired (``'shared_thread'`` / ``'shared_participant'`` /
72
+ ``'same_day_participant'``), ``weight`` is the rule's confidence
73
+ (per :mod:`brain.vault.derived_links.rules`), and ``evidence``
74
+ is the JSONB payload as a Python dict.
75
+
76
+ Derived edges are stored as ``(src=LEAST, dst=GREATEST)`` and carry
77
+ an undirected meaning; consumers that care about direction must
78
+ treat the pair symmetrically.
79
+
80
+ Self-loops (src == dst) never appear: sync's resolver excludes them
81
+ for wiki edges, and ``derived_links``' ``CHECK (src <> dst)`` does
82
+ the same for derived.
83
+ """
84
+
85
+ src_document_id: str
86
+ dst_document_id: str
87
+ link_kind: str
88
+ link_text: str
89
+ display_text: str | None
90
+ rule: str | None = None
91
+ weight: float | None = None
92
+ evidence: dict[str, Any] | None = None
93
+
94
+
95
+ @dataclass(frozen=True)
96
+ class GraphData:
97
+ """Snapshot of the link graph used by every formatter.
98
+
99
+ ``nodes`` and ``edges`` are independent: a node may have no edges
100
+ (orphan, isolated when ``include_ingested=False``); an edge always has
101
+ both endpoints in ``nodes`` (we never emit an edge that points at a
102
+ node not in the snapshot).
103
+ """
104
+
105
+ nodes: list[GraphNode]
106
+ edges: list[GraphEdge]
107
+
108
+
109
+ @dataclass(frozen=True)
110
+ class BacklinkRow:
111
+ """One row of "documents that link TO me."
112
+
113
+ ``src_*`` describes the source of the inbound link; the destination is
114
+ the document the caller passed to :func:`backlinks_for`.
115
+
116
+ For wiki/embed edges, ``link_text`` is the raw ``[[X]]`` and
117
+ ``rule`` / ``weight`` / ``evidence`` are ``None``. For derived edges,
118
+ ``link_kind='derived'``, ``link_text=''``, and ``rule`` / ``weight`` /
119
+ ``evidence`` carry the metadata-rule provenance. Derived edges are
120
+ undirected in semantics — ``backlinks_for(X)`` returns every doc
121
+ paired with X via ``derived_links`` regardless of which side X sits
122
+ on in the canonical storage row.
123
+ """
124
+
125
+ src_document_id: str
126
+ src_title: str
127
+ src_kind: str
128
+ link_text: str
129
+ link_kind: str
130
+ rule: str | None = None
131
+ weight: float | None = None
132
+ evidence: dict[str, Any] | None = None
133
+
134
+
135
+ @dataclass(frozen=True)
136
+ class OutgoingLinkRow:
137
+ """One row of "documents I link TO."
138
+
139
+ For resolved wiki/embed links (the default), every ``dst_*`` field is
140
+ populated. For unresolved (dangling) ``[[refs]]`` returned with
141
+ ``include_unresolved=True``, ``dst_document_id`` / ``dst_title`` /
142
+ ``dst_kind`` are ``None`` and ``resolved=False``.
143
+
144
+ Derived edges are also included (with ``include_derived=True``, the
145
+ default) and carry ``link_kind='derived'`` plus populated
146
+ ``rule`` / ``weight`` / ``evidence``. Because derived edges are
147
+ undirected, ``outgoing_links_for(X)`` returns the same partner set
148
+ as ``backlinks_for(X)`` for them — every doc paired with X via
149
+ ``derived_links``, regardless of canonical storage direction.
150
+ """
151
+
152
+ dst_document_id: str | None
153
+ dst_title: str | None
154
+ dst_kind: str | None
155
+ link_text: str
156
+ link_kind: str
157
+ resolved: bool
158
+ rule: str | None = None
159
+ weight: float | None = None
160
+ evidence: dict[str, Any] | None = None
161
+
162
+
163
+ def backlinks_for(
164
+ conn: psycopg.Connection[Any],
165
+ document_id: str,
166
+ *,
167
+ include_derived: bool = True,
168
+ ) -> list[BacklinkRow]:
169
+ """Return every document that links TO ``document_id``.
170
+
171
+ Wiki/embed rows come from a JOIN of ``links`` against ``documents``
172
+ (one round-trip, no N+1) and are sorted by source title
173
+ (case-insensitive) then by ``link_text`` to break ties.
174
+
175
+ With ``include_derived=True`` (the default), derived edges are
176
+ appended after the wiki block: every ``derived_links`` row whose src
177
+ or dst equals ``document_id`` contributes one row keyed on the
178
+ *partner* document. This treats derived storage as undirected per
179
+ spec §6 — a row stored ``(A, B)`` shows up in both ``backlinks_for(A)``
180
+ and ``backlinks_for(B)``. Derived rows sort within their block by
181
+ rule then partner title for deterministic output.
182
+ """
183
+ rows = conn.execute(
184
+ """
185
+ SELECT d.id::text, d.title, d.kind, l.link_text, l.link_kind
186
+ FROM links l
187
+ JOIN documents d ON d.id = l.src_document_id
188
+ WHERE l.dst_document_id = %s
189
+ ORDER BY LOWER(d.title), l.link_text
190
+ """,
191
+ (document_id,),
192
+ ).fetchall()
193
+ out: list[BacklinkRow] = [
194
+ BacklinkRow(
195
+ src_document_id=str(r[0]),
196
+ src_title=str(r[1]),
197
+ src_kind=str(r[2]),
198
+ link_text=str(r[3]),
199
+ link_kind=str(r[4]),
200
+ )
201
+ for r in rows
202
+ ]
203
+ if include_derived:
204
+ out.extend(
205
+ BacklinkRow(
206
+ src_document_id=partner.document_id,
207
+ src_title=partner.title,
208
+ src_kind=partner.kind,
209
+ link_text="",
210
+ link_kind="derived",
211
+ rule=row.rule,
212
+ weight=row.weight,
213
+ evidence=row.evidence,
214
+ )
215
+ for row, partner in _derived_partners(conn, document_id)
216
+ )
217
+ return out
218
+
219
+
220
+ def outgoing_links_for(
221
+ conn: psycopg.Connection[Any],
222
+ document_id: str,
223
+ *,
224
+ include_unresolved: bool = False,
225
+ include_derived: bool = True,
226
+ ) -> list[OutgoingLinkRow]:
227
+ """Return every document ``document_id`` links TO.
228
+
229
+ With ``include_unresolved=False`` (the default), only resolved links
230
+ are returned (``links`` table joined to ``documents`` for the dst
231
+ title/kind).
232
+
233
+ With ``include_unresolved=True``, the result also includes dangling
234
+ ``[[refs]]`` from ``unresolved_links`` (``dst_document_id`` /
235
+ ``dst_title`` / ``dst_kind`` all ``None``). Resolved rows come first,
236
+ then unresolved.
237
+
238
+ With ``include_derived=True`` (the default), derived edges are
239
+ appended after the resolved wiki block (and before the unresolved
240
+ block, when included). Because ``derived_links`` rows are undirected
241
+ in semantics, ``outgoing_links_for(X)`` returns the same partner set
242
+ as ``backlinks_for(X)`` for them — every doc paired with X
243
+ regardless of canonical direction.
244
+ """
245
+ resolved_rows = conn.execute(
246
+ """
247
+ SELECT d.id::text, d.title, d.kind, l.link_text, l.link_kind
248
+ FROM links l
249
+ JOIN documents d ON d.id = l.dst_document_id
250
+ WHERE l.src_document_id = %s
251
+ ORDER BY LOWER(d.title), l.link_text
252
+ """,
253
+ (document_id,),
254
+ ).fetchall()
255
+ out: list[OutgoingLinkRow] = [
256
+ OutgoingLinkRow(
257
+ dst_document_id=str(r[0]),
258
+ dst_title=str(r[1]),
259
+ dst_kind=str(r[2]),
260
+ link_text=str(r[3]),
261
+ link_kind=str(r[4]),
262
+ resolved=True,
263
+ )
264
+ for r in resolved_rows
265
+ ]
266
+ if include_derived:
267
+ out.extend(
268
+ OutgoingLinkRow(
269
+ dst_document_id=partner.document_id,
270
+ dst_title=partner.title,
271
+ dst_kind=partner.kind,
272
+ link_text="",
273
+ link_kind="derived",
274
+ resolved=True,
275
+ rule=row.rule,
276
+ weight=row.weight,
277
+ evidence=row.evidence,
278
+ )
279
+ for row, partner in _derived_partners(conn, document_id)
280
+ )
281
+ if include_unresolved:
282
+ unresolved_rows = conn.execute(
283
+ """
284
+ SELECT link_text, link_kind
285
+ FROM unresolved_links
286
+ WHERE src_document_id = %s
287
+ ORDER BY link_text
288
+ """,
289
+ (document_id,),
290
+ ).fetchall()
291
+ out.extend(
292
+ OutgoingLinkRow(
293
+ dst_document_id=None,
294
+ dst_title=None,
295
+ dst_kind=None,
296
+ link_text=str(r[0]),
297
+ link_kind=str(r[1]),
298
+ resolved=False,
299
+ )
300
+ for r in unresolved_rows
301
+ )
302
+ return out
303
+
304
+
305
+ def orphans(
306
+ conn: psycopg.Connection[Any],
307
+ *,
308
+ vault_only: bool = True,
309
+ ) -> list[GraphNode]:
310
+ """Return documents with zero incoming AND zero outgoing links.
311
+
312
+ "Outgoing" includes both resolved (``links.src_document_id``) and
313
+ unresolved (``unresolved_links.src_document_id``) — a note that wrote
314
+ ``[[Foo]]`` once isn't an orphan even when ``Foo`` doesn't yet exist.
315
+
316
+ A document with at least one ``derived_links`` edge (on either side)
317
+ is also not an orphan: the metadata-aware linker has surfaced it as
318
+ connected to another doc through shared thread / participant
319
+ overlap, and the user's intuition for "orphan" is "nothing
320
+ connecting it" — derived edges count.
321
+
322
+ Defaults to ``vault_only=True`` because ingested-tier orphans are
323
+ usually noise: most Krisp / Slack / Gmail mirrors have no ``[[refs]]``
324
+ yet, and surfacing all of them would drown the user's own notes.
325
+ Pass ``vault_only=False`` (or the CLI's ``--all``) to include them.
326
+
327
+ Sort: title (case-insensitive) for deterministic output, then id to
328
+ break ties when two notes share a title.
329
+ """
330
+ where = ["d.id NOT IN (SELECT src_document_id FROM links)"]
331
+ where.append("d.id NOT IN (SELECT dst_document_id FROM links)")
332
+ where.append("d.id NOT IN (SELECT src_document_id FROM unresolved_links)")
333
+ where.append("d.id NOT IN (SELECT src_document_id FROM derived_links)")
334
+ where.append("d.id NOT IN (SELECT dst_document_id FROM derived_links)")
335
+ if vault_only:
336
+ where.append("d.kind = 'vault'")
337
+ sql = f"""
338
+ SELECT d.id::text, d.title, d.kind
339
+ FROM documents d
340
+ WHERE {' AND '.join(where)}
341
+ ORDER BY LOWER(d.title), d.id
342
+ """
343
+ rows = conn.execute(sql).fetchall()
344
+ return [
345
+ GraphNode(document_id=str(r[0]), title=str(r[1]), kind=str(r[2]))
346
+ for r in rows
347
+ ]
348
+
349
+
350
+ def graph_data(
351
+ conn: psycopg.Connection[Any],
352
+ *,
353
+ root: str | None = None,
354
+ depth: int | None = None,
355
+ include_ingested: bool = False,
356
+ include_derived: bool = True,
357
+ ) -> GraphData:
358
+ """Build a :class:`GraphData` snapshot for export.
359
+
360
+ Without ``root``: every document with at least one link in or out, plus
361
+ every vault-tier orphan (so the user sees their isolated thinking
362
+ surfaces). With ``include_ingested=True`` the linked-only ingested
363
+ tier comes along too; ingested-tier orphans stay omitted (noise).
364
+
365
+ With ``root`` set: BFS outward from the root for ``depth`` hops
366
+ (``None`` = unlimited). Only nodes within the BFS frontier are
367
+ returned, and only edges where BOTH endpoints are in the frontier.
368
+ The traversal is undirected on the edge set (you reach a backlink the
369
+ same way you reach an outgoing link) so a focused view contains the
370
+ immediate neighbourhood regardless of direction. Edges in the result
371
+ keep their original direction.
372
+
373
+ With ``include_derived=True`` (the default), ``derived_links`` rows
374
+ are unioned into the edge set as ``link_kind='derived'`` edges,
375
+ carrying their canonical ``(LEAST, GREATEST)`` direction plus the
376
+ rule / weight / evidence triple. BFS treats derived edges as
377
+ undirected (same as wiki edges), and ingested-tier orphan filtering
378
+ correctly counts a doc connected only via derived edges as
379
+ connected. ``include_derived=False`` reproduces the pre-Phase-C
380
+ behavior.
381
+
382
+ The function issues at most three queries (wiki edges, derived
383
+ edges, documents) — no per-node N+1.
384
+
385
+ Cycle safety: BFS uses a visited set, so ``A → B → A`` terminates at
386
+ depth 2 instead of looping. Self-loops never appear.
387
+ """
388
+ # Single fetch of every link, plus a filter on the document set —
389
+ # cheaper at personal-corpus scale than per-node SELECTs even when
390
+ # rooted, and trivially cached.
391
+ edge_rows = conn.execute(
392
+ """
393
+ SELECT src_document_id::text, dst_document_id::text,
394
+ link_kind, link_text, display_text
395
+ FROM links
396
+ ORDER BY src_document_id, dst_document_id, link_text, link_kind
397
+ """
398
+ ).fetchall()
399
+ all_edges: list[GraphEdge] = [
400
+ GraphEdge(
401
+ src_document_id=str(r[0]),
402
+ dst_document_id=str(r[1]),
403
+ link_kind=str(r[2]),
404
+ link_text=str(r[3]),
405
+ display_text=(str(r[4]) if r[4] is not None else None),
406
+ )
407
+ for r in edge_rows
408
+ ]
409
+
410
+ if include_derived:
411
+ derived_rows = conn.execute(
412
+ """
413
+ SELECT src_document_id::text, dst_document_id::text,
414
+ rule, weight, evidence
415
+ FROM derived_links
416
+ ORDER BY src_document_id, dst_document_id, rule
417
+ """
418
+ ).fetchall()
419
+ all_edges.extend(
420
+ GraphEdge(
421
+ src_document_id=str(r[0]),
422
+ dst_document_id=str(r[1]),
423
+ link_kind="derived",
424
+ link_text="",
425
+ display_text=None,
426
+ rule=str(r[2]),
427
+ weight=float(r[3]),
428
+ evidence=_coerce_evidence(r[4]),
429
+ )
430
+ for r in derived_rows
431
+ )
432
+
433
+ # Pull every document so we can filter / look up titles in Python.
434
+ # Personal-corpus scale (low thousands) — one fetch is cheaper than
435
+ # repeated round-trips and keeps the SQL trivially auditable.
436
+ doc_rows = conn.execute(
437
+ "SELECT id::text, title, kind FROM documents ORDER BY LOWER(title), id"
438
+ ).fetchall()
439
+ all_docs: dict[str, GraphNode] = {
440
+ str(r[0]): GraphNode(
441
+ document_id=str(r[0]), title=str(r[1]), kind=str(r[2])
442
+ )
443
+ for r in doc_rows
444
+ }
445
+
446
+ if root is not None:
447
+ keep = _bfs_frontier(root, all_edges, depth=depth)
448
+ # Filter edges to those entirely within the frontier.
449
+ kept_edges = [
450
+ e
451
+ for e in all_edges
452
+ if e.src_document_id in keep and e.dst_document_id in keep
453
+ ]
454
+ kept_nodes = [
455
+ all_docs[doc_id]
456
+ for doc_id in sorted(
457
+ keep, key=lambda d: (all_docs[d].title.lower(), d)
458
+ )
459
+ if doc_id in all_docs
460
+ ]
461
+ return GraphData(nodes=kept_nodes, edges=kept_edges)
462
+
463
+ # Whole-graph mode.
464
+ if include_ingested:
465
+ node_filter: set[str] | None = None
466
+ else:
467
+ # Vault-tier nodes always included; ingested-tier nodes only when
468
+ # they sit on at least one edge (so a vault note linking to an
469
+ # ingested artifact still shows the artifact). Ingested-tier
470
+ # orphans are dropped — they're noise per spec.
471
+ connected = _connected_node_set(all_edges)
472
+ node_filter = {
473
+ doc_id
474
+ for doc_id, node in all_docs.items()
475
+ if node.kind == "vault" or doc_id in connected
476
+ }
477
+
478
+ if node_filter is None:
479
+ kept_nodes = list(all_docs.values())
480
+ kept_edges = list(all_edges)
481
+ else:
482
+ kept_nodes = [
483
+ all_docs[doc_id]
484
+ for doc_id in sorted(
485
+ node_filter, key=lambda d: (all_docs[d].title.lower(), d)
486
+ )
487
+ ]
488
+ kept_edges = [
489
+ e
490
+ for e in all_edges
491
+ if e.src_document_id in node_filter
492
+ and e.dst_document_id in node_filter
493
+ ]
494
+ return GraphData(nodes=kept_nodes, edges=kept_edges)
495
+
496
+
497
+ @dataclass(frozen=True)
498
+ class _DerivedRow:
499
+ """Internal projection of a ``derived_links`` row used by partner queries."""
500
+
501
+ rule: str
502
+ weight: float
503
+ evidence: dict[str, Any] | None
504
+
505
+
506
+ def _derived_partners(
507
+ conn: psycopg.Connection[Any], document_id: str
508
+ ) -> list[tuple[_DerivedRow, GraphNode]]:
509
+ """Return ``(row, partner_node)`` pairs for every derived edge touching ``document_id``.
510
+
511
+ ``derived_links`` rows are stored undirected as ``(LEAST, GREATEST)``,
512
+ so a single SELECT with ``OR`` over both columns finds every edge the
513
+ document participates in. We resolve the *partner* document_id (the
514
+ other endpoint) plus its title/kind in one round-trip via a CASE +
515
+ JOIN, mirroring :func:`backlinks_for`'s anti-N+1 style.
516
+
517
+ Sort order: rule, then partner title (case-insensitive), then partner
518
+ id. Stable across calls so callers can pin output bytes in tests.
519
+ """
520
+ rows = conn.execute(
521
+ """
522
+ SELECT
523
+ dl.rule,
524
+ dl.weight,
525
+ dl.evidence,
526
+ partner.id::text,
527
+ partner.title,
528
+ partner.kind
529
+ FROM derived_links dl
530
+ JOIN documents partner
531
+ ON partner.id = CASE
532
+ WHEN dl.src_document_id = %(doc)s THEN dl.dst_document_id
533
+ ELSE dl.src_document_id
534
+ END
535
+ WHERE dl.src_document_id = %(doc)s OR dl.dst_document_id = %(doc)s
536
+ ORDER BY dl.rule, LOWER(partner.title), partner.id::text
537
+ """,
538
+ {"doc": document_id},
539
+ ).fetchall()
540
+ return [
541
+ (
542
+ _DerivedRow(
543
+ rule=str(r[0]),
544
+ weight=float(r[1]),
545
+ evidence=_coerce_evidence(r[2]),
546
+ ),
547
+ GraphNode(
548
+ document_id=str(r[3]),
549
+ title=str(r[4]),
550
+ kind=str(r[5]),
551
+ ),
552
+ )
553
+ for r in rows
554
+ ]
555
+
556
+
557
+ def _coerce_evidence(raw: Any) -> dict[str, Any] | None:
558
+ """Normalize a JSONB column read into a Python ``dict``.
559
+
560
+ psycopg's default JSONB adapter already returns ``dict``; this
561
+ helper is a thin defensive copy so callers can't mutate the
562
+ underlying row's payload. ``derived_links.evidence`` is
563
+ ``NOT NULL DEFAULT '{}'::jsonb`` per migration 005, so an empty
564
+ ``{}`` is the worst case — a payload with no keys. Anything else
565
+ falls through to ``None`` and the caller treats the row as
566
+ payload-less.
567
+ """
568
+ if isinstance(raw, dict):
569
+ return dict(raw)
570
+ return None
571
+
572
+
573
+ def _connected_node_set(edges: Iterable[GraphEdge]) -> set[str]:
574
+ """Return every document_id that appears as src or dst in ``edges``."""
575
+ out: set[str] = set()
576
+ for e in edges:
577
+ out.add(e.src_document_id)
578
+ out.add(e.dst_document_id)
579
+ return out
580
+
581
+
582
+ def _bfs_frontier(
583
+ root: str, edges: Iterable[GraphEdge], *, depth: int | None
584
+ ) -> set[str]:
585
+ """Undirected BFS from ``root`` over ``edges``, capped at ``depth`` hops.
586
+
587
+ Returns the set of document_ids reachable from ``root`` within
588
+ ``depth`` hops (inclusive). ``depth=None`` means unlimited (all
589
+ reachable nodes). ``depth=0`` returns just ``{root}``.
590
+
591
+ The traversal is undirected (treats every edge as bidirectional)
592
+ because a "graph view rooted at X" intuitively pulls in both backlinks
593
+ and outgoing links — the user expects to see X's neighbourhood in
594
+ every direction. Edge orientation is preserved later when building
595
+ the final :class:`GraphData`. Derived edges already store undirected
596
+ semantics; mixing them into ``edges`` here is correct.
597
+ """
598
+ # Build an undirected adjacency map once. Cheaper than scanning the
599
+ # edge list per hop (O(|edges|) per hop vs. O(|nodes|) per hop after
600
+ # this preprocessing).
601
+ adjacency: dict[str, set[str]] = {}
602
+ for e in edges:
603
+ adjacency.setdefault(e.src_document_id, set()).add(e.dst_document_id)
604
+ adjacency.setdefault(e.dst_document_id, set()).add(e.src_document_id)
605
+
606
+ visited: set[str] = {root}
607
+ if depth == 0:
608
+ return visited
609
+
610
+ queue: deque[tuple[str, int]] = deque([(root, 0)])
611
+ while queue:
612
+ node, dist = queue.popleft()
613
+ if depth is not None and dist >= depth:
614
+ continue
615
+ for neighbour in adjacency.get(node, ()):
616
+ if neighbour in visited:
617
+ continue
618
+ visited.add(neighbour)
619
+ queue.append((neighbour, dist + 1))
620
+ return visited