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,665 @@
1
+ // Brain command palette runtime (Lane C, Cmd/Ctrl-P).
2
+ //
3
+ // This file is a TEMPLATE. It is installed at
4
+ // `<vault>/.quartz/quartz/components/scripts/commandPalette.inline.ts`
5
+ // by `brain vault render --overlay`. It does NOT compile or run from
6
+ // the brain repo itself — esbuild-loader inside the cloned Quartz
7
+ // workspace bundles it into a string that `CommandPalette.tsx`
8
+ // exports as `afterDOMLoaded`. The string is then injected into
9
+ // every page at `</body>` time by Quartz's `renderPage.tsx`.
10
+ //
11
+ // What this script does, and why: nothing in stock Quartz exposes a
12
+ // quick-open palette. We render the palette markup once globally
13
+ // (registered in `quartz.layout.ts` `afterBody` slot via the
14
+ // `CommandPalette` component), then this script owns the open/close
15
+ // lifecycle, source chips, fuzzy search against `contentIndex.json`,
16
+ // keyboard navigation, and SPA-nav cleanup.
17
+ //
18
+ // brain (Lane C audit fix 2026-05-02): refactored to native
19
+ // `<dialog>` element. We call `dialog.showModal()` to open and
20
+ // `dialog.close()` to dismiss. Native dialog gives us:
21
+ // * Focus trap inside the modal — Tab/Shift-Tab cycle within
22
+ // focusable descendants, no manual ring management needed.
23
+ // * Native `Escape` close behavior — fires a `cancel` event we
24
+ // intercept with `preventDefault()` so we can run the close
25
+ // animation before the dialog actually closes.
26
+ // * Automatic focus restoration to the previously-focused element
27
+ // on `dialog.close()` (browser-managed, no manual stash).
28
+ // * `::backdrop` pseudo-element for the scrim (styled in
29
+ // `_command_palette.scss`) — replaces the manual `<div class="brain-cmdk-backdrop">`.
30
+ //
31
+ // brain (Lane C audit fix 2026-05-02): SPA-mode aware navigation.
32
+ // `window.location.assign` defeats Quartz's micromorph SPA — every
33
+ // quick-open result selection blew the page cache and incurred a full
34
+ // paint. `spa.inline.ts` exposes `window.spaNavigate(url, isBack?)`
35
+ // at line 146; we route through it when present and only fall back
36
+ // to `window.location.assign` when SPA mode is disabled.
37
+ //
38
+ // brain (Lane C audit fix 2026-05-02): combobox + activedescendant
39
+ // a11y. The input carries `role="combobox"` (set in
40
+ // `CommandPalette.tsx`); we toggle `aria-expanded` on open/close,
41
+ // and stamp `aria-activedescendant` on the input pointing at the
42
+ // currently-selected option's id (`brain-cmdk-result-${idx}`) so
43
+ // screen readers announce each arrow-key result without DOM focus
44
+ // leaving the input.
45
+ //
46
+ // Contracts the script depends on:
47
+ //
48
+ // * `fetchData` — a `Promise<Record<FullSlug, ContentDetails>>`
49
+ // defined inline in `renderPage.tsx`'s `<head>` block. Stock
50
+ // Quartz exposes it for the search component; we piggyback so we
51
+ // don't fetch the index twice.
52
+ // * `window.spaNavigate(url, isBack?)` — set by `spa.inline.ts`
53
+ // when `enableSPA: true` in `quartz.config.ts`. Falls back to a
54
+ // full-page navigation when absent.
55
+ // * `<dialog id="brain-cmdk-root">` — the dialog element rendered
56
+ // by `CommandPalette.tsx`. Closed by default.
57
+ // * `<input class="brain-cmdk-input" role="combobox">` — the
58
+ // search input.
59
+ // * `<ul class="brain-cmdk-results" role="listbox">` — populated
60
+ // with `<li class="brain-cmdk-result" role="option">` rows.
61
+ // * `<div class="brain-cmdk-empty">` — shown when the result set
62
+ // is empty for a non-empty query.
63
+ //
64
+ // Keyboard contract:
65
+ // * `Cmd+P` (macOS) / `Ctrl+P` (Win/Linux) — open the palette
66
+ // (always; works from any page).
67
+ // * `Esc` — close the palette (handled natively via the `cancel`
68
+ // event so the close animation still runs).
69
+ // * `↑` / `↓` — move selection up/down through the result list.
70
+ // * `Enter` — navigate to the selected result via `spaNavigate`
71
+ // when available, else full reload.
72
+ // * Click on a result — same navigation.
73
+ // * Click on the dialog element itself (not its modal child) —
74
+ // close. Native `<dialog>` reports backdrop clicks as
75
+ // `event.target === dialog`.
76
+ //
77
+ // Browser support: `<dialog>` element has been baseline since 2022
78
+ // (Chrome 37, Firefox 98, Safari 15.4). All other features used
79
+ // here (`navigator.clipboard`-style globals, optional chaining, the
80
+ // `nav` event from spa.inline.ts) match stock Quartz's minimum.
81
+
82
+ // brain: tell TypeScript about the global Quartz wires
83
+ // `renderPage.tsx` and `spa.inline.ts` install. We don't import them
84
+ // because that creates a real ES module dependency; the inline
85
+ // script runs as `<script type="module">` and these are runtime
86
+ // globals, not module exports.
87
+ import { SOURCE_CHIP_ORDER, inferSource, sourceIconFor } from "../../util/sourceIcons"
88
+ declare global {
89
+ // brain: defined inline by Quartz's renderPage.tsx as
90
+ // `const fetchData = fetch("./static/contentIndex.json").then(d => d.json())`.
91
+ // The exact shape (object keyed by slug) matches stock + brain
92
+ // contentIndex emitter output. We treat it as a plain `Record`
93
+ // because we only read `title` / `tags`.
94
+ // eslint-disable-next-line no-var
95
+ var fetchData: Promise<Record<string, ContentEntry>> | undefined
96
+
97
+ interface Window {
98
+ addCleanup?: (fn: () => void) => void
99
+ // brain (Lane C audit fix): set by `spa.inline.ts` line 146 when
100
+ // `enableSPA: true`. Signature mirrors the upstream definition
101
+ // (see `spa.inline.ts` `async function navigate(url, isBack)`).
102
+ spaNavigate?: (url: URL, isBack?: boolean) => Promise<void>
103
+ }
104
+ }
105
+
106
+ // brain: minimal subset of `ContentDetails` we read. Keeping the type
107
+ // narrow makes the script independent of upstream content-index
108
+ // changes — if a future Quartz adds new fields, we don't care.
109
+ interface ContentEntry {
110
+ title?: string
111
+ tags?: string[]
112
+ content?: string
113
+ // brain: the brain content-index emitter adds these via
114
+ // `BrainContentDetails` (in `quartz/plugins/emitters/contentIndex.ts`).
115
+ // Used to render the kind icon next to each result.
116
+ tier?: string
117
+ source?: string
118
+ }
119
+
120
+ interface Result {
121
+ slug: string
122
+ title: string
123
+ tags: string[]
124
+ source: string
125
+ score: number
126
+ }
127
+
128
+ const ROOT_SELECTOR = "#brain-cmdk-root"
129
+ const INPUT_SELECTOR = ".brain-cmdk-input"
130
+ const RESULTS_SELECTOR = ".brain-cmdk-results"
131
+ const EMPTY_SELECTOR = ".brain-cmdk-empty"
132
+ const CHIPS_SELECTOR = ".brain-cmdk-chips"
133
+
134
+ // brain: prefix for each result `<li>`'s id. The `aria-activedescendant`
135
+ // on the input points at one of these per-keystroke. Single source
136
+ // of truth so renderResults + setSelected can't drift apart.
137
+ const RESULT_ID_PREFIX = "brain-cmdk-result-"
138
+
139
+ const MAX_RESULTS = 12
140
+ const ACTIVE_SOURCES_KEY = "brain.commandPalette.activeSources"
141
+ const DEFAULT_ACTIVE_SOURCES = new Set<string>(SOURCE_CHIP_ORDER)
142
+ let activeSources: Set<string> = loadActiveSources()
143
+
144
+ // brain: matches `--motion-mid` in `_tokens.scss`. Used to delay the
145
+ // actual `dialog.close()` after we strip `.is-open`, so the exit
146
+ // animation can run before the browser tears down the modal. If
147
+ // `--motion-mid` ever changes in `_tokens.scss`, update this in lock
148
+ // step (the value is documented next to the call site below).
149
+ const CLOSE_ANIMATION_MS = 200
150
+
151
+ // brain: cleanup registry — same convention as Quartz's other inline
152
+ // scripts. `window.addCleanup` is wired by `spa.inline.ts` and
153
+ // drains every cleanup before each SPA navigation, preventing
154
+ // listener leaks on long sessions.
155
+ function registerCleanup(fn: () => void): void {
156
+ if (typeof window.addCleanup === "function") {
157
+ window.addCleanup(fn)
158
+ }
159
+ }
160
+
161
+ function loadActiveSources(): Set<string> {
162
+ if (typeof localStorage === "undefined") return new Set(DEFAULT_ACTIVE_SOURCES)
163
+ try {
164
+ const raw = localStorage.getItem(ACTIVE_SOURCES_KEY)
165
+ if (raw === null) return new Set(DEFAULT_ACTIVE_SOURCES)
166
+ const parsed = JSON.parse(raw)
167
+ if (!Array.isArray(parsed)) return new Set(DEFAULT_ACTIVE_SOURCES)
168
+ const filtered = parsed.filter((value): value is string => typeof value === "string")
169
+ return new Set<string>(filtered)
170
+ } catch {
171
+ return new Set(DEFAULT_ACTIVE_SOURCES)
172
+ }
173
+ }
174
+
175
+ function persistActiveSources(): void {
176
+ if (typeof localStorage === "undefined") return
177
+ try {
178
+ localStorage.setItem(ACTIVE_SOURCES_KEY, JSON.stringify([...activeSources]))
179
+ } catch {
180
+ // localStorage can throw in private browsing; filtering should still work in memory.
181
+ }
182
+ }
183
+
184
+ function passesSourceFilter(source: string): boolean {
185
+ return activeSources.size === 0 || activeSources.has(source)
186
+ }
187
+
188
+ // ---------------------------------------------------------------------------
189
+ // Fuzzy match — small, deterministic, no dependency.
190
+ // ---------------------------------------------------------------------------
191
+ //
192
+ // We match the query against the title (heaviest weight), then tags
193
+ // (medium weight), then a slug fallback (lightest weight). The score
194
+ // is a simple substring + position heuristic — fast enough for the
195
+ // ~1k-doc personal corpus and easy to reason about.
196
+ //
197
+ // brain: NOT a full Levenshtein/BM25 — for personal-corpus scale the
198
+ // substring approach is plenty and avoids pulling in flexsearch (which
199
+ // stock Quartz already loads for the search component, but this
200
+ // script runs in `afterBody` and importing it cleanly here would
201
+ // double-load the bundle).
202
+
203
+ function scoreMatch(query: string, text: string, weight: number): number {
204
+ if (text.length === 0) return 0
205
+ const idx = text.toLowerCase().indexOf(query)
206
+ if (idx === -1) return 0
207
+ // brain: position bonus — earlier matches score higher (matching
208
+ // the start of the title beats matching mid-string).
209
+ const positionBonus = Math.max(0, 10 - idx) * 0.1
210
+ // brain: length bonus — closer match-length to text-length means
211
+ // the match consumed more of the string (1.0 for exact match).
212
+ const lengthBonus = query.length / text.length
213
+ return weight + positionBonus + lengthBonus
214
+ }
215
+
216
+ function fuzzy(
217
+ query: string,
218
+ data: Record<string, ContentEntry>,
219
+ ): Result[] {
220
+ const q = query.trim().toLowerCase()
221
+ if (q.length === 0) return []
222
+
223
+ const out: Result[] = []
224
+ for (const slug in data) {
225
+ const entry = data[slug]
226
+ const title = entry.title ?? slug
227
+ const tags = entry.tags ?? []
228
+ const source = inferSource(slug, entry.source)
229
+ if (!passesSourceFilter(source)) continue
230
+
231
+ let score = 0
232
+ score += scoreMatch(q, title, 3.0)
233
+ for (const tag of tags) {
234
+ score += scoreMatch(q, tag, 1.5) * 0.5 // dilute multi-tag matches
235
+ }
236
+ score += scoreMatch(q, slug, 0.8)
237
+
238
+ if (score > 0) {
239
+ out.push({ slug, title, tags, source, score })
240
+ }
241
+ }
242
+
243
+ // Highest score first; tie-break on title for deterministic order.
244
+ out.sort((a, b) => b.score - a.score || a.title.localeCompare(b.title))
245
+ return out.slice(0, MAX_RESULTS)
246
+ }
247
+
248
+ // ---------------------------------------------------------------------------
249
+ // Render results into the listbox
250
+ // ---------------------------------------------------------------------------
251
+
252
+ function renderResults(
253
+ list: HTMLUListElement,
254
+ empty: HTMLElement,
255
+ input: HTMLInputElement,
256
+ results: Result[],
257
+ ): void {
258
+ // brain: clear by replacing innerHTML — micromorph isn't relevant
259
+ // here because the list owns its own subtree exclusively.
260
+ list.innerHTML = ""
261
+
262
+ // brain: any prior `aria-activedescendant` is stale — clear it
263
+ // before the new render. setSelected() below will re-stamp on
264
+ // the first item if there are results.
265
+ input.removeAttribute("aria-activedescendant")
266
+
267
+ if (results.length === 0) {
268
+ empty.removeAttribute("hidden")
269
+ return
270
+ }
271
+ empty.setAttribute("hidden", "")
272
+
273
+ results.forEach((result, idx) => {
274
+ const li = document.createElement("li")
275
+ li.className = "brain-cmdk-result"
276
+ li.setAttribute("role", "option")
277
+ li.setAttribute("data-slug", result.slug)
278
+ li.setAttribute("data-index", String(idx))
279
+ // brain (Lane C audit fix): stable id for `aria-activedescendant`.
280
+ // The id MUST be unique on the page; the `RESULT_ID_PREFIX` plus
281
+ // the index gives us that without random-id generation.
282
+ li.id = RESULT_ID_PREFIX + idx
283
+ li.setAttribute("aria-selected", idx === 0 ? "true" : "false")
284
+
285
+ const icon = document.createElement("span")
286
+ icon.className = "brain-cmdk-icon"
287
+ icon.textContent = sourceIconFor(result.source)
288
+ li.appendChild(icon)
289
+
290
+ const body = document.createElement("div")
291
+ body.className = "brain-cmdk-body"
292
+
293
+ const title = document.createElement("div")
294
+ title.className = "brain-cmdk-title"
295
+ title.textContent = result.title
296
+ body.appendChild(title)
297
+
298
+ const slug = document.createElement("div")
299
+ slug.className = "brain-cmdk-slug"
300
+ slug.textContent = `${result.source} / ${result.slug}`
301
+ body.appendChild(slug)
302
+
303
+ li.appendChild(body)
304
+ list.appendChild(li)
305
+ })
306
+
307
+ // brain (Lane C audit fix): point the input's
308
+ // `aria-activedescendant` at the freshly-rendered first result.
309
+ // Without this, screen readers wouldn't announce the default
310
+ // selection (the visible `aria-selected` indicator alone isn't
311
+ // enough — combobox semantics require the link from the input).
312
+ input.setAttribute("aria-activedescendant", RESULT_ID_PREFIX + "0")
313
+ }
314
+
315
+ // ---------------------------------------------------------------------------
316
+ // Selection state
317
+ // ---------------------------------------------------------------------------
318
+
319
+ function currentSelectedIndex(list: HTMLUListElement): number {
320
+ const items = list.querySelectorAll<HTMLLIElement>(".brain-cmdk-result")
321
+ for (let i = 0; i < items.length; i++) {
322
+ if (items[i].getAttribute("aria-selected") === "true") return i
323
+ }
324
+ return -1
325
+ }
326
+
327
+ function setSelected(
328
+ list: HTMLUListElement,
329
+ input: HTMLInputElement,
330
+ target: number,
331
+ ): void {
332
+ const items = list.querySelectorAll<HTMLLIElement>(".brain-cmdk-result")
333
+ if (items.length === 0) {
334
+ input.removeAttribute("aria-activedescendant")
335
+ return
336
+ }
337
+ // brain: clamp to range and wrap — wrapping makes ↑ from the top
338
+ // jump to bottom and vice versa, which matches quick-open conventions
339
+ // (Reflect, Linear, Spotlight all wrap).
340
+ const clamped = ((target % items.length) + items.length) % items.length
341
+ items.forEach((item, i) => {
342
+ item.setAttribute("aria-selected", i === clamped ? "true" : "false")
343
+ if (i === clamped) {
344
+ item.scrollIntoView({ block: "nearest" })
345
+ }
346
+ })
347
+ // brain (Lane C audit fix): re-point the input's
348
+ // `aria-activedescendant` at the newly-selected option's id so
349
+ // AT announces it on each arrow press.
350
+ input.setAttribute("aria-activedescendant", RESULT_ID_PREFIX + clamped)
351
+ }
352
+
353
+ // ---------------------------------------------------------------------------
354
+ // Open / close (native dialog)
355
+ // ---------------------------------------------------------------------------
356
+
357
+ function openPalette(dialog: HTMLDialogElement, input: HTMLInputElement): void {
358
+ // brain (Lane D audit fix 2026-05-02): the previous order added
359
+ // `.is-open` BEFORE `showModal()`, intending the class to drive
360
+ // the entrance animation. The Lane C reviewer flagged — and
361
+ // browser testing confirmed — that this order BREAKS the
362
+ // entrance:
363
+ //
364
+ // * Before `showModal()`, the dialog is `display: none` (native
365
+ // `<dialog>` default without the `[open]` attribute). Setting
366
+ // `.is-open` on a `display:none` element doesn't trigger any
367
+ // transition because the element isn't rendered.
368
+ // * `showModal()` flips the dialog to its open state in a
369
+ // single synchronous paint. Because `.is-open` is already
370
+ // set, the modal child's computed style at first paint is
371
+ // `transform: scale(1); opacity: 1` — the FINAL state. There
372
+ // is no `from` keyframe state to interpolate from, so the
373
+ // transition has no animation to run.
374
+ // * Result: the modal pops in instantly with no scale-up.
375
+ //
376
+ // Correct order:
377
+ // 1. `showModal()` first — dialog flips to `display: flex` with
378
+ // `.is-open` ABSENT, so the modal child paints at its
379
+ // resting state (`transform: scale(0.96); opacity: 0` from
380
+ // `_command_palette.scss` line 152-154). This is the from-state of the
381
+ // entrance.
382
+ // 2. `requestAnimationFrame` — wait one frame so the resting-
383
+ // state paint commits to the screen before the next mutation.
384
+ // Without the rAF, modern browsers would batch both DOM
385
+ // changes into a single paint and the transition would still
386
+ // have nothing to interpolate.
387
+ // 3. Add `.is-open` on the next frame — the class flip retargets
388
+ // the modal child's transform/opacity to (scale(1)/opacity(1));
389
+ // the browser sees the property change and runs the
390
+ // transition (250ms scale + opacity per `--motion-mid`).
391
+ // 4. Sync `aria-expanded="true"` + clear input + focus it.
392
+ // ARIA + focus changes don't need to wait for the animation;
393
+ // AT consumers and keyboard users want immediate feedback.
394
+ //
395
+ // The `_command_palette.scss` rules are unchanged — the resting state lives
396
+ // on `.brain-cmdk-modal` directly, the open state lives on
397
+ // `#brain-cmdk-root.is-open .brain-cmdk-modal`. The fix is purely
398
+ // in the script-side mutation order.
399
+ //
400
+ // The dialog's native `cancel` event still fires on Esc and runs
401
+ // the close path (`closePalette`) unchanged — listener wired at
402
+ // attach time in `setupPalette`, independent of this entrance
403
+ // order rework. Close-path mutation order is in `closePalette`
404
+ // below.
405
+ if (typeof dialog.showModal === "function") {
406
+ dialog.showModal()
407
+ } else {
408
+ // brain: defensive fallback for the (vanishingly rare) browser
409
+ // that lacks `<dialog>` support — show the element directly so
410
+ // the script doesn't crash. Focus trap + backdrop won't work,
411
+ // but the palette itself remains usable.
412
+ dialog.setAttribute("open", "")
413
+ }
414
+ // brain (Lane D fix): rAF before adding `.is-open` so the resting-
415
+ // state paint commits before the class flip retargets the
416
+ // animation. See block comment above.
417
+ requestAnimationFrame(() => {
418
+ dialog.classList.add("is-open")
419
+ })
420
+ input.setAttribute("aria-expanded", "true")
421
+ input.value = ""
422
+ input.focus()
423
+ }
424
+
425
+ function closePalette(dialog: HTMLDialogElement, input: HTMLInputElement): void {
426
+ // brain (Lane C audit fix): mirror sequence to openPalette.
427
+ // 1. Strip `.is-open` to start the exit animation.
428
+ // 2. Wait one rAF + the close-animation duration (200ms; matches
429
+ // `--motion-mid` minus rAF latency) before calling `close()`.
430
+ // Without the delay the dialog vanishes mid-animation.
431
+ // 3. `dialog.close()` natively restores focus to the
432
+ // previously-focused element (browser-managed) — no manual
433
+ // stash/restore needed.
434
+ // 4. Sync `aria-expanded="false"` immediately so AT announces
435
+ // the collapse before the visible animation finishes.
436
+ if (!dialog.classList.contains("is-open")) return
437
+ dialog.classList.remove("is-open")
438
+ input.setAttribute("aria-expanded", "false")
439
+ requestAnimationFrame(() => {
440
+ window.setTimeout(() => {
441
+ if (typeof dialog.close === "function") {
442
+ dialog.close()
443
+ } else {
444
+ dialog.removeAttribute("open")
445
+ }
446
+ }, CLOSE_ANIMATION_MS)
447
+ })
448
+ }
449
+
450
+ function navigateToSlug(slug: string): void {
451
+ // brain (Lane C audit fix 2026-05-02): SPA-mode aware navigation.
452
+ // `window.location.assign` triggers a full page reload — defeats
453
+ // Quartz's enableSPA + micromorph and causes a flash on every
454
+ // selection. `spa.inline.ts:146` exposes `window.spaNavigate`
455
+ // when SPA mode is enabled; route through it when present.
456
+ // Falls back to `location.assign` so the palette still works on
457
+ // a hypothetical SPA-disabled build.
458
+ //
459
+ // brain: build a proper `URL` object from the slug + the page
460
+ // origin. spaNavigate expects a real URL (not a string); using
461
+ // the constructor handles slug normalization for free (it strips
462
+ // accidental double-slashes, encodes path segments, etc.).
463
+ const url = new URL("/" + slug, window.location.origin)
464
+ if (typeof window.spaNavigate === "function") {
465
+ void window.spaNavigate(url)
466
+ } else {
467
+ window.location.assign(url)
468
+ }
469
+ }
470
+
471
+ // ---------------------------------------------------------------------------
472
+ // Wire-up
473
+ // ---------------------------------------------------------------------------
474
+
475
+ async function setupPalette(): Promise<void> {
476
+ const dialog = document.querySelector<HTMLDialogElement>(ROOT_SELECTOR)
477
+ if (dialog === null) return // no palette markup on this page (e.g. 404)
478
+
479
+ const input = dialog.querySelector<HTMLInputElement>(INPUT_SELECTOR)
480
+ const list = dialog.querySelector<HTMLUListElement>(RESULTS_SELECTOR)
481
+ const empty = dialog.querySelector<HTMLElement>(EMPTY_SELECTOR)
482
+ const chipsRail = dialog.querySelector<HTMLElement>(CHIPS_SELECTOR)
483
+ if (input === null || list === null || empty === null) return
484
+
485
+ // brain: wait for the global content index. `fetchData` is
486
+ // installed by `renderPage.tsx`'s prescript; if absent for any
487
+ // reason (e.g. an experimental layout disables it), bail
488
+ // gracefully without breaking the page.
489
+ if (typeof fetchData === "undefined") return
490
+ const data = await fetchData
491
+
492
+ function rerenderCurrentQuery(): void {
493
+ const results = fuzzy(input.value, data)
494
+ renderResults(list, empty, input, results)
495
+ }
496
+
497
+ function refreshChipState(): void {
498
+ if (chipsRail === null) return
499
+ const chips = chipsRail.querySelectorAll<HTMLButtonElement>(".brain-cmdk-chip")
500
+ const values = Array.from(chips)
501
+ .map((chip) => chip.dataset["brainSource"])
502
+ .filter((value): value is string => typeof value === "string" && value !== "__all__")
503
+ const allActive = values.length > 0 && values.every((value) => activeSources.has(value))
504
+ chips.forEach((chip) => {
505
+ const value = chip.dataset["brainSource"]
506
+ if (value === "__all__") {
507
+ chip.dataset["active"] = allActive ? "true" : "false"
508
+ chip.setAttribute("aria-pressed", allActive ? "true" : "false")
509
+ } else if (typeof value === "string") {
510
+ const isActive = activeSources.has(value)
511
+ chip.dataset["active"] = isActive ? "true" : "false"
512
+ chip.setAttribute("aria-pressed", isActive ? "true" : "false")
513
+ }
514
+ })
515
+ }
516
+
517
+ function bindChipHandlers(): void {
518
+ if (chipsRail === null) return
519
+ const chips = chipsRail.querySelectorAll<HTMLButtonElement>(".brain-cmdk-chip")
520
+ const allValues = Array.from(chips)
521
+ .map((chip) => chip.dataset["brainSource"])
522
+ .filter((value): value is string => typeof value === "string" && value !== "__all__")
523
+ chips.forEach((chip) => {
524
+ const value = chip.dataset["brainSource"]
525
+ const handler = (): void => {
526
+ if (value === "__all__") {
527
+ activeSources = new Set<string>(allValues)
528
+ } else if (typeof value === "string") {
529
+ if (activeSources.has(value)) {
530
+ activeSources.delete(value)
531
+ } else {
532
+ activeSources.add(value)
533
+ }
534
+ }
535
+ persistActiveSources()
536
+ refreshChipState()
537
+ rerenderCurrentQuery()
538
+ }
539
+ chip.addEventListener("click", handler)
540
+ registerCleanup(() => chip.removeEventListener("click", handler))
541
+ })
542
+ }
543
+
544
+ refreshChipState()
545
+ bindChipHandlers()
546
+
547
+ // ---- Cmd-P toggle (page-level keyboard listener) ------------------
548
+ const onKeyDown = (event: KeyboardEvent): void => {
549
+ const isOpen = dialog.open
550
+ const isCmdP =
551
+ (event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "p"
552
+
553
+ if (isCmdP) {
554
+ event.preventDefault()
555
+ if (isOpen) {
556
+ closePalette(dialog, input)
557
+ } else {
558
+ openPalette(dialog, input)
559
+ }
560
+ return
561
+ }
562
+
563
+ if (!isOpen) return
564
+
565
+ if (event.key === "Escape") {
566
+ event.preventDefault()
567
+ closePalette(dialog, input)
568
+ return
569
+ }
570
+
571
+ if (event.key === "ArrowDown") {
572
+ event.preventDefault()
573
+ setSelected(list, input, currentSelectedIndex(list) + 1)
574
+ return
575
+ }
576
+ if (event.key === "ArrowUp") {
577
+ event.preventDefault()
578
+ setSelected(list, input, currentSelectedIndex(list) - 1)
579
+ return
580
+ }
581
+ if (event.key === "Enter") {
582
+ event.preventDefault()
583
+ const items = list.querySelectorAll<HTMLLIElement>(".brain-cmdk-result")
584
+ const idx = currentSelectedIndex(list)
585
+ const item = idx >= 0 ? items[idx] : null
586
+ if (item !== null && item !== undefined) {
587
+ const slug = item.getAttribute("data-slug")
588
+ if (slug !== null) {
589
+ closePalette(dialog, input)
590
+ navigateToSlug(slug)
591
+ }
592
+ }
593
+ return
594
+ }
595
+ }
596
+
597
+ // ---- Native `cancel` event (Esc + browser-driven close) ----------
598
+ // brain (Lane C audit fix): native dialog fires `cancel` on Esc.
599
+ // We `preventDefault()` so the browser doesn't immediately close
600
+ // the dialog (which would skip our exit animation), then run the
601
+ // brain close path which animates and then calls `close()` itself.
602
+ const onCancel = (event: Event): void => {
603
+ event.preventDefault()
604
+ closePalette(dialog, input)
605
+ }
606
+
607
+ // ---- Input → search ---------------------------------------------
608
+ const onInput = (): void => {
609
+ rerenderCurrentQuery()
610
+ }
611
+
612
+ // ---- Click on a result ------------------------------------------
613
+ const onListClick = (event: MouseEvent): void => {
614
+ const target = event.target as HTMLElement | null
615
+ if (target === null) return
616
+ const item = target.closest<HTMLLIElement>(".brain-cmdk-result")
617
+ if (item === null) return
618
+ const slug = item.getAttribute("data-slug")
619
+ if (slug === null) return
620
+ closePalette(dialog, input)
621
+ navigateToSlug(slug)
622
+ }
623
+
624
+ // ---- Backdrop dismiss -------------------------------------------
625
+ // brain (Lane C audit fix): native dialogs report backdrop clicks
626
+ // as `event.target === dialog` (clicks on dialog descendants
627
+ // bubble with `target` set to the actual descendant). This is the
628
+ // canonical way to dismiss-on-backdrop with `<dialog>`.
629
+ const onDialogClick = (event: MouseEvent): void => {
630
+ if (event.target === dialog) {
631
+ closePalette(dialog, input)
632
+ }
633
+ }
634
+
635
+ document.addEventListener("keydown", onKeyDown)
636
+ dialog.addEventListener("cancel", onCancel)
637
+ input.addEventListener("input", onInput)
638
+ list.addEventListener("click", onListClick)
639
+ dialog.addEventListener("click", onDialogClick)
640
+
641
+ registerCleanup(() => {
642
+ document.removeEventListener("keydown", onKeyDown)
643
+ dialog.removeEventListener("cancel", onCancel)
644
+ input.removeEventListener("input", onInput)
645
+ list.removeEventListener("click", onListClick)
646
+ dialog.removeEventListener("click", onDialogClick)
647
+ })
648
+ }
649
+
650
+ // brain: same registration pattern as `linkSourceTag.js` — run on
651
+ // first load + on every SPA `nav` event so the listeners get
652
+ // re-attached after micromorph swaps the DOM. Without the nav
653
+ // re-registration the palette's input would stop responding after
654
+ // the first internal-link click.
655
+ if (document.readyState === "loading") {
656
+ document.addEventListener("DOMContentLoaded", () => {
657
+ void setupPalette()
658
+ })
659
+ } else {
660
+ void setupPalette()
661
+ }
662
+
663
+ document.addEventListener("nav", () => {
664
+ void setupPalette()
665
+ })