brain-wiki 0.1.0.dev0__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 (141) hide show
  1. brain_wiki-0.1.0.dev0.dist-info/METADATA +231 -0
  2. brain_wiki-0.1.0.dev0.dist-info/RECORD +141 -0
  3. brain_wiki-0.1.0.dev0.dist-info/WHEEL +4 -0
  4. brain_wiki-0.1.0.dev0.dist-info/entry_points.txt +4 -0
  5. brain_wiki-0.1.0.dev0.dist-info/licenses/LICENSE +202 -0
  6. brainwiki/__init__.py +17 -0
  7. brainwiki/__main__.py +5 -0
  8. brainwiki/assets/plugin/.claude-plugin/marketplace.json +16 -0
  9. brainwiki/assets/plugin/.claude-plugin/plugin.json +19 -0
  10. brainwiki/assets/plugin/.mcp.json +16 -0
  11. brainwiki/assets/plugin/mcp.json +17 -0
  12. brainwiki/assets/plugin/mcp_config.json +15 -0
  13. brainwiki/assets/plugin/plugin.json +37 -0
  14. brainwiki/assets/plugin/rules/brain-wiki.md +30 -0
  15. brainwiki/assets/plugin/server.json +30 -0
  16. brainwiki/assets/plugin/skills/brain/SKILL.md +145 -0
  17. brainwiki/assets/plugin/skills/brain/references/format.md +149 -0
  18. brainwiki/assets/plugin/skills/brain-maintain/SKILL.md +111 -0
  19. brainwiki/assets/plugin/skills/brain-maintain/references/consolidate.md +91 -0
  20. brainwiki/assets/plugin/skills/brain-maintain/references/harvest.md +144 -0
  21. brainwiki/assets/plugin/skills/brain-setup/SKILL.md +184 -0
  22. brainwiki/assets/plugin/skills/brain-setup/references/hosts.md +88 -0
  23. brainwiki/assets/plugin/skills/brain-setup/references/privacy.md +67 -0
  24. brainwiki/assets/prompts/consolidate.md +89 -0
  25. brainwiki/assets/prompts/distill.md +96 -0
  26. brainwiki/assets/prompts/harvest.md +142 -0
  27. brainwiki/bench/__init__.py +8 -0
  28. brainwiki/bench/_generate.py +440 -0
  29. brainwiki/bench/_report.py +166 -0
  30. brainwiki/bench/_suites.py +959 -0
  31. brainwiki/cli/__init__.py +7 -0
  32. brainwiki/cli/_cmd_bench.py +101 -0
  33. brainwiki/cli/_cmd_browse.py +281 -0
  34. brainwiki/cli/_cmd_consolidate.py +108 -0
  35. brainwiki/cli/_cmd_core.py +155 -0
  36. brainwiki/cli/_cmd_harvest.py +137 -0
  37. brainwiki/cli/_cmd_hosts.py +52 -0
  38. brainwiki/cli/_cmd_import.py +149 -0
  39. brainwiki/cli/_cmd_lint.py +117 -0
  40. brainwiki/cli/_cmd_read.py +131 -0
  41. brainwiki/cli/_cmd_runs.py +66 -0
  42. brainwiki/cli/_cmd_viz.py +57 -0
  43. brainwiki/cli/_cmd_write.py +122 -0
  44. brainwiki/cli/_main.py +511 -0
  45. brainwiki/cli/_output.py +19 -0
  46. brainwiki/config/__init__.py +56 -0
  47. brainwiki/config/_dirs.py +69 -0
  48. brainwiki/config/_manifest.py +307 -0
  49. brainwiki/config/_resolve.py +169 -0
  50. brainwiki/config/_user.py +183 -0
  51. brainwiki/consolidate/__init__.py +19 -0
  52. brainwiki/consolidate/_apply.py +408 -0
  53. brainwiki/consolidate/_packet.py +489 -0
  54. brainwiki/consolidate/_pipeline.py +215 -0
  55. brainwiki/consolidate/_presets.py +230 -0
  56. brainwiki/consolidate/_review.py +104 -0
  57. brainwiki/consolidate/_schema.py +153 -0
  58. brainwiki/consolidate/_validate.py +252 -0
  59. brainwiki/doctor.py +216 -0
  60. brainwiki/engine.py +80 -0
  61. brainwiki/errors.py +81 -0
  62. brainwiki/format/__init__.py +112 -0
  63. brainwiki/format/_agents_md.py +26 -0
  64. brainwiki/format/_claims.py +95 -0
  65. brainwiki/format/_document.py +203 -0
  66. brainwiki/format/_edit.py +94 -0
  67. brainwiki/format/_episode.py +67 -0
  68. brainwiki/format/_fields.py +350 -0
  69. brainwiki/format/_frontmatter.py +175 -0
  70. brainwiki/format/_links.py +72 -0
  71. brainwiki/format/_meta.py +67 -0
  72. brainwiki/format/_normalize.py +70 -0
  73. brainwiki/format/_relations.py +211 -0
  74. brainwiki/format/_rules.py +41 -0
  75. brainwiki/harvest.py +414 -0
  76. brainwiki/hosts/__init__.py +22 -0
  77. brainwiki/hosts/_antigravity.py +163 -0
  78. brainwiki/hosts/_claude_code.py +174 -0
  79. brainwiki/hosts/_codex.py +149 -0
  80. brainwiki/hosts/_connect.py +443 -0
  81. brainwiki/hosts/_detect.py +39 -0
  82. brainwiki/hosts/_instructions.py +220 -0
  83. brainwiki/hosts/_sessions.py +176 -0
  84. brainwiki/ids.py +69 -0
  85. brainwiki/importer/__init__.py +17 -0
  86. brainwiki/importer/_episodes.py +112 -0
  87. brainwiki/importer/_into.py +102 -0
  88. brainwiki/importer/_normalize.py +64 -0
  89. brainwiki/importer/_overlay.py +199 -0
  90. brainwiki/importer/_survey.py +167 -0
  91. brainwiki/index/__init__.py +44 -0
  92. brainwiki/index/_brains.py +119 -0
  93. brainwiki/index/_db.py +131 -0
  94. brainwiki/index/_guards.py +154 -0
  95. brainwiki/index/_neighbors.py +174 -0
  96. brainwiki/index/_orient.py +400 -0
  97. brainwiki/index/_read.py +285 -0
  98. brainwiki/index/_resolver.py +147 -0
  99. brainwiki/index/_scanner.py +665 -0
  100. brainwiki/index/_schema.py +164 -0
  101. brainwiki/index/_search.py +659 -0
  102. brainwiki/index/_topics.py +253 -0
  103. brainwiki/index/_written.py +29 -0
  104. brainwiki/lifecycle.py +158 -0
  105. brainwiki/lint/__init__.py +20 -0
  106. brainwiki/lint/_catalog.py +85 -0
  107. brainwiki/lint/_cross.py +649 -0
  108. brainwiki/lint/_files.py +483 -0
  109. brainwiki/lint/_fixes.py +87 -0
  110. brainwiki/lint/_hook.py +54 -0
  111. brainwiki/lint/_model.py +103 -0
  112. brainwiki/lint/_reports.py +242 -0
  113. brainwiki/lint/_runner.py +156 -0
  114. brainwiki/logs.py +34 -0
  115. brainwiki/mcp/__init__.py +7 -0
  116. brainwiki/mcp/_server.py +917 -0
  117. brainwiki/origin.py +97 -0
  118. brainwiki/runs/__init__.py +21 -0
  119. brainwiki/runs/_git.py +89 -0
  120. brainwiki/runs/_lock.py +42 -0
  121. brainwiki/runs/_record.py +145 -0
  122. brainwiki/runs/_transaction.py +441 -0
  123. brainwiki/runs/_undo.py +159 -0
  124. brainwiki/setup_wizard.py +143 -0
  125. brainwiki/store/__init__.py +7 -0
  126. brainwiki/store/_local.py +153 -0
  127. brainwiki/viz/__init__.py +9 -0
  128. brainwiki/viz/_export.py +414 -0
  129. brainwiki/viz/_lod.py +455 -0
  130. brainwiki/viz/_server.py +327 -0
  131. brainwiki/viz/app/BUILD_INFO.json +5 -0
  132. brainwiki/viz/app/THIRD_PARTY_NOTICES.txt +1519 -0
  133. brainwiki/viz/app/assets/index-gK1pnQdO.js +4496 -0
  134. brainwiki/viz/app/assets/style-Cg8NUCst.css +1 -0
  135. brainwiki/viz/app/index.html +14 -0
  136. brainwiki/write/__init__.py +23 -0
  137. brainwiki/write/_edits.py +730 -0
  138. brainwiki/write/_episode.py +195 -0
  139. brainwiki/write/_move.py +181 -0
  140. brainwiki/write/_rules.py +204 -0
  141. brainwiki/write/_secrets.py +108 -0
