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/links.py ADDED
@@ -0,0 +1,260 @@
1
+ """Wiki-link parser for Markdown vault notes.
2
+
3
+ Pure parsing: input is a Markdown body string (frontmatter already stripped by
4
+ the caller), output is a list of :class:`ParsedLink` in document order. The
5
+ parser recognizes Obsidian-style ``[[wiki]]`` and ``![[embed]]`` markers plus
6
+ the brain-specific ``[[brain:<id>]]`` and ``[[<source>:<external>]]`` forms.
7
+
8
+ Skipped contexts (silently — these are not links):
9
+
10
+ - Fenced code blocks (`` ``` ... ``` `` or ``~~~ ... ~~~``).
11
+ - Inline code spans delimited by backticks.
12
+ - Indented code blocks (4+ leading spaces, with no preceding non-blank line).
13
+ - Wiki-link openings preceded by a backslash escape (``\\[[X]]``).
14
+ - Empty ``[[]]`` openers (no error, just ignored).
15
+
16
+ The parser is intentionally conservative: when in doubt about whether a
17
+ ``[[...]]`` is "really" a link, it skips. Callers can rely on the result list
18
+ being safe to materialize into the ``links``/``unresolved_links`` tables
19
+ without producing false positives.
20
+ """
21
+ import re
22
+ from dataclasses import dataclass
23
+ from typing import Literal
24
+
25
+ # Source kinds the parser recognizes in ``[[<source>:<external_id>]]`` form.
26
+ # ``brain`` is reserved for direct document-id lookup; other identifiers are
27
+ # routed to the ``sources`` table.
28
+ _SOURCE_KINDS: frozenset[str] = frozenset({"krisp", "slack", "gmail", "manual"})
29
+
30
+ # Bracket inside a wiki-link must not contain another ``]]``. Allowing nested
31
+ # brackets would conflict with Markdown reference-style links and is not part
32
+ # of the Obsidian format we conform to.
33
+ _WIKI_LINK_RE = re.compile(r"\[\[(?P<inner>[^\[\]]*?)\]\]")
34
+
35
+ _FENCE_RE = re.compile(r"^(?P<fence>`{3,}|~{3,})")
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class ParsedLink:
40
+ """One parsed ``[[...]]`` occurrence from a Markdown body.
41
+
42
+ Field semantics map 1:1 onto the spec's resolution table — see the table
43
+ in ``docs/specs/2026-04-28-vault-model-design.md``. The parser does not
44
+ resolve the link; it only classifies the surface syntax. Resolution
45
+ against the DB is :func:`brain.vault.resolver.resolve_link`'s job.
46
+
47
+ ``raw`` is the literal substring as it appeared in the document,
48
+ including the surrounding ``[[ ]]`` (or ``![[ ]]`` for embeds). Useful
49
+ for round-tripping into ``links.link_text`` so renderers can match the
50
+ original wording exactly.
51
+ """
52
+
53
+ raw: str
54
+ kind: Literal["wiki", "embed"]
55
+ target_type: Literal["title", "doc-id", "source-external"]
56
+ target_value: str
57
+ target_source: str | None
58
+ display_text: str | None
59
+ heading: str | None
60
+
61
+
62
+ def parse_wiki_links(text: str) -> list[ParsedLink]:
63
+ """Return every wiki-link found in ``text`` in document order.
64
+
65
+ The returned list is positional — callers can rely on the order matching
66
+ the order links appear in the body, which makes diagnostics ("link #3
67
+ on line 47 is dangling") straightforward.
68
+
69
+ Returns an empty list for any input that contains no parseable links
70
+ (including the empty string and bodies consisting entirely of code).
71
+ """
72
+ return [link for link, _start, _end in iter_wiki_links_with_spans(text)]
73
+
74
+
75
+ def iter_wiki_links_with_spans(
76
+ text: str,
77
+ ) -> list[tuple[ParsedLink, int, int]]:
78
+ """Same as :func:`parse_wiki_links`, but each entry carries source spans.
79
+
80
+ Each tuple is ``(parsed, start, end)`` where ``text[start:end]`` is the
81
+ exact byte range the link occupies in the original ``text``. Embeds
82
+ (``![[...]]``) include the leading ``!`` in the span so callers that
83
+ rewrite in place don't have to detect it again.
84
+
85
+ Spans are non-overlapping and yielded in document order. The
86
+ :func:`_strip_uncodelike_regions` pass that powers code-fence skipping
87
+ is length-preserving, so offsets from the regex over the masked text map
88
+ 1:1 onto the original — that's what makes rewriting safe even when a
89
+ later region of the document contains the same ``[[X]]`` inside a code
90
+ fence (it's silently skipped, not rewritten).
91
+ """
92
+ parseable = _strip_uncodelike_regions(text)
93
+ out: list[tuple[ParsedLink, int, int]] = []
94
+ for match in _WIKI_LINK_RE.finditer(parseable):
95
+ inner = match.group("inner")
96
+ if not inner.strip():
97
+ # ``[[]]`` and ``[[ ]]`` are silently ignored — Obsidian treats
98
+ # them as user typos rather than markup.
99
+ continue
100
+ # Detect ``![[...]]`` embeds: look at the character immediately
101
+ # before the opener.
102
+ start = match.start()
103
+ end = match.end()
104
+ is_embed = start > 0 and parseable[start - 1] == "!"
105
+ # Reject ``\[[X]]`` (backslash-escaped) — the ``\`` makes it literal text.
106
+ # An odd number of backslashes immediately preceding the opener escapes
107
+ # it; an even number means the user wrote ``\\`` (literal backslash)
108
+ # before a real link.
109
+ escape_index = start - 1
110
+ if is_embed:
111
+ escape_index -= 1
112
+ backslash_run = 0
113
+ while escape_index >= 0 and parseable[escape_index] == "\\":
114
+ backslash_run += 1
115
+ escape_index -= 1
116
+ if backslash_run % 2 == 1:
117
+ continue
118
+
119
+ raw = ("![[" if is_embed else "[[") + inner + "]]"
120
+ link = _classify(raw, inner, embed=is_embed)
121
+ if link is not None:
122
+ span_start = start - 1 if is_embed else start
123
+ out.append((link, span_start, end))
124
+ return out
125
+
126
+
127
+ def _classify(raw: str, inner: str, *, embed: bool) -> ParsedLink | None:
128
+ """Turn ``inner`` (the text between ``[[`` and ``]]``) into a ParsedLink.
129
+
130
+ Splits on the first ``|`` for the optional display alias, then on ``#``
131
+ for an optional heading, then on ``:`` to detect explicit prefixes
132
+ (``brain:`` / ``<source>:``). Returns ``None`` only on degenerate inputs
133
+ that survived the empty-check above (e.g. a lone ``|`` with no target);
134
+ the caller drops them silently.
135
+ """
136
+ target_part, _, display = inner.partition("|")
137
+ target_part = target_part.strip()
138
+ display_text = display.strip() if display else None
139
+ if not target_part:
140
+ return None
141
+
142
+ target_value, _, heading_part = target_part.partition("#")
143
+ target_value = target_value.strip()
144
+ heading = heading_part.strip() if heading_part else None
145
+ if not target_value:
146
+ return None
147
+
148
+ # Explicit ``<prefix>:<rest>`` form. Only a small set of known prefixes
149
+ # win; arbitrary ``foo:bar`` is treated as a title (Obsidian allows
150
+ # colons in note titles, so we don't claim them by default).
151
+ prefix, sep, rest = target_value.partition(":")
152
+ prefix = prefix.strip()
153
+ rest = rest.strip()
154
+ kind: Literal["wiki", "embed"] = "embed" if embed else "wiki"
155
+ if sep and rest:
156
+ if prefix == "brain":
157
+ return ParsedLink(
158
+ raw=raw,
159
+ kind=kind,
160
+ target_type="doc-id",
161
+ target_value=rest,
162
+ target_source=None,
163
+ display_text=display_text,
164
+ heading=heading,
165
+ )
166
+ if prefix in _SOURCE_KINDS:
167
+ return ParsedLink(
168
+ raw=raw,
169
+ kind=kind,
170
+ target_type="source-external",
171
+ target_value=rest,
172
+ target_source=prefix,
173
+ display_text=display_text,
174
+ heading=heading,
175
+ )
176
+
177
+ return ParsedLink(
178
+ raw=raw,
179
+ kind=kind,
180
+ target_type="title",
181
+ target_value=target_value,
182
+ target_source=None,
183
+ display_text=display_text,
184
+ heading=heading,
185
+ )
186
+
187
+
188
+ def _strip_uncodelike_regions(text: str) -> str:
189
+ """Replace fenced/inline/indented code regions with spaces, length-preserving.
190
+
191
+ Length-preserving so positional information (``match.start()``) over the
192
+ returned string still maps onto the original — useful for future
193
+ line/column diagnostics, even though Phase 2 doesn't surface them yet.
194
+ All other characters are left intact, so the regex scan only sees text
195
+ that's actually eligible to contain a link.
196
+ """
197
+ out_chars: list[str] = []
198
+ in_fence = False
199
+ fence_marker = ""
200
+ lines = text.splitlines(keepends=True)
201
+ for line in lines:
202
+ stripped = line.lstrip()
203
+ if in_fence:
204
+ if stripped.startswith(fence_marker):
205
+ in_fence = False
206
+ fence_marker = ""
207
+ out_chars.append(_blank_keep_newline(line))
208
+ continue
209
+ match = _FENCE_RE.match(stripped)
210
+ if match:
211
+ in_fence = True
212
+ fence_marker = match.group("fence")[0] * 3
213
+ out_chars.append(_blank_keep_newline(line))
214
+ continue
215
+ # Indented code block (4+ leading spaces or a tab) — only when the
216
+ # block is structurally a code block; for our purposes, treating any
217
+ # 4+-space indent as code is conservative-but-safe (a real list item
218
+ # at 4 spaces is rare in this corpus and a false negative on a link
219
+ # there is acceptable).
220
+ if line.startswith(" ") or line.startswith("\t"):
221
+ out_chars.append(_blank_keep_newline(line))
222
+ continue
223
+ out_chars.append(_strip_inline_code(line))
224
+ return "".join(out_chars)
225
+
226
+
227
+ def _blank_keep_newline(line: str) -> str:
228
+ """Replace every non-newline character in ``line`` with a space.
229
+
230
+ Keeps offsets stable for downstream regex matching while ensuring no
231
+ bracket-like characters survive to be mistaken for a link.
232
+ """
233
+ return "".join(" " if ch != "\n" else "\n" for ch in line)
234
+
235
+
236
+ def _strip_inline_code(line: str) -> str:
237
+ """Replace inline code spans (``` `…` ```) in ``line`` with spaces.
238
+
239
+ Only single-backtick spans are handled — Markdown allows multi-backtick
240
+ spans to escape inner backticks, but in practice vault notes don't mix
241
+ those with wiki-links, and the cost of a false negative (a missed link)
242
+ is much lower than a false positive (materializing a code-fragment as a
243
+ link).
244
+ """
245
+ out: list[str] = []
246
+ in_code = False
247
+ for ch in line:
248
+ if ch == "`":
249
+ out.append(" ")
250
+ in_code = not in_code
251
+ continue
252
+ if in_code and ch != "\n":
253
+ out.append(" ")
254
+ else:
255
+ out.append(ch)
256
+ # If the line ends with an unmatched opening backtick, treat the rest as
257
+ # text — this branch is naturally handled by the loop above (``in_code``
258
+ # leaks to the next line, but ``_strip_uncodelike_regions`` resets state
259
+ # per-line, so an unbalanced inline opener is forgiving).
260
+ return "".join(out)
@@ -0,0 +1,211 @@
1
+ """Standalone builder for vault-tier notes: render frontmatter, write, and sync.
2
+
3
+ Extracted from ``brain.cli`` so callers (e.g. the tacit-knowledge elicitation
4
+ session loop) can author vault notes WITHOUT importing the Typer CLI — which
5
+ would create an import cycle (``cli`` already imports ``brain.vault``).
6
+
7
+ The heavy collaborators (``sync_one_file`` and ``make_embedder``) are imported
8
+ lazily inside the functions on purpose: importing them at module load would
9
+ re-enter the ``brain.ingest`` package while it is only partially initialized
10
+ (``brain.ingest`` imports ``brain.vault.export``, which imports this package),
11
+ raising ``ImportError``. The module-level imports below are all cycle-safe
12
+ leaves.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import uuid
17
+ from datetime import date as date_cls
18
+ from datetime import datetime
19
+ from pathlib import Path
20
+ from typing import TYPE_CHECKING, Any
21
+
22
+ import yaml
23
+
24
+ from ..errors import VaultNoteSyncError
25
+ from ..tags import normalize_tags
26
+ from .frontmatter import dump_frontmatter, parse_frontmatter
27
+ from .slug import slugify
28
+ from .templates import render_template
29
+
30
+ if TYPE_CHECKING:
31
+ from collections.abc import Sequence
32
+
33
+ import psycopg
34
+
35
+ from ..config import Config
36
+ from ..ingest import Embedder
37
+
38
+
39
+ def _build_embedder(cfg: Config) -> Embedder:
40
+ """Build the configured embedder. Indirected so tests can substitute a fake.
41
+
42
+ Returns the :class:`Embedder` Protocol — callers should not depend on the
43
+ concrete backend. The ``make_embedder`` import is deferred to avoid a
44
+ load-time import cycle (see the module docstring).
45
+ """
46
+ from ..embeddings import make_embedder
47
+
48
+ return make_embedder(cfg)
49
+
50
+
51
+ def _build_note_text(
52
+ template_text: str,
53
+ *,
54
+ title: str,
55
+ tags: list[str],
56
+ today: date_cls,
57
+ now: datetime,
58
+ body: str | None = None,
59
+ ) -> tuple[str, str]:
60
+ """Render a template + force the brain-canonical frontmatter fields.
61
+
62
+ Returns ``(file_text, document_id)``. The template's body is preserved
63
+ verbatim unless ``body`` is provided, in which case the rendered template's
64
+ body is replaced by ``body`` (used when a caller supplies authored content
65
+ instead of letting the user fill the template in ``$EDITOR``). Only the
66
+ frontmatter is rewritten so the brain-managed fields (``id``, ``title``,
67
+ ``created``, ``updated``, ``kind``, ``tags``) are authoritative regardless
68
+ of what the template author wrote.
69
+
70
+ A user-template ``title:`` line is intentionally ignored — the caller's
71
+ ``title`` argument wins. That's the contract: if you wanted the template
72
+ to control title, you'd be using a daily template (which derives title
73
+ from the date passed in via ``vars``).
74
+ """
75
+ rendered = render_template(
76
+ template_text,
77
+ {
78
+ "title": title,
79
+ "date": today.isoformat(),
80
+ "datetime": now.isoformat(timespec="seconds"),
81
+ "slug": slugify(title),
82
+ },
83
+ )
84
+
85
+ # Try to parse the rendered template's frontmatter; if it's malformed or
86
+ # missing entirely, build a fresh header. Either way the brain-canonical
87
+ # fields are forced — the template's body is what we preserve.
88
+ try:
89
+ existing_fields, parsed_body = parse_frontmatter(rendered)
90
+ except (ValueError, yaml.YAMLError):
91
+ # Per the spec's risk: a malformed template shouldn't crash. Fall
92
+ # back to a fresh frontmatter + the raw rendered text as the body.
93
+ existing_fields = {}
94
+ parsed_body = rendered
95
+
96
+ note_body = parsed_body if body is None else body
97
+
98
+ document_id = str(uuid.uuid4())
99
+ iso_now = now.isoformat(timespec="seconds")
100
+ fields: dict[str, Any] = dict(existing_fields)
101
+ # Brain-managed fields override the template's choices in a fixed order
102
+ # so frontmatter ordering is stable across runs.
103
+ fields["id"] = document_id
104
+ fields["title"] = title
105
+ fields["created"] = iso_now
106
+ fields["updated"] = iso_now
107
+ fields["kind"] = "vault"
108
+ if tags:
109
+ fields["tags"] = list(tags)
110
+ elif "tags" not in fields:
111
+ fields["tags"] = []
112
+
113
+ return dump_frontmatter(fields, note_body), document_id
114
+
115
+
116
+ def _unique_target(base_dir: Path, slug: str) -> Path:
117
+ """Return a non-existing ``<base_dir>/<slug>.md`` path, suffixing on collision.
118
+
119
+ ``brain note new`` guards against collisions before delegating here, but the
120
+ elicitation ``_codify`` path calls :func:`create_vault_note` directly. Without
121
+ this guard a draft whose title slugifies to an existing note's slug would
122
+ silently overwrite that note (data loss). On collision we append ``-2``,
123
+ ``-3``, … until a free path is found so the existing note is never clobbered.
124
+ """
125
+ target = base_dir / f"{slug}.md"
126
+ if not target.exists():
127
+ return target
128
+ suffix = 2
129
+ while True:
130
+ candidate = base_dir / f"{slug}-{suffix}.md"
131
+ if not candidate.exists():
132
+ return candidate
133
+ suffix += 1
134
+
135
+
136
+ def create_vault_note(
137
+ conn: psycopg.Connection[Any],
138
+ *,
139
+ cfg: Config,
140
+ vault_path: Path,
141
+ title: str,
142
+ body: str | None = None,
143
+ tags: Sequence[str] = (),
144
+ template: str = "note",
145
+ folder: str = "",
146
+ embedder: Embedder | None = None,
147
+ ) -> str:
148
+ """Author a vault-tier note: render, write to disk, sync, return its id.
149
+
150
+ Resolves ``<vault>/_templates/<template>.md``, renders it (forcing the
151
+ brain-canonical frontmatter) with ``body`` substituted for the template's
152
+ placeholder body when provided, writes the file under ``vault_path`` (in
153
+ ``folder`` if given), then runs a single-file sync so the note is indexed
154
+ as ``kind='vault'`` (the tier ``sync_one_file`` infers for any file outside
155
+ ``_ingested/``).
156
+
157
+ ``embedder`` lets a caller inject a pre-built embedder (``brain note new``
158
+ passes the one it built so test fakes wired onto
159
+ ``brain.cli._build_embedder`` still apply); when ``None`` the configured
160
+ embedder is built from ``cfg``.
161
+
162
+ Raises :class:`~brain.errors.VaultNoteSyncError` if the template is missing
163
+ or the sync reports per-file errors (the file is left on disk on a sync
164
+ error). Returns the generated ``document_id``.
165
+ """
166
+ from .sync import sync_one_file
167
+
168
+ template_path = vault_path / "_templates" / f"{template}.md"
169
+ if not template_path.is_file():
170
+ raise VaultNoteSyncError(
171
+ [(template_path, f"template {template!r} not found")]
172
+ )
173
+ template_text = template_path.read_text(encoding="utf-8")
174
+
175
+ now = datetime.now()
176
+ today = now.date()
177
+ file_text, document_id = _build_note_text(
178
+ template_text,
179
+ title=title,
180
+ tags=normalize_tags(list(tags)),
181
+ today=today,
182
+ now=now,
183
+ body=body,
184
+ )
185
+
186
+ base_dir = vault_path / folder if folder else vault_path
187
+ # Path-traversal guard: a ``folder`` containing ``..`` segments or an
188
+ # absolute path would resolve outside the vault root and author files in an
189
+ # arbitrary filesystem location. Reject before any mkdir / write so nothing
190
+ # is created on violation.
191
+ vault_root_resolved = vault_path.resolve()
192
+ base_dir_resolved = base_dir.resolve()
193
+ if not base_dir_resolved.is_relative_to(vault_root_resolved):
194
+ raise VaultNoteSyncError(
195
+ [(base_dir, f"folder {folder!r} escapes the vault root")]
196
+ )
197
+ base_dir.mkdir(parents=True, exist_ok=True)
198
+ target = _unique_target(base_dir, slugify(title))
199
+ target.write_text(file_text, encoding="utf-8")
200
+
201
+ active_embedder = embedder if embedder is not None else _build_embedder(cfg)
202
+ report = sync_one_file(
203
+ conn,
204
+ embedder=active_embedder,
205
+ vault_path=vault_path,
206
+ file_path=target,
207
+ owner_participants=cfg.owner_participants,
208
+ )
209
+ if report.errors:
210
+ raise VaultNoteSyncError(report.errors)
211
+ return document_id
brain/vault/paths.py ADDED
@@ -0,0 +1,55 @@
1
+ """Shared path / wiki-link helpers used by the vault renderers.
2
+
3
+ Both helpers are pure (no I/O, no DB) and small. Three rendering surfaces
4
+ hand-rolled the same logic before this module existed:
5
+
6
+ - :mod:`brain.wiki.build_homepage` — recent-rail bullet renderer
7
+ - :mod:`brain.wiki.build_people` — People Hub per-person + index pages
8
+ - :mod:`brain.vault.daily_index` — daily-notes index renderer
9
+
10
+ Centralizing here means a future change (say, switching to ``.markdown``
11
+ extension support) flips one place rather than three.
12
+ """
13
+ from pathlib import PurePosixPath
14
+
15
+
16
+ def strip_md_extension(path: str) -> str:
17
+ """Return ``path`` with a trailing ``.md`` removed, POSIX-style.
18
+
19
+ ``documents.vault_path`` and the equivalent on-disk relative paths are
20
+ stored as forward-slash POSIX strings; we keep the same shape so wiki
21
+ links round-trip on Windows hosts too. Inputs without a trailing
22
+ ``.md`` are returned unchanged (after the POSIX normalization), so
23
+ callers can pipe path-form strings through unconditionally.
24
+
25
+ Used by every renderer that emits ``[[<vault-path-no-md>|<title>]]``
26
+ so wiki-links match what
27
+ :func:`brain.vault.resolver._resolve_by_vault_path` looks for. Empty
28
+ input yields ``'.'`` (because ``PurePosixPath('').as_posix() == '.'``);
29
+ the renderers guard for that separately if they care, and the
30
+ contract is pinned by ``tests/test_vault_paths.py::test_empty_input``.
31
+ """
32
+ posix = PurePosixPath(path).as_posix()
33
+ if posix.endswith(".md"):
34
+ return posix[:-3]
35
+ return posix
36
+
37
+
38
+ def safe_wikilink_alias(title: str) -> str:
39
+ """Strip ``[`` / ``]`` from a wiki-link alias slot.
40
+
41
+ Quartz's wiki-link regex defines the alias slot as ``[^\\[\\]\\#]``;
42
+ any ``[`` or ``]`` inside an alias makes the whole ``[[...]]`` fail
43
+ to match and emit as raw text. Doc titles in the brain corpus
44
+ routinely contain bracketed prefixes — ``Re: [External] Re: ...``
45
+ from forwarded Gmail, ``[2026-04] Plenty sync notes`` from manual
46
+ notes — so we swap brackets for parens in the alias slot only. The
47
+ wiki-link target is :data:`documents.vault_path`, which never
48
+ contains these characters by construction.
49
+
50
+ Returns the title unchanged when no replacement is needed (cheap
51
+ early-out for the common case).
52
+ """
53
+ if "[" not in title and "]" not in title:
54
+ return title
55
+ return title.replace("[", "(").replace("]", ")")