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.
- brain/__init__.py +0 -0
- brain/__main__.py +18 -0
- brain/_capture_command.py +445 -0
- brain/_compose.py +52 -0
- brain/activity.py +206 -0
- brain/ask.py +631 -0
- brain/audio.py +591 -0
- brain/backfill/__init__.py +12 -0
- brain/backfill/search_extras.py +141 -0
- brain/backfill/source_rows.py +101 -0
- brain/bin/__init__.py +1 -0
- brain/bin/_launcher.py +107 -0
- brain/bin/down.py +8 -0
- brain/bin/launchd.py +268 -0
- brain/bin/monitor.py +570 -0
- brain/bin/rebuild.py +8 -0
- brain/bin/status.py +8 -0
- brain/bin/up.py +8 -0
- brain/brief.py +272 -0
- brain/capture.py +49 -0
- brain/chat.py +293 -0
- brain/cli.py +9760 -0
- brain/cli_claude.py +81 -0
- brain/cli_connect.py +285 -0
- brain/cli_demo.py +266 -0
- brain/config.py +1949 -0
- brain/connect.py +925 -0
- brain/db.py +540 -0
- brain/demo/__init__.py +452 -0
- brain/demo/corpus/manifest.json +403 -0
- brain/demo/embedder.py +74 -0
- brain/durations.py +84 -0
- brain/edit_session.py +156 -0
- brain/editor.py +67 -0
- brain/elicit/__init__.py +16 -0
- brain/elicit/detectors.py +250 -0
- brain/elicit/drafter.py +70 -0
- brain/elicit/queue.py +220 -0
- brain/elicit/schema.py +48 -0
- brain/elicit/session.py +445 -0
- brain/embedding_targets.py +54 -0
- brain/embeddings.py +424 -0
- brain/enrichment.py +808 -0
- brain/errors.py +357 -0
- brain/eval/__init__.py +129 -0
- brain/eval/answer_eval.py +281 -0
- brain/eval/baseline.py +265 -0
- brain/eval/concept_extraction.py +378 -0
- brain/eval/corpus.py +152 -0
- brain/eval/errors.py +19 -0
- brain/eval/graph_baseline.py +226 -0
- brain/eval/graph_retrieval.py +202 -0
- brain/eval/graph_runner.py +319 -0
- brain/eval/metrics.py +101 -0
- brain/eval/runner.py +223 -0
- brain/format.py +783 -0
- brain/gaps.py +390 -0
- brain/graph_rag/__init__.py +94 -0
- brain/graph_rag/_retrieval_common.py +113 -0
- brain/graph_rag/aggregates.py +303 -0
- brain/graph_rag/aliases/__init__.py +583 -0
- brain/graph_rag/backends/__init__.py +10 -0
- brain/graph_rag/backends/_age_helpers.py +473 -0
- brain/graph_rag/backends/age.py +782 -0
- brain/graph_rag/backends/base.py +272 -0
- brain/graph_rag/build.py +344 -0
- brain/graph_rag/communities.py +644 -0
- brain/graph_rag/communities_summary.py +437 -0
- brain/graph_rag/concepts.py +202 -0
- brain/graph_rag/cooccur.py +193 -0
- brain/graph_rag/cross_type.py +312 -0
- brain/graph_rag/extract.py +885 -0
- brain/graph_rag/fuse.py +371 -0
- brain/graph_rag/global_.py +412 -0
- brain/graph_rag/grouping.py +372 -0
- brain/graph_rag/person_resolver.py +167 -0
- brain/graph_rag/reconcile.py +792 -0
- brain/graph_rag/relational.py +353 -0
- brain/graph_rag/retrieve.py +526 -0
- brain/graph_rag/router.py +288 -0
- brain/graph_rag/schema.py +320 -0
- brain/graph_rag/sync.py +237 -0
- brain/graph_rag/tenancy.py +43 -0
- brain/graph_rag/themes.py +501 -0
- brain/graph_rag/weighting.py +202 -0
- brain/ingest/__init__.py +1926 -0
- brain/ingest/chunker.py +249 -0
- brain/ingest/docx.py +40 -0
- brain/ingest/gmail.py +621 -0
- brain/ingest/markdown.py +37 -0
- brain/ingest/pdf.py +61 -0
- brain/ingest/stdin.py +22 -0
- brain/ingest/sub_tokens.py +91 -0
- brain/ingest/text.py +16 -0
- brain/interactions.py +205 -0
- brain/maintenance.py +355 -0
- brain/mcp_server.py +3405 -0
- brain/migrations/001_init.sql +43 -0
- brain/migrations/002_qwen3_embedding.sql +17 -0
- brain/migrations/003_vault_model.sql +41 -0
- brain/migrations/004_relax_content_hash_uniqueness.sql +18 -0
- brain/migrations/005_derived_links.sql +67 -0
- brain/migrations/006_dedup_file_by_source_path.sql +25 -0
- brain/migrations/007_email_thread_and_draft.sql +15 -0
- brain/migrations/008_gmail_thread_unique.sql +11 -0
- brain/migrations/009_chunks_weighted_tsv.sql +28 -0
- brain/migrations/010_interactions.sql +30 -0
- brain/migrations/011_documents_summary.sql +23 -0
- brain/migrations/012_graphrag.sql +171 -0
- brain/migrations/013_graphrag_communities.sql +125 -0
- brain/migrations/014_graphrag_community_summary_hash.sql +33 -0
- brain/migrations/015_interactions_graph_targets.sql +89 -0
- brain/migrations/016_index_hygiene.sql +61 -0
- brain/migrations/017_elicit.sql +30 -0
- brain/migrations/018_review_gap_signal_kinds.sql +40 -0
- brain/migrations/019_search_queries.sql +35 -0
- brain/migrations/020_link_suggestions.sql +40 -0
- brain/migrations/021_timeline_doc_date.sql +34 -0
- brain/migrations/022_link_suggestions_undirected.sql +84 -0
- brain/migrations/023_search_queries_fts_count.sql +28 -0
- brain/quartz_overrides/__init__.py +8 -0
- brain/quartz_overrides/quartz/bootstrap-cli.mjs +65 -0
- brain/quartz_overrides/quartz/build.ts +568 -0
- brain/quartz_overrides/quartz/cli/args.js +152 -0
- brain/quartz_overrides/quartz/cli/build_partial_handler.js +544 -0
- brain/quartz_overrides/quartz/cli/handlers.js +636 -0
- brain/quartz_overrides/quartz/components/CommandPalette.tsx +172 -0
- brain/quartz_overrides/quartz/components/Explorer.tsx +198 -0
- brain/quartz_overrides/quartz/components/Footer.tsx +27 -0
- brain/quartz_overrides/quartz/components/Graph.tsx +468 -0
- brain/quartz_overrides/quartz/components/PageTitle.tsx +72 -0
- brain/quartz_overrides/quartz/components/RelatedDocs.tsx +38 -0
- brain/quartz_overrides/quartz/components/Search.tsx +161 -0
- brain/quartz_overrides/quartz/components/SummaryLede.tsx +72 -0
- brain/quartz_overrides/quartz/components/index.ts +92 -0
- brain/quartz_overrides/quartz/components/pages/TagContent.tsx +272 -0
- brain/quartz_overrides/quartz/components/scripts/commandPalette.inline.ts +665 -0
- brain/quartz_overrides/quartz/components/scripts/explorer.inline.ts +768 -0
- brain/quartz_overrides/quartz/components/scripts/graph.inline.ts +2302 -0
- brain/quartz_overrides/quartz/components/scripts/relatedDocs.inline.ts +163 -0
- brain/quartz_overrides/quartz/components/scripts/search.inline.ts +1011 -0
- brain/quartz_overrides/quartz/plugins/emitters/contentIndex.ts +546 -0
- brain/quartz_overrides/quartz/plugins/transformers/codeCopy.ts +94 -0
- brain/quartz_overrides/quartz/plugins/transformers/derivedFenceMark.ts +302 -0
- brain/quartz_overrides/quartz/plugins/transformers/emailThread.ts +148 -0
- brain/quartz_overrides/quartz/plugins/transformers/emptyDoorFilter.ts +213 -0
- brain/quartz_overrides/quartz/plugins/transformers/index.ts +114 -0
- brain/quartz_overrides/quartz/plugins/transformers/linkKindMark.ts +205 -0
- brain/quartz_overrides/quartz/plugins/transformers/linkSourceTag.ts +104 -0
- brain/quartz_overrides/quartz/plugins/transformers/relativeDate.ts +100 -0
- brain/quartz_overrides/quartz/plugins/transformers/reloadSignal.ts +131 -0
- brain/quartz_overrides/quartz/processors/parse.ts +371 -0
- brain/quartz_overrides/quartz/processors/parser_cache.ts +78 -0
- brain/quartz_overrides/quartz/static/brain-logo-dark.png +0 -0
- brain/quartz_overrides/quartz/static/brain-logo-light.png +0 -0
- brain/quartz_overrides/quartz/static/codeCopy.js +196 -0
- brain/quartz_overrides/quartz/static/emailThread.js +334 -0
- brain/quartz_overrides/quartz/static/favicon.ico +0 -0
- brain/quartz_overrides/quartz/static/icon.png +0 -0
- brain/quartz_overrides/quartz/static/linkSourceTag.js +104 -0
- brain/quartz_overrides/quartz/static/relativeDate.js +142 -0
- brain/quartz_overrides/quartz/static/reload.js +168 -0
- brain/quartz_overrides/quartz/styles/brain/_article.scss +252 -0
- brain/quartz_overrides/quartz/styles/brain/_atmosphere.scss +113 -0
- brain/quartz_overrides/quartz/styles/brain/_callouts.scss +180 -0
- brain/quartz_overrides/quartz/styles/brain/_cmdk.scss +7 -0
- brain/quartz_overrides/quartz/styles/brain/_code.scss +208 -0
- brain/quartz_overrides/quartz/styles/brain/_command_palette.scss +369 -0
- brain/quartz_overrides/quartz/styles/brain/_email_thread.scss +228 -0
- brain/quartz_overrides/quartz/styles/brain/_explorer.scss +142 -0
- brain/quartz_overrides/quartz/styles/brain/_home.scss +182 -0
- brain/quartz_overrides/quartz/styles/brain/_links.scss +322 -0
- brain/quartz_overrides/quartz/styles/brain/_marginalia.scss +117 -0
- brain/quartz_overrides/quartz/styles/brain/_motion.scss +175 -0
- brain/quartz_overrides/quartz/styles/brain/_people_hub.scss +100 -0
- brain/quartz_overrides/quartz/styles/brain/_related_docs.scss +137 -0
- brain/quartz_overrides/quartz/styles/brain/_search.scss +252 -0
- brain/quartz_overrides/quartz/styles/brain/_sidebar.scss +468 -0
- brain/quartz_overrides/quartz/styles/brain/_summary_lede.scss +56 -0
- brain/quartz_overrides/quartz/styles/brain/_surface.scss +43 -0
- brain/quartz_overrides/quartz/styles/brain/_tag_content.scss +118 -0
- brain/quartz_overrides/quartz/styles/brain/_tokens.scss +197 -0
- brain/quartz_overrides/quartz/styles/brain/_typography.scss +92 -0
- brain/quartz_overrides/quartz/styles/custom.scss +89 -0
- brain/quartz_overrides/quartz/styles/graph.scss +505 -0
- brain/quartz_overrides/quartz/util/ctx.ts +92 -0
- brain/quartz_overrides/quartz/util/fastpath_manifest.ts +608 -0
- brain/quartz_overrides/quartz/util/path.ts +358 -0
- brain/quartz_overrides/quartz/util/sourceIcons.ts +55 -0
- brain/quartz_overrides/quartz.config.ts +270 -0
- brain/quartz_overrides/quartz.layout.ts +314 -0
- brain/queries.py +1188 -0
- brain/rank_fusion.py +8 -0
- brain/resurface.py +210 -0
- brain/review/__init__.py +26 -0
- brain/review/emit.py +27 -0
- brain/review/queries.py +436 -0
- brain/review/render.py +196 -0
- brain/review/scans.py +355 -0
- brain/review/weekly.py +413 -0
- brain/search.py +704 -0
- brain/set_similarity.py +15 -0
- brain/setup.py +1205 -0
- brain/tags.py +56 -0
- brain/templates/Caddyfile.j2 +9 -0
- brain/templates/__init__.py +1 -0
- brain/templates/bin/__init__.py +1 -0
- brain/templates/bin/_brain-brief-fg.sh +25 -0
- brain/templates/bin/_brain-build-fg.sh +53 -0
- brain/templates/bin/_brain-watcher-fg.sh +65 -0
- brain/templates/bin/brain-down.sh +89 -0
- brain/templates/bin/brain-status.sh +83 -0
- brain/templates/bin/brain-up.sh +221 -0
- brain/templates/docker/age/Dockerfile +79 -0
- brain/templates/docker-compose.stock.yml.j2 +26 -0
- brain/templates/docker-compose.yml.j2 +34 -0
- brain/templates/env.example +190 -0
- brain/templates/launchd/__init__.py +1 -0
- brain/templates/launchd/com.brain.brief.plist.j2 +45 -0
- brain/templates/launchd/com.brain.build.plist.j2 +46 -0
- brain/templates/launchd/com.brain.watcher.plist.j2 +46 -0
- brain/templates/skill/SKILL.md +63 -0
- brain/templates/skill/__init__.py +1 -0
- brain/timeline.py +834 -0
- brain/todo.py +124 -0
- brain/uninstall.py +185 -0
- brain/vault/__init__.py +115 -0
- brain/vault/_atomic.py +25 -0
- brain/vault/daily_index.py +228 -0
- brain/vault/derived_links/__init__.py +50 -0
- brain/vault/derived_links/directory.py +683 -0
- brain/vault/derived_links/fence.py +408 -0
- brain/vault/derived_links/gws.py +64 -0
- brain/vault/derived_links/participants.py +143 -0
- brain/vault/derived_links/pass_runner.py +362 -0
- brain/vault/derived_links/rules.py +137 -0
- brain/vault/export.py +683 -0
- brain/vault/frontmatter.py +165 -0
- brain/vault/graph.py +620 -0
- brain/vault/graph_format.py +388 -0
- brain/vault/link_rewrite.py +235 -0
- brain/vault/links.py +260 -0
- brain/vault/note_builder.py +211 -0
- brain/vault/paths.py +55 -0
- brain/vault/quartz_overlay.py +236 -0
- brain/vault/rename.py +591 -0
- brain/vault/resolver.py +304 -0
- brain/vault/slug.py +127 -0
- brain/vault/sync.py +1513 -0
- brain/vault/sync_summaries.py +264 -0
- brain/vault/templates.py +145 -0
- brain/vault/watch.py +1052 -0
- brain/wiki/__init__.py +6 -0
- brain/wiki/_github_slugger.py +76 -0
- brain/wiki/_person_name.py +314 -0
- brain/wiki/build_homepage.py +541 -0
- brain/wiki/build_partial.py +273 -0
- brain/wiki/build_people.py +934 -0
- brain/wiki/build_related.py +758 -0
- brain/wiki/build_swap.py +585 -0
- brain/wiki/build_watcher.py +975 -0
- brain/wiki/edit_classifier.py +215 -0
- brain/wiki/errors.py +10 -0
- brain/wiki/fastpath_manifest.py +475 -0
- brain/wiki/fastpath_state.py +174 -0
- brain/wiki/install.py +296 -0
- brain/wiki/slug.py +111 -0
- secondbrain_py-0.2.1.dist-info/METADATA +195 -0
- secondbrain_py-0.2.1.dist-info/RECORD +273 -0
- secondbrain_py-0.2.1.dist-info/WHEEL +5 -0
- secondbrain_py-0.2.1.dist-info/entry_points.txt +11 -0
- secondbrain_py-0.2.1.dist-info/licenses/LICENSE +21 -0
- 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("]", ")")
|