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.
- brain_wiki-0.1.0.dev0.dist-info/METADATA +231 -0
- brain_wiki-0.1.0.dev0.dist-info/RECORD +141 -0
- brain_wiki-0.1.0.dev0.dist-info/WHEEL +4 -0
- brain_wiki-0.1.0.dev0.dist-info/entry_points.txt +4 -0
- brain_wiki-0.1.0.dev0.dist-info/licenses/LICENSE +202 -0
- brainwiki/__init__.py +17 -0
- brainwiki/__main__.py +5 -0
- brainwiki/assets/plugin/.claude-plugin/marketplace.json +16 -0
- brainwiki/assets/plugin/.claude-plugin/plugin.json +19 -0
- brainwiki/assets/plugin/.mcp.json +16 -0
- brainwiki/assets/plugin/mcp.json +17 -0
- brainwiki/assets/plugin/mcp_config.json +15 -0
- brainwiki/assets/plugin/plugin.json +37 -0
- brainwiki/assets/plugin/rules/brain-wiki.md +30 -0
- brainwiki/assets/plugin/server.json +30 -0
- brainwiki/assets/plugin/skills/brain/SKILL.md +145 -0
- brainwiki/assets/plugin/skills/brain/references/format.md +149 -0
- brainwiki/assets/plugin/skills/brain-maintain/SKILL.md +111 -0
- brainwiki/assets/plugin/skills/brain-maintain/references/consolidate.md +91 -0
- brainwiki/assets/plugin/skills/brain-maintain/references/harvest.md +144 -0
- brainwiki/assets/plugin/skills/brain-setup/SKILL.md +184 -0
- brainwiki/assets/plugin/skills/brain-setup/references/hosts.md +88 -0
- brainwiki/assets/plugin/skills/brain-setup/references/privacy.md +67 -0
- brainwiki/assets/prompts/consolidate.md +89 -0
- brainwiki/assets/prompts/distill.md +96 -0
- brainwiki/assets/prompts/harvest.md +142 -0
- brainwiki/bench/__init__.py +8 -0
- brainwiki/bench/_generate.py +440 -0
- brainwiki/bench/_report.py +166 -0
- brainwiki/bench/_suites.py +959 -0
- brainwiki/cli/__init__.py +7 -0
- brainwiki/cli/_cmd_bench.py +101 -0
- brainwiki/cli/_cmd_browse.py +281 -0
- brainwiki/cli/_cmd_consolidate.py +108 -0
- brainwiki/cli/_cmd_core.py +155 -0
- brainwiki/cli/_cmd_harvest.py +137 -0
- brainwiki/cli/_cmd_hosts.py +52 -0
- brainwiki/cli/_cmd_import.py +149 -0
- brainwiki/cli/_cmd_lint.py +117 -0
- brainwiki/cli/_cmd_read.py +131 -0
- brainwiki/cli/_cmd_runs.py +66 -0
- brainwiki/cli/_cmd_viz.py +57 -0
- brainwiki/cli/_cmd_write.py +122 -0
- brainwiki/cli/_main.py +511 -0
- brainwiki/cli/_output.py +19 -0
- brainwiki/config/__init__.py +56 -0
- brainwiki/config/_dirs.py +69 -0
- brainwiki/config/_manifest.py +307 -0
- brainwiki/config/_resolve.py +169 -0
- brainwiki/config/_user.py +183 -0
- brainwiki/consolidate/__init__.py +19 -0
- brainwiki/consolidate/_apply.py +408 -0
- brainwiki/consolidate/_packet.py +489 -0
- brainwiki/consolidate/_pipeline.py +215 -0
- brainwiki/consolidate/_presets.py +230 -0
- brainwiki/consolidate/_review.py +104 -0
- brainwiki/consolidate/_schema.py +153 -0
- brainwiki/consolidate/_validate.py +252 -0
- brainwiki/doctor.py +216 -0
- brainwiki/engine.py +80 -0
- brainwiki/errors.py +81 -0
- brainwiki/format/__init__.py +112 -0
- brainwiki/format/_agents_md.py +26 -0
- brainwiki/format/_claims.py +95 -0
- brainwiki/format/_document.py +203 -0
- brainwiki/format/_edit.py +94 -0
- brainwiki/format/_episode.py +67 -0
- brainwiki/format/_fields.py +350 -0
- brainwiki/format/_frontmatter.py +175 -0
- brainwiki/format/_links.py +72 -0
- brainwiki/format/_meta.py +67 -0
- brainwiki/format/_normalize.py +70 -0
- brainwiki/format/_relations.py +211 -0
- brainwiki/format/_rules.py +41 -0
- brainwiki/harvest.py +414 -0
- brainwiki/hosts/__init__.py +22 -0
- brainwiki/hosts/_antigravity.py +163 -0
- brainwiki/hosts/_claude_code.py +174 -0
- brainwiki/hosts/_codex.py +149 -0
- brainwiki/hosts/_connect.py +443 -0
- brainwiki/hosts/_detect.py +39 -0
- brainwiki/hosts/_instructions.py +220 -0
- brainwiki/hosts/_sessions.py +176 -0
- brainwiki/ids.py +69 -0
- brainwiki/importer/__init__.py +17 -0
- brainwiki/importer/_episodes.py +112 -0
- brainwiki/importer/_into.py +102 -0
- brainwiki/importer/_normalize.py +64 -0
- brainwiki/importer/_overlay.py +199 -0
- brainwiki/importer/_survey.py +167 -0
- brainwiki/index/__init__.py +44 -0
- brainwiki/index/_brains.py +119 -0
- brainwiki/index/_db.py +131 -0
- brainwiki/index/_guards.py +154 -0
- brainwiki/index/_neighbors.py +174 -0
- brainwiki/index/_orient.py +400 -0
- brainwiki/index/_read.py +285 -0
- brainwiki/index/_resolver.py +147 -0
- brainwiki/index/_scanner.py +665 -0
- brainwiki/index/_schema.py +164 -0
- brainwiki/index/_search.py +659 -0
- brainwiki/index/_topics.py +253 -0
- brainwiki/index/_written.py +29 -0
- brainwiki/lifecycle.py +158 -0
- brainwiki/lint/__init__.py +20 -0
- brainwiki/lint/_catalog.py +85 -0
- brainwiki/lint/_cross.py +649 -0
- brainwiki/lint/_files.py +483 -0
- brainwiki/lint/_fixes.py +87 -0
- brainwiki/lint/_hook.py +54 -0
- brainwiki/lint/_model.py +103 -0
- brainwiki/lint/_reports.py +242 -0
- brainwiki/lint/_runner.py +156 -0
- brainwiki/logs.py +34 -0
- brainwiki/mcp/__init__.py +7 -0
- brainwiki/mcp/_server.py +917 -0
- brainwiki/origin.py +97 -0
- brainwiki/runs/__init__.py +21 -0
- brainwiki/runs/_git.py +89 -0
- brainwiki/runs/_lock.py +42 -0
- brainwiki/runs/_record.py +145 -0
- brainwiki/runs/_transaction.py +441 -0
- brainwiki/runs/_undo.py +159 -0
- brainwiki/setup_wizard.py +143 -0
- brainwiki/store/__init__.py +7 -0
- brainwiki/store/_local.py +153 -0
- brainwiki/viz/__init__.py +9 -0
- brainwiki/viz/_export.py +414 -0
- brainwiki/viz/_lod.py +455 -0
- brainwiki/viz/_server.py +327 -0
- brainwiki/viz/app/BUILD_INFO.json +5 -0
- brainwiki/viz/app/THIRD_PARTY_NOTICES.txt +1519 -0
- brainwiki/viz/app/assets/index-gK1pnQdO.js +4496 -0
- brainwiki/viz/app/assets/style-Cg8NUCst.css +1 -0
- brainwiki/viz/app/index.html +14 -0
- brainwiki/write/__init__.py +23 -0
- brainwiki/write/_edits.py +730 -0
- brainwiki/write/_episode.py +195 -0
- brainwiki/write/_move.py +181 -0
- brainwiki/write/_rules.py +204 -0
- brainwiki/write/_secrets.py +108 -0
brainwiki/viz/_server.py
ADDED
|
@@ -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()
|