brainpick 0.4.2__tar.gz → 0.4.4__tar.gz

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 (105) hide show
  1. {brainpick-0.4.2 → brainpick-0.4.4}/PKG-INFO +1 -1
  2. {brainpick-0.4.2 → brainpick-0.4.4}/pyproject.toml +1 -1
  3. {brainpick-0.4.2 → brainpick-0.4.4}/src/brainpick/__init__.py +1 -1
  4. {brainpick-0.4.2 → brainpick-0.4.4}/src/brainpick/_skill/SKILL.md +22 -1
  5. {brainpick-0.4.2 → brainpick-0.4.4}/src/brainpick/cli.py +22 -8
  6. {brainpick-0.4.2 → brainpick-0.4.4}/src/brainpick/mcp_server.py +65 -16
  7. {brainpick-0.4.2 → brainpick-0.4.4}/src/brainpick.egg-info/PKG-INFO +1 -1
  8. {brainpick-0.4.2 → brainpick-0.4.4}/tests/test_e2e_mcp.py +8 -0
  9. {brainpick-0.4.2 → brainpick-0.4.4}/tests/test_e2e_serve.py +1 -1
  10. {brainpick-0.4.2 → brainpick-0.4.4}/tests/test_federation.py +5 -1
  11. {brainpick-0.4.2 → brainpick-0.4.4}/tests/test_mcp_tools.py +103 -5
  12. {brainpick-0.4.2 → brainpick-0.4.4}/README.md +0 -0
  13. {brainpick-0.4.2 → brainpick-0.4.4}/setup.cfg +0 -0
  14. {brainpick-0.4.2 → brainpick-0.4.4}/src/brainpick/__main__.py +0 -0
  15. {brainpick-0.4.2 → brainpick-0.4.4}/src/brainpick/_static/assets/Editor-x4G_0KV0.js +0 -0
  16. {brainpick-0.4.2 → brainpick-0.4.4}/src/brainpick/_static/assets/index-CTcBQZsl.js +0 -0
  17. {brainpick-0.4.2 → brainpick-0.4.4}/src/brainpick/_static/assets/index-Dn6TTKMy.css +0 -0
  18. {brainpick-0.4.2 → brainpick-0.4.4}/src/brainpick/_static/assets/virtual_pwa-register-Cb81Y-Xh.js +0 -0
  19. {brainpick-0.4.2 → brainpick-0.4.4}/src/brainpick/_static/assets/workbox-window.prod.es5-BBnX5xw4.js +0 -0
  20. {brainpick-0.4.2 → brainpick-0.4.4}/src/brainpick/_static/assets/worker-CVB4_RMu.js +0 -0
  21. {brainpick-0.4.2 → brainpick-0.4.4}/src/brainpick/_static/icons/apple-touch-icon.png +0 -0
  22. {brainpick-0.4.2 → brainpick-0.4.4}/src/brainpick/_static/icons/favicon.svg +0 -0
  23. {brainpick-0.4.2 → brainpick-0.4.4}/src/brainpick/_static/icons/icon-192.png +0 -0
  24. {brainpick-0.4.2 → brainpick-0.4.4}/src/brainpick/_static/icons/icon-512.png +0 -0
  25. {brainpick-0.4.2 → brainpick-0.4.4}/src/brainpick/_static/icons/icon-maskable-512.png +0 -0
  26. {brainpick-0.4.2 → brainpick-0.4.4}/src/brainpick/_static/index.html +0 -0
  27. {brainpick-0.4.2 → brainpick-0.4.4}/src/brainpick/_static/manifest.webmanifest +0 -0
  28. {brainpick-0.4.2 → brainpick-0.4.4}/src/brainpick/_static/sw.js +0 -0
  29. {brainpick-0.4.2 → brainpick-0.4.4}/src/brainpick/_static/workbox-a3c94b52.js +0 -0
  30. {brainpick-0.4.2 → brainpick-0.4.4}/src/brainpick/auth.py +0 -0
  31. {brainpick-0.4.2 → brainpick-0.4.4}/src/brainpick/compile/__init__.py +0 -0
  32. {brainpick-0.4.2 → brainpick-0.4.4}/src/brainpick/compile/pipeline.py +0 -0
  33. {brainpick-0.4.2 → brainpick-0.4.4}/src/brainpick/compile/similarity_gaps.py +0 -0
  34. {brainpick-0.4.2 → brainpick-0.4.4}/src/brainpick/compile/t1.py +0 -0
  35. {brainpick-0.4.2 → brainpick-0.4.4}/src/brainpick/compile/t2.py +0 -0
  36. {brainpick-0.4.2 → brainpick-0.4.4}/src/brainpick/compile/t3.py +0 -0
  37. {brainpick-0.4.2 → brainpick-0.4.4}/src/brainpick/config.py +0 -0
  38. {brainpick-0.4.2 → brainpick-0.4.4}/src/brainpick/core/__init__.py +0 -0
  39. {brainpick-0.4.2 → brainpick-0.4.4}/src/brainpick/core/bundle.py +0 -0
  40. {brainpick-0.4.2 → brainpick-0.4.4}/src/brainpick/core/canonical.py +0 -0
  41. {brainpick-0.4.2 → brainpick-0.4.4}/src/brainpick/core/frontmatter.py +0 -0
  42. {brainpick-0.4.2 → brainpick-0.4.4}/src/brainpick/core/fs.py +0 -0
  43. {brainpick-0.4.2 → brainpick-0.4.4}/src/brainpick/core/links.py +0 -0
  44. {brainpick-0.4.2 → brainpick-0.4.4}/src/brainpick/deltas.py +0 -0
  45. {brainpick-0.4.2 → brainpick-0.4.4}/src/brainpick/detect.py +0 -0
  46. {brainpick-0.4.2 → brainpick-0.4.4}/src/brainpick/embed.py +0 -0
  47. {brainpick-0.4.2 → brainpick-0.4.4}/src/brainpick/federation.py +0 -0
  48. {brainpick-0.4.2 → brainpick-0.4.4}/src/brainpick/integrate.py +0 -0
  49. {brainpick-0.4.2 → brainpick-0.4.4}/src/brainpick/kg.py +0 -0
  50. {brainpick-0.4.2 → brainpick-0.4.4}/src/brainpick/kgadapt/__init__.py +0 -0
  51. {brainpick-0.4.2 → brainpick-0.4.4}/src/brainpick/kgadapt/algorithmic.py +0 -0
  52. {brainpick-0.4.2 → brainpick-0.4.4}/src/brainpick/kgadapt/protocol.py +0 -0
  53. {brainpick-0.4.2 → brainpick-0.4.4}/src/brainpick/llm.py +0 -0
  54. {brainpick-0.4.2 → brainpick-0.4.4}/src/brainpick/merge.py +0 -0
  55. {brainpick-0.4.2 → brainpick-0.4.4}/src/brainpick/query/__init__.py +0 -0
  56. {brainpick-0.4.2 → brainpick-0.4.4}/src/brainpick/query/keyword.py +0 -0
  57. {brainpick-0.4.2 → brainpick-0.4.4}/src/brainpick/query/present.py +0 -0
  58. {brainpick-0.4.2 → brainpick-0.4.4}/src/brainpick/query/router.py +0 -0
  59. {brainpick-0.4.2 → brainpick-0.4.4}/src/brainpick/query/vectors.py +0 -0
  60. {brainpick-0.4.2 → brainpick-0.4.4}/src/brainpick/scaffold.py +0 -0
  61. {brainpick-0.4.2 → brainpick-0.4.4}/src/brainpick/serve/__init__.py +0 -0
  62. {brainpick-0.4.2 → brainpick-0.4.4}/src/brainpick/serve/app.py +0 -0
  63. {brainpick-0.4.2 → brainpick-0.4.4}/src/brainpick/serve/live.py +0 -0
  64. {brainpick-0.4.2 → brainpick-0.4.4}/src/brainpick/serve/rest.py +0 -0
  65. {brainpick-0.4.2 → brainpick-0.4.4}/src/brainpick/serve/state.py +0 -0
  66. {brainpick-0.4.2 → brainpick-0.4.4}/src/brainpick/serve/watcher.py +0 -0
  67. {brainpick-0.4.2 → brainpick-0.4.4}/src/brainpick/timeline.py +0 -0
  68. {brainpick-0.4.2 → brainpick-0.4.4}/src/brainpick/vectorstore.py +0 -0
  69. {brainpick-0.4.2 → brainpick-0.4.4}/src/brainpick.egg-info/SOURCES.txt +0 -0
  70. {brainpick-0.4.2 → brainpick-0.4.4}/src/brainpick.egg-info/dependency_links.txt +0 -0
  71. {brainpick-0.4.2 → brainpick-0.4.4}/src/brainpick.egg-info/entry_points.txt +0 -0
  72. {brainpick-0.4.2 → brainpick-0.4.4}/src/brainpick.egg-info/requires.txt +0 -0
  73. {brainpick-0.4.2 → brainpick-0.4.4}/src/brainpick.egg-info/top_level.txt +0 -0
  74. {brainpick-0.4.2 → brainpick-0.4.4}/tests/test_auth.py +0 -0
  75. {brainpick-0.4.2 → brainpick-0.4.4}/tests/test_bundle.py +0 -0
  76. {brainpick-0.4.2 → brainpick-0.4.4}/tests/test_cli.py +0 -0
  77. {brainpick-0.4.2 → brainpick-0.4.4}/tests/test_config.py +0 -0
  78. {brainpick-0.4.2 → brainpick-0.4.4}/tests/test_conformance.py +0 -0
  79. {brainpick-0.4.2 → brainpick-0.4.4}/tests/test_deltas.py +0 -0
  80. {brainpick-0.4.2 → brainpick-0.4.4}/tests/test_detect.py +0 -0
  81. {brainpick-0.4.2 → brainpick-0.4.4}/tests/test_frontmatter.py +0 -0
  82. {brainpick-0.4.2 → brainpick-0.4.4}/tests/test_integrate.py +0 -0
  83. {brainpick-0.4.2 → brainpick-0.4.4}/tests/test_keyword.py +0 -0
  84. {brainpick-0.4.2 → brainpick-0.4.4}/tests/test_kg.py +0 -0
  85. {brainpick-0.4.2 → brainpick-0.4.4}/tests/test_links.py +0 -0
  86. {brainpick-0.4.2 → brainpick-0.4.4}/tests/test_llm.py +0 -0
  87. {brainpick-0.4.2 → brainpick-0.4.4}/tests/test_merge.py +0 -0
  88. {brainpick-0.4.2 → brainpick-0.4.4}/tests/test_pipeline.py +0 -0
  89. {brainpick-0.4.2 → brainpick-0.4.4}/tests/test_present.py +0 -0
  90. {brainpick-0.4.2 → brainpick-0.4.4}/tests/test_report.py +0 -0
  91. {brainpick-0.4.2 → brainpick-0.4.4}/tests/test_router.py +0 -0
  92. {brainpick-0.4.2 → brainpick-0.4.4}/tests/test_scaffold.py +0 -0
  93. {brainpick-0.4.2 → brainpick-0.4.4}/tests/test_serve_state.py +0 -0
  94. {brainpick-0.4.2 → brainpick-0.4.4}/tests/test_similarity_gaps.py +0 -0
  95. {brainpick-0.4.2 → brainpick-0.4.4}/tests/test_similarity_gaps_stage.py +0 -0
  96. {brainpick-0.4.2 → brainpick-0.4.4}/tests/test_t1.py +0 -0
  97. {brainpick-0.4.2 → brainpick-0.4.4}/tests/test_t2_chunker.py +0 -0
  98. {brainpick-0.4.2 → brainpick-0.4.4}/tests/test_t2_embed.py +0 -0
  99. {brainpick-0.4.2 → brainpick-0.4.4}/tests/test_t2_stage.py +0 -0
  100. {brainpick-0.4.2 → brainpick-0.4.4}/tests/test_t2_store.py +0 -0
  101. {brainpick-0.4.2 → brainpick-0.4.4}/tests/test_t3_algorithmic.py +0 -0
  102. {brainpick-0.4.2 → brainpick-0.4.4}/tests/test_t3_backend.py +0 -0
  103. {brainpick-0.4.2 → brainpick-0.4.4}/tests/test_t3_export.py +0 -0
  104. {brainpick-0.4.2 → brainpick-0.4.4}/tests/test_t3_stage.py +0 -0
  105. {brainpick-0.4.2 → brainpick-0.4.4}/tests/test_timeline.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: brainpick
