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,141 @@
|
|
|
1
|
+
"""Backfill ``chunks.title_text`` / ``tags_text`` / ``search_extras`` after migration 009."""
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
|
|
4
|
+
import psycopg
|
|
5
|
+
|
|
6
|
+
from ..ingest.sub_tokens import extract_sub_tokens
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class BackfillReport:
|
|
11
|
+
"""Outcome of :func:`run`.
|
|
12
|
+
|
|
13
|
+
``stage_a_rows`` is the number of chunk rows updated by the SQL
|
|
14
|
+
denormalization pass (title/tags from the parent document). ``stage_b_rows``
|
|
15
|
+
is the number of chunk rows whose ``search_extras`` was written by the
|
|
16
|
+
Python recompute pass — only rows whose computed value differs from the
|
|
17
|
+
stored value get rewritten, so a converged corpus reports 0 here even
|
|
18
|
+
when called repeatedly. ``total_chunks`` is the row count of the
|
|
19
|
+
``chunks`` table at the start of the run; provided for observability so
|
|
20
|
+
operators can sanity-check the proportions.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
stage_a_rows: int
|
|
24
|
+
stage_b_rows: int
|
|
25
|
+
total_chunks: int
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# Stage A — denormalize ``documents.title`` and ``documents.tags`` onto chunks.
|
|
29
|
+
# ``IS DISTINCT FROM`` on both columns makes this an idempotent UPDATE; a
|
|
30
|
+
# converged corpus matches zero rows on the second call. The join is on
|
|
31
|
+
# ``chunks.document_id = d.id`` (FK), so every chunk has exactly one source
|
|
32
|
+
# document — the UPDATE rowcount equals the count of mismatching rows.
|
|
33
|
+
_STAGE_A_SQL = (
|
|
34
|
+
"UPDATE chunks "
|
|
35
|
+
"SET title_text = d.title, "
|
|
36
|
+
" tags_text = array_to_string(d.tags, ' ') "
|
|
37
|
+
"FROM documents d "
|
|
38
|
+
"WHERE chunks.document_id = d.id "
|
|
39
|
+
" AND (chunks.title_text IS DISTINCT FROM d.title "
|
|
40
|
+
" OR chunks.tags_text IS DISTINCT FROM array_to_string(d.tags, ' '))"
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# Stage B reads chunks in pages of this size to keep memory bounded on a
|
|
45
|
+
# 100k-chunk corpus. ``content`` is the only large column we pull, but
|
|
46
|
+
# materializing all of it in one shot would cost on the order of (chunk_size
|
|
47
|
+
# * row_count) of RSS — at 1k tokens (~4kB) per chunk and 100k chunks that's
|
|
48
|
+
# ~400MB. Paging keeps it bounded at ~4MB per batch.
|
|
49
|
+
_STAGE_B_PAGE_SIZE = 1000
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def run(conn: psycopg.Connection) -> BackfillReport:
|
|
53
|
+
"""Backfill ``chunks.title_text`` / ``tags_text`` / ``search_extras``.
|
|
54
|
+
|
|
55
|
+
Two stages, both idempotent and rerunnable:
|
|
56
|
+
|
|
57
|
+
Stage A — one SQL ``UPDATE`` denormalizes ``documents.title`` and
|
|
58
|
+
``documents.tags`` onto every chunk via the ``document_id`` FK. The
|
|
59
|
+
``IS DISTINCT FROM`` guards mean a converged corpus is a no-op (returns
|
|
60
|
+
``stage_a_rows=0``).
|
|
61
|
+
|
|
62
|
+
Stage B — Python loop. Reads chunks in pages of ``_STAGE_B_PAGE_SIZE``,
|
|
63
|
+
recomputes ``extract_sub_tokens(content)`` for each, and writes back only
|
|
64
|
+
when the computed value differs from the stored ``search_extras``. This
|
|
65
|
+
"compute and compare" shape (per revision #5 of the plan) restores the
|
|
66
|
+
canonical value when an operator manually edits ``search_extras`` to a
|
|
67
|
+
stale string.
|
|
68
|
+
|
|
69
|
+
Both stages run within ``conn.transaction()`` blocks. Caller is
|
|
70
|
+
responsible for connection lifecycle.
|
|
71
|
+
|
|
72
|
+
Returns a :class:`BackfillReport` with per-stage rowcounts and the
|
|
73
|
+
total chunk count observed at the start of the run.
|
|
74
|
+
"""
|
|
75
|
+
total_row = conn.execute("SELECT count(*) FROM chunks").fetchone()
|
|
76
|
+
assert total_row is not None # COUNT(*) always returns one row
|
|
77
|
+
total_chunks = int(total_row[0])
|
|
78
|
+
|
|
79
|
+
# Stage A — single SQL UPDATE in its own transaction. Empty-corpus case
|
|
80
|
+
# returns rowcount 0 naturally; no special-casing needed.
|
|
81
|
+
with conn.transaction():
|
|
82
|
+
cur = conn.execute(_STAGE_A_SQL)
|
|
83
|
+
stage_a_rows = cur.rowcount or 0
|
|
84
|
+
|
|
85
|
+
if total_chunks == 0:
|
|
86
|
+
return BackfillReport(
|
|
87
|
+
stage_a_rows=stage_a_rows,
|
|
88
|
+
stage_b_rows=0,
|
|
89
|
+
total_chunks=0,
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
# Stage B — page through chunks, recompute, write only on diff. We use
|
|
93
|
+
# keyset pagination on the primary key (UUID) rather than OFFSET so the
|
|
94
|
+
# page boundary stays stable when concurrent writes shift row positions.
|
|
95
|
+
# Mirrors :func:`brain.queries.iter_chunks_missing_embedding`'s pattern
|
|
96
|
+
# of casting ``id::text`` for transit and ``%s::uuid`` for the bound on
|
|
97
|
+
# the next page.
|
|
98
|
+
stage_b_rows = 0
|
|
99
|
+
last_id: str | None = None
|
|
100
|
+
while True:
|
|
101
|
+
if last_id is None:
|
|
102
|
+
page = conn.execute(
|
|
103
|
+
"SELECT id::text, content, search_extras FROM chunks "
|
|
104
|
+
"ORDER BY id LIMIT %s",
|
|
105
|
+
(_STAGE_B_PAGE_SIZE,),
|
|
106
|
+
).fetchall()
|
|
107
|
+
else:
|
|
108
|
+
page = conn.execute(
|
|
109
|
+
"SELECT id::text, content, search_extras FROM chunks "
|
|
110
|
+
"WHERE id > %s::uuid ORDER BY id LIMIT %s",
|
|
111
|
+
(last_id, _STAGE_B_PAGE_SIZE),
|
|
112
|
+
).fetchall()
|
|
113
|
+
if not page:
|
|
114
|
+
break
|
|
115
|
+
|
|
116
|
+
with conn.transaction():
|
|
117
|
+
for chunk_id, content, stored_extras in page:
|
|
118
|
+
computed = extract_sub_tokens(content or "")
|
|
119
|
+
# Treat NULL stored_extras and empty string as equivalent —
|
|
120
|
+
# the generated tsv column ``coalesce(...)``s NULL to '', so
|
|
121
|
+
# writing "" back when the stored value is NULL would be an
|
|
122
|
+
# observable no-op only at the row level. Skip it to keep
|
|
123
|
+
# the rowcount honest.
|
|
124
|
+
current = stored_extras or ""
|
|
125
|
+
if computed == current:
|
|
126
|
+
continue
|
|
127
|
+
conn.execute(
|
|
128
|
+
"UPDATE chunks SET search_extras = %s WHERE id = %s::uuid",
|
|
129
|
+
(computed, chunk_id),
|
|
130
|
+
)
|
|
131
|
+
stage_b_rows += 1
|
|
132
|
+
|
|
133
|
+
last_id = str(page[-1][0])
|
|
134
|
+
if len(page) < _STAGE_B_PAGE_SIZE:
|
|
135
|
+
break
|
|
136
|
+
|
|
137
|
+
return BackfillReport(
|
|
138
|
+
stage_a_rows=stage_a_rows,
|
|
139
|
+
stage_b_rows=stage_b_rows,
|
|
140
|
+
total_chunks=total_chunks,
|
|
141
|
+
)
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Backfill ``documents.source_id`` for legacy file-ingested rows."""
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
|
|
4
|
+
import psycopg
|
|
5
|
+
|
|
6
|
+
from ..ingest import _upsert_source
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class BackfillReport:
|
|
11
|
+
"""Outcome of :func:`backfill_source_rows`.
|
|
12
|
+
|
|
13
|
+
``candidates`` is the number of rows matched by the WHERE clause, BEFORE
|
|
14
|
+
any writes. ``sources_created`` counts new ``sources`` rows inserted by
|
|
15
|
+
this run (existing rows reused via the ``(kind, external_id)`` unique
|
|
16
|
+
pair don't count). ``documents_updated`` is the number of ``documents``
|
|
17
|
+
rows whose ``source_id`` was set by this run. ``dry_run`` mirrors the
|
|
18
|
+
``commit`` argument: ``True`` means no writes were applied (counts come
|
|
19
|
+
from the same query that would have been used for the live run).
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
candidates: int
|
|
23
|
+
sources_created: int
|
|
24
|
+
documents_updated: int
|
|
25
|
+
dry_run: bool
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
_BACKFILL_SQL = (
|
|
29
|
+
"SELECT id, source_path FROM documents "
|
|
30
|
+
"WHERE source_id IS NULL "
|
|
31
|
+
" AND content_type = 'markdown' "
|
|
32
|
+
" AND source_path IS NOT NULL "
|
|
33
|
+
"ORDER BY id"
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def backfill_source_rows(
|
|
38
|
+
conn: psycopg.Connection, *, commit: bool = True
|
|
39
|
+
) -> BackfillReport:
|
|
40
|
+
"""Set ``source_id`` to a "manual" sources row for legacy markdown docs.
|
|
41
|
+
|
|
42
|
+
Selection: ``content_type = 'markdown'`` AND ``source_id IS NULL`` AND
|
|
43
|
+
``source_path IS NOT NULL``. For each match, upsert a ``sources`` row
|
|
44
|
+
with ``kind="manual"`` and ``external_id = source_path`` (so two docs
|
|
45
|
+
sharing a path collapse onto one source row), then point the document
|
|
46
|
+
at it.
|
|
47
|
+
|
|
48
|
+
Idempotent: a second run is a no-op because the WHERE clause filters
|
|
49
|
+
on ``source_id IS NULL``. The whole pass runs in a single transaction
|
|
50
|
+
— either every row gets fixed or none do. Set ``commit=False`` for a
|
|
51
|
+
preview that returns the candidate counts without writing.
|
|
52
|
+
|
|
53
|
+
Stdin-ingested rows (``source_path IS NULL``) are intentionally skipped.
|
|
54
|
+
Their kind cannot be inferred from the path alone — they would need
|
|
55
|
+
a per-source backfill (e.g. krisp re-ingest) instead.
|
|
56
|
+
"""
|
|
57
|
+
rows = conn.execute(_BACKFILL_SQL).fetchall()
|
|
58
|
+
candidates = len(rows)
|
|
59
|
+
|
|
60
|
+
if not commit or candidates == 0:
|
|
61
|
+
return BackfillReport(
|
|
62
|
+
candidates=candidates,
|
|
63
|
+
sources_created=0,
|
|
64
|
+
documents_updated=0,
|
|
65
|
+
dry_run=not commit,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
sources_before = conn.execute(
|
|
69
|
+
"SELECT count(*) FROM sources WHERE kind = 'manual'"
|
|
70
|
+
).fetchone()
|
|
71
|
+
assert sources_before is not None # COUNT(*) always returns one row
|
|
72
|
+
before = int(sources_before[0])
|
|
73
|
+
|
|
74
|
+
documents_updated = 0
|
|
75
|
+
with conn.transaction():
|
|
76
|
+
for doc_id, source_path in rows:
|
|
77
|
+
source_id = _upsert_source(
|
|
78
|
+
conn, kind="manual", external_id=source_path, metadata={}
|
|
79
|
+
)
|
|
80
|
+
# External_id is non-NULL for every row in this scan (WHERE
|
|
81
|
+
# clause filters source_path IS NOT NULL), so _upsert_source's
|
|
82
|
+
# "no external id and no metadata" early-return cannot fire.
|
|
83
|
+
assert source_id is not None
|
|
84
|
+
conn.execute(
|
|
85
|
+
"UPDATE documents SET source_id = %s WHERE id = %s",
|
|
86
|
+
(source_id, doc_id),
|
|
87
|
+
)
|
|
88
|
+
documents_updated += 1
|
|
89
|
+
|
|
90
|
+
sources_after = conn.execute(
|
|
91
|
+
"SELECT count(*) FROM sources WHERE kind = 'manual'"
|
|
92
|
+
).fetchone()
|
|
93
|
+
assert sources_after is not None
|
|
94
|
+
sources_created = int(sources_after[0]) - before
|
|
95
|
+
|
|
96
|
+
return BackfillReport(
|
|
97
|
+
candidates=candidates,
|
|
98
|
+
sources_created=sources_created,
|
|
99
|
+
documents_updated=documents_updated,
|
|
100
|
+
dry_run=False,
|
|
101
|
+
)
|
brain/bin/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Python launchers for the brain-* console scripts."""
|
brain/bin/_launcher.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Shared shim-install drift-protection and execvpe helper for brain-* console scripts."""
|
|
2
|
+
import contextlib
|
|
3
|
+
import hashlib
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
import tempfile
|
|
7
|
+
from collections.abc import Sequence
|
|
8
|
+
from importlib.resources import files as resource_files
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import NoReturn
|
|
11
|
+
|
|
12
|
+
from ..config import _brain_home_root
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _sha256(data: bytes) -> str:
|
|
16
|
+
"""Return the lowercase hex SHA-256 digest of *data*."""
|
|
17
|
+
return hashlib.sha256(data).hexdigest()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def ensure_shim(name: str, brain_home: Path) -> Path:
|
|
21
|
+
"""Ensure $BRAIN_HOME/.shims/<name> is installed and up-to-date.
|
|
22
|
+
|
|
23
|
+
Copies from package data brain.templates.bin/<name>.sh, stripping the
|
|
24
|
+
.sh suffix at the installed path. Idempotent — safe to re-run.
|
|
25
|
+
|
|
26
|
+
Drift protection: if the installed shim's sha256 differs from the
|
|
27
|
+
package-data source, atomically replace via unique same-dir tmpfile
|
|
28
|
+
+ os.replace. Concurrent-launcher races are safe because tempfile
|
|
29
|
+
names are unique.
|
|
30
|
+
|
|
31
|
+
Shims live under .shims/ (not bin/) to avoid colliding with the
|
|
32
|
+
dev-checkout bin/ wrappers when $BRAIN_HOME resolves to the repo
|
|
33
|
+
root. A pipx-installed user has $BRAIN_HOME = ~/.brain so .shims/
|
|
34
|
+
is fresh under that root; either way the shim location is
|
|
35
|
+
repo-bin-independent.
|
|
36
|
+
|
|
37
|
+
Returns the installed shim path.
|
|
38
|
+
"""
|
|
39
|
+
# Read source bytes from package data (brain.templates.bin/<name>.sh).
|
|
40
|
+
src = resource_files("brain.templates.bin") / f"{name}.sh"
|
|
41
|
+
src_bytes: bytes = src.read_bytes()
|
|
42
|
+
src_hash = _sha256(src_bytes)
|
|
43
|
+
|
|
44
|
+
# Target path: <brain_home>/.shims/<name> (no .sh suffix).
|
|
45
|
+
bin_dir = brain_home / ".shims"
|
|
46
|
+
bin_dir.mkdir(parents=True, exist_ok=True)
|
|
47
|
+
installed_path = bin_dir / name
|
|
48
|
+
|
|
49
|
+
# Check if the installed shim already exists and is up-to-date.
|
|
50
|
+
if installed_path.exists():
|
|
51
|
+
installed_bytes = installed_path.read_bytes()
|
|
52
|
+
if _sha256(installed_bytes) == src_hash:
|
|
53
|
+
# Already up-to-date — no-op (preserves mtime for dev workflows).
|
|
54
|
+
return installed_path
|
|
55
|
+
# Stale — atomically replace.
|
|
56
|
+
_atomic_write(src_bytes, installed_path, bin_dir)
|
|
57
|
+
print(f"shim updated: {name} (sha mismatch)", file=sys.stderr)
|
|
58
|
+
else:
|
|
59
|
+
# Fresh install.
|
|
60
|
+
_atomic_write(src_bytes, installed_path, bin_dir)
|
|
61
|
+
print(f"shim installed: {name}", file=sys.stderr)
|
|
62
|
+
|
|
63
|
+
return installed_path
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _atomic_write(data: bytes, dest: Path, parent_dir: Path) -> None:
|
|
67
|
+
"""Write *data* to *dest* atomically via a unique same-dir tmpfile.
|
|
68
|
+
|
|
69
|
+
Uses ``tempfile.NamedTemporaryFile(dir=parent_dir, delete=False)`` so the
|
|
70
|
+
temp file is on the same filesystem as *dest* — required for ``os.replace``
|
|
71
|
+
to be atomic. The random suffix from NamedTemporaryFile makes concurrent
|
|
72
|
+
launchers race-safe (each gets its own tmpfile; last ``os.replace`` wins
|
|
73
|
+
with the correct content).
|
|
74
|
+
|
|
75
|
+
On any failure after the tmpfile is created, the tmpfile is unlinked to
|
|
76
|
+
avoid leaving stale files in *parent_dir*.
|
|
77
|
+
"""
|
|
78
|
+
tmp_path: str | None = None
|
|
79
|
+
try:
|
|
80
|
+
with tempfile.NamedTemporaryFile(dir=parent_dir, delete=False) as tf:
|
|
81
|
+
tmp_path = tf.name
|
|
82
|
+
tf.write(data)
|
|
83
|
+
Path(tmp_path).chmod(0o755)
|
|
84
|
+
os.replace(tmp_path, dest)
|
|
85
|
+
tmp_path = None # os.replace succeeded — no cleanup needed.
|
|
86
|
+
except Exception:
|
|
87
|
+
if tmp_path is not None:
|
|
88
|
+
with contextlib.suppress(OSError):
|
|
89
|
+
os.unlink(tmp_path)
|
|
90
|
+
raise
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def exec_shim(name: str, args: Sequence[str]) -> NoReturn:
|
|
94
|
+
"""Resolve $BRAIN_HOME, ensure the shim is installed/up-to-date, then
|
|
95
|
+
os.execvpe it with BRAIN_PY set in the env.
|
|
96
|
+
|
|
97
|
+
BRAIN_PY defaults to ``sys.executable`` (the interpreter running this
|
|
98
|
+
launcher — by construction the one that has ``brain`` importable).
|
|
99
|
+
An existing BRAIN_PY env var is preserved untouched so tests can stub
|
|
100
|
+
the interpreter (see ``tests/test_bin_scripts.py``) and advanced users
|
|
101
|
+
can pin a specific Python without editing the launcher.
|
|
102
|
+
"""
|
|
103
|
+
brain_home = _brain_home_root()
|
|
104
|
+
shim = ensure_shim(name, brain_home)
|
|
105
|
+
env = dict(os.environ)
|
|
106
|
+
env.setdefault("BRAIN_PY", sys.executable)
|
|
107
|
+
os.execvpe(str(shim), [str(shim), *args], env)
|
brain/bin/down.py
ADDED
brain/bin/launchd.py
ADDED
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
"""Python launchd plist generator for brain-install-launchd / brain-uninstall-launchd.
|
|
2
|
+
|
|
3
|
+
Renders the plist.j2 templates in brain.templates.launchd via manual str.replace
|
|
4
|
+
with xml.sax.saxutils.escape so paths containing &/</> can't produce invalid
|
|
5
|
+
XML. Resolves PIPX_BIN_DIR via `pipx environment --value` with a ~/.local/bin
|
|
6
|
+
fallback. Resolves the Python interpreter via sys.executable — the running brain
|
|
7
|
+
process is by definition the right Python.
|
|
8
|
+
|
|
9
|
+
Entry points (registered in pyproject.toml by T1.8):
|
|
10
|
+
brain-install-launchd → install_main()
|
|
11
|
+
brain-uninstall-launchd → uninstall_main()
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import contextlib
|
|
15
|
+
import os
|
|
16
|
+
import subprocess
|
|
17
|
+
import sys
|
|
18
|
+
import tempfile
|
|
19
|
+
import time
|
|
20
|
+
import xml.etree.ElementTree as ET
|
|
21
|
+
from importlib.resources import files as resource_files
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from xml.sax.saxutils import escape as xml_escape
|
|
24
|
+
|
|
25
|
+
from ..config import _brain_home_root
|
|
26
|
+
from ..errors import BrainError
|
|
27
|
+
from ._launcher import ensure_shim
|
|
28
|
+
|
|
29
|
+
# The launchd labels managed by brain. watcher/build are KeepAlive daemons;
|
|
30
|
+
# brief (Plan 01) is a one-shot StartCalendarInterval job (07:00 daily). All are
|
|
31
|
+
# installed + cleaned together — adding brief here means uninstall sweeps it too.
|
|
32
|
+
_LABELS = ("com.brain.watcher", "com.brain.build", "com.brain.brief")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class LaunchdError(BrainError):
|
|
36
|
+
"""Raised when a launchd plist render or launchctl operation fails."""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# ---------------------------------------------------------------------------
|
|
40
|
+
# Public helpers (test-visible)
|
|
41
|
+
# ---------------------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def resolve_pipx_bin_dir() -> Path:
|
|
45
|
+
"""Return the pipx binary directory.
|
|
46
|
+
|
|
47
|
+
Calls ``pipx environment --value PIPX_BIN_DIR``; falls back to
|
|
48
|
+
``~/.local/bin`` if pipx is not on PATH or the command fails.
|
|
49
|
+
"""
|
|
50
|
+
try:
|
|
51
|
+
result = subprocess.run(
|
|
52
|
+
["pipx", "environment", "--value", "PIPX_BIN_DIR"],
|
|
53
|
+
capture_output=True,
|
|
54
|
+
text=True,
|
|
55
|
+
check=True,
|
|
56
|
+
timeout=10,
|
|
57
|
+
)
|
|
58
|
+
raw = result.stdout.strip()
|
|
59
|
+
if raw:
|
|
60
|
+
return Path(raw)
|
|
61
|
+
except (FileNotFoundError, subprocess.CalledProcessError, subprocess.TimeoutExpired):
|
|
62
|
+
pass
|
|
63
|
+
return Path.home() / ".local" / "bin"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def resolve_brain_py() -> Path:
|
|
67
|
+
"""Resolve the Python interpreter that has ``brain`` importable.
|
|
68
|
+
|
|
69
|
+
Since this module is part of the brain package, ``sys.executable`` is
|
|
70
|
+
by definition the right Python — whether we are running inside a pipx venv,
|
|
71
|
+
a dev-checkout venv, or any other environment.
|
|
72
|
+
"""
|
|
73
|
+
return Path(sys.executable)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def render_plist(
|
|
77
|
+
label: str,
|
|
78
|
+
brain_home: Path,
|
|
79
|
+
vault_path: Path,
|
|
80
|
+
pipx_bin_dir: Path,
|
|
81
|
+
brain_py: Path,
|
|
82
|
+
) -> str:
|
|
83
|
+
"""Render the .plist.j2 template for *label* into a string.
|
|
84
|
+
|
|
85
|
+
XML-escapes every substituted value so the resulting document is always
|
|
86
|
+
valid XML, even when paths contain ``&``, ``<``, or ``>``.
|
|
87
|
+
|
|
88
|
+
Raises :class:`LaunchdError` if the rendered XML does not parse.
|
|
89
|
+
"""
|
|
90
|
+
template_ref = resource_files("brain.templates.launchd") / f"{label}.plist.j2"
|
|
91
|
+
text: str = template_ref.read_text(encoding="utf-8")
|
|
92
|
+
|
|
93
|
+
substitutions: dict[str, str] = {
|
|
94
|
+
"brain_home": xml_escape(str(brain_home)),
|
|
95
|
+
"user_home": xml_escape(str(Path.home())),
|
|
96
|
+
"vault_path": xml_escape(str(vault_path)),
|
|
97
|
+
"pipx_bin_dir": xml_escape(str(pipx_bin_dir)),
|
|
98
|
+
"brain_py": xml_escape(str(brain_py)),
|
|
99
|
+
}
|
|
100
|
+
for key, value in substitutions.items():
|
|
101
|
+
text = text.replace("{{ " + key + " }}", value)
|
|
102
|
+
|
|
103
|
+
# Sanity-check: validate that the result is well-formed XML.
|
|
104
|
+
try:
|
|
105
|
+
ET.fromstring(text)
|
|
106
|
+
except ET.ParseError as exc:
|
|
107
|
+
raise LaunchdError(f"rendered plist for {label} is not valid XML: {exc}") from exc
|
|
108
|
+
|
|
109
|
+
return text
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def install_plists(
|
|
113
|
+
brain_home: Path,
|
|
114
|
+
launchd_dir: Path,
|
|
115
|
+
launchctl: str = "launchctl",
|
|
116
|
+
vault_path: Path | None = None,
|
|
117
|
+
) -> None:
|
|
118
|
+
"""Write both plists and load them into launchd. Idempotent.
|
|
119
|
+
|
|
120
|
+
Steps for each label:
|
|
121
|
+
1. Render the plist template.
|
|
122
|
+
2. Write to *launchd_dir*/<label>.plist atomically.
|
|
123
|
+
3. ``launchctl bootout`` (suppress errors — idempotent).
|
|
124
|
+
4. ``launchctl bootstrap`` (fail loudly on error).
|
|
125
|
+
"""
|
|
126
|
+
launchd_dir.mkdir(parents=True, exist_ok=True)
|
|
127
|
+
|
|
128
|
+
# Install the foreground wrapper scripts the plists reference.
|
|
129
|
+
# ensure_shim() is idempotent — skips the write if the shim is current.
|
|
130
|
+
for wrapper in ("_brain-watcher-fg", "_brain-build-fg", "_brain-brief-fg"):
|
|
131
|
+
ensure_shim(wrapper, brain_home)
|
|
132
|
+
|
|
133
|
+
effective_vault_path = vault_path if vault_path is not None else Path.home() / "brain-vault"
|
|
134
|
+
pipx_bin_dir = resolve_pipx_bin_dir()
|
|
135
|
+
brain_py = resolve_brain_py()
|
|
136
|
+
uid: int = os.getuid()
|
|
137
|
+
|
|
138
|
+
for label in _LABELS:
|
|
139
|
+
plist_content = render_plist(
|
|
140
|
+
label,
|
|
141
|
+
brain_home=brain_home,
|
|
142
|
+
vault_path=effective_vault_path,
|
|
143
|
+
pipx_bin_dir=pipx_bin_dir,
|
|
144
|
+
brain_py=brain_py,
|
|
145
|
+
)
|
|
146
|
+
plist_path = launchd_dir / f"{label}.plist"
|
|
147
|
+
|
|
148
|
+
# Atomic write via same-dir tempfile + os.replace.
|
|
149
|
+
_atomic_write_text(plist_content, plist_path, launchd_dir)
|
|
150
|
+
|
|
151
|
+
domain = f"gui/{uid}/{label}"
|
|
152
|
+
|
|
153
|
+
# Bootout any prior incarnation — suppress errors when not loaded.
|
|
154
|
+
subprocess.run(
|
|
155
|
+
[launchctl, "bootout", domain],
|
|
156
|
+
check=False,
|
|
157
|
+
capture_output=True,
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
# Bootstrap — fail loudly if this step fails. Retry once on the
|
|
161
|
+
# "Bootstrap failed: 5: Input/output error" race that happens when
|
|
162
|
+
# the prior service hasn't fully torn down before the new one tries
|
|
163
|
+
# to load (observed on Apple Silicon under load).
|
|
164
|
+
for attempt in range(2):
|
|
165
|
+
result = subprocess.run(
|
|
166
|
+
[launchctl, "bootstrap", f"gui/{uid}", str(plist_path)],
|
|
167
|
+
check=False,
|
|
168
|
+
capture_output=True,
|
|
169
|
+
text=True,
|
|
170
|
+
)
|
|
171
|
+
if result.returncode == 0:
|
|
172
|
+
break
|
|
173
|
+
if attempt == 0:
|
|
174
|
+
time.sleep(1.0)
|
|
175
|
+
continue
|
|
176
|
+
stderr = result.stderr.strip()
|
|
177
|
+
raise LaunchdError(
|
|
178
|
+
f"launchctl bootstrap failed for {label}"
|
|
179
|
+
+ (f": {stderr}" if stderr else "")
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def uninstall_plists(
|
|
184
|
+
launchd_dir: Path,
|
|
185
|
+
launchctl: str = "launchctl",
|
|
186
|
+
) -> None:
|
|
187
|
+
"""Bootout and remove both plists. Idempotent.
|
|
188
|
+
|
|
189
|
+
For each label:
|
|
190
|
+
1. Probe ``launchctl print gui/<uid>/<label>`` — if loaded, bootout.
|
|
191
|
+
2. Remove the plist file if it exists.
|
|
192
|
+
3. Print a "nothing to remove" message if neither action was taken.
|
|
193
|
+
"""
|
|
194
|
+
uid: int = os.getuid()
|
|
195
|
+
|
|
196
|
+
for label in _LABELS:
|
|
197
|
+
plist_path = launchd_dir / f"{label}.plist"
|
|
198
|
+
domain = f"gui/{uid}/{label}"
|
|
199
|
+
did_something = False
|
|
200
|
+
|
|
201
|
+
# Check if the service is currently loaded.
|
|
202
|
+
probe = subprocess.run(
|
|
203
|
+
[launchctl, "print", domain],
|
|
204
|
+
check=False,
|
|
205
|
+
capture_output=True,
|
|
206
|
+
)
|
|
207
|
+
if probe.returncode == 0:
|
|
208
|
+
subprocess.run(
|
|
209
|
+
[launchctl, "bootout", domain],
|
|
210
|
+
check=False,
|
|
211
|
+
capture_output=True,
|
|
212
|
+
)
|
|
213
|
+
did_something = True
|
|
214
|
+
|
|
215
|
+
if plist_path.exists():
|
|
216
|
+
plist_path.unlink()
|
|
217
|
+
did_something = True
|
|
218
|
+
|
|
219
|
+
if not did_something:
|
|
220
|
+
print(f"{label}: nothing to remove")
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
# ---------------------------------------------------------------------------
|
|
224
|
+
# Entry points
|
|
225
|
+
# ---------------------------------------------------------------------------
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def install_main() -> None:
|
|
229
|
+
"""Entry point: brain-install-launchd."""
|
|
230
|
+
launchd_dir = Path(
|
|
231
|
+
os.environ.get("BRAIN_LAUNCHD_DIR") or Path.home() / "Library" / "LaunchAgents"
|
|
232
|
+
)
|
|
233
|
+
launchctl = os.environ.get("BRAIN_LAUNCHCTL") or "launchctl"
|
|
234
|
+
brain_home = _brain_home_root()
|
|
235
|
+
install_plists(brain_home, launchd_dir, launchctl)
|
|
236
|
+
print(f"🧠 brain LaunchAgents installed in {launchd_dir}")
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def uninstall_main() -> None:
|
|
240
|
+
"""Entry point: brain-uninstall-launchd."""
|
|
241
|
+
launchd_dir = Path(
|
|
242
|
+
os.environ.get("BRAIN_LAUNCHD_DIR") or Path.home() / "Library" / "LaunchAgents"
|
|
243
|
+
)
|
|
244
|
+
launchctl = os.environ.get("BRAIN_LAUNCHCTL") or "launchctl"
|
|
245
|
+
uninstall_plists(launchd_dir, launchctl)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
# ---------------------------------------------------------------------------
|
|
249
|
+
# Internal helpers
|
|
250
|
+
# ---------------------------------------------------------------------------
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _atomic_write_text(content: str, dest: Path, parent_dir: Path) -> None:
|
|
254
|
+
"""Write *content* to *dest* atomically via a unique same-dir tempfile."""
|
|
255
|
+
tmp_path: str | None = None
|
|
256
|
+
try:
|
|
257
|
+
with tempfile.NamedTemporaryFile(
|
|
258
|
+
mode="w", encoding="utf-8", dir=parent_dir, delete=False, suffix=".tmp"
|
|
259
|
+
) as tf:
|
|
260
|
+
tmp_path = tf.name
|
|
261
|
+
tf.write(content)
|
|
262
|
+
os.replace(tmp_path, dest)
|
|
263
|
+
tmp_path = None
|
|
264
|
+
except Exception:
|
|
265
|
+
if tmp_path is not None:
|
|
266
|
+
with contextlib.suppress(OSError):
|
|
267
|
+
os.unlink(tmp_path)
|
|
268
|
+
raise
|