"""BigMind Profile Web Server — Flask app served on localhost:BIGMIND_PORT (default 7700). Started automatically as a daemon thread when the MCP server starts. Serves a single live profile page built from the BigMind DB. """ import os import threading import logging from pathlib import Path from datetime import datetime, timezone, timedelta from bigmind.web_render import _render_html, _render_gallery_html # all HTML rendering lives there logger = logging.getLogger("BigMindWeb") _PORT = int(os.environ.get("BIGMIND_PORT", "7700")) _AUTOOPEN = os.environ.get("BIGMIND_AUTOOPEN", "").lower() in ("1", "true", "yes") _server_started = False # Gallery directory — images served from here _GALLERY_DIR = Path(os.environ.get("BIGMIND_GALLERY_DIR", Path.home() / ".mcp" / "bigmind" / "gallery")) # Profile image — last entry in gallery dir wins; fallback to original lumen-profile.png def _get_profile_image_path() -> Path | None: """Return the path of the current profile image, or None if not found.""" # 1. Check gallery dir for lumen_profile* images (seed 568659042 = lumen_profile) if _GALLERY_DIR.exists(): candidates = sorted(_GALLERY_DIR.glob("*.png"), reverse=True) if candidates: return candidates[0] # most recently named = most recent timestamp return None # ── Flask app ───────────────────────────────────────────────────────────────── def _create_app(): from flask import Flask, jsonify, request, send_file, abort from bigmind import memory_store from bigmind.profile_builder import build_profile_data from bigmind.db import db as _db app = Flask(__name__) app.logger.setLevel(logging.WARNING) # silence Flask request logs @app.route("/") def profile(): user = memory_store.get_or_create_user(memory_store.get_current_username()) data = build_profile_data(user["id"]) return _render_html(data) @app.route("/profile-image") def profile_image(): """Serve the current Lumen profile picture.""" img_path = _get_profile_image_path() if img_path and img_path.exists(): return send_file(str(img_path), mimetype="image/png") abort(404) @app.route("/gallery/image/") def gallery_image(filename: str): """Serve a specific gallery image by filename.""" # Security: only allow alphanumeric + underscores + dots, no path traversal safe_name = Path(filename).name img_path = _GALLERY_DIR / safe_name if img_path.exists() and img_path.suffix.lower() in (".png", ".jpg", ".jpeg", ".webp"): mimetype = "image/png" if img_path.suffix.lower() == ".png" else "image/jpeg" return send_file(str(img_path), mimetype=mimetype) abort(404) @app.route("/gallery") def gallery(): """Render the AI-generated image gallery page.""" _GALLERY_DIR.mkdir(parents=True, exist_ok=True) with _db() as conn: rows = conn.execute( """SELECT id, filename, prompt, tags, model, created_at, width, height, file_size_bytes FROM gallery_images ORDER BY created_at DESC""" ).fetchall() images = [dict(r) for r in rows] return _render_gallery_html(images) @app.route("/api/session/") def api_session(session_id): """Return Tier-2 summary JSON for a given session id.""" detail = memory_store.get_session_detail(session_id) if not detail: return jsonify({"error": "No detailed summary for this session."}) return jsonify(detail) @app.route("/api/search") def api_search(): """Unified memory search — facts + chunks + session one-liners.""" q = (request.args.get("q") or "").strip() if not q: return jsonify([]) user = memory_store.get_or_create_user(memory_store.get_current_username()) uid = user["id"] results = [] # Facts try: facts = memory_store.search_facts(uid, q, limit=5) for f in facts: results.append({ "type": "fact", "content": f"[{f.get('category','')}] {f.get('fact','')}", "date": (f.get("created_at") or "")[:10], "score": 3, }) except Exception: pass # Chunks try: chunks = memory_store.search_chunks(uid, q, limit=5) for c in chunks: results.append({ "type": "chunk", "content": (c.get("content") or "")[:300], "date": (c.get("created_at") or "")[:10], "score": 2, }) except Exception: pass # Session one-liners (simple LIKE — no FTS needed) try: from bigmind.db import db as _db with _db() as conn: rows = conn.execute( """SELECT id, one_liner, started_at FROM sessions WHERE user_id=? AND ended_at IS NOT NULL AND one_liner LIKE ? ORDER BY started_at DESC LIMIT 5""", (uid, f"%{q}%"), ).fetchall() for r in rows: results.append({ "type": "session", "content": r["one_liner"], "date": (r.get("started_at") or "")[:10], "score": 1, }) except Exception: pass # Sort by type score desc, deduplicate by content seen = set() final = [] for r in sorted(results, key=lambda x: -x["score"]): key = r["content"][:80] if key not in seen: seen.add(key) final.append(r) return jsonify(final[:15]) @app.route('/static/achievements/') def achievements_image(filename: str): from pathlib import Path safe_name = Path(filename).name img_path = Path(__file__).parent / 'static' / 'achievements' / safe_name if img_path.exists() and img_path.suffix.lower() in ['.png', '.jpg', '.jpeg', '.webp', '.gif']: mimetype = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp', '.gif': 'image/gif', }.get(img_path.suffix.lower(), 'image/png') return send_file(str(img_path), mimetype=mimetype) abort(404) return app # ── Daemon thread startup ───────────────────────────────────────────────────── def _port_in_use(port: int) -> bool: """Return True if something is already listening on 127.0.0.1:.""" import socket with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: s.settimeout(0.2) return s.connect_ex(("127.0.0.1", port)) == 0 def start_web_server() -> str: """Start the Flask profile server in a background daemon thread. Safe to call multiple times (only starts once). If another BigMind instance is already serving the port this process skips Flask startup gracefully — the MCP tools still work, the profile page is served by the other instance (same DB, same data). The fix for the multi-IDE port-conflict bug: we check the port *before* setting _server_started = True. Previously the flag was set immediately after t.start(), so a failed Flask bind (Address already in use) left _server_started = True with no Flask running — permanent lock-out. """ global _server_started if _server_started: return f"http://localhost:{_PORT}" if _port_in_use(_PORT): # Another BigMind process already owns the port — skip Flask startup. # Don't set _server_started = True so if that process dies and this # one is restarted, it can try again cleanly. logger.info( "BigMind profile server already running at http://localhost:%d " "(another IDE instance). Skipping Flask startup.", _PORT ) return f"http://localhost:{_PORT}" app = _create_app() _started_event = threading.Event() _bind_failed = [] # non-empty if Flask couldn't bind def _run(): import logging as _log _log.getLogger("werkzeug").setLevel(_log.ERROR) try: _started_event.set() # signal that the thread is running app.run(host="127.0.0.1", port=_PORT, debug=False, use_reloader=False) except OSError as exc: _bind_failed.append(str(exc)) logger.warning("BigMind web server failed to bind port %d: %s", _PORT, exc) t = threading.Thread(target=_run, daemon=True, name="BigMindWebServer") t.start() _started_event.wait(timeout=2.0) # wait for thread to actually start # Only mark as started if Flask didn't immediately report a bind error if not _bind_failed: _server_started = True logger.info("BigMind profile server started at http://localhost:%d", _PORT) if _AUTOOPEN: import webbrowser, time time.sleep(1.0) webbrowser.open(f"http://localhost:{_PORT}") else: logger.warning( "BigMind web server could not start (port %d in use). " "Profile page unavailable from this IDE instance.", _PORT ) return f"http://localhost:{_PORT}" def get_profile_url() -> str: return f"http://localhost:{_PORT}"