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
@@ -0,0 +1,43 @@
1
+ CREATE EXTENSION IF NOT EXISTS vector;
2
+ CREATE EXTENSION IF NOT EXISTS pgcrypto;
3
+
4
+ CREATE TABLE sources (
5
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
6
+ kind TEXT NOT NULL,
7
+ external_id TEXT,
8
+ metadata JSONB NOT NULL DEFAULT '{}',
9
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
10
+ UNIQUE (kind, external_id)
11
+ );
12
+
13
+ CREATE TABLE documents (
14
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
15
+ source_id UUID REFERENCES sources(id) ON DELETE CASCADE,
16
+ title TEXT NOT NULL,
17
+ content TEXT NOT NULL,
18
+ content_hash TEXT NOT NULL UNIQUE,
19
+ content_type TEXT NOT NULL,
20
+ source_path TEXT,
21
+ tags TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[],
22
+ metadata JSONB NOT NULL DEFAULT '{}',
23
+ ingested_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
24
+ tsv tsvector GENERATED ALWAYS AS
25
+ (to_tsvector('english', coalesce(title,'') || ' ' || coalesce(content,''))) STORED
26
+ );
27
+ CREATE INDEX documents_tsv_idx ON documents USING GIN (tsv);
28
+ CREATE INDEX documents_tags_idx ON documents USING GIN (tags);
29
+ CREATE INDEX documents_source_idx ON documents(source_id);
30
+
31
+ CREATE TABLE chunks (
32
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
33
+ document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
34
+ chunk_index INT NOT NULL,
35
+ content TEXT NOT NULL,
36
+ embedding vector(1024) NOT NULL,
37
+ metadata JSONB NOT NULL DEFAULT '{}',
38
+ tsv tsvector GENERATED ALWAYS AS (to_tsvector('english', content)) STORED,
39
+ UNIQUE (document_id, chunk_index)
40
+ );
41
+ CREATE INDEX chunks_embedding_idx ON chunks USING hnsw (embedding vector_cosine_ops);
42
+ CREATE INDEX chunks_tsv_idx ON chunks USING GIN (tsv);
43
+ CREATE INDEX chunks_document_idx ON chunks(document_id);
@@ -0,0 +1,17 @@
1
+ -- Phase 2 of Voyage→Qwen3 swap. The user has explicitly approved discarding
2
+ -- existing Voyage embeddings; chunks.content is preserved so Phase 3's
3
+ -- `brain reembed` can repopulate the new column from the original chunk text.
4
+ --
5
+ -- The NOT NULL constraint is deferred to post-backfill — `brain reembed --finalize`
6
+ -- applies it once 0 NULL rows remain. No vector index is created: pgvector 0.8.2
7
+ -- caps both HNSW and IVFFlat at 2000 dims for `vector` and 4000 for `halfvec`,
8
+ -- neither of which fits our native 4096-dim Qwen3 output. Sequential scan is
9
+ -- acceptable at personal scale (~150ms @ 10K chunks).
10
+
11
+ BEGIN;
12
+
13
+ DROP INDEX IF EXISTS chunks_embedding_idx;
14
+ ALTER TABLE chunks DROP COLUMN embedding;
15
+ ALTER TABLE chunks ADD COLUMN embedding vector(4096);
16
+
17
+ COMMIT;
@@ -0,0 +1,41 @@
1
+ -- Vault model phase 1 — schema additions for the headless-Obsidian vault.
2
+ --
3
+ -- Additive only: every existing row gets ``kind='ingested'`` via the column
4
+ -- default (matches the legacy semantics where everything in the DB came from
5
+ -- an upstream source). ``links`` and ``unresolved_links`` are new; nothing in
6
+ -- prior migrations is altered or dropped.
7
+
8
+ BEGIN;
9
+
10
+ ALTER TABLE documents ADD COLUMN kind TEXT NOT NULL DEFAULT 'ingested'
11
+ CHECK (kind IN ('vault', 'ingested'));
12
+
13
+ ALTER TABLE documents ADD COLUMN vault_path TEXT;
14
+ CREATE UNIQUE INDEX documents_vault_path_idx
15
+ ON documents (vault_path) WHERE vault_path IS NOT NULL;
16
+ CREATE INDEX documents_kind_idx ON documents (kind);
17
+
18
+ CREATE TABLE links (
19
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
20
+ src_document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
21
+ dst_document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
22
+ link_text TEXT NOT NULL,
23
+ link_kind TEXT NOT NULL CHECK (link_kind IN ('wiki', 'embed')),
24
+ display_text TEXT,
25
+ UNIQUE (src_document_id, dst_document_id, link_text, link_kind)
26
+ );
27
+ CREATE INDEX links_src_idx ON links (src_document_id);
28
+ CREATE INDEX links_dst_idx ON links (dst_document_id);
29
+
30
+ CREATE TABLE unresolved_links (
31
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
32
+ src_document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
33
+ link_text TEXT NOT NULL,
34
+ link_kind TEXT NOT NULL CHECK (link_kind IN ('wiki', 'embed')),
35
+ display_text TEXT,
36
+ UNIQUE (src_document_id, link_text, link_kind)
37
+ );
38
+ CREATE INDEX unresolved_links_src_idx ON unresolved_links (src_document_id);
39
+ CREATE INDEX unresolved_links_text_idx ON unresolved_links (link_text);
40
+
41
+ COMMIT;
@@ -0,0 +1,18 @@
1
+ -- Phase 2 follow-up: vault-tier notes can legitimately share body bytes.
2
+ --
3
+ -- Examples that hit the prior UNIQUE constraint and crashed sync:
4
+ -- - Two empty notes (e.g. drafts the user just created from a template)
5
+ -- - Two notes whose only body is "TBD" or a placeholder string
6
+ -- - Daily notes for two days that happen to start with the same scaffold
7
+ --
8
+ -- Ingested-tier dedup is still desirable: re-ingesting the same Krisp call
9
+ -- should idempotently no-op rather than producing two rows. Keep the unique
10
+ -- constraint scoped to ``kind='ingested'`` via a partial index.
11
+
12
+ BEGIN;
13
+
14
+ ALTER TABLE documents DROP CONSTRAINT documents_content_hash_key;
15
+ CREATE UNIQUE INDEX documents_content_hash_ingested_idx
16
+ ON documents (content_hash) WHERE kind = 'ingested';
17
+
18
+ COMMIT;
@@ -0,0 +1,67 @@
1
+ -- Metadata-aware linker — schema for derived edges + person directory.
2
+ --
3
+ -- Additive only: nothing in prior migrations is altered or dropped. Three
4
+ -- new tables back the metadata-aware linker pipeline:
5
+ --
6
+ -- * ``derived_links`` — sibling of ``links`` (which holds wiki-link edges
7
+ -- only). Each row is an edge inferred from metadata overlap between two
8
+ -- documents (e.g. shared Gmail thread, shared participant on the same
9
+ -- day). Carries the rule that produced it, JSONB evidence, and a weight.
10
+ -- * ``directory_entries`` — persistent name↔email directory used to bridge
11
+ -- Krisp speaker labels (often a name) with Gmail headers (always an
12
+ -- email). Built incrementally from already-stored Gmail headers, MCP
13
+ -- calendar events, MCP contacts, and an optional ``people.yml`` override.
14
+ -- * ``directory_refresh_state`` — high-water mark per source so calendar /
15
+ -- contacts refreshes are incremental: each refresh fetches
16
+ -- ``(last_refreshed_at, now]`` only.
17
+ --
18
+ -- Two CHECK constraints tighter than spec §5 (intentional, mirrors the
19
+ -- `link_kind` pattern from migrations/003_vault_model.sql:23):
20
+ -- - `rule` is enum-restricted; adding a 4th rule will require ALTER.
21
+ -- - `weight` is range-restricted to [0, 1.0]; emphasized overrides above
22
+ -- 1.0 will require ALTER.
23
+
24
+ BEGIN;
25
+
26
+ -- Edges derived from metadata overlap; sibling of `links` (which holds
27
+ -- wiki-link edges only).
28
+ CREATE TABLE derived_links (
29
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
30
+ src_document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
31
+ dst_document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
32
+ rule TEXT NOT NULL CHECK (rule IN ('shared_thread', 'shared_participant', 'same_day_participant')),
33
+ evidence JSONB NOT NULL DEFAULT '{}'::jsonb,
34
+ weight REAL NOT NULL DEFAULT 1.0 CHECK (weight >= 0.0 AND weight <= 1.0),
35
+ CHECK (src_document_id <> dst_document_id),
36
+ UNIQUE (src_document_id, dst_document_id, rule)
37
+ );
38
+ CREATE INDEX derived_links_src_idx ON derived_links (src_document_id);
39
+ CREATE INDEX derived_links_dst_idx ON derived_links (dst_document_id);
40
+ CREATE INDEX derived_links_rule_idx ON derived_links (rule);
41
+
42
+ -- Persistent name↔email directory used to bridge Krisp speaker labels
43
+ -- (often a name) with Gmail headers (always an email). Built incrementally
44
+ -- from already-stored Gmail headers, MCP calendar events, MCP contacts,
45
+ -- and an optional override file.
46
+ CREATE TABLE directory_entries (
47
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
48
+ display_name TEXT NOT NULL, -- normalized lowercase
49
+ email TEXT NOT NULL, -- normalized lowercase
50
+ source TEXT NOT NULL CHECK (source IN ('gmail', 'calendar', 'contacts', 'people_yml')),
51
+ occurrence_count INT NOT NULL DEFAULT 1,
52
+ first_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
53
+ last_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
54
+ UNIQUE (display_name, email, source)
55
+ );
56
+ CREATE INDEX directory_entries_name_idx ON directory_entries (display_name);
57
+ CREATE INDEX directory_entries_email_idx ON directory_entries (email);
58
+
59
+ -- High-water mark per source so calendar / contacts refreshes are
60
+ -- incremental: each refresh fetches `(last_refreshed_at, now]` only.
61
+ CREATE TABLE directory_refresh_state (
62
+ source TEXT PRIMARY KEY CHECK (source IN ('gmail', 'calendar', 'contacts')),
63
+ last_refreshed_at TIMESTAMPTZ NOT NULL,
64
+ records_seen INT NOT NULL DEFAULT 0
65
+ );
66
+
67
+ COMMIT;
@@ -0,0 +1,25 @@
1
+ -- File-based ingests dedup by ``source_path`` (in code), not ``content_hash``.
2
+ --
3
+ -- Why: when a source file's bytes change on disk and we re-ingest, the new
4
+ -- content_hash differs from the prior row's, so the prior content_hash lookup
5
+ -- missed and a NEW row was inserted — leaving the corpus with two documents
6
+ -- for the same path. Triggering case: the markdown extractor fix in commit
7
+ -- 85a6394 changed extracted-content bytes for every existing markdown doc, so
8
+ -- every re-ingest would have silently duplicated.
9
+ --
10
+ -- Migration 004 introduced a partial UNIQUE index on ``content_hash`` scoped
11
+ -- to ``kind='ingested'``. That worked while file ingests deduped by hash, but
12
+ -- it conflicts with the new "two files with byte-identical content at
13
+ -- different paths are separate documents" semantic — the second INSERT would
14
+ -- fail. Tighten the partial predicate so the constraint only applies to
15
+ -- stdin ingests (``source_path IS NULL``: krisp / slack / gmail), where we
16
+ -- still want belt-and-suspenders protection against duplicate transcripts.
17
+
18
+ BEGIN;
19
+
20
+ DROP INDEX IF EXISTS documents_content_hash_ingested_idx;
21
+ CREATE UNIQUE INDEX IF NOT EXISTS documents_content_hash_stdin_idx
22
+ ON documents (content_hash)
23
+ WHERE kind = 'ingested' AND source_path IS NULL;
24
+
25
+ COMMIT;
@@ -0,0 +1,15 @@
1
+ -- Migration 007: email-thread metadata + draft flag
2
+ -- Additive only. Existing rows untouched.
3
+
4
+ ALTER TABLE documents
5
+ ADD COLUMN IF NOT EXISTS thread_id TEXT,
6
+ ADD COLUMN IF NOT EXISTS rfc_message_id TEXT,
7
+ ADD COLUMN IF NOT EXISTS in_reply_to TEXT,
8
+ ADD COLUMN IF NOT EXISTS sent_at TIMESTAMPTZ,
9
+ ADD COLUMN IF NOT EXISTS participants TEXT[],
10
+ ADD COLUMN IF NOT EXISTS duration_min INTEGER,
11
+ ADD COLUMN IF NOT EXISTS draft BOOLEAN NOT NULL DEFAULT FALSE;
12
+
13
+ CREATE INDEX IF NOT EXISTS idx_documents_thread_id ON documents (thread_id) WHERE thread_id IS NOT NULL;
14
+ CREATE INDEX IF NOT EXISTS idx_documents_sent_at ON documents (sent_at DESC) WHERE sent_at IS NOT NULL;
15
+ CREATE INDEX IF NOT EXISTS idx_documents_draft ON documents (draft) WHERE draft = TRUE;
@@ -0,0 +1,11 @@
1
+ -- Migration 008: enforce one merged-thread row per gmail thread_id.
2
+ -- Additive only. Existing rows untouched (no thread_id collision today since
3
+ -- the legacy ingest path keys on message_id; once P2.3 + P2.4 land, the
4
+ -- collapsed corpus will have exactly one content_type='email_thread' row
5
+ -- per thread and this index protects against accidental re-creation).
6
+
7
+ CREATE UNIQUE INDEX IF NOT EXISTS uq_documents_gmail_thread
8
+ ON documents (thread_id)
9
+ WHERE kind = 'ingested'
10
+ AND thread_id IS NOT NULL
11
+ AND content_type = 'email_thread';
@@ -0,0 +1,28 @@
1
+ -- Migration 009: title- and tag-weighted FTS for chunks.
2
+ -- See docs/plans/2026-05-06-search-ranking-fix.md.
3
+ --
4
+ -- Adds denormalized title/tags/extras columns on chunks and recomputes
5
+ -- chunks.tsv as a weighted multi-field tsvector so title/tag hits rank
6
+ -- ahead of body hits. Additive: title_text/tags_text/search_extras are
7
+ -- nullable; coalesce(...) inside the generated column keeps the tsv
8
+ -- well-defined for rows that haven't been backfilled yet.
9
+ --
10
+ -- The DROP COLUMN tsv is technically destructive but the column is
11
+ -- generated (no user data); it's rebuilt below from the new sources in
12
+ -- the same migration. The GIN index is recreated against the new tsv.
13
+
14
+ ALTER TABLE chunks ADD COLUMN IF NOT EXISTS title_text TEXT;
15
+ ALTER TABLE chunks ADD COLUMN IF NOT EXISTS tags_text TEXT;
16
+ ALTER TABLE chunks ADD COLUMN IF NOT EXISTS search_extras TEXT;
17
+
18
+ DROP INDEX IF EXISTS chunks_tsv_idx;
19
+ ALTER TABLE chunks DROP COLUMN IF EXISTS tsv;
20
+
21
+ ALTER TABLE chunks ADD COLUMN tsv tsvector GENERATED ALWAYS AS (
22
+ setweight(to_tsvector('english', coalesce(title_text, '')), 'A') ||
23
+ setweight(to_tsvector('english', coalesce(tags_text, '')), 'B') ||
24
+ setweight(to_tsvector('english', coalesce(content, '')), 'C') ||
25
+ setweight(to_tsvector('english', coalesce(search_extras, '')), 'C')
26
+ ) STORED;
27
+
28
+ CREATE INDEX chunks_tsv_idx ON chunks USING GIN (tsv);
@@ -0,0 +1,30 @@
1
+ -- Wave Q1-C — Interactions table.
2
+ -- Append-only feedback log. One row per user event (open / rate / pin / click).
3
+ -- Q1-C populates from CLI (brain rate) and MCP (brain_show with
4
+ -- originating_query). The wiki click surface lands in a later wave
5
+ -- (source='wiki' is accepted at the SQL layer so the future wave is
6
+ -- purely additive).
7
+ -- Additive-only: nothing in prior migrations is altered.
8
+
9
+ BEGIN;
10
+
11
+ CREATE TABLE IF NOT EXISTS interactions (
12
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
13
+ document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
14
+ query TEXT,
15
+ action TEXT NOT NULL
16
+ CHECK (action IN ('clicked', 'opened',
17
+ 'rated_useful', 'rated_irrelevant',
18
+ 'pinned')),
19
+ source TEXT NOT NULL
20
+ CHECK (source IN ('cli', 'mcp', 'wiki')),
21
+ session_id UUID, -- nullable: CLI ratings have no session
22
+ at TIMESTAMPTZ NOT NULL DEFAULT NOW()
23
+ );
24
+
25
+ CREATE INDEX IF NOT EXISTS interactions_document_at_idx ON interactions (document_id, at DESC);
26
+ CREATE INDEX IF NOT EXISTS interactions_action_idx ON interactions (action);
27
+ CREATE INDEX IF NOT EXISTS interactions_session_idx ON interactions (session_id)
28
+ WHERE session_id IS NOT NULL;
29
+
30
+ COMMIT;
@@ -0,0 +1,23 @@
1
+ -- Migration 011 — Wave Q1-D per-document auto-summary columns.
2
+ --
3
+ -- Additive only. Existing rows untouched. Pre-Q1-D rows keep
4
+ -- ``summary IS NULL``; ``brain enrich --backfill`` populates them.
5
+ --
6
+ -- Three nullable columns + one partial index on ``summary IS NULL`` so the
7
+ -- backfill iterator (``brain enrich --backfill``) can keyset-walk only the
8
+ -- unenriched subset. The index stays tiny once enrichment has run over the
9
+ -- corpus — only rows that genuinely failed enrichment (short content,
10
+ -- Ollama unavailable, repeated parse failures) remain in it. Mirrors the
11
+ -- ``idx_documents_thread_id`` shape from migration 007.
12
+
13
+ BEGIN;
14
+
15
+ ALTER TABLE documents
16
+ ADD COLUMN IF NOT EXISTS summary TEXT,
17
+ ADD COLUMN IF NOT EXISTS summary_model TEXT,
18
+ ADD COLUMN IF NOT EXISTS summary_at TIMESTAMPTZ;
19
+
20
+ CREATE INDEX IF NOT EXISTS idx_documents_summary_null
21
+ ON documents (id) WHERE summary IS NULL;
22
+
23
+ COMMIT;
@@ -0,0 +1,171 @@
1
+ -- Migration 012 — GraphRAG relational source-of-truth (wave G0, AGE pivot v5).
2
+ --
3
+ -- Additive only. This migration owns the RELATIONAL source-of-truth +
4
+ -- pgvector side tables for the GraphRAG layer per spec
5
+ -- docs/specs/2026-05-20-graphrag-design.md §5(a). The Apache AGE graph itself
6
+ -- (graph `brain_graph`, vertex/edge labels, property indexes) is NOT created
7
+ -- here — it is bootstrapped idempotently by ``brain init`` (a ``create_graph``
8
+ -- function call, not frozen DDL) and its labels/indexes are owned by the G0-4
9
+ -- GraphBackend. Keep this file pure SQL with no AGE/Cypher DDL.
10
+ --
11
+ -- graph_entities — entity nodes (durable app identity; AGE vertex
12
+ -- IDs are internal and not the app identity).
13
+ -- People imported from the people pipeline;
14
+ -- concepts from the gated extractor.
15
+ -- graph_entity_mentions — SOURCE OF TRUTH: per-doc entity mentions
16
+ -- graph_edge_contributions — SOURCE OF TRUTH: per-doc raw window co-occurrence
17
+ -- graph_relationships — DERIVED aggregate edges (recomputed, never
18
+ -- blind-incremented); the SQL mirror of the AGE
19
+ -- CO_OCCURS edges (kept in sync by the same refresh)
20
+ -- graph_index_state — per-aspect incremental watermark (people vs
21
+ -- concepts re-index independently)
22
+ --
23
+ -- MULTI-TENANCY (spec §4 D9, §5): every table carries
24
+ -- ``tenant_id TEXT NOT NULL DEFAULT 'default'`` and ``tenant_id`` is part of
25
+ -- every PK / UNIQUE and every lookup index — every key below is tenant-scoped.
26
+ -- ``graph_entities`` is keyed on the COMPOSITE ``(tenant_id, id)`` so the child
27
+ -- tables can carry TENANT-SAFE composite FKs ``(tenant_id, <entity_col>) ->
28
+ -- graph_entities(tenant_id, id)`` — a child row can only ever reference an
29
+ -- entity in its OWN tenant. ``id`` remains a globally-unique UUID (it is the AGE
30
+ -- ``Entity.entity_uuid`` vertex property); the composite key just makes the
31
+ -- relational key tenant-inclusive. Single-user local deployments use the fixed
32
+ -- default tenant ``'default'`` so the local experience is unchanged.
33
+ --
34
+ -- Per-document rows are the only source of truth; aggregates are derived by a
35
+ -- full refresh (spec §7). Undirected edges are canonicalized ``src_id < dst_id``
36
+ -- and CHECK-enforced. ``graph_relationships.weight`` is normalized lift in
37
+ -- (0, 1] (CHECK-enforced, NOT NULL with NO default — callers always compute it).
38
+ -- Count columns are CHECK-enforced non-negative. Embedding columns ship as
39
+ -- ``vector(1024)`` NULLABLE; the generalized dim reconciliation (G0b) resizes
40
+ -- ``graph_entities.embedding`` to the active embedder's dim and a later wave
41
+ -- embeds it. HNSW is intentionally skipped here (small row counts →
42
+ -- sequential scan; spec §5 note).
43
+ --
44
+ -- v5.1 note: the paused g0a created an uncommitted, pre-tenant/pre-AGE version
45
+ -- of this file; since it was never committed/shipped, v5.1 rewrites it in place
46
+ -- to this tenantized schema (no separate migration).
47
+ --
48
+ -- Communities tables arrive in 013 (G3); the interaction-logging change in
49
+ -- 014 (G4).
50
+
51
+ BEGIN;
52
+
53
+ -- Entity nodes. Person rows imported from the people pipeline; concepts from
54
+ -- the extractor. ``doc_count`` is DERIVED from mentions (refreshed, never
55
+ -- authoritative on write). Durable app identity lives in ``id``; the AGE
56
+ -- ``Entity`` vertex carries ``entity_uuid = id`` as a property for joins back.
57
+ -- COMPOSITE PK ``(tenant_id, id)`` makes the key tenant-inclusive and is the
58
+ -- target of the child tables' tenant-safe FKs.
59
+ CREATE TABLE IF NOT EXISTS graph_entities (
60
+ id UUID NOT NULL DEFAULT gen_random_uuid(),
61
+ tenant_id TEXT NOT NULL DEFAULT 'default',
62
+ entity_type TEXT NOT NULL, -- 'person'|'org'|'project'|'topic'|'tool'
63
+ name TEXT NOT NULL,
64
+ canonical_key TEXT NOT NULL, -- dedup key (resolved person-key | lower(name))
65
+ description TEXT,
66
+ embedding vector(1024), -- dim-aware; nullable until reconciled
67
+ doc_count INTEGER NOT NULL DEFAULT 0, -- DERIVED from mentions (refreshed)
68
+ properties JSONB NOT NULL DEFAULT '{}',
69
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
70
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
71
+ PRIMARY KEY (tenant_id, id),
72
+ CONSTRAINT graph_entities_type_chk
73
+ CHECK (entity_type IN ('person', 'org', 'project', 'topic', 'tool')),
74
+ CONSTRAINT graph_entities_doc_count_chk CHECK (doc_count >= 0),
75
+ -- Tenant-scoped dedup: the same canonical_key may exist independently per tenant.
76
+ CONSTRAINT uq_graph_entities UNIQUE (tenant_id, entity_type, canonical_key)
77
+ );
78
+ CREATE INDEX IF NOT EXISTS idx_graph_entities_type
79
+ ON graph_entities (tenant_id, entity_type);
80
+
81
+ -- SOURCE OF TRUTH: per-doc entity mentions. Re-ingest deletes+reinserts this
82
+ -- doc's rows. Tenant-safe composite FK ``(tenant_id, entity_id)`` guarantees the
83
+ -- referenced entity is in the SAME tenant. The document_id FK stays single-col
84
+ -- (documents is not tenantized in G0).
85
+ CREATE TABLE IF NOT EXISTS graph_entity_mentions (
86
+ tenant_id TEXT NOT NULL DEFAULT 'default',
87
+ entity_id UUID NOT NULL,
88
+ document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
89
+ mention_count INTEGER NOT NULL DEFAULT 1,
90
+ source TEXT NOT NULL, -- 'people' | 'extractor:<model>@<ver>'
91
+ PRIMARY KEY (tenant_id, entity_id, document_id),
92
+ CONSTRAINT gem_mention_count_chk CHECK (mention_count >= 0),
93
+ CONSTRAINT fk_gem_entity
94
+ FOREIGN KEY (tenant_id, entity_id)
95
+ REFERENCES graph_entities (tenant_id, id) ON DELETE CASCADE
96
+ );
97
+ CREATE INDEX IF NOT EXISTS idx_gem_document
98
+ ON graph_entity_mentions (tenant_id, document_id);
99
+
100
+ -- SOURCE OF TRUTH: per-doc edge contributions (raw window co-occurrence over
101
+ -- raw text). RAW counts only — no generic suppression applied here (suppression
102
+ -- is derive/query-time). Canonical src<dst. Both endpoints carry tenant-safe
103
+ -- composite FKs.
104
+ CREATE TABLE IF NOT EXISTS graph_edge_contributions (
105
+ tenant_id TEXT NOT NULL DEFAULT 'default',
106
+ document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
107
+ src_id UUID NOT NULL,
108
+ dst_id UUID NOT NULL,
109
+ cooccur_count INTEGER NOT NULL DEFAULT 1,
110
+ PRIMARY KEY (tenant_id, document_id, src_id, dst_id),
111
+ CONSTRAINT gec_canonical CHECK (src_id < dst_id),
112
+ CONSTRAINT gec_cooccur_count_chk CHECK (cooccur_count >= 0),
113
+ CONSTRAINT fk_gec_src
114
+ FOREIGN KEY (tenant_id, src_id)
115
+ REFERENCES graph_entities (tenant_id, id) ON DELETE CASCADE,
116
+ CONSTRAINT fk_gec_dst
117
+ FOREIGN KEY (tenant_id, dst_id)
118
+ REFERENCES graph_entities (tenant_id, id) ON DELETE CASCADE
119
+ );
120
+ CREATE INDEX IF NOT EXISTS idx_gec_src ON graph_edge_contributions (tenant_id, src_id);
121
+ CREATE INDEX IF NOT EXISTS idx_gec_dst ON graph_edge_contributions (tenant_id, dst_id);
122
+
123
+ -- DERIVED aggregate edges (recomputed from contributions; never
124
+ -- blind-incremented). SQL counterpart of the AGE CO_OCCURS edges (kept in sync
125
+ -- by the same refresh) and backs evidence/ranking. ``weight`` is normalized
126
+ -- lift in (0, 1] — NOT NULL with NO default (callers always compute it; 0 would
127
+ -- violate the range). Both endpoints carry tenant-safe composite FKs.
128
+ CREATE TABLE IF NOT EXISTS graph_relationships (
129
+ tenant_id TEXT NOT NULL DEFAULT 'default',
130
+ src_id UUID NOT NULL,
131
+ dst_id UUID NOT NULL,
132
+ rel_type TEXT NOT NULL DEFAULT 'co_occurs',
133
+ weight REAL NOT NULL, -- normalized lift in (0,1] (derived/recomputed)
134
+ co_count INTEGER NOT NULL DEFAULT 0, -- SUM(contributions.cooccur_count)
135
+ doc_count INTEGER NOT NULL DEFAULT 0, -- COUNT(DISTINCT document_id)
136
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
137
+ PRIMARY KEY (tenant_id, src_id, dst_id, rel_type),
138
+ CONSTRAINT grel_canonical CHECK (src_id < dst_id),
139
+ CONSTRAINT grel_weight_chk CHECK (weight > 0 AND weight <= 1),
140
+ CONSTRAINT grel_co_count_chk CHECK (co_count >= 0),
141
+ CONSTRAINT grel_doc_count_chk CHECK (doc_count >= 0),
142
+ CONSTRAINT fk_grel_src
143
+ FOREIGN KEY (tenant_id, src_id)
144
+ REFERENCES graph_entities (tenant_id, id) ON DELETE CASCADE,
145
+ CONSTRAINT fk_grel_dst
146
+ FOREIGN KEY (tenant_id, dst_id)
147
+ REFERENCES graph_entities (tenant_id, id) ON DELETE CASCADE
148
+ );
149
+ CREATE INDEX IF NOT EXISTS idx_grel_src ON graph_relationships (tenant_id, src_id);
150
+ CREATE INDEX IF NOT EXISTS idx_grel_dst ON graph_relationships (tenant_id, dst_id);
151
+
152
+ -- Incremental watermark, PER ASPECT (people vs concepts re-index
153
+ -- independently). An aspect is re-indexed when ANY of its tracked input
154
+ -- fingerprints changes (content_hash + inputs_hash + extractor_ver +
155
+ -- suppress_ver; spec §7). ``aspect`` is constrained to the two known aspects:
156
+ -- 'people' (G1) and 'concepts' (G2).
157
+ CREATE TABLE IF NOT EXISTS graph_index_state (
158
+ tenant_id TEXT NOT NULL DEFAULT 'default',
159
+ document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
160
+ aspect TEXT NOT NULL, -- 'people' | 'concepts'
161
+ content_hash TEXT NOT NULL, -- body text fingerprint
162
+ inputs_hash TEXT NOT NULL, -- aspect inputs: people→participants+directory+owner-key
163
+ -- versions; concepts→content+window/cap config
164
+ extractor_ver TEXT NOT NULL, -- extractor model@version; bump forces re-extract
165
+ suppress_ver TEXT NOT NULL DEFAULT '',-- suppression/weighting config version (derive-time)
166
+ indexed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
167
+ PRIMARY KEY (tenant_id, document_id, aspect),
168
+ CONSTRAINT gis_aspect_chk CHECK (aspect IN ('people', 'concepts'))
169
+ );
170
+
171
+ COMMIT;
@@ -0,0 +1,125 @@
1
+ -- Migration 013 — GraphRAG global communities (wave G3, AGE pivot v5).
2
+ --
3
+ -- Additive only. This migration owns the RELATIONAL community tables for the
4
+ -- LazyGraphRAG-style global mode per spec docs/specs/2026-05-20-graphrag-design.md
5
+ -- §17c Q1. Communities are RELATIONAL-ONLY (§17c Q2): there is NO AGE
6
+ -- ``Community`` vertex label and no membership edges in AGE — the graph stays
7
+ -- ``Entity``/``Document`` + ``MENTIONED_IN``/``CO_OCCURS``. Community summaries,
8
+ -- memberships, and embeddings live here in SQL exactly like the embeddings +
9
+ -- derived aggregates. Keep this file pure SQL with no AGE/Cypher DDL.
10
+ --
11
+ -- graph_communities — one row per detected community (networkx Louvain
12
+ -- over the tenant entity graph). Carries the
13
+ -- stable ``community_key`` identity, the dirty
14
+ -- fingerprint (``source_graph_hash``), the
15
+ -- per-community identity hash (``members_hash``),
16
+ -- aggregate stats, and the lazily-built (eager at
17
+ -- community build/refresh — §17c Q10) summary +
18
+ -- summary embedding/tsv for the global retrieval
19
+ -- RRF (§17c Q4).
20
+ -- graph_community_members — community ↔ entity membership (per-community
21
+ -- rank + weight). Tenant-safe composite FKs to
22
+ -- BOTH graph_communities and graph_entities.
23
+ --
24
+ -- SINGLE LEVEL ONLY (§17c Q1 / §15): ``level`` is present but pinned to 0 via a
25
+ -- CHECK; hierarchical multi-level communities stay deferred (YAGNI).
26
+ --
27
+ -- MULTI-TENANCY (spec §4 D9, §5): every table carries
28
+ -- ``tenant_id TEXT NOT NULL DEFAULT 'default'`` and ``tenant_id`` is part of
29
+ -- every PK / UNIQUE and every lookup index — every key below is tenant-scoped.
30
+ -- The membership table carries TENANT-SAFE composite FKs
31
+ -- ``(tenant_id, community_key) -> graph_communities(tenant_id, community_key)``
32
+ -- and ``(tenant_id, entity_id) -> graph_entities(tenant_id, id)`` so a member
33
+ -- row can only ever reference a community / entity in its OWN tenant. Mirrors
34
+ -- the 012 conventions exactly.
35
+ --
36
+ -- DIRTY FINGERPRINT (§17c Q1/Q3): ``source_graph_hash`` is a tenant-graph edge
37
+ -- hash over the ordered ``graph_relationships``; ``communities build`` skips
38
+ -- when the stored ``(build_version, source_graph_hash)`` already matches.
39
+ -- ``members_hash`` is per-community identity only (Jaccard stable-identity).
40
+ --
41
+ -- EMBEDDINGS: ``summary_embedding`` ships as ``vector(1024)`` NULLABLE. The
42
+ -- generalized dim reconciliation (G0b — allowlisted in
43
+ -- ``brain.embedding_targets`` as ``graph_communities.summary_embedding``)
44
+ -- resizes it to the active embedder's dim, and the conditional HNSW is owned by
45
+ -- the finalize-time machinery (``brain.queries.finalize_embedding_index``), NOT
46
+ -- hardcoded here — exactly as 012 defers ``graph_entities.embedding`` (small row
47
+ -- counts → sequential cosine scan until finalize). The ``summary_tsv`` GENERATED
48
+ -- column backs the FTS leg of the global RRF (§17c Q4); it uses the immutable
49
+ -- 2-arg ``to_tsvector('english', …)`` form so it is valid in a STORED column
50
+ -- (matches 009's chunks.tsv style).
51
+
52
+ BEGIN;
53
+
54
+ -- One row per detected community. ``community_key`` is the durable, stable
55
+ -- identity preserved across rebuilds by Jaccard matching (§17c Q3/Q7).
56
+ -- ``source_graph_hash`` + ``members_hash`` have NO default — the detector always
57
+ -- computes them. Aggregate counts default to 0 and are CHECK-enforced
58
+ -- non-negative (mirrors 012). PK ``(tenant_id, community_key)``; UNIQUE
59
+ -- ``(tenant_id, level, members_hash)`` is the per-community identity guard.
60
+ CREATE TABLE IF NOT EXISTS graph_communities (
61
+ tenant_id TEXT NOT NULL DEFAULT 'default',
62
+ community_key UUID NOT NULL DEFAULT gen_random_uuid(),
63
+ level INTEGER NOT NULL DEFAULT 0, -- single-level only (§15)
64
+ build_version TEXT NOT NULL DEFAULT 'networkx-louvain-v1',
65
+ source_graph_hash TEXT NOT NULL, -- dirty fingerprint (§17c Q3)
66
+ members_hash TEXT NOT NULL, -- per-community identity hash
67
+ member_count INTEGER NOT NULL DEFAULT 0,
68
+ edge_count INTEGER NOT NULL DEFAULT 0,
69
+ total_weight REAL NOT NULL DEFAULT 0,
70
+ summary TEXT, -- lazy/eager at build (§17c Q10)
71
+ summary_model TEXT,
72
+ summary_at TIMESTAMPTZ,
73
+ summary_embedding vector(1024), -- dim-aware; nullable; HNSW deferred
74
+ summary_tsv tsvector GENERATED ALWAYS AS (
75
+ to_tsvector('english', coalesce(summary, ''))
76
+ ) STORED,
77
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
78
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
79
+ PRIMARY KEY (tenant_id, community_key),
80
+ CONSTRAINT gc_level_chk CHECK (level = 0),
81
+ CONSTRAINT gc_member_count_chk CHECK (member_count >= 0),
82
+ CONSTRAINT gc_edge_count_chk CHECK (edge_count >= 0),
83
+ CONSTRAINT gc_total_weight_chk CHECK (total_weight >= 0),
84
+ -- Per-community identity is tenant-scoped: the same members_hash may exist
85
+ -- independently per tenant (and, once multi-level lands, per level).
86
+ CONSTRAINT uq_graph_communities UNIQUE (tenant_id, level, members_hash)
87
+ );
88
+ -- Global retrieval scans a tenant's level-0 communities; the PK leads with
89
+ -- tenant_id, but the explicit (tenant_id, level) index documents the
90
+ -- candidate-set access pattern and survives the multi-level deferral.
91
+ CREATE INDEX IF NOT EXISTS idx_graph_communities_tenant_level
92
+ ON graph_communities (tenant_id, level);
93
+ -- FTS leg of the global RRF (§17c Q4).
94
+ CREATE INDEX IF NOT EXISTS idx_graph_communities_summary_tsv
95
+ ON graph_communities USING GIN (summary_tsv);
96
+
97
+ -- Community ↔ entity membership. Re-detection deletes+reinserts a tenant's
98
+ -- rows. Tenant-safe composite FKs guarantee both the community and the entity
99
+ -- live in the SAME tenant. ``member_rank`` / ``member_weight`` order entities
100
+ -- within a community (e.g. by degree / centrality); both CHECK-enforced
101
+ -- non-negative.
102
+ CREATE TABLE IF NOT EXISTS graph_community_members (
103
+ tenant_id TEXT NOT NULL DEFAULT 'default',
104
+ community_key UUID NOT NULL,
105
+ entity_id UUID NOT NULL,
106
+ member_rank INTEGER NOT NULL DEFAULT 0,
107
+ member_weight REAL NOT NULL DEFAULT 0,
108
+ PRIMARY KEY (tenant_id, community_key, entity_id),
109
+ CONSTRAINT gcm_member_rank_chk CHECK (member_rank >= 0),
110
+ CONSTRAINT gcm_member_weight_chk CHECK (member_weight >= 0),
111
+ CONSTRAINT fk_gcm_community
112
+ FOREIGN KEY (tenant_id, community_key)
113
+ REFERENCES graph_communities (tenant_id, community_key) ON DELETE CASCADE,
114
+ CONSTRAINT fk_gcm_entity
115
+ FOREIGN KEY (tenant_id, entity_id)
116
+ REFERENCES graph_entities (tenant_id, id) ON DELETE CASCADE
117
+ );
118
+ -- Reverse lookup (which communities an entity belongs to) + makes the
119
+ -- graph_entities-delete cascade over this FK fast (Postgres does not
120
+ -- auto-index FK referencing columns). The (tenant_id, community_key) FK is
121
+ -- already covered by the PK's leading columns.
122
+ CREATE INDEX IF NOT EXISTS idx_gcm_entity
123
+ ON graph_community_members (tenant_id, entity_id);
124
+
125
+ COMMIT;