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.
Files changed (273) hide show
  1. brain/__init__.py +0 -0
  2. brain/__main__.py +18 -0
  3. brain/_capture_command.py +445 -0
  4. brain/_compose.py +52 -0
  5. brain/activity.py +206 -0
  6. brain/ask.py +631 -0
  7. brain/audio.py +591 -0
  8. brain/backfill/__init__.py +12 -0
  9. brain/backfill/search_extras.py +141 -0
  10. brain/backfill/source_rows.py +101 -0
  11. brain/bin/__init__.py +1 -0
  12. brain/bin/_launcher.py +107 -0
  13. brain/bin/down.py +8 -0
  14. brain/bin/launchd.py +268 -0
  15. brain/bin/monitor.py +570 -0
  16. brain/bin/rebuild.py +8 -0
  17. brain/bin/status.py +8 -0
  18. brain/bin/up.py +8 -0
  19. brain/brief.py +272 -0
  20. brain/capture.py +49 -0
  21. brain/chat.py +293 -0
  22. brain/cli.py +9760 -0
  23. brain/cli_claude.py +81 -0
  24. brain/cli_connect.py +285 -0
  25. brain/cli_demo.py +266 -0
  26. brain/config.py +1949 -0
  27. brain/connect.py +925 -0
  28. brain/db.py +540 -0
  29. brain/demo/__init__.py +452 -0
  30. brain/demo/corpus/manifest.json +403 -0
  31. brain/demo/embedder.py +74 -0
  32. brain/durations.py +84 -0
  33. brain/edit_session.py +156 -0
  34. brain/editor.py +67 -0
  35. brain/elicit/__init__.py +16 -0
  36. brain/elicit/detectors.py +250 -0
  37. brain/elicit/drafter.py +70 -0
  38. brain/elicit/queue.py +220 -0
  39. brain/elicit/schema.py +48 -0
  40. brain/elicit/session.py +445 -0
  41. brain/embedding_targets.py +54 -0
  42. brain/embeddings.py +424 -0
  43. brain/enrichment.py +808 -0
  44. brain/errors.py +357 -0
  45. brain/eval/__init__.py +129 -0
  46. brain/eval/answer_eval.py +281 -0
  47. brain/eval/baseline.py +265 -0
  48. brain/eval/concept_extraction.py +378 -0
  49. brain/eval/corpus.py +152 -0
  50. brain/eval/errors.py +19 -0
  51. brain/eval/graph_baseline.py +226 -0
  52. brain/eval/graph_retrieval.py +202 -0
  53. brain/eval/graph_runner.py +319 -0
  54. brain/eval/metrics.py +101 -0
  55. brain/eval/runner.py +223 -0
  56. brain/format.py +783 -0
  57. brain/gaps.py +390 -0
  58. brain/graph_rag/__init__.py +94 -0
  59. brain/graph_rag/_retrieval_common.py +113 -0
  60. brain/graph_rag/aggregates.py +303 -0
  61. brain/graph_rag/aliases/__init__.py +583 -0
  62. brain/graph_rag/backends/__init__.py +10 -0
  63. brain/graph_rag/backends/_age_helpers.py +473 -0
  64. brain/graph_rag/backends/age.py +782 -0
  65. brain/graph_rag/backends/base.py +272 -0
  66. brain/graph_rag/build.py +344 -0
  67. brain/graph_rag/communities.py +644 -0
  68. brain/graph_rag/communities_summary.py +437 -0
  69. brain/graph_rag/concepts.py +202 -0
  70. brain/graph_rag/cooccur.py +193 -0
  71. brain/graph_rag/cross_type.py +312 -0
  72. brain/graph_rag/extract.py +885 -0
  73. brain/graph_rag/fuse.py +371 -0
  74. brain/graph_rag/global_.py +412 -0
  75. brain/graph_rag/grouping.py +372 -0
  76. brain/graph_rag/person_resolver.py +167 -0
  77. brain/graph_rag/reconcile.py +792 -0
  78. brain/graph_rag/relational.py +353 -0
  79. brain/graph_rag/retrieve.py +526 -0
  80. brain/graph_rag/router.py +288 -0
  81. brain/graph_rag/schema.py +320 -0
  82. brain/graph_rag/sync.py +237 -0
  83. brain/graph_rag/tenancy.py +43 -0
  84. brain/graph_rag/themes.py +501 -0
  85. brain/graph_rag/weighting.py +202 -0
  86. brain/ingest/__init__.py +1926 -0
  87. brain/ingest/chunker.py +249 -0
  88. brain/ingest/docx.py +40 -0
  89. brain/ingest/gmail.py +621 -0
  90. brain/ingest/markdown.py +37 -0
  91. brain/ingest/pdf.py +61 -0
  92. brain/ingest/stdin.py +22 -0
  93. brain/ingest/sub_tokens.py +91 -0
  94. brain/ingest/text.py +16 -0
  95. brain/interactions.py +205 -0
  96. brain/maintenance.py +355 -0
  97. brain/mcp_server.py +3405 -0
  98. brain/migrations/001_init.sql +43 -0
  99. brain/migrations/002_qwen3_embedding.sql +17 -0
  100. brain/migrations/003_vault_model.sql +41 -0
  101. brain/migrations/004_relax_content_hash_uniqueness.sql +18 -0
  102. brain/migrations/005_derived_links.sql +67 -0
  103. brain/migrations/006_dedup_file_by_source_path.sql +25 -0
  104. brain/migrations/007_email_thread_and_draft.sql +15 -0
  105. brain/migrations/008_gmail_thread_unique.sql +11 -0
  106. brain/migrations/009_chunks_weighted_tsv.sql +28 -0
  107. brain/migrations/010_interactions.sql +30 -0
  108. brain/migrations/011_documents_summary.sql +23 -0
  109. brain/migrations/012_graphrag.sql +171 -0
  110. brain/migrations/013_graphrag_communities.sql +125 -0
  111. brain/migrations/014_graphrag_community_summary_hash.sql +33 -0
  112. brain/migrations/015_interactions_graph_targets.sql +89 -0
  113. brain/migrations/016_index_hygiene.sql +61 -0
  114. brain/migrations/017_elicit.sql +30 -0
  115. brain/migrations/018_review_gap_signal_kinds.sql +40 -0
  116. brain/migrations/019_search_queries.sql +35 -0
  117. brain/migrations/020_link_suggestions.sql +40 -0
  118. brain/migrations/021_timeline_doc_date.sql +34 -0
  119. brain/migrations/022_link_suggestions_undirected.sql +84 -0
  120. brain/migrations/023_search_queries_fts_count.sql +28 -0
  121. brain/quartz_overrides/__init__.py +8 -0
  122. brain/quartz_overrides/quartz/bootstrap-cli.mjs +65 -0
  123. brain/quartz_overrides/quartz/build.ts +568 -0
  124. brain/quartz_overrides/quartz/cli/args.js +152 -0
  125. brain/quartz_overrides/quartz/cli/build_partial_handler.js +544 -0
  126. brain/quartz_overrides/quartz/cli/handlers.js +636 -0
  127. brain/quartz_overrides/quartz/components/CommandPalette.tsx +172 -0
  128. brain/quartz_overrides/quartz/components/Explorer.tsx +198 -0
  129. brain/quartz_overrides/quartz/components/Footer.tsx +27 -0
  130. brain/quartz_overrides/quartz/components/Graph.tsx +468 -0
  131. brain/quartz_overrides/quartz/components/PageTitle.tsx +72 -0
  132. brain/quartz_overrides/quartz/components/RelatedDocs.tsx +38 -0
  133. brain/quartz_overrides/quartz/components/Search.tsx +161 -0
  134. brain/quartz_overrides/quartz/components/SummaryLede.tsx +72 -0
  135. brain/quartz_overrides/quartz/components/index.ts +92 -0
  136. brain/quartz_overrides/quartz/components/pages/TagContent.tsx +272 -0
  137. brain/quartz_overrides/quartz/components/scripts/commandPalette.inline.ts +665 -0
  138. brain/quartz_overrides/quartz/components/scripts/explorer.inline.ts +768 -0
  139. brain/quartz_overrides/quartz/components/scripts/graph.inline.ts +2302 -0
  140. brain/quartz_overrides/quartz/components/scripts/relatedDocs.inline.ts +163 -0
  141. brain/quartz_overrides/quartz/components/scripts/search.inline.ts +1011 -0
  142. brain/quartz_overrides/quartz/plugins/emitters/contentIndex.ts +546 -0
  143. brain/quartz_overrides/quartz/plugins/transformers/codeCopy.ts +94 -0
  144. brain/quartz_overrides/quartz/plugins/transformers/derivedFenceMark.ts +302 -0
  145. brain/quartz_overrides/quartz/plugins/transformers/emailThread.ts +148 -0
  146. brain/quartz_overrides/quartz/plugins/transformers/emptyDoorFilter.ts +213 -0
  147. brain/quartz_overrides/quartz/plugins/transformers/index.ts +114 -0
  148. brain/quartz_overrides/quartz/plugins/transformers/linkKindMark.ts +205 -0
  149. brain/quartz_overrides/quartz/plugins/transformers/linkSourceTag.ts +104 -0
  150. brain/quartz_overrides/quartz/plugins/transformers/relativeDate.ts +100 -0
  151. brain/quartz_overrides/quartz/plugins/transformers/reloadSignal.ts +131 -0
  152. brain/quartz_overrides/quartz/processors/parse.ts +371 -0
  153. brain/quartz_overrides/quartz/processors/parser_cache.ts +78 -0
  154. brain/quartz_overrides/quartz/static/brain-logo-dark.png +0 -0
  155. brain/quartz_overrides/quartz/static/brain-logo-light.png +0 -0
  156. brain/quartz_overrides/quartz/static/codeCopy.js +196 -0
  157. brain/quartz_overrides/quartz/static/emailThread.js +334 -0
  158. brain/quartz_overrides/quartz/static/favicon.ico +0 -0
  159. brain/quartz_overrides/quartz/static/icon.png +0 -0
  160. brain/quartz_overrides/quartz/static/linkSourceTag.js +104 -0
  161. brain/quartz_overrides/quartz/static/relativeDate.js +142 -0
  162. brain/quartz_overrides/quartz/static/reload.js +168 -0
  163. brain/quartz_overrides/quartz/styles/brain/_article.scss +252 -0
  164. brain/quartz_overrides/quartz/styles/brain/_atmosphere.scss +113 -0
  165. brain/quartz_overrides/quartz/styles/brain/_callouts.scss +180 -0
  166. brain/quartz_overrides/quartz/styles/brain/_cmdk.scss +7 -0
  167. brain/quartz_overrides/quartz/styles/brain/_code.scss +208 -0
  168. brain/quartz_overrides/quartz/styles/brain/_command_palette.scss +369 -0
  169. brain/quartz_overrides/quartz/styles/brain/_email_thread.scss +228 -0
  170. brain/quartz_overrides/quartz/styles/brain/_explorer.scss +142 -0
  171. brain/quartz_overrides/quartz/styles/brain/_home.scss +182 -0
  172. brain/quartz_overrides/quartz/styles/brain/_links.scss +322 -0
  173. brain/quartz_overrides/quartz/styles/brain/_marginalia.scss +117 -0
  174. brain/quartz_overrides/quartz/styles/brain/_motion.scss +175 -0
  175. brain/quartz_overrides/quartz/styles/brain/_people_hub.scss +100 -0
  176. brain/quartz_overrides/quartz/styles/brain/_related_docs.scss +137 -0
  177. brain/quartz_overrides/quartz/styles/brain/_search.scss +252 -0
  178. brain/quartz_overrides/quartz/styles/brain/_sidebar.scss +468 -0
  179. brain/quartz_overrides/quartz/styles/brain/_summary_lede.scss +56 -0
  180. brain/quartz_overrides/quartz/styles/brain/_surface.scss +43 -0
  181. brain/quartz_overrides/quartz/styles/brain/_tag_content.scss +118 -0
  182. brain/quartz_overrides/quartz/styles/brain/_tokens.scss +197 -0
  183. brain/quartz_overrides/quartz/styles/brain/_typography.scss +92 -0
  184. brain/quartz_overrides/quartz/styles/custom.scss +89 -0
  185. brain/quartz_overrides/quartz/styles/graph.scss +505 -0
  186. brain/quartz_overrides/quartz/util/ctx.ts +92 -0
  187. brain/quartz_overrides/quartz/util/fastpath_manifest.ts +608 -0
  188. brain/quartz_overrides/quartz/util/path.ts +358 -0
  189. brain/quartz_overrides/quartz/util/sourceIcons.ts +55 -0
  190. brain/quartz_overrides/quartz.config.ts +270 -0
  191. brain/quartz_overrides/quartz.layout.ts +314 -0
  192. brain/queries.py +1188 -0
  193. brain/rank_fusion.py +8 -0
  194. brain/resurface.py +210 -0
  195. brain/review/__init__.py +26 -0
  196. brain/review/emit.py +27 -0
  197. brain/review/queries.py +436 -0
  198. brain/review/render.py +196 -0
  199. brain/review/scans.py +355 -0
  200. brain/review/weekly.py +413 -0
  201. brain/search.py +704 -0
  202. brain/set_similarity.py +15 -0
  203. brain/setup.py +1205 -0
  204. brain/tags.py +56 -0
  205. brain/templates/Caddyfile.j2 +9 -0
  206. brain/templates/__init__.py +1 -0
  207. brain/templates/bin/__init__.py +1 -0
  208. brain/templates/bin/_brain-brief-fg.sh +25 -0
  209. brain/templates/bin/_brain-build-fg.sh +53 -0
  210. brain/templates/bin/_brain-watcher-fg.sh +65 -0
  211. brain/templates/bin/brain-down.sh +89 -0
  212. brain/templates/bin/brain-status.sh +83 -0
  213. brain/templates/bin/brain-up.sh +221 -0
  214. brain/templates/docker/age/Dockerfile +79 -0
  215. brain/templates/docker-compose.stock.yml.j2 +26 -0
  216. brain/templates/docker-compose.yml.j2 +34 -0
  217. brain/templates/env.example +190 -0
  218. brain/templates/launchd/__init__.py +1 -0
  219. brain/templates/launchd/com.brain.brief.plist.j2 +45 -0
  220. brain/templates/launchd/com.brain.build.plist.j2 +46 -0
  221. brain/templates/launchd/com.brain.watcher.plist.j2 +46 -0
  222. brain/templates/skill/SKILL.md +63 -0
  223. brain/templates/skill/__init__.py +1 -0
  224. brain/timeline.py +834 -0
  225. brain/todo.py +124 -0
  226. brain/uninstall.py +185 -0
  227. brain/vault/__init__.py +115 -0
  228. brain/vault/_atomic.py +25 -0
  229. brain/vault/daily_index.py +228 -0
  230. brain/vault/derived_links/__init__.py +50 -0
  231. brain/vault/derived_links/directory.py +683 -0
  232. brain/vault/derived_links/fence.py +408 -0
  233. brain/vault/derived_links/gws.py +64 -0
  234. brain/vault/derived_links/participants.py +143 -0
  235. brain/vault/derived_links/pass_runner.py +362 -0
  236. brain/vault/derived_links/rules.py +137 -0
  237. brain/vault/export.py +683 -0
  238. brain/vault/frontmatter.py +165 -0
  239. brain/vault/graph.py +620 -0
  240. brain/vault/graph_format.py +388 -0
  241. brain/vault/link_rewrite.py +235 -0
  242. brain/vault/links.py +260 -0
  243. brain/vault/note_builder.py +211 -0
  244. brain/vault/paths.py +55 -0
  245. brain/vault/quartz_overlay.py +236 -0
  246. brain/vault/rename.py +591 -0
  247. brain/vault/resolver.py +304 -0
  248. brain/vault/slug.py +127 -0
  249. brain/vault/sync.py +1513 -0
  250. brain/vault/sync_summaries.py +264 -0
  251. brain/vault/templates.py +145 -0
  252. brain/vault/watch.py +1052 -0
  253. brain/wiki/__init__.py +6 -0
  254. brain/wiki/_github_slugger.py +76 -0
  255. brain/wiki/_person_name.py +314 -0
  256. brain/wiki/build_homepage.py +541 -0
  257. brain/wiki/build_partial.py +273 -0
  258. brain/wiki/build_people.py +934 -0
  259. brain/wiki/build_related.py +758 -0
  260. brain/wiki/build_swap.py +585 -0
  261. brain/wiki/build_watcher.py +975 -0
  262. brain/wiki/edit_classifier.py +215 -0
  263. brain/wiki/errors.py +10 -0
  264. brain/wiki/fastpath_manifest.py +475 -0
  265. brain/wiki/fastpath_state.py +174 -0
  266. brain/wiki/install.py +296 -0
  267. brain/wiki/slug.py +111 -0
  268. secondbrain_py-0.2.1.dist-info/METADATA +195 -0
  269. secondbrain_py-0.2.1.dist-info/RECORD +273 -0
  270. secondbrain_py-0.2.1.dist-info/WHEEL +5 -0
  271. secondbrain_py-0.2.1.dist-info/entry_points.txt +11 -0
  272. secondbrain_py-0.2.1.dist-info/licenses/LICENSE +21 -0
  273. 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)")