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
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
"""Backfill `summary:` frontmatter into existing vault mirror files.
|
|
2
|
+
|
|
3
|
+
Wave Q2-SUMMARY-WIKI Item 3.
|
|
4
|
+
|
|
5
|
+
Q1-D ships ``documents.summary`` and exposes it via ``brain show`` /
|
|
6
|
+
MCP ``brain_show``. The vault mirror writer
|
|
7
|
+
(``brain.vault.export._build_frontmatter``) was extended in the same
|
|
8
|
+
wave to emit ``summary:`` into per-doc frontmatter, but every doc
|
|
9
|
+
ingested BEFORE that change has stale on-disk frontmatter — the DB
|
|
10
|
+
carries a summary, but the ``.md`` file under ``<vault>/_ingested/``
|
|
11
|
+
(or the user-authored vault tier) doesn't yet. This one-shot module
|
|
12
|
+
reconciles the on-disk frontmatter for those rows so the Quartz
|
|
13
|
+
``SummaryLede`` component has something to render after the next
|
|
14
|
+
``brain vault render`` pass.
|
|
15
|
+
|
|
16
|
+
Idempotent: rerunning on a synced vault is a fast NO-OP (every row
|
|
17
|
+
returns ``unchanged``). Non-destructive: only the ``summary:`` key is
|
|
18
|
+
mutated — every other frontmatter key (and the file body) round-trips
|
|
19
|
+
verbatim through :mod:`brain.vault.frontmatter`. Atomic per file via
|
|
20
|
+
:func:`brain.vault._atomic.atomic_write_text` (sibling tempfile +
|
|
21
|
+
``os.replace``).
|
|
22
|
+
|
|
23
|
+
Discovery is the same shape as ``brain enrich --backfill``: keyset
|
|
24
|
+
pagination over ``documents.id``, batches yielded to keep the in-memory
|
|
25
|
+
footprint bounded. The driver loops over batches until exhaustion.
|
|
26
|
+
"""
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import logging
|
|
30
|
+
from collections.abc import Iterator
|
|
31
|
+
from dataclasses import dataclass, field
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
|
|
34
|
+
import psycopg
|
|
35
|
+
import yaml
|
|
36
|
+
|
|
37
|
+
from ._atomic import atomic_write_text
|
|
38
|
+
from .frontmatter import dump_frontmatter, parse_frontmatter
|
|
39
|
+
|
|
40
|
+
_logger = logging.getLogger(__name__)
|
|
41
|
+
|
|
42
|
+
_BATCH_SIZE = 100
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
class SyncSummariesReport:
|
|
47
|
+
"""Outcome counters for one :func:`sync_summaries` run.
|
|
48
|
+
|
|
49
|
+
``inspected`` counts every row pulled from the DB (the universe of
|
|
50
|
+
``summary IS NOT NULL AND vault_path IS NOT NULL`` docs);
|
|
51
|
+
``updated`` / ``unchanged`` / ``missing_file`` / ``errored`` are the
|
|
52
|
+
four mutually-exclusive outcomes per row. ``errored`` carries one
|
|
53
|
+
string per failure so the CLI can surface them at the end of the
|
|
54
|
+
run without aborting the loop mid-corpus.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
inspected: int = 0
|
|
58
|
+
updated: int = 0
|
|
59
|
+
unchanged: int = 0
|
|
60
|
+
missing_file: int = 0
|
|
61
|
+
errored: int = 0
|
|
62
|
+
errors: list[str] = field(default_factory=list)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass
|
|
66
|
+
class _SummaryRow:
|
|
67
|
+
"""Internal projection of one document for the sync loop."""
|
|
68
|
+
|
|
69
|
+
id: str
|
|
70
|
+
summary: str
|
|
71
|
+
vault_path: str
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _iter_rows(
|
|
75
|
+
conn: psycopg.Connection,
|
|
76
|
+
*,
|
|
77
|
+
limit: int | None,
|
|
78
|
+
batch_size: int = _BATCH_SIZE,
|
|
79
|
+
) -> Iterator[_SummaryRow]:
|
|
80
|
+
"""Yield documents that have a summary AND an on-disk vault path.
|
|
81
|
+
|
|
82
|
+
Keyset pagination over ``documents.id`` (same shape as
|
|
83
|
+
:func:`brain.queries.iter_unenriched_documents`) keeps memory
|
|
84
|
+
bounded on a large corpus. ``limit`` caps the total emitted across
|
|
85
|
+
all batches — when set, the last batch is shrunk so we stop on the
|
|
86
|
+
exact requested row count.
|
|
87
|
+
"""
|
|
88
|
+
last_id: str | None = None
|
|
89
|
+
emitted = 0
|
|
90
|
+
while True:
|
|
91
|
+
if limit is not None and emitted >= limit:
|
|
92
|
+
return
|
|
93
|
+
remaining = batch_size if limit is None else min(batch_size, limit - emitted)
|
|
94
|
+
if last_id is None:
|
|
95
|
+
rows = conn.execute(
|
|
96
|
+
"SELECT id::text, summary, vault_path FROM documents "
|
|
97
|
+
"WHERE summary IS NOT NULL AND vault_path IS NOT NULL "
|
|
98
|
+
"ORDER BY id LIMIT %s",
|
|
99
|
+
(remaining,),
|
|
100
|
+
).fetchall()
|
|
101
|
+
else:
|
|
102
|
+
rows = conn.execute(
|
|
103
|
+
"SELECT id::text, summary, vault_path FROM documents "
|
|
104
|
+
"WHERE summary IS NOT NULL AND vault_path IS NOT NULL "
|
|
105
|
+
"AND id > %s::uuid "
|
|
106
|
+
"ORDER BY id LIMIT %s",
|
|
107
|
+
(last_id, remaining),
|
|
108
|
+
).fetchall()
|
|
109
|
+
if not rows:
|
|
110
|
+
return
|
|
111
|
+
last_id = str(rows[-1][0])
|
|
112
|
+
for r in rows:
|
|
113
|
+
yield _SummaryRow(
|
|
114
|
+
id=str(r[0]),
|
|
115
|
+
summary=str(r[1]),
|
|
116
|
+
vault_path=str(r[2]),
|
|
117
|
+
)
|
|
118
|
+
emitted += 1
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _rewrite_with_summary(target: Path, summary: str) -> bool:
|
|
122
|
+
"""Insert/refresh ``summary:`` on ``target``'s frontmatter atomically.
|
|
123
|
+
|
|
124
|
+
Returns ``True`` when the file was rewritten, ``False`` when the
|
|
125
|
+
existing frontmatter already matches (idempotent skip).
|
|
126
|
+
|
|
127
|
+
Field order preserved: ``summary`` is inserted immediately after
|
|
128
|
+
``content_type`` when that key exists (mirrors the order
|
|
129
|
+
:func:`brain.vault.export._build_frontmatter` uses for fresh
|
|
130
|
+
writes), otherwise appended to the end. Existing user-authored keys
|
|
131
|
+
are not reordered.
|
|
132
|
+
|
|
133
|
+
Raises :class:`OSError` if reading or writing fails. Raises
|
|
134
|
+
:class:`yaml.YAMLError` or :class:`ValueError` if the existing
|
|
135
|
+
frontmatter is malformed — the caller treats both as "errored" so
|
|
136
|
+
a corrupt file doesn't kill the whole backfill loop.
|
|
137
|
+
"""
|
|
138
|
+
text = target.read_text(encoding="utf-8")
|
|
139
|
+
fields, body = parse_frontmatter(text)
|
|
140
|
+
|
|
141
|
+
existing = fields.get("summary")
|
|
142
|
+
if isinstance(existing, str) and existing == summary:
|
|
143
|
+
return False
|
|
144
|
+
|
|
145
|
+
# Build a fresh ordered dict so the inserted key sits in the
|
|
146
|
+
# canonical slot (after ``content_type`` when present). Re-creating
|
|
147
|
+
# the dict is necessary because Python preserves insertion order —
|
|
148
|
+
# mutating in place would put ``summary`` at the END of the file,
|
|
149
|
+
# which works but reads oddly when compared against a freshly-
|
|
150
|
+
# exported mirror.
|
|
151
|
+
new_fields: dict[str, object] = {}
|
|
152
|
+
inserted = False
|
|
153
|
+
for key, value in fields.items():
|
|
154
|
+
if key == "summary":
|
|
155
|
+
# Drop the stale entry; we'll insert a fresh one below at
|
|
156
|
+
# the canonical position (or here if content_type already
|
|
157
|
+
# passed). If we already inserted, just skip — guards
|
|
158
|
+
# against a duplicate ``summary:`` line ending up in the
|
|
159
|
+
# output.
|
|
160
|
+
if not inserted:
|
|
161
|
+
new_fields["summary"] = summary
|
|
162
|
+
inserted = True
|
|
163
|
+
continue
|
|
164
|
+
new_fields[key] = value
|
|
165
|
+
if key == "content_type" and not inserted:
|
|
166
|
+
new_fields["summary"] = summary
|
|
167
|
+
inserted = True
|
|
168
|
+
if not inserted:
|
|
169
|
+
new_fields["summary"] = summary
|
|
170
|
+
|
|
171
|
+
atomic_write_text(target, dump_frontmatter(new_fields, body))
|
|
172
|
+
return True
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def sync_summaries(
|
|
176
|
+
conn: psycopg.Connection,
|
|
177
|
+
*,
|
|
178
|
+
vault_root: Path,
|
|
179
|
+
dry_run: bool = False,
|
|
180
|
+
limit: int | None = None,
|
|
181
|
+
) -> SyncSummariesReport:
|
|
182
|
+
"""Reconcile ``summary:`` frontmatter for every enriched doc on disk.
|
|
183
|
+
|
|
184
|
+
Drives the backfill loop: iterate every ``documents`` row that has
|
|
185
|
+
both a ``summary`` and a ``vault_path``, read the corresponding
|
|
186
|
+
file under ``vault_root``, parse its frontmatter, and either
|
|
187
|
+
rewrite (with :func:`_rewrite_with_summary`) or skip when the
|
|
188
|
+
on-disk ``summary:`` already matches.
|
|
189
|
+
|
|
190
|
+
``dry_run=True`` performs every read + comparison but writes
|
|
191
|
+
nothing — the report reflects what WOULD have happened. Useful for
|
|
192
|
+
sanity-checking a backfill before it touches the disk on a large
|
|
193
|
+
corpus.
|
|
194
|
+
|
|
195
|
+
``limit`` caps the total number of rows inspected. The CLI exposes
|
|
196
|
+
this for testing (``--limit 5``) and for incremental drains in case
|
|
197
|
+
a backfill needs to be paced.
|
|
198
|
+
|
|
199
|
+
The report's ``missing_file`` counter is bumped when a DB row
|
|
200
|
+
references a ``vault_path`` whose ``.md`` file no longer exists on
|
|
201
|
+
disk (mirror was rm'd manually, vault wiped, etc.). ``errored``
|
|
202
|
+
captures parsing / OS errors per row with a one-line message —
|
|
203
|
+
the loop continues so a single bad file doesn't halt the run.
|
|
204
|
+
"""
|
|
205
|
+
report = SyncSummariesReport()
|
|
206
|
+
|
|
207
|
+
for row in _iter_rows(conn, limit=limit):
|
|
208
|
+
report.inspected += 1
|
|
209
|
+
target = vault_root / row.vault_path
|
|
210
|
+
if not target.is_file():
|
|
211
|
+
report.missing_file += 1
|
|
212
|
+
_logger.warning(
|
|
213
|
+
"sync-summaries: vault_path missing for %s at %s; "
|
|
214
|
+
"run `brain vault export --force` to recreate",
|
|
215
|
+
row.id,
|
|
216
|
+
target,
|
|
217
|
+
)
|
|
218
|
+
continue
|
|
219
|
+
|
|
220
|
+
try:
|
|
221
|
+
# ``parse_frontmatter`` returns ``({}, text)`` on a file
|
|
222
|
+
# with no frontmatter fences — we re-emit with a fresh
|
|
223
|
+
# ``summary:`` block in that case, which adds the fences
|
|
224
|
+
# the file is missing. That's the right thing: the file
|
|
225
|
+
# has a corresponding DB row, so it SHOULD carry the
|
|
226
|
+
# canonical frontmatter shape.
|
|
227
|
+
existing_text = target.read_text(encoding="utf-8")
|
|
228
|
+
fields, _body = parse_frontmatter(existing_text)
|
|
229
|
+
except (OSError, yaml.YAMLError, ValueError) as exc:
|
|
230
|
+
report.errored += 1
|
|
231
|
+
msg = f"{row.vault_path}: parse failed ({exc})"
|
|
232
|
+
report.errors.append(msg)
|
|
233
|
+
_logger.warning("sync-summaries: %s", msg)
|
|
234
|
+
continue
|
|
235
|
+
|
|
236
|
+
on_disk_summary = fields.get("summary")
|
|
237
|
+
if isinstance(on_disk_summary, str) and on_disk_summary == row.summary:
|
|
238
|
+
report.unchanged += 1
|
|
239
|
+
continue
|
|
240
|
+
|
|
241
|
+
if dry_run:
|
|
242
|
+
report.updated += 1
|
|
243
|
+
continue
|
|
244
|
+
|
|
245
|
+
try:
|
|
246
|
+
wrote = _rewrite_with_summary(target, row.summary)
|
|
247
|
+
except (OSError, yaml.YAMLError, ValueError) as exc:
|
|
248
|
+
report.errored += 1
|
|
249
|
+
msg = f"{row.vault_path}: rewrite failed ({exc})"
|
|
250
|
+
report.errors.append(msg)
|
|
251
|
+
_logger.warning("sync-summaries: %s", msg)
|
|
252
|
+
continue
|
|
253
|
+
|
|
254
|
+
# ``wrote`` is False only when ``_rewrite_with_summary``'s own
|
|
255
|
+
# idempotency check (re-reading the frontmatter inside the
|
|
256
|
+
# helper) matched — defensive belt-and-suspenders against a
|
|
257
|
+
# race where another writer touched the file between the
|
|
258
|
+
# outer read and the rewrite call.
|
|
259
|
+
if wrote:
|
|
260
|
+
report.updated += 1
|
|
261
|
+
else:
|
|
262
|
+
report.unchanged += 1
|
|
263
|
+
|
|
264
|
+
return report
|
brain/vault/templates.py
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""Embedded template strings + the Phase 3 renderer.
|
|
2
|
+
|
|
3
|
+
The strings below are written by ``brain vault init`` into ``_templates/`` on
|
|
4
|
+
first run; the user owns them after that and we never overwrite them.
|
|
5
|
+
|
|
6
|
+
:func:`render_template` is the Phase 3 renderer used by ``brain note new`` and
|
|
7
|
+
``brain daily``. It supports a tiny grammar — ``{{name}}`` placeholders only
|
|
8
|
+
— so user templates stay readable in any text editor and don't accidentally
|
|
9
|
+
acquire surface area we'd have to maintain (no conditionals, no loops, no
|
|
10
|
+
filters). Unknown placeholders pass through unchanged.
|
|
11
|
+
|
|
12
|
+
:func:`list_template_names` enumerates the templates in a vault's
|
|
13
|
+
``_templates/`` directory so the CLI can validate ``--template T``.
|
|
14
|
+
"""
|
|
15
|
+
import re
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
DAILY_TEMPLATE = """\
|
|
19
|
+
---
|
|
20
|
+
title: "{{date}}"
|
|
21
|
+
tags: [daily]
|
|
22
|
+
---
|
|
23
|
+
|
|
24
|
+
# {{date}}
|
|
25
|
+
|
|
26
|
+
## Notes
|
|
27
|
+
|
|
28
|
+
## Tasks
|
|
29
|
+
|
|
30
|
+
## Reflection
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
NOTE_TEMPLATE = """\
|
|
34
|
+
---
|
|
35
|
+
title: "{{title}}"
|
|
36
|
+
tags: []
|
|
37
|
+
---
|
|
38
|
+
|
|
39
|
+
# {{title}}
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
INGESTED_README = """\
|
|
43
|
+
# Ingested artifacts
|
|
44
|
+
|
|
45
|
+
Files in this folder are mirrors of documents in the brain DB whose source of
|
|
46
|
+
truth lives elsewhere (Krisp, Slack, Gmail, raw files). They are rewritten by
|
|
47
|
+
`brain vault sync` whenever their upstream source is re-ingested.
|
|
48
|
+
|
|
49
|
+
**Do not edit these files** — your edits will be overwritten on the next
|
|
50
|
+
re-ingest. To capture thoughts about an ingested artifact, create a vault-tier
|
|
51
|
+
note (anywhere outside `_ingested/`) and link to it with `[[brain:<id-prefix>]]`.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
VAULT_README = """\
|
|
55
|
+
# Brain vault
|
|
56
|
+
|
|
57
|
+
This is your second brain's vault. Plain Markdown files are the source of truth
|
|
58
|
+
for vault-tier notes; the `brain` CLI keeps a Postgres index in sync.
|
|
59
|
+
|
|
60
|
+
## Layout
|
|
61
|
+
|
|
62
|
+
- `_templates/` — note templates (`daily.md`, `note.md`)
|
|
63
|
+
- `_attachments/` — binary files referenced by notes
|
|
64
|
+
- `_ingested/` — read-only mirrors of DB-authoritative artifacts
|
|
65
|
+
- `capture/` — quick-capture inbox (`brain capture` lands authored thoughts here)
|
|
66
|
+
- `daily/<YYYY>/<YYYY-MM-DD>.md` — daily notes
|
|
67
|
+
- (anything else) — your authored notes
|
|
68
|
+
|
|
69
|
+
## Frontmatter contract
|
|
70
|
+
|
|
71
|
+
Every `.md` file has a YAML frontmatter block with at minimum `id` and `title`.
|
|
72
|
+
`brain vault sync` auto-assigns `id` on first sight if missing.
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
CAPTURE_README = """\
|
|
76
|
+
# Capture inbox
|
|
77
|
+
|
|
78
|
+
Quick-capture inbox — `brain capture` lands authored thoughts here, tagged `inbox`.
|
|
79
|
+
|
|
80
|
+
Review and route captures with `brain capture review`.
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
# ``{{ name }}`` — surrounding whitespace inside the braces is tolerated so
|
|
84
|
+
# users who write ``{{ title }}`` get the same substitution as ``{{title}}``.
|
|
85
|
+
# We deliberately disallow nested braces, dots, and pipes to keep the grammar
|
|
86
|
+
# narrow (no risk of clashing with future template extensions).
|
|
87
|
+
_PLACEHOLDER_RE = re.compile(r"\{\{\s*(?P<name>[A-Za-z_][A-Za-z0-9_]*)\s*\}\}")
|
|
88
|
+
|
|
89
|
+
_TEMPLATES_DIRNAME = "_templates"
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def render_template(template_text: str, vars: dict[str, str]) -> str:
|
|
93
|
+
"""Substitute ``{{name}}`` placeholders in ``template_text`` from ``vars``.
|
|
94
|
+
|
|
95
|
+
The grammar is intentionally minimal:
|
|
96
|
+
|
|
97
|
+
- Only ``{{name}}`` (or ``{{ name }}`` with optional inner whitespace).
|
|
98
|
+
- ``name`` is a Python-style identifier (``[A-Za-z_][A-Za-z0-9_]*``).
|
|
99
|
+
- Unknown placeholders are left **as-is** in the output — they're not an
|
|
100
|
+
error. This lets a template keep ``{{some_future_var}}`` around without
|
|
101
|
+
forcing the renderer to know about every variable a future call site
|
|
102
|
+
might pass.
|
|
103
|
+
- No conditionals, no loops, no filters. If a template needs more, the
|
|
104
|
+
user is better served by a real templating engine outside the brain.
|
|
105
|
+
|
|
106
|
+
The function is pure: same inputs → same output, no I/O, no datetime
|
|
107
|
+
side effects (callers compute ``{{date}}`` / ``{{datetime}}`` themselves
|
|
108
|
+
and pass them in via ``vars``).
|
|
109
|
+
"""
|
|
110
|
+
|
|
111
|
+
def _replace(match: re.Match[str]) -> str:
|
|
112
|
+
name = match.group("name")
|
|
113
|
+
if name in vars:
|
|
114
|
+
return vars[name]
|
|
115
|
+
# Preserve the original token verbatim — including any inner whitespace
|
|
116
|
+
# the user typed — so the template round-trips losslessly when no
|
|
117
|
+
# value is supplied.
|
|
118
|
+
return match.group(0)
|
|
119
|
+
|
|
120
|
+
return _PLACEHOLDER_RE.sub(_replace, template_text)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def list_template_names(vault_path: Path) -> list[str]:
|
|
124
|
+
"""Return the ``.md`` template basenames (without extension) under ``_templates/``.
|
|
125
|
+
|
|
126
|
+
Used by the CLI to validate ``--template T`` and (eventually) to print a
|
|
127
|
+
helpful "available templates: …" diagnostic when the user asks for one
|
|
128
|
+
that doesn't exist.
|
|
129
|
+
|
|
130
|
+
Returns an empty list if the vault doesn't have a ``_templates/`` directory
|
|
131
|
+
yet (e.g. the user pointed ``--vault`` at an unmanaged folder). The caller
|
|
132
|
+
decides whether the empty case is an error — for ``brain note new`` it is
|
|
133
|
+
(we suggest ``brain vault init``); for diagnostics it isn't.
|
|
134
|
+
|
|
135
|
+
Iteration order is sorted so output is deterministic across platforms.
|
|
136
|
+
"""
|
|
137
|
+
templates_dir = vault_path / _TEMPLATES_DIRNAME
|
|
138
|
+
if not templates_dir.is_dir():
|
|
139
|
+
return []
|
|
140
|
+
names = [
|
|
141
|
+
p.stem
|
|
142
|
+
for p in sorted(templates_dir.iterdir())
|
|
143
|
+
if p.is_file() and p.suffix == ".md"
|
|
144
|
+
]
|
|
145
|
+
return names
|