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/cli_claude.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""brain claude install-skill — install the Claude Code skill into ~/.claude/skills/brain/."""
|
|
2
|
+
import sys
|
|
3
|
+
from importlib.resources import files as resource_files
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
|
|
8
|
+
from .errors import BrainError
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class SkillInstallError(BrainError):
|
|
12
|
+
"""Raised when the Claude skill install can't proceed."""
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
_DEFAULT_TARGET_ROOT = Path.home() / ".claude" / "skills"
|
|
16
|
+
_SKILL_DIR_NAME = "brain"
|
|
17
|
+
_SKILL_FILENAME = "SKILL.md"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def install_skill(
|
|
21
|
+
target_root: Path | None = None,
|
|
22
|
+
force: bool = False,
|
|
23
|
+
uninstall: bool = False,
|
|
24
|
+
) -> None:
|
|
25
|
+
"""Install (or uninstall) the brain Claude Code skill."""
|
|
26
|
+
root = target_root if target_root is not None else _DEFAULT_TARGET_ROOT
|
|
27
|
+
skill_dir = root / _SKILL_DIR_NAME
|
|
28
|
+
target = skill_dir / _SKILL_FILENAME
|
|
29
|
+
|
|
30
|
+
if uninstall:
|
|
31
|
+
_uninstall(skill_dir, target)
|
|
32
|
+
return
|
|
33
|
+
|
|
34
|
+
src_bytes = _read_skill_template()
|
|
35
|
+
_install(target, skill_dir, src_bytes, force=force)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _read_skill_template() -> bytes:
|
|
39
|
+
"""Read SKILL.md from package data."""
|
|
40
|
+
res = resource_files("brain.templates.skill") / _SKILL_FILENAME
|
|
41
|
+
return res.read_bytes()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _install(target: Path, skill_dir: Path, src_bytes: bytes, *, force: bool) -> None:
|
|
45
|
+
"""Write src_bytes to target, creating skill_dir as needed."""
|
|
46
|
+
skill_dir.mkdir(parents=True, exist_ok=True)
|
|
47
|
+
if target.is_file():
|
|
48
|
+
if target.read_bytes() == src_bytes:
|
|
49
|
+
print(f"skill up to date: {target}")
|
|
50
|
+
return
|
|
51
|
+
if not force:
|
|
52
|
+
typer.confirm(
|
|
53
|
+
f"{target} differs from the packaged SKILL.md. Overwrite?",
|
|
54
|
+
abort=True,
|
|
55
|
+
)
|
|
56
|
+
target.write_bytes(src_bytes)
|
|
57
|
+
print(f"skill installed: {target}")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _uninstall(skill_dir: Path, target: Path) -> None:
|
|
61
|
+
"""Remove SKILL.md and rmdir the brain/ dir if empty."""
|
|
62
|
+
target_removed = False
|
|
63
|
+
if target.is_file():
|
|
64
|
+
target.unlink()
|
|
65
|
+
print(f"removed {target}")
|
|
66
|
+
target_removed = True
|
|
67
|
+
|
|
68
|
+
if skill_dir.is_dir():
|
|
69
|
+
remaining = list(skill_dir.iterdir())
|
|
70
|
+
if remaining:
|
|
71
|
+
print(
|
|
72
|
+
f"warning: {skill_dir} not empty — leaving it in place "
|
|
73
|
+
f"({len(remaining)} other entries)",
|
|
74
|
+
file=sys.stderr,
|
|
75
|
+
)
|
|
76
|
+
else:
|
|
77
|
+
skill_dir.rmdir()
|
|
78
|
+
print(f"removed {skill_dir}")
|
|
79
|
+
|
|
80
|
+
if not target_removed and not skill_dir.is_dir():
|
|
81
|
+
print("nothing to uninstall")
|
brain/cli_connect.py
ADDED
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
"""`brain connect` auto-link suggestion sub-app (Plan 07).
|
|
2
|
+
|
|
3
|
+
Thin Typer orchestration over :mod:`brain.connect`: list the review queue,
|
|
4
|
+
refresh candidates, accept (optionally writing the wikilink into the source
|
|
5
|
+
vault file), reject, and show stats. All scoring + SQL + writeback primitives
|
|
6
|
+
live in :mod:`brain.connect`; this module only maps results to Rich/Typer
|
|
7
|
+
output and the plain :mod:`brain.errors` exceptions to ``typer.Exit``.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
import psycopg
|
|
15
|
+
import typer
|
|
16
|
+
from rich.table import Table
|
|
17
|
+
|
|
18
|
+
from . import connect as connect_mod
|
|
19
|
+
from .config import Config
|
|
20
|
+
from .db import connect
|
|
21
|
+
from .errors import (
|
|
22
|
+
ConnectError,
|
|
23
|
+
IdPrefixAmbiguous,
|
|
24
|
+
IdPrefixNotFound,
|
|
25
|
+
IdPrefixNotHex,
|
|
26
|
+
IdPrefixTooShort,
|
|
27
|
+
)
|
|
28
|
+
from .format import console, emit_json
|
|
29
|
+
|
|
30
|
+
# Default rows shown by ``brain connect list`` / the bare ``brain connect``.
|
|
31
|
+
_LIST_DEFAULT_LIMIT = 20
|
|
32
|
+
|
|
33
|
+
connect_app = typer.Typer(
|
|
34
|
+
name="connect",
|
|
35
|
+
help=(
|
|
36
|
+
"Proactive auto-link suggestions: surface note pairs that share "
|
|
37
|
+
"entities / semantics but aren't linked yet, then accept (write the "
|
|
38
|
+
"wikilink) or reject each one."
|
|
39
|
+
),
|
|
40
|
+
invoke_without_command=True,
|
|
41
|
+
no_args_is_help=False,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _suggestion_json(row: connect_mod.SuggestionRow) -> dict[str, Any]:
|
|
46
|
+
"""Serialize a suggestion row for ``--json`` output."""
|
|
47
|
+
return {
|
|
48
|
+
"id": row.id,
|
|
49
|
+
"source_doc_id": row.source_doc_id,
|
|
50
|
+
"target_doc_id": row.target_doc_id,
|
|
51
|
+
"source_title": row.source_title,
|
|
52
|
+
"target_title": row.target_title,
|
|
53
|
+
"score": round(row.score, 6),
|
|
54
|
+
"graph_score": None if row.graph_score is None else round(row.graph_score, 6),
|
|
55
|
+
"embed_score": None if row.embed_score is None else round(row.embed_score, 6),
|
|
56
|
+
"status": row.status,
|
|
57
|
+
"suggested_at": row.suggested_at,
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _fmt_leg(value: float | None) -> str:
|
|
62
|
+
"""Render a leg score (graph / embed) or an em dash when absent."""
|
|
63
|
+
return "—" if value is None else f"{value:.2f}"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _render_list(*, limit: int, json_output: bool, show_all: bool) -> None:
|
|
67
|
+
"""Shared body for the bare ``brain connect`` and ``brain connect list``."""
|
|
68
|
+
cfg = Config.load()
|
|
69
|
+
status = None if show_all else "pending"
|
|
70
|
+
with connect(cfg.database_url) as conn:
|
|
71
|
+
rows = connect_mod.iter_suggestions(conn, status=status, limit=limit)
|
|
72
|
+
if json_output:
|
|
73
|
+
emit_json([_suggestion_json(r) for r in rows])
|
|
74
|
+
return
|
|
75
|
+
if not rows:
|
|
76
|
+
typer.echo("no suggestions" if show_all else "no pending suggestions")
|
|
77
|
+
return
|
|
78
|
+
title = "Link suggestions" if show_all else "Pending link suggestions"
|
|
79
|
+
table = Table(title=title)
|
|
80
|
+
table.add_column("ID", style="cyan", no_wrap=True)
|
|
81
|
+
table.add_column("Source title")
|
|
82
|
+
table.add_column("Target title")
|
|
83
|
+
table.add_column("Score", justify="right")
|
|
84
|
+
table.add_column("Graph", justify="right")
|
|
85
|
+
table.add_column("Embed", justify="right")
|
|
86
|
+
if show_all:
|
|
87
|
+
table.add_column("Status")
|
|
88
|
+
for r in rows:
|
|
89
|
+
cells = [
|
|
90
|
+
r.id[:8],
|
|
91
|
+
r.source_title,
|
|
92
|
+
r.target_title,
|
|
93
|
+
f"{r.score:.3f}",
|
|
94
|
+
_fmt_leg(r.graph_score),
|
|
95
|
+
_fmt_leg(r.embed_score),
|
|
96
|
+
]
|
|
97
|
+
if show_all:
|
|
98
|
+
cells.append(r.status)
|
|
99
|
+
table.add_row(*cells)
|
|
100
|
+
console.print(table)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@connect_app.callback(invoke_without_command=True)
|
|
104
|
+
def connect_default(
|
|
105
|
+
ctx: typer.Context,
|
|
106
|
+
limit: int = typer.Option(
|
|
107
|
+
_LIST_DEFAULT_LIMIT, "--limit", "-n", help="Max suggestions to show."
|
|
108
|
+
),
|
|
109
|
+
json_output: bool = typer.Option(False, "--json", help="Emit JSON instead."),
|
|
110
|
+
show_all: bool = typer.Option(
|
|
111
|
+
False, "--all", help="Show every status, not just pending."
|
|
112
|
+
),
|
|
113
|
+
) -> None:
|
|
114
|
+
"""Show the pending suggestion queue (alias for ``brain connect list``)."""
|
|
115
|
+
if ctx.invoked_subcommand is not None:
|
|
116
|
+
return
|
|
117
|
+
_render_list(limit=limit, json_output=json_output, show_all=show_all)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@connect_app.command("list")
|
|
121
|
+
def connect_list(
|
|
122
|
+
limit: int = typer.Option(
|
|
123
|
+
_LIST_DEFAULT_LIMIT, "--limit", "-n", help="Max suggestions to show."
|
|
124
|
+
),
|
|
125
|
+
json_output: bool = typer.Option(False, "--json", help="Emit JSON instead."),
|
|
126
|
+
show_all: bool = typer.Option(
|
|
127
|
+
False, "--all", help="Show every status, not just pending."
|
|
128
|
+
),
|
|
129
|
+
) -> None:
|
|
130
|
+
"""List the link-suggestion review queue (pending by default)."""
|
|
131
|
+
_render_list(limit=limit, json_output=json_output, show_all=show_all)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
@connect_app.command("refresh")
|
|
135
|
+
def connect_refresh(
|
|
136
|
+
doc: str | None = typer.Option(
|
|
137
|
+
None, "--doc", help="Limit refresh to a single source doc (id prefix)."
|
|
138
|
+
),
|
|
139
|
+
dry_run: bool = typer.Option(
|
|
140
|
+
False, "--dry-run", help="Compute candidates without writing to the DB."
|
|
141
|
+
),
|
|
142
|
+
) -> None:
|
|
143
|
+
"""Recompute auto-link candidates and upsert pending suggestions.
|
|
144
|
+
|
|
145
|
+
Blends an entity-graph affinity leg with an embedding affinity leg (RRF),
|
|
146
|
+
drops already-linked and below-threshold pairs, and persists the top
|
|
147
|
+
suggestions per source doc. Accepted/rejected rows are never overwritten.
|
|
148
|
+
"""
|
|
149
|
+
cfg = Config.load()
|
|
150
|
+
try:
|
|
151
|
+
with connect(cfg.database_url) as conn:
|
|
152
|
+
conn.autocommit = True
|
|
153
|
+
result = connect_mod.refresh_suggestions(
|
|
154
|
+
conn, cfg, doc_prefix=doc, dry_run=dry_run
|
|
155
|
+
)
|
|
156
|
+
except (
|
|
157
|
+
IdPrefixTooShort,
|
|
158
|
+
IdPrefixNotHex,
|
|
159
|
+
IdPrefixNotFound,
|
|
160
|
+
IdPrefixAmbiguous,
|
|
161
|
+
ConnectError,
|
|
162
|
+
) as exc:
|
|
163
|
+
typer.secho(str(exc), fg="red", err=True)
|
|
164
|
+
raise typer.Exit(code=1) from exc
|
|
165
|
+
prefix = "would write" if dry_run else "wrote"
|
|
166
|
+
typer.echo(
|
|
167
|
+
f"scanned {result.source_docs} source doc(s); "
|
|
168
|
+
f"{result.candidates} candidate pair(s); "
|
|
169
|
+
f"{prefix} {result.written} suggestion(s)"
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _resolve(conn: psycopg.Connection[Any], prefix: str) -> str:
|
|
174
|
+
"""Resolve a suggestion-id prefix or exit non-zero with a message."""
|
|
175
|
+
try:
|
|
176
|
+
return connect_mod.resolve_suggestion_prefix(conn, prefix)
|
|
177
|
+
except ConnectError as exc:
|
|
178
|
+
typer.secho(str(exc), fg="red", err=True)
|
|
179
|
+
raise typer.Exit(code=1) from exc
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
@connect_app.command("accept")
|
|
183
|
+
def connect_accept(
|
|
184
|
+
suggestion_id: str = typer.Argument(..., help="Suggestion id prefix (>= 6 chars)."),
|
|
185
|
+
write: bool = typer.Option(
|
|
186
|
+
False, "--write", help="Insert the wikilink into the source vault file."
|
|
187
|
+
),
|
|
188
|
+
) -> None:
|
|
189
|
+
"""Mark a suggestion accepted; with ``--write``, insert the wikilink.
|
|
190
|
+
|
|
191
|
+
The wikilink is appended (path-form alias) under a ``## See Also`` section
|
|
192
|
+
at the end of the source doc's vault file. The write is idempotent — a
|
|
193
|
+
repeated accept never duplicates the link.
|
|
194
|
+
"""
|
|
195
|
+
cfg = Config.load()
|
|
196
|
+
with connect(cfg.database_url) as conn:
|
|
197
|
+
conn.autocommit = True
|
|
198
|
+
resolved = _resolve(conn, suggestion_id)
|
|
199
|
+
try:
|
|
200
|
+
ctx = connect_mod.load_action_context(conn, resolved)
|
|
201
|
+
except ConnectError as exc:
|
|
202
|
+
typer.secho(str(exc), fg="red", err=True)
|
|
203
|
+
raise typer.Exit(code=1) from exc
|
|
204
|
+
# Write the wikilink FIRST so a write failure (missing file / IO error)
|
|
205
|
+
# leaves the suggestion pending rather than frozen accepted-without-link.
|
|
206
|
+
written = _write_wikilink(cfg, ctx) if write else False
|
|
207
|
+
try:
|
|
208
|
+
connect_mod.set_suggestion_status(conn, resolved, "accepted")
|
|
209
|
+
except ConnectError as exc:
|
|
210
|
+
typer.secho(str(exc), fg="red", err=True)
|
|
211
|
+
raise typer.Exit(code=1) from exc
|
|
212
|
+
suffix = " (wikilink written)" if written else (
|
|
213
|
+
" (wikilink already present)" if write else ""
|
|
214
|
+
)
|
|
215
|
+
typer.echo(f"✓ accepted {resolved[:8]}{suffix}")
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _write_wikilink(cfg: Config, action: connect_mod.ActionResult) -> bool:
|
|
219
|
+
"""Insert the accepted suggestion's wikilink into the source vault file.
|
|
220
|
+
|
|
221
|
+
Returns ``True`` when a new link was written, ``False`` when it was already
|
|
222
|
+
present. Exits non-zero when the source/target vault paths are missing
|
|
223
|
+
(the link can't be located) so the user gets clear feedback.
|
|
224
|
+
"""
|
|
225
|
+
if action.source_vault_path is None:
|
|
226
|
+
typer.secho(
|
|
227
|
+
"cannot write wikilink: source doc has no vault file",
|
|
228
|
+
fg="red",
|
|
229
|
+
err=True,
|
|
230
|
+
)
|
|
231
|
+
raise typer.Exit(code=1)
|
|
232
|
+
if action.target_vault_path is None:
|
|
233
|
+
typer.secho(
|
|
234
|
+
"cannot write wikilink: target doc has no vault path",
|
|
235
|
+
fg="red",
|
|
236
|
+
err=True,
|
|
237
|
+
)
|
|
238
|
+
raise typer.Exit(code=1)
|
|
239
|
+
source_file = Path(cfg.vault_path) / action.source_vault_path
|
|
240
|
+
if not source_file.is_file():
|
|
241
|
+
typer.secho(
|
|
242
|
+
f"cannot write wikilink: source vault file not found at {source_file}",
|
|
243
|
+
fg="red",
|
|
244
|
+
err=True,
|
|
245
|
+
)
|
|
246
|
+
raise typer.Exit(code=1)
|
|
247
|
+
wikilink = connect_mod.build_see_also_wikilink(
|
|
248
|
+
action.target_vault_path, action.target_title
|
|
249
|
+
)
|
|
250
|
+
return connect_mod.append_see_also_link(source_file, wikilink)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
@connect_app.command("reject")
|
|
254
|
+
def connect_reject(
|
|
255
|
+
suggestion_id: str = typer.Argument(..., help="Suggestion id prefix (>= 6 chars)."),
|
|
256
|
+
) -> None:
|
|
257
|
+
"""Mark a suggestion rejected; it is frozen and not re-proposed on refresh."""
|
|
258
|
+
cfg = Config.load()
|
|
259
|
+
with connect(cfg.database_url) as conn:
|
|
260
|
+
conn.autocommit = True
|
|
261
|
+
resolved = _resolve(conn, suggestion_id)
|
|
262
|
+
try:
|
|
263
|
+
connect_mod.set_suggestion_status(conn, resolved, "rejected")
|
|
264
|
+
except ConnectError as exc:
|
|
265
|
+
typer.secho(str(exc), fg="red", err=True)
|
|
266
|
+
raise typer.Exit(code=1) from exc
|
|
267
|
+
typer.echo(f"✓ rejected {resolved[:8]}")
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
@connect_app.command("stats")
|
|
271
|
+
def connect_stats(
|
|
272
|
+
json_output: bool = typer.Option(False, "--json", help="Emit JSON instead."),
|
|
273
|
+
) -> None:
|
|
274
|
+
"""Show pending / accepted / rejected suggestion counts."""
|
|
275
|
+
cfg = Config.load()
|
|
276
|
+
with connect(cfg.database_url) as conn:
|
|
277
|
+
counts = connect_mod.suggestion_counts(conn)
|
|
278
|
+
if json_output:
|
|
279
|
+
emit_json(counts)
|
|
280
|
+
return
|
|
281
|
+
typer.echo(
|
|
282
|
+
f"pending {counts['pending']} "
|
|
283
|
+
f"accepted {counts['accepted']} "
|
|
284
|
+
f"rejected {counts['rejected']}"
|
|
285
|
+
)
|
brain/cli_demo.py
ADDED
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
"""`brain demo` CLI sub-app — a zero-Ollama taste test with a synthetic corpus.
|
|
2
|
+
|
|
3
|
+
Thin Typer orchestration over :mod:`brain.demo`: the bare ``brain demo``
|
|
4
|
+
provisions an isolated throwaway Postgres (or seeds a caller-supplied
|
|
5
|
+
``--database-url``), seeds the 22-doc synthetic Larkspur corpus with the
|
|
6
|
+
deterministic :class:`~brain.demo.embedder.DemoEmbedder`, and runs the hero
|
|
7
|
+
query inline. Sub-commands ``query`` / ``status`` / ``teardown`` operate the
|
|
8
|
+
running sandbox. All provisioning + search primitives live in
|
|
9
|
+
:mod:`brain.demo`; this module only maps them to Rich/Typer output.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import logging
|
|
14
|
+
import shutil
|
|
15
|
+
from collections.abc import Iterator
|
|
16
|
+
from contextlib import contextmanager
|
|
17
|
+
from datetime import datetime
|
|
18
|
+
|
|
19
|
+
import typer
|
|
20
|
+
|
|
21
|
+
from . import demo as demo_mod
|
|
22
|
+
from .errors import BrainError
|
|
23
|
+
from .format import console, emit_json, search_table
|
|
24
|
+
from .search import SearchResult
|
|
25
|
+
|
|
26
|
+
# The headline query a first-time visitor sees ranked results for.
|
|
27
|
+
HERO_QUERY = "compliance horror stories"
|
|
28
|
+
|
|
29
|
+
# Valid host-port range for --port. Below 1024 needs root; above 65535 is not a
|
|
30
|
+
# port at all (an unbounded value would overflow deep inside socket.bind()).
|
|
31
|
+
_MIN_PORT = 1024
|
|
32
|
+
_MAX_PORT = 65535
|
|
33
|
+
|
|
34
|
+
# Follow-up query prompts printed after the hero query so the visitor keeps
|
|
35
|
+
# exploring (the show + teardown lines are appended dynamically).
|
|
36
|
+
_NEXT_STEPS: tuple[str, ...] = (
|
|
37
|
+
'brain demo query "SOC 2 evidence request"',
|
|
38
|
+
'brain demo query "PCI scope creep"',
|
|
39
|
+
'brain demo query "vendor risk" --source gmail',
|
|
40
|
+
'brain demo query "GDPR deletion request"',
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
# The exact guidance when Docker is missing and no --database-url was supplied.
|
|
44
|
+
_DOCKER_MISSING_MSG = (
|
|
45
|
+
"Docker not found — brain demo needs Docker, or pass --database-url "
|
|
46
|
+
"<postgres-url> to seed an existing empty database."
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
demo_app = typer.Typer(
|
|
50
|
+
name="demo",
|
|
51
|
+
help=(
|
|
52
|
+
"Zero-Ollama taste test: spin up a throwaway Postgres, seed a synthetic "
|
|
53
|
+
"compliance corpus, and search it — see ranked results in under two "
|
|
54
|
+
"minutes with no personal data and no model downloads."
|
|
55
|
+
),
|
|
56
|
+
invoke_without_command=True,
|
|
57
|
+
no_args_is_help=False,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _fail(message: str) -> None:
|
|
62
|
+
"""Print an error and exit non-zero (mirrors the cli_connect idiom)."""
|
|
63
|
+
typer.secho(message, fg="red", err=True)
|
|
64
|
+
raise typer.Exit(code=1)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@contextmanager
|
|
68
|
+
def _quiet_internal_logs() -> Iterator[None]:
|
|
69
|
+
"""Silence brain's INFO/WARNING logs for a clean demo transcript.
|
|
70
|
+
|
|
71
|
+
The seed path emits operational warnings (e.g. the Krisp hook noting no
|
|
72
|
+
``gws`` runner) that are irrelevant to a first-time visitor and make the
|
|
73
|
+
marketing demo look noisy. Real errors (ERROR+) still surface. The prior
|
|
74
|
+
level is restored on exit so this never leaks into a longer-lived process
|
|
75
|
+
(e.g. cross-test contamination under pytest).
|
|
76
|
+
"""
|
|
77
|
+
logger = logging.getLogger("brain")
|
|
78
|
+
previous = logger.level
|
|
79
|
+
logger.setLevel(logging.ERROR)
|
|
80
|
+
try:
|
|
81
|
+
yield
|
|
82
|
+
finally:
|
|
83
|
+
logger.setLevel(previous)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _render_results(
|
|
87
|
+
results: list[SearchResult], *, json_output: bool, query: str
|
|
88
|
+
) -> None:
|
|
89
|
+
"""Render hero/query results as JSON or a Rich table titled with ``query``."""
|
|
90
|
+
if json_output:
|
|
91
|
+
emit_json(
|
|
92
|
+
[
|
|
93
|
+
{
|
|
94
|
+
"id": r.document_id,
|
|
95
|
+
"title": r.title,
|
|
96
|
+
"source_kind": r.source_kind,
|
|
97
|
+
"snippet": r.snippet,
|
|
98
|
+
"score": r.score,
|
|
99
|
+
"content_type": r.content_type,
|
|
100
|
+
"tags": r.tags,
|
|
101
|
+
}
|
|
102
|
+
for r in results
|
|
103
|
+
]
|
|
104
|
+
)
|
|
105
|
+
return
|
|
106
|
+
if not results:
|
|
107
|
+
typer.echo("(no results)")
|
|
108
|
+
return
|
|
109
|
+
console.print(search_table(results, title=f"brain demo · {query!r}"))
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _print_next_steps(results: list[SearchResult]) -> None:
|
|
113
|
+
"""Print the "Try these next" block after the inline hero query."""
|
|
114
|
+
console.print("\n[bold]Try these next:[/bold]")
|
|
115
|
+
for suggestion in _NEXT_STEPS:
|
|
116
|
+
console.print(f" [cyan]{suggestion}[/cyan]")
|
|
117
|
+
if results:
|
|
118
|
+
short_id = results[0].document_id[:8]
|
|
119
|
+
console.print(
|
|
120
|
+
f" [cyan]brain show {short_id}[/cyan]"
|
|
121
|
+
" # read the top hit in full (in your own brain)"
|
|
122
|
+
)
|
|
123
|
+
console.print(" [cyan]brain demo teardown[/cyan] # remove the sandbox when done")
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _resolve_default_database_url(port: int, database_url: str | None) -> str:
|
|
127
|
+
"""Resolve the DB URL for the default flow: caller-supplied or provisioned.
|
|
128
|
+
|
|
129
|
+
With ``--database-url`` the demo seeds that database and never touches
|
|
130
|
+
Docker (the CI / power-user seam). Otherwise Docker is required: absent, we
|
|
131
|
+
exit with actionable guidance; present, we auto-bump off a busy port and
|
|
132
|
+
provision the isolated sandbox.
|
|
133
|
+
"""
|
|
134
|
+
if database_url is not None:
|
|
135
|
+
return database_url
|
|
136
|
+
if shutil.which("docker") is None:
|
|
137
|
+
_fail(_DOCKER_MISSING_MSG)
|
|
138
|
+
resolved_port = demo_mod.resolve_port(port)
|
|
139
|
+
typer.echo(f"Provisioning the demo Postgres on port {resolved_port} …")
|
|
140
|
+
return demo_mod.provision(resolved_port)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
@demo_app.callback(invoke_without_command=True)
|
|
144
|
+
def demo_default(
|
|
145
|
+
ctx: typer.Context,
|
|
146
|
+
port: int = typer.Option(
|
|
147
|
+
demo_mod.DEFAULT_DEMO_PORT, "--port", min=_MIN_PORT, max=_MAX_PORT,
|
|
148
|
+
help="Host port for the demo Postgres (auto-bumps if busy).",
|
|
149
|
+
),
|
|
150
|
+
with_embeddings: bool = typer.Option(
|
|
151
|
+
False, "--with-embeddings",
|
|
152
|
+
help="Also build vector embeddings + HNSW index (default: FTS-only).",
|
|
153
|
+
),
|
|
154
|
+
database_url: str | None = typer.Option(
|
|
155
|
+
None, "--database-url",
|
|
156
|
+
help="Seed this existing empty Postgres instead of provisioning Docker.",
|
|
157
|
+
),
|
|
158
|
+
json_output: bool = typer.Option(
|
|
159
|
+
False, "--json", help="Emit the hero-query results as JSON."
|
|
160
|
+
),
|
|
161
|
+
) -> None:
|
|
162
|
+
"""Provision, seed, and run the hero query inline (the default flow)."""
|
|
163
|
+
if ctx.invoked_subcommand is not None:
|
|
164
|
+
return
|
|
165
|
+
try:
|
|
166
|
+
with _quiet_internal_logs():
|
|
167
|
+
resolved_url = _resolve_default_database_url(port, database_url)
|
|
168
|
+
typer.echo("Seeding the synthetic Larkspur corpus (22 docs, no Ollama) …")
|
|
169
|
+
report = demo_mod.seed_demo(resolved_url, with_embeddings=with_embeddings)
|
|
170
|
+
typer.echo(
|
|
171
|
+
f"Seeded {report.ingested} new doc(s) "
|
|
172
|
+
f"({report.skipped} already present).\n"
|
|
173
|
+
)
|
|
174
|
+
results = demo_mod.query_demo(
|
|
175
|
+
resolved_url,
|
|
176
|
+
HERO_QUERY,
|
|
177
|
+
limit=5,
|
|
178
|
+
with_embeddings=with_embeddings,
|
|
179
|
+
)
|
|
180
|
+
except BrainError as exc:
|
|
181
|
+
_fail(str(exc))
|
|
182
|
+
_render_results(results, json_output=json_output, query=HERO_QUERY)
|
|
183
|
+
if not json_output:
|
|
184
|
+
_print_next_steps(results)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
@demo_app.command("query")
|
|
188
|
+
def demo_query(
|
|
189
|
+
query: str = typer.Argument(..., help="Search text to run over the demo corpus."),
|
|
190
|
+
limit: int = typer.Option(5, "--limit", "-n", min=1),
|
|
191
|
+
source: str | None = typer.Option(
|
|
192
|
+
None, "--source", help="Filter by source kind (manual/krisp/slack/gmail)."
|
|
193
|
+
),
|
|
194
|
+
tag: str | None = typer.Option(None, "--tag", help="Filter by tag."),
|
|
195
|
+
person: str | None = typer.Option(
|
|
196
|
+
None, "--person", help="Filter by a participant name (e.g. 'Priya Okafor')."
|
|
197
|
+
),
|
|
198
|
+
after: datetime | None = typer.Option(
|
|
199
|
+
None, "--after", formats=["%Y-%m-%d", "%Y-%m-%dT%H:%M:%S"],
|
|
200
|
+
help="Only docs dated on or after this ISO date.",
|
|
201
|
+
),
|
|
202
|
+
with_embeddings: bool = typer.Option(
|
|
203
|
+
False, "--with-embeddings", help="Use the vector leg too (default: FTS-only)."
|
|
204
|
+
),
|
|
205
|
+
json_output: bool = typer.Option(False, "--json", help="Emit JSON instead."),
|
|
206
|
+
port: int = typer.Option(
|
|
207
|
+
demo_mod.DEFAULT_DEMO_PORT, "--port", min=_MIN_PORT, max=_MAX_PORT,
|
|
208
|
+
help="Host port of the demo Postgres.",
|
|
209
|
+
),
|
|
210
|
+
database_url: str | None = typer.Option(
|
|
211
|
+
None, "--database-url", help="Query this Postgres instead of the demo container."
|
|
212
|
+
),
|
|
213
|
+
) -> None:
|
|
214
|
+
"""Search the seeded demo corpus (the running sandbox, or --database-url)."""
|
|
215
|
+
resolved_url = database_url or demo_mod.demo_database_url(port)
|
|
216
|
+
try:
|
|
217
|
+
results = demo_mod.query_demo(
|
|
218
|
+
resolved_url,
|
|
219
|
+
query,
|
|
220
|
+
limit=limit,
|
|
221
|
+
source=source,
|
|
222
|
+
tag=tag,
|
|
223
|
+
person=person,
|
|
224
|
+
after=after,
|
|
225
|
+
with_embeddings=with_embeddings,
|
|
226
|
+
)
|
|
227
|
+
except BrainError as exc:
|
|
228
|
+
_fail(str(exc))
|
|
229
|
+
_render_results(results, json_output=json_output, query=query)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
@demo_app.command("status")
|
|
233
|
+
def demo_status(
|
|
234
|
+
port: int = typer.Option(
|
|
235
|
+
demo_mod.DEFAULT_DEMO_PORT, "--port", min=_MIN_PORT, max=_MAX_PORT,
|
|
236
|
+
help="Host port of the demo Postgres.",
|
|
237
|
+
),
|
|
238
|
+
json_output: bool = typer.Option(False, "--json", help="Emit JSON instead."),
|
|
239
|
+
) -> None:
|
|
240
|
+
"""Report whether the demo sandbox is running and how many docs it holds."""
|
|
241
|
+
snapshot = demo_mod.status(port)
|
|
242
|
+
if json_output:
|
|
243
|
+
emit_json(
|
|
244
|
+
{
|
|
245
|
+
"running": snapshot.running,
|
|
246
|
+
"container": snapshot.container,
|
|
247
|
+
"database_url": snapshot.database_url,
|
|
248
|
+
"doc_count": snapshot.doc_count,
|
|
249
|
+
}
|
|
250
|
+
)
|
|
251
|
+
return
|
|
252
|
+
if not snapshot.running:
|
|
253
|
+
typer.echo("demo: not running (run `brain demo` to start it)")
|
|
254
|
+
return
|
|
255
|
+
docs = "unknown" if snapshot.doc_count is None else str(snapshot.doc_count)
|
|
256
|
+
typer.echo(f"demo: running · container {snapshot.container} · {docs} doc(s)")
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
@demo_app.command("teardown")
|
|
260
|
+
def demo_teardown() -> None:
|
|
261
|
+
"""Destroy the demo sandbox and its data (`docker compose down -v`)."""
|
|
262
|
+
try:
|
|
263
|
+
demo_mod.teardown()
|
|
264
|
+
except BrainError as exc:
|
|
265
|
+
_fail(str(exc))
|
|
266
|
+
typer.echo("demo: torn down (container + volume removed)")
|