3
- Version: 0.4.2
3
+ Version: 0.4.4
4
4
  Summary: A turn-key brain stack for agents: compile an OKF markdown bundle into a queryable, servable, visualizable brain.
5
5
  Author: Benquemax
6
6
  License: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "brainpick"
7
- version = "0.4.2"
7
+ version = "0.4.4"
8
8
  description = "A turn-key brain stack for agents: compile an OKF markdown bundle into a queryable, servable, visualizable brain."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -1,4 +1,4 @@
1
1
  """brainpick — a turn-key brain stack for agents."""
2
2
 
3
- __version__ = "0.4.2"
3
+ __version__ = "0.4.4"
4
4
  SPEC_VERSION = "0.1"
@@ -17,7 +17,7 @@ the brain comes up short.
17
17
 
18
18
  Start every session with one call to get oriented, then search.
19
19
 
20
- ## The five MCP tools
20
+ ## The six MCP tools
21
21
 
22
22
  Call these if a `brainpick` MCP server is connected (tools are named `brain_*`):
23
23
 
@@ -26,6 +26,7 @@ Call these if a `brainpick` MCP server is connected (tools are named `brain_*`):
26
26
  - `brain_read({doc})` — open one doc. `doc` is forgiving: a path (`kuu.md`), a bare stem (`kuu`), or an approximate title. Pass `sections:["Heading"]` to read just parts.
