Launch the zettelkasten knowledge-graph dashboard (backend + frontend).
Adapts to the install: the backend always starts and, in a plain
(non-editable) install, serves the bundled UI itself on :8742. In a source
checkout it also starts the Vite dev server on :5174 (live reload). The chat
agent runs in-process in the backend (cursor-sdk's bundled bridge — no
system Node.js needed). If already running, returns the URL immediately
unless force_restart=True.
This is the zettelkasten visualizer — an interactive graph explorer
for concept boxes. NOT the memory dashboard.
FEDERATED VIEW — browsing another repo's graphs inside this one:
The dashboard is the only place that shows a related repo's zettelkasten
alongside this one (the MCP tools stay local-repo only). It is a read-only
overlay — external graphs/projects are projected in, never imported or
mutated. To set it up, add a federated_repos list to
.zettelkasten/config.yaml in THIS repo; each entry needs path (absolute,
or relative to the repo root) pointing at the other repo, plus an id and
name. The target repo must contain a .zettelkasten/ directory. Its graphs
then appear in the picker under a <repo> (read-only) section, namespaced
<repo_id>:<graph>. Misconfigured repos surface non-fatal errors at
/api/federation.
Returns the dashboard URL and component status.
Source code in zettelkasten/dashboard_launch.py
| def launch_dashboard(force_restart: bool = False) -> str:
"""Launch the zettelkasten knowledge-graph dashboard (backend + frontend).
Adapts to the install: the backend always starts and, in a plain
(non-editable) install, serves the bundled UI itself on :8742. In a source
checkout it also starts the Vite dev server on :5174 (live reload). The chat
agent runs in-process in the backend (cursor-sdk's bundled bridge — no
system Node.js needed). If already running, returns the URL immediately
unless force_restart=True.
This is the zettelkasten visualizer — an interactive graph explorer
for concept boxes. NOT the memory dashboard.
FEDERATED VIEW — browsing another repo's graphs inside this one:
The dashboard is the only place that shows a related repo's zettelkasten
alongside this one (the MCP tools stay local-repo only). It is a read-only
overlay — external graphs/projects are projected in, never imported or
mutated. To set it up, add a `federated_repos` list to
`.zettelkasten/config.yaml` in THIS repo; each entry needs `path` (absolute,
or relative to the repo root) pointing at the other repo, plus an `id` and
`name`. The target repo must contain a `.zettelkasten/` directory. Its graphs
then appear in the picker under a `<repo> (read-only)` section, namespaced
`<repo_id>:<graph>`. Misconfigured repos surface non-fatal errors at
`/api/federation`.
Returns the dashboard URL and component status.
"""
import shutil
dashboard_dir = Path(__file__).resolve().parent / "dashboard"
frontend_dir = dashboard_dir / "frontend"
logger.info("launching zettelkasten dashboard (force_restart=%s)", force_restart)
from memory.dashboard.launcher_core import (
build_id,
fetch_health,
health_url,
resolved_ports,
)
workspace = Path.cwd()
# Resolve ports the SAME way the `python -m zettelkasten.dashboard` launcher
# does (per-workspace offset unless an env var overrides), so the MCP tool and
# the launcher never land on different ports for one workspace and spawn dupes.
backend_port, frontend_port = resolved_ports(
workspace, 8742, 5174, "ZK_DASHBOARD_BACKEND_PORT", "ZK_DASHBOARD_FRONTEND_PORT"
)
# Capability detection — mode-aware, never exits the server.
have_built_ui = (frontend_dir / "dist" / "index.html").is_file()
have_frontend_src = (frontend_dir / "package.json").is_file()
status: dict = {"actions": []}
if not have_built_ui and not have_frontend_src:
status["status"] = "error"
status["error"] = (
"no dashboard UI found (no built bundle and no frontend source) — "
"reinstall angelo"
)
return json.dumps(status)
# --- Backend (always; serves bundled UI when Vite isn't running) ---
reload_flag = ["--reload"] if have_frontend_src else []
backend_alive = _port_listening(backend_port)
# Recycle a backend that's alive but running stale code (old build after an
# upgrade or source edit), so every launch_dashboard call self-heals — not
# just explicit force_restart=True.
backend_stale = False
if backend_alive:
payload = fetch_health(health_url(backend_port))
if payload and payload.get("build") and payload.get("build") != build_id():
backend_stale = True
if backend_alive and (force_restart or backend_stale):
_kill_port(backend_port)
time.sleep(1)
backend_alive = False
status["actions"].append(
f"recycled stale backend on :{backend_port}"
if backend_stale and not force_restart
else f"killed stale backend on :{backend_port}"
)
if not backend_alive:
proc = subprocess.Popen(
[sys.executable, "-m", "uvicorn",
"zettelkasten.dashboard.backend.main:app",
*reload_flag, "--port", str(backend_port)],
cwd=str(Path.cwd()),
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
_dashboard_procs.append(proc)
if _wait_for_port(backend_port):
status["actions"].append(f"started backend on :{backend_port}")
else:
status["actions"].append(f"backend failed to start on :{backend_port}")
status["status"] = "error"
return json.dumps(status)
else:
status["actions"].append(f"backend already running on :{backend_port}")
# The chat agent runs in-process in the backend (cursor-sdk's bundled
# bridge); the Cursor API key is read lazily on the first chat, so there is
# no separate sidecar to launch and no Node.js requirement.
# --- Frontend: live Vite only in a source checkout ---
run_vite = have_frontend_src and _ensure_node_deps_quiet(frontend_dir)
if run_vite:
npm = shutil.which("npm") or "npm"
frontend_alive = _port_listening(frontend_port)
if frontend_alive and force_restart:
_kill_port(frontend_port)
time.sleep(1)
frontend_alive = False
status["actions"].append(f"killed stale frontend on :{frontend_port}")
if not frontend_alive:
proc = subprocess.Popen(
[npm, "run", "dev"],
cwd=str(frontend_dir),
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
_dashboard_procs.append(proc)
if _wait_for_port(frontend_port, timeout=15.0):
status["actions"].append(f"started frontend on :{frontend_port}")
else:
status["actions"].append(f"frontend failed to start on :{frontend_port}")
run_vite = False
else:
status["actions"].append(f"frontend already running on :{frontend_port}")
elif have_frontend_src:
status["actions"].append("frontend (Vite) skipped: serving bundled UI from backend")
else:
status["actions"].append(f"serving bundled UI from backend on :{backend_port}")
from memory.dashboard.launcher_core import dashboard_url
ui_port = frontend_port if run_vite else backend_port
status["url"] = dashboard_url(ui_port)
status["status"] = "running"
return json.dumps(status)
|