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/bin/monitor.py
ADDED
|
@@ -0,0 +1,570 @@
|
|
|
1
|
+
"""brain-monitor — diagnose the brain sync + build watchers."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import contextlib
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import signal
|
|
8
|
+
import subprocess
|
|
9
|
+
import sys
|
|
10
|
+
import threading
|
|
11
|
+
import time
|
|
12
|
+
import urllib.error
|
|
13
|
+
import urllib.request
|
|
14
|
+
from datetime import datetime
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from types import FrameType
|
|
17
|
+
|
|
18
|
+
from ..config import _brain_home_root
|
|
19
|
+
|
|
20
|
+
# ---------------------------------------------------------------------------
|
|
21
|
+
# Env defaults
|
|
22
|
+
# ---------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _vault() -> Path:
|
|
26
|
+
return Path(os.environ.get("BRAIN_VAULT_PATH", str(Path.home() / "brain-vault")))
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _port() -> int:
|
|
30
|
+
return int(os.environ.get("BRAIN_WIKI_PORT", "8080"))
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# ---------------------------------------------------------------------------
|
|
34
|
+
# Runtime state paths.
|
|
35
|
+
#
|
|
36
|
+
# PID files live under $BRAIN_HOME/run/, matching the foreground wrapper
|
|
37
|
+
# scripts (`_brain-{watcher,build}-fg.sh`). They used to default to
|
|
38
|
+
# /tmp/brain-{watch,build}.pid, but macOS's tmp_cleaner reaps files left
|
|
39
|
+
# untouched for 3 days — and these daemons write their pid file once at launch
|
|
40
|
+
# then run for weeks, so the /tmp pid file silently vanished and we misreported
|
|
41
|
+
# a live daemon as "stopped". $BRAIN_HOME/run/ is not reaped. The BRAIN_*_PID
|
|
42
|
+
# env overrides mirror the shell shims (and let the test suite point them at a
|
|
43
|
+
# tmp dir). Log files live at
|
|
44
|
+
# `$BRAIN_HOME/logs/com.brain.{watcher,build}.{out,err}.log` (the launchd
|
|
45
|
+
# plists redirect StandardOut/StandardError there).
|
|
46
|
+
# ---------------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
WATCH_PID = Path(
|
|
49
|
+
os.environ.get("BRAIN_WATCH_PID", str(_brain_home_root() / "run" / "brain-watch.pid"))
|
|
50
|
+
)
|
|
51
|
+
BUILD_PID = Path(
|
|
52
|
+
os.environ.get("BRAIN_BUILD_PID", str(_brain_home_root() / "run" / "brain-build.pid"))
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _watch_log() -> Path:
|
|
57
|
+
return _brain_home_root() / "logs" / "com.brain.watcher.out.log"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _build_log() -> Path:
|
|
61
|
+
return _brain_home_root() / "logs" / "com.brain.build.out.log"
|
|
62
|
+
|
|
63
|
+
# Directories to skip when walking the vault tree (mirrors the bash `find`
|
|
64
|
+
# -not -path invocations that used BSD-only stat -f flags).
|
|
65
|
+
_VAULT_SKIP_DIRS: frozenset[str] = frozenset({".quartz", ".git"})
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
# ---------------------------------------------------------------------------
|
|
69
|
+
# Build-log noise filter — mirrors the bash grep -Ev shape exactly.
|
|
70
|
+
# Also drops blank lines so trailing KaTeX bursts don't pollute the tail.
|
|
71
|
+
# ---------------------------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
_BUILD_LOG_NOISE = re.compile(
|
|
74
|
+
r"^\s*$"
|
|
75
|
+
r"|LaTeX-incompatible input"
|
|
76
|
+
r"|No character metrics for"
|
|
77
|
+
r"|Warning: couldn't find git repository"
|
|
78
|
+
r"|^Parsing input files using"
|
|
79
|
+
r"|^Cleaned output directory"
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# ---------------------------------------------------------------------------
|
|
84
|
+
# Cross-platform helpers (replace the BSD-only stat -f calls in the bash)
|
|
85
|
+
# ---------------------------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _pid_alive(pid_file: Path) -> bool:
|
|
89
|
+
"""Return True if the process whose PID is stored in *pid_file* is running."""
|
|
90
|
+
if not pid_file.is_file():
|
|
91
|
+
return False
|
|
92
|
+
try:
|
|
93
|
+
pid = int(pid_file.read_text().strip())
|
|
94
|
+
except (ValueError, OSError):
|
|
95
|
+
return False
|
|
96
|
+
try:
|
|
97
|
+
os.kill(pid, 0) # signal 0 = existence check only
|
|
98
|
+
except (OSError, ProcessLookupError):
|
|
99
|
+
return False
|
|
100
|
+
return True
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _pid_or_dash(pid_file: Path) -> str:
|
|
104
|
+
"""Return the PID from *pid_file* as a string, or '-' if missing/unreadable."""
|
|
105
|
+
if not pid_file.is_file():
|
|
106
|
+
return "-"
|
|
107
|
+
try:
|
|
108
|
+
return pid_file.read_text().strip() or "-"
|
|
109
|
+
except OSError:
|
|
110
|
+
return "-"
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _current_build_id(vault: Path) -> str:
|
|
114
|
+
"""Return the current build ID from vault/.quartz/current/.build-id."""
|
|
115
|
+
build_id_file = vault / ".quartz" / "current" / ".build-id"
|
|
116
|
+
if not build_id_file.is_file():
|
|
117
|
+
return "(none)"
|
|
118
|
+
try:
|
|
119
|
+
return build_id_file.read_text().strip()
|
|
120
|
+
except OSError:
|
|
121
|
+
return "(none)"
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _recent_builds(vault: Path, n: int = 5) -> list[str]:
|
|
125
|
+
"""Return the *n* most-recent build directory names (lexical == chronological)."""
|
|
126
|
+
builds_dir = vault / ".quartz" / "builds"
|
|
127
|
+
if not builds_dir.is_dir():
|
|
128
|
+
return []
|
|
129
|
+
names = sorted(p.name for p in builds_dir.iterdir())
|
|
130
|
+
return names[-n:]
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _recent_vault_files(vault: Path, n: int = 5) -> list[tuple[float, str]]:
|
|
134
|
+
"""Return the *n* most-recently-modified vault files as (mtime, path) tuples.
|
|
135
|
+
|
|
136
|
+
Excludes .quartz/, .git/ sub-trees and .DS_Store files, matching the bash
|
|
137
|
+
``find … -not -path … -not -name .DS_Store`` invocation that used
|
|
138
|
+
BSD-only ``stat -f '%m %N'`` for cross-platform mtime retrieval.
|
|
139
|
+
"""
|
|
140
|
+
results: list[tuple[float, str]] = []
|
|
141
|
+
for root, dirs, files in os.walk(str(vault)):
|
|
142
|
+
# Prune skip-dirs in-place so os.walk won't descend into them.
|
|
143
|
+
dirs[:] = [d for d in dirs if d not in _VAULT_SKIP_DIRS]
|
|
144
|
+
for fname in files:
|
|
145
|
+
if fname == ".DS_Store":
|
|
146
|
+
continue
|
|
147
|
+
p = Path(root) / fname
|
|
148
|
+
try:
|
|
149
|
+
mtime = p.stat().st_mtime
|
|
150
|
+
results.append((mtime, str(p)))
|
|
151
|
+
except OSError:
|
|
152
|
+
continue
|
|
153
|
+
results.sort(key=lambda x: x[0], reverse=True)
|
|
154
|
+
return results[:n]
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _mtime_human(p: Path, fmt: str = "%Y-%m-%d %H:%M:%S") -> str:
|
|
158
|
+
"""Return the mtime of *p* formatted according to *fmt*, or '?' on error.
|
|
159
|
+
|
|
160
|
+
Replaces the BSD-only ``stat -f '%Sm' -t '<fmt>'`` calls in the bash.
|
|
161
|
+
"""
|
|
162
|
+
try:
|
|
163
|
+
return datetime.fromtimestamp(p.stat().st_mtime).strftime(fmt)
|
|
164
|
+
except OSError:
|
|
165
|
+
return "?"
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _filter_build_log_line(line: str) -> bool:
|
|
169
|
+
"""Return True if *line* should be KEPT (i.e. it is not noise)."""
|
|
170
|
+
return not bool(_BUILD_LOG_NOISE.search(line))
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _url_responds(url: str, timeout: float = 2.0) -> bool:
|
|
174
|
+
"""Return True if *url* returns a non-error response within *timeout* seconds."""
|
|
175
|
+
try:
|
|
176
|
+
with urllib.request.urlopen(url, timeout=timeout) as resp:
|
|
177
|
+
return int(resp.status) < 400
|
|
178
|
+
except (urllib.error.URLError, OSError, ValueError):
|
|
179
|
+
return False
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _pgrep_count(pattern: str) -> int:
|
|
183
|
+
"""Return the number of running processes matching *pattern* (0 on any error)."""
|
|
184
|
+
try:
|
|
185
|
+
result = subprocess.run(
|
|
186
|
+
["pgrep", "-f", pattern],
|
|
187
|
+
capture_output=True,
|
|
188
|
+
text=True,
|
|
189
|
+
timeout=5,
|
|
190
|
+
)
|
|
191
|
+
return len([ln for ln in result.stdout.splitlines() if ln.strip()])
|
|
192
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
193
|
+
return 0
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
# ---------------------------------------------------------------------------
|
|
197
|
+
# Snapshot mode
|
|
198
|
+
# ---------------------------------------------------------------------------
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _snapshot(vault: Path, port: int) -> int:
|
|
202
|
+
"""Print a status snapshot of both watchers and recent activity."""
|
|
203
|
+
sync_pid = _pid_or_dash(WATCH_PID)
|
|
204
|
+
build_pid = _pid_or_dash(BUILD_PID)
|
|
205
|
+
sync_state = "running" if _pid_alive(WATCH_PID) else "stopped"
|
|
206
|
+
build_state = "running" if _pid_alive(BUILD_PID) else "stopped"
|
|
207
|
+
url = f"http://localhost:{port}"
|
|
208
|
+
|
|
209
|
+
print(f"🧠 brain-monitor vault={vault}")
|
|
210
|
+
print()
|
|
211
|
+
print(f" sync watcher {sync_state:<7} pid={sync_pid:<7} log={_watch_log()}")
|
|
212
|
+
print(f" build watcher {build_state:<7} pid={build_pid:<7} log={_build_log()}")
|
|
213
|
+
|
|
214
|
+
# Cross-check: PID file says one thing; ps may say another. Surface mismatches.
|
|
215
|
+
sync_seen = _pgrep_count("brain vault sync --watch")
|
|
216
|
+
build_seen = _pgrep_count("brain.wiki.build_watcher")
|
|
217
|
+
if sync_seen != 1 or build_seen != 1:
|
|
218
|
+
print()
|
|
219
|
+
print(" ⚠️ process count vs pid file:")
|
|
220
|
+
print(f" sync_watcher pgrep={sync_seen} pidfile={sync_pid}")
|
|
221
|
+
print(f" build_watcher pgrep={build_seen} pidfile={build_pid}")
|
|
222
|
+
print(" (>1 = orphan running; 0 with pidfile = stale pid)")
|
|
223
|
+
|
|
224
|
+
# Current build
|
|
225
|
+
print()
|
|
226
|
+
print(" current build:")
|
|
227
|
+
current = vault / ".quartz" / "current"
|
|
228
|
+
if current.is_symlink():
|
|
229
|
+
target = os.readlink(str(current))
|
|
230
|
+
print(f" symlink → {target}")
|
|
231
|
+
print(f" build-id {_current_build_id(vault)}")
|
|
232
|
+
index_html = current / "index.html"
|
|
233
|
+
if index_html.is_file():
|
|
234
|
+
age = _mtime_human(index_html, "%Y-%m-%d %H:%M:%S")
|
|
235
|
+
print(f" last build {age}")
|
|
236
|
+
else:
|
|
237
|
+
print(" (no symlink — run brain-up to bootstrap)")
|
|
238
|
+
|
|
239
|
+
# Recent builds
|
|
240
|
+
builds_dir = vault / ".quartz" / "builds"
|
|
241
|
+
if builds_dir.is_dir():
|
|
242
|
+
print()
|
|
243
|
+
print(" recent builds:")
|
|
244
|
+
cur_id = _current_build_id(vault)
|
|
245
|
+
for b in _recent_builds(vault, 5):
|
|
246
|
+
mark = "→ " if b == cur_id else " "
|
|
247
|
+
mt = _mtime_human(builds_dir / b, "%H:%M:%S")
|
|
248
|
+
print(f" {mark}{mt} {b}")
|
|
249
|
+
|
|
250
|
+
# Recent vault edits
|
|
251
|
+
print()
|
|
252
|
+
print(" recent vault edits:")
|
|
253
|
+
for mtime, path in _recent_vault_files(vault, 5):
|
|
254
|
+
t = datetime.fromtimestamp(mtime).strftime("%H:%M:%S")
|
|
255
|
+
print(f" {t} {path}")
|
|
256
|
+
|
|
257
|
+
# Wiki URL
|
|
258
|
+
print()
|
|
259
|
+
print(" wiki url:")
|
|
260
|
+
if _url_responds(f"{url}/"):
|
|
261
|
+
print(f" {url} reachable ✅")
|
|
262
|
+
else:
|
|
263
|
+
print(f" {url} unreachable ⚠️")
|
|
264
|
+
|
|
265
|
+
# Last 8 build-log lines (filtered)
|
|
266
|
+
print()
|
|
267
|
+
print(" last 8 build-log lines (filtered):")
|
|
268
|
+
if _build_log().is_file() and _build_log().stat().st_size > 0:
|
|
269
|
+
try:
|
|
270
|
+
tail_lines = _build_log().read_text(errors="replace").splitlines()[-1000:]
|
|
271
|
+
filtered = [ln for ln in tail_lines if _filter_build_log_line(ln)]
|
|
272
|
+
last_8 = filtered[-8:]
|
|
273
|
+
except OSError:
|
|
274
|
+
last_8 = []
|
|
275
|
+
if last_8:
|
|
276
|
+
for ln in last_8:
|
|
277
|
+
print(f" {ln}")
|
|
278
|
+
else:
|
|
279
|
+
print(
|
|
280
|
+
" (only KaTeX/git-warnings in the tail window — filter saw nothing else)"
|
|
281
|
+
)
|
|
282
|
+
else:
|
|
283
|
+
print(" (empty)")
|
|
284
|
+
|
|
285
|
+
# Last 5 sync-log lines
|
|
286
|
+
print()
|
|
287
|
+
print(" last 5 sync-log lines:")
|
|
288
|
+
if _watch_log().is_file() and _watch_log().stat().st_size > 0:
|
|
289
|
+
try:
|
|
290
|
+
watch_lines = _watch_log().read_text(errors="replace").splitlines()[-5:]
|
|
291
|
+
for ln in watch_lines:
|
|
292
|
+
print(f" {ln}")
|
|
293
|
+
except OSError:
|
|
294
|
+
print(" (error reading watch log)")
|
|
295
|
+
else:
|
|
296
|
+
print(" (empty)")
|
|
297
|
+
|
|
298
|
+
return 0
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
# ---------------------------------------------------------------------------
|
|
302
|
+
# Live tail mode
|
|
303
|
+
# ---------------------------------------------------------------------------
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _tail(vault: Path) -> int:
|
|
307
|
+
"""Stream both watcher logs to stdout with source prefixes."""
|
|
308
|
+
if not _build_log().is_file() and not _watch_log().is_file():
|
|
309
|
+
print("no logs to tail (neither watcher has started)", file=sys.stderr)
|
|
310
|
+
return 1
|
|
311
|
+
|
|
312
|
+
print("tailing watchers — Ctrl-C to stop")
|
|
313
|
+
print(f" sync: {_watch_log()}")
|
|
314
|
+
print(f" build: {_build_log()} (KaTeX/git-warnings filtered)")
|
|
315
|
+
print()
|
|
316
|
+
|
|
317
|
+
procs: list[subprocess.Popen[str]] = []
|
|
318
|
+
|
|
319
|
+
def _stream(log_path: Path, prefix: str, filtered: bool) -> None:
|
|
320
|
+
"""Tail *log_path* and print prefixed lines to stdout."""
|
|
321
|
+
try:
|
|
322
|
+
proc: subprocess.Popen[str] = subprocess.Popen(
|
|
323
|
+
["tail", "-n", "0", "-F", str(log_path)],
|
|
324
|
+
stdout=subprocess.PIPE,
|
|
325
|
+
stderr=subprocess.DEVNULL,
|
|
326
|
+
text=True,
|
|
327
|
+
bufsize=1, # line-buffered: each line flushed immediately (matches sed -u)
|
|
328
|
+
)
|
|
329
|
+
procs.append(proc)
|
|
330
|
+
if proc.stdout is None:
|
|
331
|
+
return
|
|
332
|
+
for raw_line in proc.stdout:
|
|
333
|
+
stripped = raw_line.rstrip("\n")
|
|
334
|
+
if filtered and not _filter_build_log_line(stripped):
|
|
335
|
+
continue
|
|
336
|
+
print(f"{prefix}{stripped}", flush=True)
|
|
337
|
+
except (OSError, ValueError):
|
|
338
|
+
pass
|
|
339
|
+
|
|
340
|
+
def _cleanup(signum: int, frame: FrameType | None) -> None:
|
|
341
|
+
for p in procs:
|
|
342
|
+
with contextlib.suppress(OSError):
|
|
343
|
+
p.terminate()
|
|
344
|
+
raise SystemExit(0)
|
|
345
|
+
|
|
346
|
+
signal.signal(signal.SIGINT, _cleanup)
|
|
347
|
+
signal.signal(signal.SIGTERM, _cleanup)
|
|
348
|
+
|
|
349
|
+
threads = [
|
|
350
|
+
threading.Thread(target=_stream, args=(_watch_log(), "[sync ] ", False), daemon=True),
|
|
351
|
+
threading.Thread(target=_stream, args=(_build_log(), "[build] ", True), daemon=True),
|
|
352
|
+
]
|
|
353
|
+
for t in threads:
|
|
354
|
+
t.start()
|
|
355
|
+
for t in threads:
|
|
356
|
+
t.join()
|
|
357
|
+
|
|
358
|
+
return 0
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
# ---------------------------------------------------------------------------
|
|
362
|
+
# Probe mode
|
|
363
|
+
# ---------------------------------------------------------------------------
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def _probe(vault: Path, timeout: int = 90) -> int:
|
|
367
|
+
"""Write a probe file into vault and time the build watcher's response."""
|
|
368
|
+
if not vault.is_dir():
|
|
369
|
+
print(f"vault not found at {vault}", file=sys.stderr)
|
|
370
|
+
return 1
|
|
371
|
+
if not _pid_alive(BUILD_PID):
|
|
372
|
+
print("build watcher not running — start with brain-up", file=sys.stderr)
|
|
373
|
+
return 1
|
|
374
|
+
|
|
375
|
+
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
|
376
|
+
probe_id = f"probe-{stamp}-{os.getpid()}"
|
|
377
|
+
probe_file = vault / f"_brain_monitor_{probe_id}.md"
|
|
378
|
+
baseline = _current_build_id(vault)
|
|
379
|
+
now_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
380
|
+
|
|
381
|
+
probe_file.write_text(
|
|
382
|
+
f"---\n"
|
|
383
|
+
f"title: brain-monitor probe {probe_id}\n"
|
|
384
|
+
f"---\n"
|
|
385
|
+
f"\n"
|
|
386
|
+
f"This file was written by brain-monitor at {now_str}.\n"
|
|
387
|
+
f"Marker: {probe_id}\n"
|
|
388
|
+
)
|
|
389
|
+
|
|
390
|
+
print(f"🧠 probe {probe_id}")
|
|
391
|
+
print(f" wrote {probe_file}")
|
|
392
|
+
print(f" baseline build-id: {baseline}")
|
|
393
|
+
print(f" waiting up to {timeout}s for the build watcher to react...")
|
|
394
|
+
print()
|
|
395
|
+
|
|
396
|
+
started = time.monotonic()
|
|
397
|
+
swap_elapsed: str | None = None
|
|
398
|
+
rendered_elapsed: str | None = None
|
|
399
|
+
current_dir = vault / ".quartz" / "current"
|
|
400
|
+
|
|
401
|
+
try:
|
|
402
|
+
while True:
|
|
403
|
+
elapsed = int(time.monotonic() - started)
|
|
404
|
+
cur = _current_build_id(vault)
|
|
405
|
+
|
|
406
|
+
if swap_elapsed is None and cur != baseline and cur != "(none)":
|
|
407
|
+
swap_elapsed = f"{elapsed}s"
|
|
408
|
+
print(f" +{elapsed}s current swapped → {cur}")
|
|
409
|
+
|
|
410
|
+
if rendered_elapsed is None:
|
|
411
|
+
matches = list(current_dir.glob(f"_brain_monitor_{probe_id}*"))
|
|
412
|
+
if matches:
|
|
413
|
+
rendered_elapsed = f"{elapsed}s"
|
|
414
|
+
print(f" +{elapsed}s probe HTML appeared in current/")
|
|
415
|
+
break
|
|
416
|
+
|
|
417
|
+
if elapsed >= timeout:
|
|
418
|
+
break
|
|
419
|
+
|
|
420
|
+
time.sleep(1)
|
|
421
|
+
finally:
|
|
422
|
+
with contextlib.suppress(OSError):
|
|
423
|
+
probe_file.unlink(missing_ok=True)
|
|
424
|
+
|
|
425
|
+
print()
|
|
426
|
+
print(" result:")
|
|
427
|
+
if swap_elapsed is not None and rendered_elapsed is not None:
|
|
428
|
+
print(" ✅ end-to-end OK")
|
|
429
|
+
print(f" swap to new build: {swap_elapsed}")
|
|
430
|
+
print(f" probe content live: {rendered_elapsed}")
|
|
431
|
+
elif swap_elapsed is not None:
|
|
432
|
+
print(f" ⚠️ build ran ({swap_elapsed}) but probe HTML never appeared in current/")
|
|
433
|
+
print(" likely a build_swap mismatch or the probe was filtered out")
|
|
434
|
+
print(f" inspect: tail -n 200 {_build_log()} | grep -i {probe_id}")
|
|
435
|
+
else:
|
|
436
|
+
print(f" ❌ no rebuild within {timeout}s — watcher is not seeing the vault")
|
|
437
|
+
print(" things to check:")
|
|
438
|
+
print(
|
|
439
|
+
" pgrep -af brain.wiki.build_watcher"
|
|
440
|
+
" # one process? right --vault?"
|
|
441
|
+
)
|
|
442
|
+
print(" pgrep -af 'brain vault sync --watch' # same")
|
|
443
|
+
print(f" tail -n 50 {_build_log()}")
|
|
444
|
+
print(f" tail -n 50 {_watch_log()}")
|
|
445
|
+
print(" brain-down && brain-up # nuclear option")
|
|
446
|
+
|
|
447
|
+
return 0
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
# ---------------------------------------------------------------------------
|
|
451
|
+
# Argument parser
|
|
452
|
+
# ---------------------------------------------------------------------------
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def _make_parser() -> argparse.ArgumentParser:
|
|
456
|
+
parser = argparse.ArgumentParser(
|
|
457
|
+
prog="brain-monitor",
|
|
458
|
+
description=(
|
|
459
|
+
"Diagnose the brain sync + build watchers.\n\n"
|
|
460
|
+
"Modes:\n"
|
|
461
|
+
" (default) Snapshot of both watchers and recent activity.\n"
|
|
462
|
+
" -f/--tail Live stream of relevant events from both logs.\n"
|
|
463
|
+
" probe [N] Touch a vault file and time the rebuild (timeout N sec).\n"
|
|
464
|
+
),
|
|
465
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
466
|
+
epilog=(
|
|
467
|
+
"Env vars:\n"
|
|
468
|
+
" BRAIN_VAULT_PATH default ~/brain-vault\n"
|
|
469
|
+
" BRAIN_WIKI_PORT default 8080"
|
|
470
|
+
),
|
|
471
|
+
)
|
|
472
|
+
# Accept all bash-compatible positional aliases. "tail" and "help" are
|
|
473
|
+
# kept as aliases so existing scripts using the bash form still work.
|
|
474
|
+
parser.add_argument(
|
|
475
|
+
"command",
|
|
476
|
+
nargs="?",
|
|
477
|
+
choices=["probe", "snapshot", "status", "tail", "help"],
|
|
478
|
+
default=None,
|
|
479
|
+
metavar="COMMAND",
|
|
480
|
+
help="probe | snapshot | status | tail | help (default: snapshot)",
|
|
481
|
+
)
|
|
482
|
+
parser.add_argument(
|
|
483
|
+
"-f",
|
|
484
|
+
"--tail",
|
|
485
|
+
action="store_true",
|
|
486
|
+
help="Live-stream both watcher logs (KaTeX noise filtered on build log).",
|
|
487
|
+
)
|
|
488
|
+
parser.add_argument(
|
|
489
|
+
"--timeout",
|
|
490
|
+
type=int,
|
|
491
|
+
default=90,
|
|
492
|
+
metavar="SECONDS",
|
|
493
|
+
help="Timeout for probe mode in seconds (default: 90).",
|
|
494
|
+
)
|
|
495
|
+
return parser
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
# ---------------------------------------------------------------------------
|
|
499
|
+
# Entry points
|
|
500
|
+
# ---------------------------------------------------------------------------
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def main(argv: list[str] | None = None) -> int:
|
|
504
|
+
"""Parse args and dispatch to the appropriate mode. Returns exit code."""
|
|
505
|
+
parser = _make_parser()
|
|
506
|
+
# parse_known_args is used solely to allow `brain-monitor probe 60`
|
|
507
|
+
# (positional timeout — bash backward-compat). The leftover `extra` list
|
|
508
|
+
# is validated immediately below; anything unexpected is still an error.
|
|
509
|
+
try:
|
|
510
|
+
args, extra = parser.parse_known_args(argv)
|
|
511
|
+
except SystemExit as exc:
|
|
512
|
+
code = exc.code
|
|
513
|
+
if isinstance(code, int):
|
|
514
|
+
return code
|
|
515
|
+
return 0
|
|
516
|
+
|
|
517
|
+
vault = _vault()
|
|
518
|
+
port = _port()
|
|
519
|
+
|
|
520
|
+
cmd: str = str(args.command) if args.command else "snapshot"
|
|
521
|
+
|
|
522
|
+
# Validate leftover tokens from parse_known_args BEFORE dispatching to any
|
|
523
|
+
# mode (including --tail). The only valid case for a non-empty `extra` is
|
|
524
|
+
# `probe <N>` where N is a bare integer (bash compat for the positional
|
|
525
|
+
# timeout). Anything else — unknown flags, extra words on --tail or any
|
|
526
|
+
# other command — is an error so that e.g. `brain-monitor --tail --bogus`
|
|
527
|
+
# or `brain-monitor snapshot --x` fail loudly rather than being silently
|
|
528
|
+
# swallowed.
|
|
529
|
+
if extra:
|
|
530
|
+
probe_timeout_ok = cmd == "probe" and len(extra) == 1 and extra[0].isdigit()
|
|
531
|
+
if not probe_timeout_ok:
|
|
532
|
+
print(
|
|
533
|
+
f"brain-monitor: error: unrecognized arguments: {' '.join(extra)}",
|
|
534
|
+
file=sys.stderr,
|
|
535
|
+
)
|
|
536
|
+
parser.print_usage(sys.stderr)
|
|
537
|
+
return 2
|
|
538
|
+
|
|
539
|
+
# -f / --tail flag takes precedence over any positional.
|
|
540
|
+
if args.tail:
|
|
541
|
+
return _tail(vault)
|
|
542
|
+
|
|
543
|
+
if cmd in ("snapshot", "status"):
|
|
544
|
+
return _snapshot(vault, port)
|
|
545
|
+
|
|
546
|
+
# "tail" as a bare positional: bash-compat alias for -f/--tail.
|
|
547
|
+
if cmd == "tail":
|
|
548
|
+
return _tail(vault)
|
|
549
|
+
|
|
550
|
+
# "help" as a bare positional: bash-compat alias for -h/--help.
|
|
551
|
+
if cmd == "help":
|
|
552
|
+
parser.print_help()
|
|
553
|
+
return 0
|
|
554
|
+
|
|
555
|
+
if cmd == "probe":
|
|
556
|
+
# Accept positional timeout for bash compat: `brain-monitor probe 60`
|
|
557
|
+
timeout = args.timeout
|
|
558
|
+
if extra and extra[0].isdigit():
|
|
559
|
+
timeout = int(extra[0])
|
|
560
|
+
return _probe(vault, timeout)
|
|
561
|
+
|
|
562
|
+
# Defensive: argparse choices= already rejects truly unknown values.
|
|
563
|
+
print(f"unknown command: {cmd}", file=sys.stderr)
|
|
564
|
+
parser.print_help(sys.stderr)
|
|
565
|
+
return 2
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
def cli_main() -> None:
|
|
569
|
+
"""Console-script entry point. Calls sys.exit with main()'s return code."""
|
|
570
|
+
sys.exit(main())
|
brain/bin/rebuild.py
ADDED
brain/bin/status.py
ADDED