27
27
  - `brain_neighbors({doc})` — walk the links around a doc (`depth` 1–3). Find what connects to what.
28
28
  - `brain_write({doc, content})` — add a doc, guarded by the repo's contract. See "Writing" below.
29
+ - `brain_show({nodes})` — spotlight a subgraph live in an open brainpick UI: highlight nodes, fly the camera, caption it. "Let me explain" becomes "let me show you." See "Showing the brain" below — **it needs a running `brainpick serve`, unlike the other five.**
29
30
 
30
31
  Every result carries a `hint` naming a sensible next call. Follow it.
31
32
 
@@ -62,6 +63,26 @@ federated:
62
63
  - `brain_write` with a bare target writes to the project you are in; qualify it to
63
64
  write elsewhere. It never guesses.
64
65
 
66
+ ## Showing the brain (brain_show)
67
+
68
+ Use `brain_show` when explaining beats showing — "let me show you" instead of a
69
+ wall of text. It highlights nodes, flies the camera, and drops a caption into
70
+ every **connected** brainpick UI. It never writes the brain.
71
+
72
+ **It needs a running `brainpick serve`.** The other five tools work over a plain
73
+ stdio MCP connection (`claude mcp add brainpick -- brainpick mcp --root <bundle>`,
74
+ the common setup) because they only read compiled artifacts. `brain_show`
75
+ broadcasts to a live browser — and a stdio-connected MCP server has none attached;
76
+ it builds its own private, unshared state. Call it over stdio today and you get
77
+ back `{"ok": true, "shown": N}` while nothing appears anywhere — a false positive,
78
+ not a "no UI open" signal.
79
+
80
+ To actually see it: start `brainpick serve --root <bundle>` first (a browser tab
81
+ open to it, or `POST /api/show` with the same body, both work), or connect your
82
+ MCP client over HTTP to that server's `/mcp` endpoint instead of stdio. If you
83
+ called `brain_show` and the human says they saw nothing, don't trust the `"ok"`
84
+ — ask what's actually running before repeating the call.
85
+
65
86
  ## Writing knowledge back (brain_write)