@@ -0,0 +1,327 @@
1
+ """Loopback-only, token-guarded, read-only HTTP server for the visualizer (design doc 12 §5, §8)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hmac
6
+ import json
7
+ import re
8
+ import secrets
9
+ import socketserver
10
+ import threading
11
+ import time
12
+ import webbrowser
13
+ from http import HTTPStatus
14
+ from http.cookies import CookieError, SimpleCookie
15
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
16
+ from pathlib import Path
17
+ from typing import TYPE_CHECKING, Any, cast
18
+ from urllib.parse import parse_qs, unquote, urlsplit
19
+
20
+ from brainwiki.errors import Ambiguous, NotFound
21
+ from brainwiki.viz import _export as export
22
+ from brainwiki.viz._export import build_graph
23
+ from brainwiki.viz._lod import MAX_SEARCH, Atlas
24
+
25
+ if TYPE_CHECKING:
26
+ from brainwiki.engine import Engine
27
+
28
+ _HOST = "127.0.0.1"
29
+ _IDLE_SECONDS = 30 * 60
30
+ _MAX_MARKDOWN = 200_000
31
+ # Read at request time, so tests can point the server at a stand-in build.
32
+ _APP_DIR: Path = export.APP_DIR
33
+ _CSP = (
34
+ "default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self' data:; "
35
+ "connect-src 'self'; worker-src 'self'; font-src 'self'"
36
+ )
37
+ _MIME = {
38
+ ".js": "text/javascript; charset=utf-8",
39
+ ".css": "text/css; charset=utf-8",
40
+ ".html": "text/html; charset=utf-8",
41
+ ".json": "application/json; charset=utf-8",
42
+ ".map": "application/json; charset=utf-8",
43
+ ".svg": "image/svg+xml",
44
+ ".png": "image/png",
45
+ ".woff2": "font/woff2",
46
+ }
47
+ _ASSET_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]*$")
48
+ _NOTE_FIELDS = (
49
+ "id",
50
+ "title",
51
+ "path",
52
+ "kind",
53
+ "type",
54
+ "status",
55
+ "topic",
56
+ "updated",
57
+ "banners",
58
+ "summary",
59
+ "claims",
60
+ "relations",
61
+ "backlinks",
62
+ )
63
+
64
+
65
+ class _VizServer(ThreadingHTTPServer):
66
+ daemon_threads = True
67
+
68
+ def __init__(self, engine: Engine, port: int, topics: list[str] | None = None):
69
+ super().__init__((_HOST, port), _Handler)
70
+ self.engine = engine
71
+ self.topics = topics
72
+ self.token = secrets.token_urlsafe(24)
73
+ self.lock = threading.Lock()
74
+ self.last_request = time.monotonic()
75
+ self._atlas: Atlas | None = None
76
+
77
+ def server_bind(self) -> None:
78
+ # HTTPServer.server_bind names the server with socket.getfqdn, a reverse DNS lookup
79
+ # that can stall for seconds on macOS; a loopback server never uses the name.
80
+ socketserver.TCPServer.server_bind(self)
81
+ self.server_name, self.server_port = _HOST, self.server_address[1]
82
+
83
+ @property
84
+ def url(self) -> str:
85
+ return f"http://{_HOST}:{self.server_address[1]}/?t={self.token}"
86
+
87
+ @property
88
+ def cookie_name(self) -> str:
89
+ # Browsers share cookies across ports on one host; the port keeps two servers apart.
90
+ return f"brainviz_{self.server_address[1]}"
91
+
92
+ def atlas(self, *, fresh: bool = True) -> Atlas:
93
+ """The graph; ``fresh=False`` reuses the cached one without a change scan."""
94
+ with self.lock:
95
+ if not fresh and self._atlas is not None:
96
+ return self._atlas
97
+ return self._fresh_atlas()
98
+
99
+ def _fresh_atlas(self) -> Atlas:
100
+ """Caller holds the lock. Rebuild only when a scan reports changes."""
101
+ stats = self.engine.scan()
102
+ changed = stats.added or stats.changed or stats.removed or stats.renamed or stats.reresolved
103
+ if self._atlas is None or changed:
104
+ self._atlas = Atlas(build_graph(self.engine, topics=self.topics))
105
+ return self._atlas
106
+
107
+ def search(self, query: str) -> dict[str, Any]:
108
+ from brainwiki.index import search
109
+
110
+ query = query.strip()
111
+ if not query:
112
+ return {"results": []}
113
+ with self.lock:
114
+ atlas = self._fresh_atlas()
115
+ results = atlas.search_titles(query)
116
+ seen = {r["id"] for r in results}
117
+ if len(results) < MAX_SEARCH:
118
+ found = search(
119
+ self.engine,
120
+ query,
121
+ limit=MAX_SEARCH,
122
+ topics=self.topics,
123
+ intent="none",
124
+ include_episodes=False,
125
+ )
126
+ for hit in found["results"]:
127
+ node = atlas.note(hit["id"])
128
+ if node is None or hit["id"] in seen:
129
+ continue
130
+ seen.add(hit["id"])
131
+ results.append(
132
+ {
133
+ "id": hit["id"],
134
+ "title": node["title"],
135
+ "topic": node["topic"],
136
+ "kind": node["kind"],
137
+ "snippet": hit.get("snippet"),
138
+ }
139
+ )
140
+ return {"results": results[:MAX_SEARCH]}
141
+
142
+ def note(self, node_id: str) -> dict[str, Any]:
143
+ from brainwiki.format import split
144
+ from brainwiki.index import read
145
+
146
+ with self.lock:
147
+ result = read(self.engine, node_id, mode="outline")
148
+ root = self.engine.brain.root
149
+ text = self.engine.store.read_text(result["path"])
150
+ links = self._links(result["id"])
151
+ out = {key: result.get(key) for key in _NOTE_FIELDS}
152
+ out["abs_path"] = str(root / result["path"])
153
+ # The preview renders the note like Obsidian: frontmatter as properties, the body as
154
+ # Markdown, and wikilinks resolved to the notes they name.
155
+ fm = split(text)
156
+ out["properties"] = fm.raw # as written: no plain-name copies of bw- fields
157
+ out["markdown"] = fm.body[:_MAX_MARKDOWN]
158
+ out["links"] = links
159
+ return out
160
+
161
+ def _links(self, node_id: str) -> dict[str, dict[str, Any]]:
162
+ """Caller holds the lock. Link text as written -> the note it resolves to."""
163
+ rows = self.engine.db.execute(
164
+ """SELECT e.raw_target, n.id, n.title, n.topic FROM edges e
165
+ JOIN nodes n ON n.id = e.other WHERE e.declared_in = ?""",
166
+ (node_id,),
167
+ )
168
+ return {
169
+ row["raw_target"]: {"id": row["id"], "title": row["title"], "topic": row["topic"]}
170
+ for row in rows
171
+ }
172
+
173
+ def watch_idle(self, idle_seconds: float = _IDLE_SECONDS, interval: float = 30.0) -> None:
174
+ def run() -> None:
175
+ while True:
176
+ time.sleep(interval)
177
+ if time.monotonic() - self.last_request > idle_seconds:
178
+ self.shutdown()
179
+ return
180
+
181
+ threading.Thread(target=run, name="brain-viz-idle", daemon=True).start()
182
+
183
+
184
+ class _Handler(BaseHTTPRequestHandler):
185
+ server_version = "brain-viz"
186
+ sys_version = ""
187
+ _set_cookie = False
188
+
189
+ @property
190
+ def viz(self) -> _VizServer:
191
+ return cast(_VizServer, self.server)
192
+
193
+ def log_message(self, format: str, *args: Any) -> None:
194
+ return
195
+
196
+ def do_GET(self) -> None:
197
+ self.viz.last_request = time.monotonic()
198
+ parts = urlsplit(self.path)
199
+ query = parse_qs(parts.query)
200
+ expected = self.viz.token.encode()
201
+ by_query = hmac.compare_digest(query.get("t", [""])[0].encode(), expected)
202
+ self._set_cookie = False
203
+ if not by_query and not hmac.compare_digest(self._cookie().encode(), expected):
204
+ self._send(HTTPStatus.FORBIDDEN, b"forbidden\n", "text/plain; charset=utf-8")
205
+ return
206
+ self._set_cookie = by_query
207
+ try:
208
+ self._route(parts.path, query)
209
+ except Exception as exc: # noqa: BLE001 - report, keep serving
210
+ body = f"error: {type(exc).__name__}\n".encode()
211
+ self._send(HTTPStatus.INTERNAL_SERVER_ERROR, body, "text/plain; charset=utf-8")
212
+
213
+ def _cookie(self) -> str:
214
+ try:
215
+ jar = SimpleCookie(self.headers.get("Cookie", ""))
216
+ except CookieError:
217
+ return ""
218
+ morsel = jar.get(self.viz.cookie_name)
219
+ return morsel.value if morsel else ""
220
+
221
+ def _route(self, path: str, query: dict[str, list[str]]) -> None:
222
+ if path in ("/", "/index.html"):
223
+ self._file(_app_dir() / "index.html", _MIME[".html"])
224
+ elif path.startswith("/assets/"):
225
+ self._asset(path[len("/assets/") :])
226
+ elif path == "/api/world":
227
+ self._json(self.viz.atlas().world())
228
+ elif path.startswith("/api/topic/"):
229
+ # Topics come from the graph the page's world came from, and a reveal never waits on a scan.
230
+ topic = self.viz.atlas(fresh=False).topic(unquote(path[len("/api/topic/") :]))
231
+ if topic is None:
232
+ self._not_found()
233
+ else:
234
+ self._json(topic)
235
+ elif path.startswith("/api/note/"):
236
+ self._note(unquote(path[len("/api/note/") :]))
237
+ elif path == "/api/search":
238
+ self._json(self.viz.search(query.get("q", [""])[0]))
239
+ elif path == "/api/health":
240
+ self._json(self.viz.atlas().health())
241
+ else:
242
+ self._not_found()
243
+
244
+ def do_HEAD(self) -> None:
245
+ self._send(HTTPStatus.METHOD_NOT_ALLOWED, b"", "text/plain; charset=utf-8")
246
+
247
+ def do_POST(self) -> None:
248
+ self._send(HTTPStatus.METHOD_NOT_ALLOWED, b"read-only\n", "text/plain; charset=utf-8")
249
+
250
+ def _file(self, path: Path, mime: str) -> None:
251
+ if not path.is_file():
252
+ body = b"the visualizer app is not built; see CONTRIBUTING.md (brain-wiki-viz)\n"
253
+ self._send(HTTPStatus.SERVICE_UNAVAILABLE, body, "text/plain; charset=utf-8")
254
+ return
255
+ self._send(HTTPStatus.OK, path.read_bytes(), mime)
256
+
257
+ def _asset(self, name: str) -> None:
258
+ name = unquote(name)
259
+ if not _ASSET_RE.match(name) or ".." in name:
260
+ self._not_found()
261
+ return
262
+ base = (_app_dir() / "assets").resolve()
263
+ target = (base / name).resolve()
264
+ mime = _MIME.get(target.suffix)
265
+ if not target.is_relative_to(base) or not target.is_file() or mime is None:
266
+ self._not_found()
267
+ return
268
+ self._send(HTTPStatus.OK, target.read_bytes(), mime)
269
+
270
+ def _note(self, node_id: str) -> None:
271
+ try:
272
+ data = self.viz.note(node_id)
273
+ except (NotFound, Ambiguous):
274
+ self._not_found()
275
+ return
276
+ self._json(data)
277
+
278
+ def _json(self, data: Any) -> None:
279
+ body = json.dumps(data, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
280
+ self._send(HTTPStatus.OK, body, _MIME[".json"])
281
+
282
+ def _not_found(self) -> None:
283
+ self._send(HTTPStatus.NOT_FOUND, b"not found\n", "text/plain; charset=utf-8")
284
+
285
+ def _send(self, status: HTTPStatus, body: bytes, mime: str) -> None:
286
+ self.send_response(status)
287
+ self.send_header("Content-Type", mime)
288
+ self.send_header("Content-Length", str(len(body)))
289
+ self.send_header("Content-Security-Policy", _CSP)
290
+ self.send_header("X-Content-Type-Options", "nosniff")
291
+ self.send_header("Referrer-Policy", "no-referrer")
292
+ self.send_header("Cache-Control", "no-store")
293
+ if self._set_cookie:
294
+ self.send_header(
295
+ "Set-Cookie",
296
+ f"{self.viz.cookie_name}={self.viz.token}; HttpOnly; SameSite=Strict; Path=/",
297
+ )
298
+ self.end_headers()
299
+ if self.command != "HEAD":
300
+ self.wfile.write(body)
301
+
302
+
303
+ def _app_dir() -> Path:
304
+ return _APP_DIR
305
+
306
+
307
+ def _make_server(engine: Engine, port: int = 0, topics: list[str] | None = None) -> _VizServer:
308
+ return _VizServer(engine, port, topics)
309
+
310
+
311
+ def serve(
312
+ engine: Engine, port: int, open_browser: bool, *, topics: list[str] | None = None
313
+ ) -> None:
314
+ server = _make_server(engine, port, topics)
315
+ server.watch_idle()
316
+ print(f"brain viz: {server.url}")
317
+ # Flush: through a pipe (an agent's shell tool, `| tee`) the token URL would otherwise
318
+ # stay buffered until the server exits, and the map is unreachable without it.
319
+ print("Press Ctrl-C to stop (stops by itself after 30 minutes idle).", flush=True)
320
+ if open_browser:
321
+ webbrowser.open(server.url)
322
+ try:
323
+ server.serve_forever()
324
+ except KeyboardInterrupt:
325
+ pass
326
+ finally:
327
+ server.server_close()
@@ -0,0 +1,5 @@
1
+ {
2
+ "source": "brain-wiki-viz",
3
+ "commit": "88d2a178ddeacf608bea9bffa54f0d8978306f16",
4
+ "dirty": false
5
+ }