66
87
 
67
88
  Only write when asked to record knowledge. Follow the wiki's conventions or the
@@ -229,10 +229,24 @@ def _cmd_integrate(args: argparse.Namespace) -> int:
229
229
  return run_integrate(args.target, Path(args.root), dry_run=args.dry_run)
230
230
 
231
231
 
232
- def post_show(base_url: str, body: dict, token: str | None = None) -> tuple[dict | None, str | None]:
232
+ def connectable_host(host: str) -> str:
233
+ """A server may bind 0.0.0.0/:: (everywhere); a client must connect via a
234
+ real address — shared by the CLI's `show` and brain_show's server-proxy
235
+ attempt (mcp_server.py) so both resolve a running `brainpick serve` the
236
+ same way."""
237
+ return "127.0.0.1" if host in ("0.0.0.0", "::") else host
238
+
239
+
240
+ def post_show(base_url: str, body: dict,
241
+ token: str | None = None) -> tuple[dict | None, str | None, bool]:
233
242
  """POST a presentation body to a running server's /api/show (spec/95). Returns
234
- (response, None) or (None, instruction) — the CLI is a client here, never
235
- resolving locally: the live server resolves and broadcasts to open UIs."""
243
+ (response, None, False) on success, or (None, instruction, unreachable) — the
244
+ caller is a client here, never resolving locally: the live server resolves and
245
+ broadcasts to open UIs. `unreachable` is True only when nothing answered at all
246
+ (connection refused/DNS/timeout) — a caller that wants to fall back to a local,
247
+ UI-less presentation must check it, so a server that's up but REJECTS the
248
+ request (bad auth, bad body) is never mistaken for "no server running" and
249
+ silently papered over."""
236
250
  import json
237
251
  import urllib.error
238
252
  import urllib.request
@@ -244,17 +258,17 @@ def post_show(base_url: str, body: dict, token: str | None = None) -> tuple[dict
244
258
  request = urllib.request.Request(f"{base_url}/api/show", data=data, headers=headers, method="POST")
245
259
  try:
246
260
  with urllib.request.urlopen(request, timeout=10) as response: # noqa: S310 (localhost control-plane)
247
- return json.loads(response.read().decode("utf-8")), None
261
+ return json.loads(response.read().decode("utf-8")), None, False
248
262
  except urllib.error.HTTPError as error:
249
263
  detail = error.read().decode("utf-8", errors="replace")
250
264
  try:
251
265
  message = json.loads(detail).get("error", detail)
252
266
  except (ValueError, AttributeError):
253
267
  message = detail
254
- return None, f"the server rejected the presentation ({error.code}): {message}"
268
+ return None, f"the server rejected the presentation ({error.code}): {message}", False
255
269
  except urllib.error.URLError as error:
256
270
  return None, (f"no brainpick server at {base_url} — start one with 'brainpick serve' "
257
- f"({error.reason})")
271
+ f"({error.reason})"), True
258
272
 
259
273
 
260
274
  def _cmd_show(args: argparse.Namespace) -> int:
@@ -265,7 +279,7 @@ def _cmd_show(args: argparse.Namespace) -> int:
265
279
  config = load_config(root)
266
280
  host = args.host or config.serve.host
267
281
  port = args.port or config.serve.port
268
- display_host = "127.0.0.1" if host in ("0.0.0.0", "::") else host
282
+ display_host = connectable_host(host)
269
283
  base_url = f"http://{display_host}:{port}"
270
284
  token = args.token or (config.serve.token or None)
271
285
 
@@ -279,7 +293,7 @@ def _cmd_show(args: argparse.Namespace) -> int:
279
293
  if args.clear:
280
294
  body["clear"] = True
281
295
 
282
- result, error = post_show(base_url, body, token)
296
+ result, error, _unreachable = post_show(base_url, body, token)
283
297
  if error is not None:
284
298
  if args.json:
285
299
  print(to_json({"error": error, "hint": "start the server with: brainpick serve"}))
@@ -575,38 +575,87 @@ def _single_write(state: ServeState, doc: str, content: str, mode: str = "create
575
575
  # -- brain_show ----------------------------------------------------------------------
576
576
 
577
577
 
578
- def show_hint(presentation: dict, dropped: list[str]) -> str:
579
- """The 'what to do next' line for brain_show (spec/95 small-LLM ergonomics)."""
580
- cleared = (not presentation["nodes"] and presentation["focus"] is None
581
- and presentation["mode"] is None and presentation["annotation"] is None)
578
+ NO_SERVER_CAVEAT = (" No brainpick serve is running for this bundle, so nothing is actually "
579
+ "visible yet — start one with `brainpick serve --root <bundle>`.")
580
+
581
+
582
+ def show_hint(shown: int, dropped: list[str], cleared: bool, server_reachable: bool = True) -> str:
583
+ """The 'what to do next' line for brain_show (spec/95 small-LLM ergonomics).
584
+ `server_reachable` is False only for the local-fallback path (no running
585
+ `brainpick serve` answered) — the one case where "live in every open UI" would
586
+ otherwise be a flat lie, so it earns an honest caveat."""
582
587
  if cleared:
583
- return "cleared — every open UI dropped its spotlight and caption."
584
- shown = len(presentation["nodes"])
585
- if shown == 0 and dropped:
586
- return ("nothing resolved — no name matched a doc or entity; "
588
+ base = "cleared — every open UI dropped its spotlight and caption."
589
+ elif shown == 0 and dropped:
590
+ base = ("nothing resolved — no name matched a doc or entity; "
587
591
  "check them with brain_search, then brain_show again.")
588
- base = (f"showing {shown} node(s) live in every open UI — "
589
- "call brain_show again to change it, or with clear:true to dismiss.")
590
- if dropped:
591
- base += f" (dropped {len(dropped)}: {', '.join(dropped)})"
592
+ else:
593
+ base = (f"showing {shown} node(s) live in every open UI — "
594
+ "call brain_show again to change it, or with clear:true to dismiss.")
595
+ if dropped:
596
+ base += f" (dropped {len(dropped)}: {', '.join(dropped)})"
597
+ if not server_reachable:
598
+ base += NO_SERVER_CAVEAT
592
599
  return base
593
600
 
594
601
 
602
+ def _proxy_show(state: ServeState, body: dict) -> dict | None:
603
+ """Try to hand this presentation to an already-running `brainpick serve` for
604
+ the same bundle root, the same way `brainpick show` (the CLI) already does —
605
+ it broadcasts to a REAL UI, unlike the stdio MCP process's own private,
606
+ unattached ServeState. Returns the server's result (with our hint attached),
607
+ an error dict if a server answered but rejected the request, or None if no
608
+ server answered at all — the caller's cue to fall back to the local, UI-less
609
+ presentation (spec/95 follow-up: this proxy is Python-engine-only for now,
610
+ the Node twin still only does the local fallback — see NO_SERVER_CAVEAT)."""
611
+ from brainpick.cli import connectable_host, post_show
612
+
613
+ base_url = f"http://{connectable_host(state.config.serve.host)}:{state.config.serve.port}"
614
+ token = state.config.serve.token or None
615
+ result, error, unreachable = post_show(base_url, body, token)
616
+ if result is not None:
617
+ result["hint"] = show_hint(result["shown"], result["dropped"], cleared=bool(body.get("clear")))
618
+ return result
619
+ if unreachable:
620
+ return None
621
+ return {"ok": False, "dropped": [], "error": error,
622
+ "hint": "a brainpick serve is running but rejected the presentation — fix the error above, then try again."}
623
+
624
+
595
625
  def _single_show(state: ServeState, nodes: list[str] | None = None, focus: str | None = None,
596
626
  mode: str | None = None, annotation: str | None = None,
597
627
  clear: bool = False) -> dict:
598
- """brain_show's MCP result (spec/95): resolve + broadcast a presentation, then
599
- report {ok, shown, dropped, seq, hint}. Never writes — not behind [serve]
600
- writes, only the normal auth. Forgiving: unresolved nodes are dropped, listed."""
628
+ """brain_show's MCP result (spec/95): proxy to a running `brainpick serve` when
629
+ one answers (it does the real resolving + broadcasting); otherwise fall back to
630
+ resolving + broadcasting on this process's own orphaned state, honestly caveated
631
+ since nothing is actually watching it. Never writes — not behind [serve] writes,
632
+ only the normal auth. Forgiving: unresolved nodes are dropped, listed."""
633
+ body: dict = {}
634
+ if nodes is not None:
635
+ body["nodes"] = nodes
636
+ if focus is not None:
637
+ body["focus"] = focus
638
+ if mode is not None:
639
+ body["mode"] = mode
640
+ if annotation is not None:
641
+ body["annotation"] = annotation
642
+ if clear:
643
+ body["clear"] = True
644
+ proxied = _proxy_show(state, body)
645
+ if proxied is not None:
646
+ return proxied
647
+
601
648
  presentation, dropped = state.present(
602
649
  nodes=nodes, focus=focus, mode=mode, annotation=annotation, clear=clear,
603
650
  )
651
+ cleared = (not presentation["nodes"] and presentation["focus"] is None
652
+ and presentation["mode"] is None and presentation["annotation"] is None)
604
653
  return {
605
654
  "ok": True,
606
655
  "shown": len(presentation["nodes"]),
607
656
  "dropped": dropped,
608
657
  "seq": presentation["seq"],
609
- "hint": show_hint(presentation, dropped),
658
+ "hint": show_hint(len(presentation["nodes"]), dropped, cleared, server_reachable=False),
610
659
  }
611
660
 
612
661
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: brainpick
3
- Version: 0.4.2
3
+ Version: 0.4.4
4
4
  Summary: A turn-key brain stack for agents: compile an OKF markdown bundle into a queryable, servable, visualizable brain.
5
5
  Author: Benquemax
6
6
  License: MIT
@@ -2,6 +2,7 @@
2
2
  import asyncio
3
3
  import json
4
4
  import re
5
+ import socket
5
6
  import sys
6
7
 
7
8
  import pytest
@@ -103,6 +104,13 @@ async def _scenario(root):
103
104
 
104
105
 
105
106
  def test_mcp_stdio_roundtrip(kotiaurinko):
107
+ # brain_show now tries a real `brainpick serve` first (spec/95 follow-up) — pin
108
+ # it to a port nothing binds, so this doesn't depend on whatever else happens to
109
+ # be listening on the default :4747 on the machine running the suite.
110
+ with socket.socket() as probe:
111
+ probe.bind(("127.0.0.1", 0))
112
+ free_port = probe.getsockname()[1]
113
+ (kotiaurinko / "brainpick.toml").write_text(f"[serve]\nport = {free_port}\n", encoding="utf-8")
106
114
  run_compile(kotiaurinko)
107
115
  _run_scenario(_scenario(kotiaurinko))
108
116
  text = (kotiaurinko / "uusi-kivi.md").read_text(encoding="utf-8")
@@ -107,7 +107,7 @@ def test_sse_frame_format():
107
107
  def test_health(kotiaurinko):
108
108
  with TestClient(make_app(kotiaurinko)) as client:
109
109
  assert client.get("/api/health").json() == {
110
- "impl": "python", "name": "brainpick", "spec_version": "0.1", "version": "0.4.2",
110
+ "impl": "python", "name": "brainpick", "spec_version": "0.1", "version": "0.4.4",
111
111
  }
112
112
 
113
113
 
@@ -352,7 +352,11 @@ def test_write_targets_here_or_declines(tmp_path):
352
352
  assert result["ok"] is True and result["path"] == "aurinko:uusi-kivi.md"
353
353
 
354
354
 
355
- def test_show_targets_one_brain_and_drops_the_rest(tmp_path):
355
+ def test_show_targets_one_brain_and_drops_the_rest(tmp_path, monkeypatch):
356
+ # no running `brainpick serve` here — pin brain_show to its local fallback so
357
+ # this doesn't silently depend on whatever happens to be on :4747 (spec/95 follow-up)
358
+ monkeypatch.setattr("brainpick.cli.post_show",
359
+ lambda base_url, body, token=None: (None, "no server", True))
356
360
  brain_set = make_set(tmp_path)
357
361
  result = show_payload(brain_set, nodes=["kirja:kahvi.md", "aurinko:kuu.md"])
358
362
  assert result["shown"] == 1 and result["dropped"] == ["aurinko:kuu.md"]
@@ -539,9 +539,21 @@ def test_write_henxels_contract_at_repo_root_governs_bundle(monkeypatch, tmp_pat
539
539
  # -- brain_show (spec/95): the 6th tool — ephemeral presentations, not write-gated --
540
540
 
541
541
 
542
- def test_show_payload_reports_shown_dropped_seq_and_hint(kotiaurinko):
542
+ def _no_server(monkeypatch, reason="[Errno 111] Connection refused"):
543
+ """Make brain_show's proxy attempt behave as if no `brainpick serve` is
544
+ listening — every existing local-fallback test needs this so it doesn't
545
+ silently depend on whatever happens to be bound to 127.0.0.1:4747 on the
546
+ machine running the suite (spec/95 follow-up: the stdio MCP process has no
547
+ server of its own to ask, so it must genuinely try one over HTTP first)."""
548
+ def fake_post_show(base_url, body, token=None):
549
+ return None, f"no brainpick server at {base_url} — start one with 'brainpick serve' ({reason})", True
550
+ monkeypatch.setattr("brainpick.cli.post_show", fake_post_show)
551
+
552
+
553
+ def test_show_payload_reports_shown_dropped_seq_and_hint(kotiaurinko, monkeypatch):
543
554
  from brainpick.mcp_server import show_payload
544
555
 
556
+ _no_server(monkeypatch)
545
557
  state = make_state(kotiaurinko)
546
558
  queue = state.subscribe()
547
559
  result = show_payload(state, nodes=["aurinko.md", "ei-ole"], annotation="hi")
@@ -549,27 +561,113 @@ def test_show_payload_reports_shown_dropped_seq_and_hint(kotiaurinko):
549
561
  assert result["shown"] == 1
550
562
  assert result["dropped"] == ["ei-ole"]
551
563
  assert result["seq"] == 1
552
- # the exact hint — pinned so the Node twin stays byte-identical (cross-engine parity)
564
+ # the exact hint — pinned so the Node twin stays byte-identical (cross-engine parity),
565
+ # aside from the trailing no-server caveat (Python-only for now, see mcp_server.py)
553
566
  assert result["hint"] == (
554
567
  "showing 1 node(s) live in every open UI — "
555
568
  "call brain_show again to change it, or with clear:true to dismiss. (dropped 1: ei-ole)"
569
+ " No brainpick serve is running for this bundle, so nothing is actually visible yet"
570
+ " — start one with `brainpick serve --root <bundle>`."
556
571
  )
557
- # it broadcasts (the open UIs light up), and never writes / compiles
572
+ # local fallback still updates its own (UI-less) state so the CLI/tests can introspect it
558
573
  assert [name for name, _, _ in drain(queue)] == ["brain.show"]
559
574
  assert state.seq == 1
560
575
 
561
576
 
562
- def test_show_payload_clear_has_a_dedicated_hint(kotiaurinko):
577
+ def test_show_payload_clear_has_a_dedicated_hint(kotiaurinko, monkeypatch):
563
578
  from brainpick.mcp_server import show_payload
564
579
 
580
+ _no_server(monkeypatch)
565
581
  state = make_state(kotiaurinko)
566
582
  result = show_payload(state, clear=True)
567
583
  assert result == {
568
584
  "ok": True, "shown": 0, "dropped": [], "seq": 1,
569
- "hint": "cleared — every open UI dropped its spotlight and caption.",
585
+ "hint": ("cleared — every open UI dropped its spotlight and caption."
586
+ " No brainpick serve is running for this bundle, so nothing is actually visible yet"
587
+ " — start one with `brainpick serve --root <bundle>`."),
570
588
  }
571
589
 
572
590
 
591
+ def test_show_payload_proxies_to_a_running_server_and_never_touches_local_state(kotiaurinko, monkeypatch):
592
+ """The actual bug fix: when a `brainpick serve` answers, brain_show must use
593
+ ITS response (a real broadcast to a real UI) and leave the stdio process's
594
+ own orphaned state untouched — no local seq bump, no local broadcast."""
595
+ from brainpick.mcp_server import show_payload
596
+
597
+ calls = []
598
+
599
+ def fake_post_show(base_url, body, token=None):
600
+ calls.append((base_url, body, token))
601
+ return {"ok": True, "shown": 2, "dropped": [], "seq": 7}, None, False
602
+
603
+ monkeypatch.setattr("brainpick.cli.post_show", fake_post_show)
604
+ state = make_state(kotiaurinko)
605
+ queue = state.subscribe()
606
+
607
+ result = show_payload(state, nodes=["aurinko.md", "kuu"], annotation="hi", mode="brain")
608
+
609
+ assert result == {
610
+ "ok": True, "shown": 2, "dropped": [], "seq": 7,
611
+ "hint": "showing 2 node(s) live in every open UI — "
612
+ "call brain_show again to change it, or with clear:true to dismiss.",
613
+ }
614
+ assert len(calls) == 1
615
+ base_url, body, token = calls[0]
616
+ assert base_url == "http://127.0.0.1:4747" # the bundle's default [serve] config
617
+ assert body == {"nodes": ["aurinko.md", "kuu"], "annotation": "hi", "mode": "brain"}
618
+ assert token is None # no token configured for this fixture bundle
619
+ # the orphaned local state was never touched — the real server did the broadcasting
620
+ assert drain(queue) == []
621
+ assert state.presentation_seq == 0
622
+
623
+
624
+ def test_show_payload_normalizes_a_wildcard_bind_to_a_connectable_host(kotiaurinko, monkeypatch):
625
+ """A server can bind 0.0.0.0 (everywhere); a client can't connect TO that
626
+ address — must resolve to 127.0.0.1 before POSTing, same as `brainpick show`."""
627
+ from brainpick.config import load_config
628
+ from brainpick.mcp_server import show_payload
629
+ from brainpick.serve.state import ServeState
630
+
631
+ calls = []
632
+ monkeypatch.setattr(
633
+ "brainpick.cli.post_show",
634
+ lambda base_url, body, token=None: (calls.append(base_url), ({"ok": True, "shown": 0, "dropped": [], "seq": 1}, None, False))[1],
635
+ )
636
+ config = load_config(kotiaurinko)
637
+ config.serve.host = "0.0.0.0"
638
+ state = ServeState(kotiaurinko, config)
639
+ state.load()
640
+
641
+ show_payload(state, clear=True)
642
+
643
+ assert calls == ["http://127.0.0.1:4747"]
644
+
645
+
646
+ def test_show_payload_reports_a_server_side_rejection_without_touching_local_state(kotiaurinko, monkeypatch):
647
+ """A running server that REJECTS the presentation (e.g. a bad bearer token
648
+ on a non-local bind) must surface as an error, never silently paper over it
649
+ by falling back to updating the orphaned local state — that would hide a
650
+ real misconfiguration behind a false 'ok'."""
651
+ from brainpick.mcp_server import show_payload
652
+
653
+ def fake_post_show(base_url, body, token=None):
654
+ return None, "the server rejected the presentation (401): auth required", False
655
+
656
+ monkeypatch.setattr("brainpick.cli.post_show", fake_post_show)
657
+ state = make_state(kotiaurinko)
658
+ queue = state.subscribe()
659
+
660
+ result = show_payload(state, nodes=["aurinko.md"])
661
+
662
+ assert result == {
663
+ "ok": False, "dropped": [],
664
+ "error": "the server rejected the presentation (401): auth required",
665
+ "hint": "a brainpick serve is running but rejected the presentation — fix the error above, then try again.",
666
+ }
667
+ assert drain(queue) == []
668
+ assert state.presentation_seq == 0
669
+
670
+
573
671
  def test_brain_show_registered_as_sixth_tool_even_when_writes_refused(kotiaurinko):
574
672
  import asyncio
575
673
 
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes