chore: reorganize into polyglot monorepo (workshop)

- Move bigmind/ -> mcp/bigmind/
- Move webscraper/ -> mcp/webscraper/
- Move mss-failsafe/ -> java/mss-failsafe/
- Move Wellmann-Shop/ -> java/wellmann-shop/ (normalize to kebab-case)
- Add .roo/ IDE config files to tracking
- Add plans/REPO_STRATEGY.md (monorepo strategy document)
- Expand .gitignore: Java/Maven, Node/TS, coverage, uv.lock
- Rewrite README.md as navigation index
- Update .roo/mcp.json webscraper path to mcp/webscraper/
This commit is contained in:
Patrick Plate
2026-04-04 08:51:15 +02:00
parent 4167e15ed9
commit 155d56e8e8
1598 changed files with 19429 additions and 23 deletions
View File
+93
View File
@@ -0,0 +1,93 @@
"""Auto-close stale sessions older than 24 hours."""
import logging
from datetime import datetime, timezone, timedelta
from bigmind.db import db
logger = logging.getLogger("BigMindAutoClose")
STALE_THRESHOLD_HOURS = 24
def auto_close_stale_sessions(user_id: str) -> int:
"""
Close any open sessions for this user that are older than 24 hours.
Returns the number of sessions auto-closed.
"""
cutoff = (
datetime.now(timezone.utc) - timedelta(hours=STALE_THRESHOLD_HOURS)
).isoformat()
with db() as conn:
stale = conn.execute(
"""SELECT id, started_at FROM sessions
WHERE user_id=? AND ended_at IS NULL AND started_at < ?""",
(user_id, cutoff),
).fetchall()
for session in stale:
conn.execute(
"""UPDATE sessions
SET ended_at=CURRENT_TIMESTAMP,
one_liner='[auto-closed — session exceeded 24h]',
outcome='Session automatically closed after exceeding 24h without a proper close call.'
WHERE id=?""",
(session["id"],),
)
logger.info(
"Auto-closed stale session %s (started %s)",
session["id"],
session["started_at"],
)
return len(stale)
def close_orphaned_sessions(user_id: str, keep_session_id: str) -> list[str]:
"""
Close all open sessions for this user EXCEPT the specified keep_session_id.
Returns the list of session IDs that were closed.
Use this to clean up orphaned sessions from crashed IDEs, dead VS Code
windows, or any parallel session that was never properly closed.
"""
with db() as conn:
orphans = conn.execute(
"""SELECT id, started_at FROM sessions
WHERE user_id=? AND ended_at IS NULL AND id != ?""",
(user_id, keep_session_id),
).fetchall()
closed_ids = []
for session in orphans:
conn.execute(
"""UPDATE sessions
SET ended_at=CURRENT_TIMESTAMP,
one_liner='[orphaned — closed by memory_close_stale_sessions]',
outcome='Session was open but never properly closed (IDE crash or forgotten). Cleaned up manually.'
WHERE id=?""",
(session["id"],),
)
closed_ids.append(session["id"])
logger.info(
"Closed orphaned session %s (started %s)",
session["id"],
session["started_at"],
)
return closed_ids
def restart_server_in_place() -> None:
"""
Replace the current process image with a fresh copy via os.execv.
Called from a background thread so the MCP response is delivered first.
Inherits stdin/stdout file descriptors so the IDE stdio connection survives.
"""
import os
import sys
import time
time.sleep(0.5)
logger.info("🔄 os.execv — replacing process image with fresh copy")
os.execv(sys.executable, [sys.executable] + sys.argv)
+98
View File
@@ -0,0 +1,98 @@
"""Builds the bootstrapped markdown context string injected at session start.
Tier 0 (identity profile) + Tier 1 (recent session index).
Tier G (global knowledge) will be added in Phase 3.
"""
import logging
from datetime import datetime
from typing import Optional
from bigmind import memory_store
logger = logging.getLogger("BigMindContext")
def _format_date(iso_str: Optional[str]) -> str:
if not iso_str:
return ""
try:
dt = datetime.fromisoformat(iso_str.replace("Z", "+00:00"))
return dt.strftime("%Y-%m-%d")
except Exception:
return iso_str[:10]
def build_context(user_id: str, n_sessions: int = 10) -> str:
"""
Assemble the full bootstrapped context markdown for injection at session start.
Returns a markdown string (target: ≤ 800 tokens in personal mode).
"""
lines = [
f"## 🧠 BigMind Context — loaded {datetime.now().strftime('%Y-%m-%d %H:%M')}",
"",
]
# ── TIER 0: Identity Profile ──────────────────────────────────────────────
profile = memory_store.get_identity_profile(user_id)
if profile:
lines.append("### 👤 Who you are")
if profile.get("role"):
lines.append(f"**Role:** {profile['role']}")
if profile.get("preferences"):
lines.append("")
lines.append("**Preferences:**")
lines.append(profile["preferences"])
if profile.get("pinned_facts"):
lines.append("")
lines.append("### 📌 Pinned facts")
for line in profile["pinned_facts"].strip().splitlines():
lines.append(line if line.startswith("-") else f"- {line}")
else:
lines.append("### 👤 Who you are")
lines.append(
"*(No profile yet — call `memory_update_profile` to set up your identity)*"
)
lines.append("")
# ── FACTS: Atomic personal facts ─────────────────────────────────────────
facts = memory_store.get_facts(user_id)
if facts:
lines.append("### 🗂️ Stored facts")
for f in facts:
cat = f.get("category", "")
fact = f.get("fact", "")
lines.append(f"- **[{cat}]** {fact}")
lines.append("")
# ── TIER 1: Recent Sessions ───────────────────────────────────────────────
open_sessions = memory_store.get_open_sessions(user_id)
closed_sessions = memory_store.get_recent_sessions(user_id, limit=n_sessions)
if open_sessions or closed_sessions:
lines.append(f"### 📅 Recent sessions (last {len(closed_sessions)} closed)")
lines.append("| Date | Headline | Topics | Outcome |")
lines.append("|---|---|---|---|")
for s in open_sessions:
date = _format_date(s.get("started_at"))
sid = (s.get("id") or "")[:8]
lines.append(f"| {date} | 🟡 **[in progress]** `{sid}…` | — | (session not yet closed) |")
for s in closed_sessions:
date = _format_date(s.get("started_at"))
headline = (s.get("one_liner") or "")[:80]
topics = (s.get("topics") or "")[:40]
outcome = (s.get("outcome") or "")[:80]
tier2_hint = " 📄" if s.get("has_tier2") else ""
lines.append(f"| {date} | {headline}{tier2_hint} | {topics} | {outcome} |")
lines.append("")
lines.append(
"*(📄 = detailed Tier-2 summary available via `memory_get_session_detail`)*"
)
else:
lines.append("### 📅 Recent sessions")
lines.append(
"*(No past sessions yet — this is the beginning of your BigMind history)*"
)
lines.append("")
return "\n".join(lines)
+469
View File
@@ -0,0 +1,469 @@
"""Database layer for BigMind memory store.
Handles SQLite connection, schema creation, and migrations.
The DB file location is controlled by the BIGMIND_DB_PATH env var,
defaulting to ~/.mcp/bigmind/memory.db.
"""
import sqlite3
import os
import logging
from pathlib import Path
from contextlib import contextmanager
from typing import Generator
logger = logging.getLogger("BigMindDB")
SCHEMA_VERSION = 7
DEFAULT_DB_PATH = Path.home() / ".mcp" / "bigmind" / "memory.db"
# ─── DDL ─────────────────────────────────────────────────────────────────────
_DDL_STATEMENTS = [
# Schema version guard
"""CREATE TABLE IF NOT EXISTS schema_version (
version INTEGER PRIMARY KEY
)""",
# ── USERS ──────────────────────────────────────────────────────────────
"""CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
username TEXT UNIQUE NOT NULL,
display_name TEXT,
role TEXT DEFAULT 'member',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
last_seen DATETIME
)""",
# ── TIER G — Global / Company Knowledge ────────────────────────────────
"""CREATE TABLE IF NOT EXISTS global_knowledge (
id INTEGER PRIMARY KEY AUTOINCREMENT,
category TEXT NOT NULL,
title TEXT NOT NULL,
content TEXT NOT NULL,
importance INTEGER DEFAULT 5,
status TEXT DEFAULT 'pending',
promoted_by TEXT REFERENCES users(id),
source_session TEXT,
approved_by TEXT REFERENCES users(id),
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)""",
# FTS for global_knowledge — rowid-based (no content-table sync needed)
"""CREATE VIRTUAL TABLE IF NOT EXISTS global_knowledge_fts USING fts5(
content,
title
)""",
# ── TIER 0 — Identity Profile ───────────────────────────────────────────
"""CREATE TABLE IF NOT EXISTS identity_profile (
id TEXT PRIMARY KEY,
user_id TEXT UNIQUE NOT NULL REFERENCES users(id),
role TEXT,
preferences TEXT,
pinned_facts TEXT,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)""",
# ── TIER 1 — Session Index ──────────────────────────────────────────────
"""CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id),
started_at DATETIME NOT NULL,
ended_at DATETIME,
one_liner TEXT NOT NULL DEFAULT '[session in progress]',
topics TEXT,
outcome TEXT,
importance INTEGER DEFAULT 5,
has_tier2 INTEGER DEFAULT 0
)""",
"""CREATE INDEX IF NOT EXISTS idx_sessions_user_date
ON sessions(user_id, started_at DESC)""",
"""CREATE INDEX IF NOT EXISTS idx_sessions_topics
ON sessions(topics)""",
# ── TIER 2 — Session Summaries ──────────────────────────────────────────
"""CREATE TABLE IF NOT EXISTS session_summaries (
id TEXT PRIMARY KEY,
summary TEXT NOT NULL,
key_facts TEXT,
code_refs TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)""",
# ── TIER 3 — Flagged Conversation Chunks ────────────────────────────────
"""CREATE TABLE IF NOT EXISTS conversation_chunks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL REFERENCES sessions(id),
user_id TEXT NOT NULL REFERENCES users(id),
role TEXT NOT NULL CHECK (role IN ('user', 'assistant', 'system')),
content TEXT NOT NULL,
flag_reason TEXT,
seq INTEGER NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)""",
"""CREATE INDEX IF NOT EXISTS idx_chunks_session
ON conversation_chunks(session_id)""",
"""CREATE INDEX IF NOT EXISTS idx_chunks_user
ON conversation_chunks(user_id)""",
# FTS for chunks — rowid = conversation_chunks.id (managed manually)
"""CREATE VIRTUAL TABLE IF NOT EXISTS conversation_chunks_fts USING fts5(
content,
flag_reason,
tokenize = 'porter unicode61'
)""",
# ── FACTS ───────────────────────────────────────────────────────────────
"""CREATE TABLE IF NOT EXISTS facts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL REFERENCES users(id),
category TEXT NOT NULL,
fact TEXT NOT NULL,
source_session TEXT REFERENCES sessions(id),
confidence REAL DEFAULT 1.0,
deprecated INTEGER DEFAULT 0,
deprecation_reason TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)""",
"""CREATE INDEX IF NOT EXISTS idx_facts_user
ON facts(user_id)""",
# FTS for facts — rowid = facts.id (managed manually)
"""CREATE VIRTUAL TABLE IF NOT EXISTS facts_fts USING fts5(
fact,
category,
tokenize = 'porter unicode61'
)""",
# ── THOUGHT JOURNAL — Hypotheses ────────────────────────────────────────
"""CREATE TABLE IF NOT EXISTS hypotheses (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT REFERENCES sessions(id),
user_id TEXT NOT NULL REFERENCES users(id),
hypothesis TEXT NOT NULL,
confidence REAL DEFAULT 0.7,
status TEXT NOT NULL DEFAULT 'open'
CHECK (status IN ('open', 'confirmed', 'refuted', 'abandoned')),
resolution TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
resolved_at DATETIME
)""",
"""CREATE INDEX IF NOT EXISTS idx_hypotheses_user_status
ON hypotheses(user_id, status)""",
# ── UPGRADE REQUESTS — AI self-improvement wish list ────────────────────
"""CREATE TABLE IF NOT EXISTS upgrade_requests (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT REFERENCES sessions(id),
user_id TEXT NOT NULL REFERENCES users(id),
description TEXT NOT NULL,
reason TEXT NOT NULL,
priority TEXT NOT NULL DEFAULT 'medium'
CHECK (priority IN ('low', 'medium', 'high')),
certainty REAL NOT NULL DEFAULT 0.7,
status TEXT NOT NULL DEFAULT 'open'
CHECK (status IN ('open', 'resolved', 'rejected')),
resolution TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
resolved_at DATETIME
)""",
"""CREATE INDEX IF NOT EXISTS idx_upgrade_requests_user_status
ON upgrade_requests(user_id, status)""",
# ── TOKEN SAVES — efficiency tracker (Phase 2.7 Feature 6) ─────────────
"""CREATE TABLE IF NOT EXISTS token_saves (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL REFERENCES sessions(id),
user_id TEXT NOT NULL REFERENCES users(id),
description TEXT NOT NULL,
method_used TEXT,
tokens_saved_estimate INTEGER NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)""",
"""CREATE INDEX IF NOT EXISTS idx_token_saves_user
ON token_saves(user_id)""",
# ── PEOPLE — Contacts & AI peers directory ───────────────────────────────
"""CREATE TABLE IF NOT EXISTS people (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL REFERENCES users(id),
username TEXT NOT NULL,
display_name TEXT,
role TEXT,
team TEXT,
notes TEXT,
bigmind_user TEXT,
bigmind_url TEXT,
last_mentioned_at DATETIME DEFAULT CURRENT_TIMESTAMP,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(user_id, username)
)""",
"""CREATE INDEX IF NOT EXISTS idx_people_user
ON people(user_id)""",
# FTS for people — search by name/role/team/notes
"""CREATE VIRTUAL TABLE IF NOT EXISTS people_fts USING fts5(
username,
display_name,
role,
team,
notes,
tokenize = 'porter unicode61'
)""",
]
# ─── Connection helpers ───────────────────────────────────────────────────────
def get_db_path() -> Path:
"""Return the active database file path."""
path_env = os.environ.get("BIGMIND_DB_PATH")
if path_env:
return Path(path_env)
return DEFAULT_DB_PATH
def get_connection() -> sqlite3.Connection:
"""Open and return a configured SQLite connection."""
db_path = get_db_path()
db_path.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(str(db_path), timeout=30) # 30s wait on write lock (multi-IDE safe)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA foreign_keys=ON")
return conn
@contextmanager
def db() -> Generator[sqlite3.Connection, None, None]:
"""Context manager that yields a connection, commits on success, rolls back on error."""
conn = get_connection()
try:
yield conn
conn.commit()
except Exception:
conn.rollback()
raise
finally:
conn.close()
# ─── Schema initialisation ────────────────────────────────────────────────────
def _migrate_v1_to_v2(conn: sqlite3.Connection) -> None:
"""v1 → v2: add deprecated columns to the facts table."""
for col_ddl in (
"ALTER TABLE facts ADD COLUMN deprecated INTEGER DEFAULT 0",
"ALTER TABLE facts ADD COLUMN deprecation_reason TEXT",
):
try:
conn.execute(col_ddl)
except sqlite3.OperationalError as exc:
if "duplicate column" not in str(exc).lower():
raise
logger.info("BigMind schema migrated v1 → v2 (deprecated facts support)")
def _migrate_v2_to_v3(conn: sqlite3.Connection) -> None:
"""v2 → v3: add the thought journal (hypotheses table)."""
conn.execute("""
CREATE TABLE IF NOT EXISTS hypotheses (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT REFERENCES sessions(id),
user_id TEXT NOT NULL REFERENCES users(id),
hypothesis TEXT NOT NULL,
confidence REAL DEFAULT 0.7,
status TEXT NOT NULL DEFAULT 'open'
CHECK (status IN ('open', 'confirmed', 'refuted', 'abandoned')),
resolution TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
resolved_at DATETIME
)
""")
conn.execute("""
CREATE INDEX IF NOT EXISTS idx_hypotheses_user_status
ON hypotheses(user_id, status)
""")
logger.info("BigMind schema migrated v2 → v3 (thought journal / hypotheses)")
def _migrate_v4_to_v5(conn: sqlite3.Connection) -> None:
"""v4 → v5: add FTS index for facts table."""
conn.execute("""
CREATE VIRTUAL TABLE IF NOT EXISTS facts_fts USING fts5(
fact,
category,
tokenize = 'porter unicode61'
)
""")
# Back-fill existing facts into FTS
conn.execute("""
INSERT INTO facts_fts(rowid, fact, category)
SELECT id, fact, category FROM facts
""")
logger.info("BigMind schema migrated v4 → v5 (facts FTS index)")
def _migrate_v5_to_v6(conn: sqlite3.Connection) -> None:
"""v5 → v6: add token_saves table (Feature 6) and focus/ide columns on sessions (Feature 7)."""
# token_saves table — efficiency tracker
conn.execute("""
CREATE TABLE IF NOT EXISTS token_saves (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL REFERENCES sessions(id),
user_id TEXT NOT NULL REFERENCES users(id),
description TEXT NOT NULL,
method_used TEXT,
tokens_saved_estimate INTEGER NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
""")
conn.execute("""
CREATE INDEX IF NOT EXISTS idx_token_saves_user
ON token_saves(user_id)
""")
# Live Session Awareness columns on sessions table
for col_ddl in (
"ALTER TABLE sessions ADD COLUMN current_focus TEXT",
"ALTER TABLE sessions ADD COLUMN focus_files TEXT", # JSON array
"ALTER TABLE sessions ADD COLUMN focus_updated_at DATETIME",
"ALTER TABLE sessions ADD COLUMN ide_hint TEXT",
):
try:
conn.execute(col_ddl)
except sqlite3.OperationalError as exc:
if "duplicate column" not in str(exc).lower():
raise
logger.info(
"BigMind schema migrated v5 → v6 "
"(token_saves table + focus/ide columns on sessions)"
)
def _migrate_v3_to_v4(conn: sqlite3.Connection) -> None:
"""v3 → v4: add upgrade requests table."""
conn.execute("""
CREATE TABLE IF NOT EXISTS upgrade_requests (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT REFERENCES sessions(id),
user_id TEXT NOT NULL REFERENCES users(id),
description TEXT NOT NULL,
reason TEXT NOT NULL,
priority TEXT NOT NULL DEFAULT 'medium'
CHECK (priority IN ('low', 'medium', 'high')),
certainty REAL NOT NULL DEFAULT 0.7,
status TEXT NOT NULL DEFAULT 'open'
CHECK (status IN ('open', 'resolved', 'rejected')),
resolution TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
resolved_at DATETIME
)
""")
conn.execute("""
CREATE INDEX IF NOT EXISTS idx_upgrade_requests_user_status
ON upgrade_requests(user_id, status)
""")
logger.info("BigMind schema migrated v3 → v4 (upgrade requests)")
def init_db() -> None:
"""Initialise database schema. Idempotent — safe to call on every startup."""
with db() as conn:
for stmt in _DDL_STATEMENTS:
try:
conn.execute(stmt)
except sqlite3.OperationalError as exc:
# Virtual tables raise "already exists" on some SQLite builds
if "already exists" not in str(exc).lower():
raise
row = conn.execute("SELECT version FROM schema_version").fetchone()
current_version = row["version"] if row else 0
# ── Run migrations ────────────────────────────────────────────────────
if current_version < 2:
_migrate_v1_to_v2(conn)
if current_version < 3:
_migrate_v2_to_v3(conn)
if current_version < 4:
_migrate_v3_to_v4(conn)
if current_version < 5:
_migrate_v4_to_v5(conn)
if current_version < 6:
_migrate_v5_to_v6(conn)
if current_version < 7:
_migrate_v6_to_v7(conn)
# Write / update the version
if row:
conn.execute(
"UPDATE schema_version SET version=?", (SCHEMA_VERSION,)
)
else:
conn.execute(
"INSERT INTO schema_version (version) VALUES (?)", (SCHEMA_VERSION,)
)
logger.info(
"BigMind DB ready at %s (schema v%d)", get_db_path(), SCHEMA_VERSION
)
def _migrate_v6_to_v7(conn: sqlite3.Connection) -> None:
"""v6 → v7: add people/contacts directory."""
conn.execute("""
CREATE TABLE IF NOT EXISTS people (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL REFERENCES users(id),
username TEXT NOT NULL,
display_name TEXT,
role TEXT,
team TEXT,
notes TEXT,
bigmind_user TEXT,
bigmind_url TEXT,
last_mentioned_at DATETIME DEFAULT CURRENT_TIMESTAMP,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(user_id, username)
)
""")
conn.execute("""
CREATE INDEX IF NOT EXISTS idx_people_user
ON people(user_id)
""")
conn.execute("""
CREATE VIRTUAL TABLE IF NOT EXISTS people_fts USING fts5(
username,
display_name,
role,
team,
notes,
tokenize = 'porter unicode61'
)
""")
logger.info("BigMind schema migrated v6 → v7 (people/contacts directory)")
def vacuum_db() -> None:
"""Run VACUUM outside of any transaction (SQLite requirement)."""
db_path = get_db_path()
conn = sqlite3.connect(str(db_path))
conn.isolation_level = None # autocommit mode required for VACUUM
try:
conn.execute("VACUUM")
finally:
conn.close()
+926
View File
@@ -0,0 +1,926 @@
"""Memory store: CRUD operations for all BigMind tiers."""
import uuid
import os
import logging
from datetime import datetime, timezone
from typing import Optional
from bigmind.db import db, get_db_path
logger = logging.getLogger("BigMindStore")
def get_current_username() -> str:
return (
os.environ.get("BIGMIND_USER")
or os.environ.get("USER")
or os.environ.get("USERNAME")
or "default"
)
# ── USERS ──────────────────────────────────────────────────────────────────────
def get_or_create_user(username: str, display_name: str = None) -> dict:
with db() as conn:
row = conn.execute(
"SELECT * FROM users WHERE username = ?", (username,)
).fetchone()
if row:
conn.execute(
"UPDATE users SET last_seen = ? WHERE id = ?",
(datetime.now(timezone.utc).isoformat(), row["id"]),
)
return dict(row)
uid = str(uuid.uuid4())
conn.execute(
"INSERT INTO users (id, username, display_name, last_seen) VALUES (?,?,?,?)",
(uid, username, display_name or username,
datetime.now(timezone.utc).isoformat()),
)
return {
"id": uid, "username": username,
"display_name": display_name or username, "role": "member",
}
# ── TIER 0 ─────────────────────────────────────────────────────────────────────
def get_identity_profile(user_id: str) -> Optional[dict]:
with db() as conn:
row = conn.execute(
"SELECT * FROM identity_profile WHERE user_id = ?", (user_id,)
).fetchone()
return dict(row) if row else None
def upsert_identity_profile(
user_id: str,
role: str = None,
preferences: str = None,
pinned_facts: str = None,
) -> dict:
now = datetime.now(timezone.utc).isoformat()
with db() as conn:
existing = conn.execute(
"SELECT id FROM identity_profile WHERE user_id = ?", (user_id,)
).fetchone()
if existing:
conn.execute(
"""UPDATE identity_profile
SET role=COALESCE(?,role),
preferences=COALESCE(?,preferences),
pinned_facts=COALESCE(?,pinned_facts),
updated_at=?
WHERE user_id=?""",
(role, preferences, pinned_facts, now, user_id),
)
else:
conn.execute(
"""INSERT INTO identity_profile
(id, user_id, role, preferences, pinned_facts, updated_at)
VALUES (?,?,?,?,?,?)""",
(user_id, user_id, role, preferences, pinned_facts, now),
)
row = conn.execute(
"SELECT * FROM identity_profile WHERE user_id=?", (user_id,)
).fetchone()
return dict(row)
# ── TIER 1 ─────────────────────────────────────────────────────────────────────
def create_session(user_id: str) -> str:
session_id = str(uuid.uuid4())
with db() as conn:
conn.execute(
"""INSERT INTO sessions (id, user_id, started_at, one_liner)
VALUES (?, ?, ?, '[session in progress]')""",
(session_id, user_id, datetime.now(timezone.utc).isoformat()),
)
return session_id
def close_session(
session_id: str,
one_liner: str,
topics: str = None,
outcome: str = None,
importance: int = 5,
) -> None:
with db() as conn:
conn.execute(
"""UPDATE sessions
SET ended_at=?, one_liner=?, topics=?, outcome=?, importance=?,
current_focus=NULL, focus_files=NULL, focus_updated_at=NULL
WHERE id=?""",
(
datetime.now(timezone.utc).isoformat(),
one_liner[:120],
topics,
outcome,
importance,
session_id,
),
)
def save_session_summary(
session_id: str,
summary: str,
key_facts: str = None,
code_refs: str = None,
) -> None:
with db() as conn:
existing = conn.execute(
"SELECT id FROM session_summaries WHERE id=?", (session_id,)
).fetchone()
if existing:
conn.execute(
"""UPDATE session_summaries
SET summary=?, key_facts=?, code_refs=? WHERE id=?""",
(summary, key_facts, code_refs, session_id),
)
else:
conn.execute(
"""INSERT INTO session_summaries (id, summary, key_facts, code_refs)
VALUES (?,?,?,?)""",
(session_id, summary, key_facts, code_refs),
)
conn.execute(
"UPDATE sessions SET has_tier2=1 WHERE id=?", (session_id,)
)
def get_recent_sessions(user_id: str, limit: int = 10) -> list:
with db() as conn:
rows = conn.execute(
"""SELECT id, started_at, ended_at, one_liner, topics,
outcome, importance, has_tier2
FROM sessions
WHERE user_id=? AND ended_at IS NOT NULL
ORDER BY started_at DESC LIMIT ?""",
(user_id, limit),
).fetchall()
return [dict(r) for r in rows]
def get_session_detail(session_id: str) -> Optional[dict]:
with db() as conn:
row = conn.execute(
"SELECT * FROM session_summaries WHERE id=?", (session_id,)
).fetchone()
return dict(row) if row else None
def get_open_sessions(user_id: str) -> list:
with db() as conn:
rows = conn.execute(
"SELECT * FROM sessions WHERE user_id=? AND ended_at IS NULL",
(user_id,),
).fetchall()
return [dict(r) for r in rows]
def announce_focus(
session_id: str,
description: str,
files: list = None,
ide_hint: str = None,
) -> dict:
"""Atomically update this session's focus and check for conflicts with other open sessions.
Uses BEGIN IMMEDIATE to make the conflict-check + write atomic — eliminates the
TOCTOU race condition where two sessions could both pass the conflict check before
either writes. Returns a dict with 'conflicts' (list of colliding sessions) and
'updated' (bool).
"""
import json
files = files or []
files_json = json.dumps(files)
now = datetime.now(timezone.utc).isoformat()
conflicts = []
conn = None
try:
from bigmind.db import get_connection
conn = get_connection()
# BEGIN IMMEDIATE acquires the write lock before we read other sessions —
# no other writer can sneak in between our check and our update.
conn.execute("BEGIN IMMEDIATE")
# Find other open sessions that share any of our files
if files:
other_sessions = conn.execute(
"""SELECT id, current_focus, focus_files, ide_hint, focus_updated_at
FROM sessions
WHERE user_id = (SELECT user_id FROM sessions WHERE id=?)
AND ended_at IS NULL
AND id != ?
AND focus_files IS NOT NULL""",
(session_id, session_id),
).fetchall()
for row in other_sessions:
try:
other_files = json.loads(row["focus_files"] or "[]")
except (json.JSONDecodeError, TypeError):
other_files = []
overlapping = [f for f in files if f in other_files]
if overlapping:
conflicts.append({
"session_id": row["id"][:8],
"ide_hint": row["ide_hint"],
"focus": row["current_focus"],
"overlapping_files": overlapping,
"focus_updated_at": row["focus_updated_at"],
})
# Write our focus atomically — under the same lock as the check above
update_fields: list = [description, files_json, now]
if ide_hint is not None:
conn.execute(
"""UPDATE sessions
SET current_focus=?, focus_files=?, focus_updated_at=?, ide_hint=?
WHERE id=?""",
(description, files_json, now, ide_hint, session_id),
)
else:
conn.execute(
"""UPDATE sessions
SET current_focus=?, focus_files=?, focus_updated_at=?
WHERE id=?""",
(description, files_json, now, session_id),
)
conn.commit()
except Exception:
if conn:
conn.rollback()
raise
finally:
if conn:
conn.close()
return {"updated": True, "conflicts": conflicts}
def get_active_sessions(user_id: str) -> list:
"""Return all open sessions with their focus data and idle_minutes computed."""
import json
now = datetime.now(timezone.utc)
with db() as conn:
rows = conn.execute(
"""SELECT id, started_at, current_focus, focus_files,
focus_updated_at, ide_hint
FROM sessions
WHERE user_id=? AND ended_at IS NULL
ORDER BY COALESCE(focus_updated_at, started_at) DESC""",
(user_id,),
).fetchall()
result = []
for row in rows:
r = dict(row)
# Compute idle_minutes from focus_updated_at (or started_at as fallback)
ts_str = r.get("focus_updated_at") or r.get("started_at")
idle_minutes = None
if ts_str:
try:
ts = datetime.fromisoformat(ts_str.replace("Z", "+00:00"))
if ts.tzinfo is None:
ts = ts.replace(tzinfo=timezone.utc)
idle_minutes = int((now - ts).total_seconds() / 60)
except (ValueError, TypeError):
idle_minutes = None
try:
files = json.loads(r.get("focus_files") or "[]")
except (json.JSONDecodeError, TypeError):
files = []
result.append({
"session_id": r["id"],
"ide_hint": r.get("ide_hint"),
"focus": r.get("current_focus"),
"files": files,
"focus_updated_at": r.get("focus_updated_at"),
"idle_minutes": idle_minutes,
})
return result
# ── TOKEN SAVES ────────────────────────────────────────────────────────────────
def log_token_save(
session_id: str,
user_id: str,
description: str,
tokens_saved_estimate: int,
method_used: str = None,
) -> int:
"""Record a token efficiency event in the token_saves table. Returns the new row id."""
with db() as conn:
cur = conn.execute(
"""INSERT INTO token_saves (session_id, user_id, description, tokens_saved_estimate, method_used)
VALUES (?,?,?,?,?)""",
(session_id, user_id, description, tokens_saved_estimate, method_used),
)
return cur.lastrowid
def get_token_efficiency_stats(user_id: str, session_id: str = None) -> dict:
"""Return aggregated token efficiency stats for profile display."""
with db() as conn:
total = conn.execute(
"SELECT COALESCE(SUM(tokens_saved_estimate),0) FROM token_saves WHERE user_id=?",
(user_id,),
).fetchone()[0]
session_total = 0
if session_id:
session_total = conn.execute(
"SELECT COALESCE(SUM(tokens_saved_estimate),0) FROM token_saves WHERE user_id=? AND session_id=?",
(user_id, session_id),
).fetchone()[0]
best_row = conn.execute(
"""SELECT description, tokens_saved_estimate, method_used, created_at
FROM token_saves WHERE user_id=?
ORDER BY tokens_saved_estimate DESC LIMIT 1""",
(user_id,),
).fetchone()
by_method = conn.execute(
"""SELECT method_used, SUM(tokens_saved_estimate) as total
FROM token_saves WHERE user_id=?
GROUP BY method_used ORDER BY total DESC""",
(user_id,),
).fetchall()
recent = conn.execute(
"""SELECT description, tokens_saved_estimate, method_used, created_at
FROM token_saves WHERE user_id=?
ORDER BY created_at DESC LIMIT 5""",
(user_id,),
).fetchall()
return {
"total_tokens_saved": total,
"session_tokens_saved": session_total,
"best_save": dict(best_row) if best_row else None,
"by_method": [dict(r) for r in by_method],
"recent_saves": [dict(r) for r in recent],
}
# ── TIER 3 ─────────────────────────────────────────────────────────────────────
def append_chunk(
session_id: str,
user_id: str,
role: str,
content: str,
flag_reason: str = None,
) -> int:
with db() as conn:
max_seq = conn.execute(
"SELECT COALESCE(MAX(seq),0) FROM conversation_chunks WHERE session_id=?",
(session_id,),
).fetchone()[0]
seq = max_seq + 1
cur = conn.execute(
"""INSERT INTO conversation_chunks
(session_id, user_id, role, content, flag_reason, seq)
VALUES (?,?,?,?,?,?)""",
(session_id, user_id, role, content, flag_reason, seq),
)
chunk_id = cur.lastrowid
# Keep FTS in sync — rowid of FTS row = chunk_id
conn.execute(
"INSERT INTO conversation_chunks_fts(rowid, content, flag_reason) VALUES(?,?,?)",
(chunk_id, content, flag_reason or ""),
)
return chunk_id
def _fts_safe_query(query: str) -> str:
"""Wrap each token in double-quotes for safe FTS5 matching.
Prevents FTS5 reserved-word collisions (rank, content, category, etc.)
while correctly AND-matching multi-word queries — NOT phrase matching.
FTS5 semantics:
"word1" "word2" → documents containing BOTH words anywhere (AND match ✅)
"word1 word2" → documents where word1 appears directly before word2 (phrase ❌)
Bug history: the 2026-03-31 fix used f'"{query}"' which wraps the entire string,
accidentally turning every multi-word query into a phrase search that almost never
matches. This helper fixes that by quoting each token independently.
"""
tokens = [t.strip('"\'') for t in query.split() if t.strip()]
return ' '.join(f'"{t}"' for t in tokens if t)
def search_chunks(user_id: str, query: str, limit: int = 10) -> list:
with db() as conn:
rows = conn.execute(
"""SELECT cc.id, cc.session_id, cc.role, cc.content,
cc.flag_reason, cc.created_at,
bm25(conversation_chunks_fts) AS rank
FROM conversation_chunks_fts
JOIN conversation_chunks cc ON cc.id = conversation_chunks_fts.rowid
WHERE conversation_chunks_fts MATCH ?
AND cc.user_id = ?
ORDER BY rank
LIMIT ?""",
(_fts_safe_query(query), user_id, limit),
).fetchall()
return [dict(r) for r in rows]
def delete_chunks_before(user_id: str, cutoff_iso: str) -> int:
"""Delete Tier-3 chunks older than cutoff. Returns count deleted."""
with db() as conn:
count = conn.execute(
"SELECT COUNT(*) FROM conversation_chunks WHERE user_id=? AND created_at < ?",
(user_id, cutoff_iso),
).fetchone()[0]
if count == 0:
return 0
conn.execute(
"DELETE FROM conversation_chunks WHERE user_id=? AND created_at < ?",
(user_id, cutoff_iso),
)
# Rebuild the FTS5 index from the content table — always correct for content= tables
conn.execute(
"INSERT INTO conversation_chunks_fts(conversation_chunks_fts) VALUES('rebuild')"
)
return count
# ── FACTS ───────────────────────────────────────────────────────────────────────
def store_fact(
user_id: str,
category: str,
fact: str,
source_session: str = None,
confidence: float = 1.0,
) -> int:
with db() as conn:
cur = conn.execute(
"""INSERT INTO facts (user_id, category, fact, source_session, confidence)
VALUES (?,?,?,?,?)""",
(user_id, category, fact, source_session, confidence),
)
fact_id = cur.lastrowid
conn.execute(
"INSERT INTO facts_fts(rowid, fact, category) VALUES (?,?,?)",
(fact_id, fact, category),
)
return fact_id
def get_facts(user_id: str, category: str = None, include_deprecated: bool = False) -> list:
with db() as conn:
clauses = ["user_id=?"]
params: list = [user_id]
if category:
clauses.append("category=?")
params.append(category)
if not include_deprecated:
clauses.append("(deprecated IS NULL OR deprecated=0)")
where = " AND ".join(clauses)
rows = conn.execute(
f"SELECT * FROM facts WHERE {where} ORDER BY created_at DESC",
params,
).fetchall()
return [dict(r) for r in rows]
def deprecate_fact(fact_id: int, user_id: str, reason: str = None) -> bool:
"""Mark a fact as deprecated. Returns True if a row was updated."""
now = datetime.now(timezone.utc).isoformat()
with db() as conn:
row = conn.execute(
"SELECT id FROM facts WHERE id=? AND user_id=?", (fact_id, user_id)
).fetchone()
if not row:
return False
conn.execute(
"""UPDATE facts
SET deprecated=1, deprecation_reason=?, updated_at=?
WHERE id=?""",
(reason, now, fact_id),
)
# Remove from FTS so deprecated facts don't appear in search results
conn.execute("DELETE FROM facts_fts WHERE rowid=?", (fact_id,))
return True
def search_facts(user_id: str, query: str, limit: int = 10) -> list:
"""Full-text search across non-deprecated facts for a user."""
with db() as conn:
rows = conn.execute(
"""SELECT f.id, f.category, f.fact, f.confidence, f.created_at,
bm25(facts_fts) AS rank
FROM facts_fts
JOIN facts f ON f.id = facts_fts.rowid
WHERE facts_fts MATCH ?
AND f.user_id = ?
AND (f.deprecated IS NULL OR f.deprecated = 0)
ORDER BY rank
LIMIT ?""",
(_fts_safe_query(query), user_id, limit),
).fetchall()
return [dict(r) for r in rows]
# ── THOUGHT JOURNAL ──────────────────────────────────────────────────────────────
def add_hypothesis(
user_id: str,
session_id: str,
hypothesis: str,
confidence: float = 0.7,
) -> int:
"""Record a new hypothesis. Returns the hypothesis id."""
with db() as conn:
cur = conn.execute(
"""INSERT INTO hypotheses (user_id, session_id, hypothesis, confidence)
VALUES (?, ?, ?, ?)""",
(user_id, session_id, hypothesis, confidence),
)
return cur.lastrowid
def resolve_hypothesis(
hypothesis_id: int,
user_id: str,
status: str,
resolution: str = None,
) -> bool:
"""Resolve a hypothesis. Returns True if updated, False if not found / wrong user."""
if status not in ("confirmed", "refuted", "abandoned"):
raise ValueError(f"Invalid status '{status}'. Must be confirmed, refuted, or abandoned.")
now = datetime.now(timezone.utc).isoformat()
with db() as conn:
row = conn.execute(
"SELECT id FROM hypotheses WHERE id=? AND user_id=?",
(hypothesis_id, user_id),
).fetchone()
if not row:
return False
conn.execute(
"""UPDATE hypotheses
SET status=?, resolution=?, resolved_at=?
WHERE id=?""",
(status, resolution, now, hypothesis_id),
)
return True
def list_hypotheses(user_id: str, status: str = None) -> list:
"""Return hypotheses for a user, optionally filtered by status."""
with db() as conn:
if status:
rows = conn.execute(
"""SELECT * FROM hypotheses WHERE user_id=? AND status=?
ORDER BY created_at DESC""",
(user_id, status),
).fetchall()
else:
rows = conn.execute(
"SELECT * FROM hypotheses WHERE user_id=? ORDER BY created_at DESC",
(user_id,),
).fetchall()
return [dict(r) for r in rows]
# ── UPGRADE REQUESTS ────────────────────────────────────────────────────────────────────────────────────
def add_upgrade_request(
user_id: str,
session_id: str,
description: str,
reason: str,
priority: str = "medium",
certainty: float = 0.7,
) -> int:
"""Record a new upgrade request. Returns the request id."""
with db() as conn:
cur = conn.execute(
"""INSERT INTO upgrade_requests
(user_id, session_id, description, reason, priority, certainty)
VALUES (?, ?, ?, ?, ?, ?)""",
(user_id, session_id, description, reason, priority, certainty),
)
return cur.lastrowid
def list_upgrade_requests(user_id: str, status: str = None) -> list:
"""Return upgrade requests for a user, optionally filtered by status."""
with db() as conn:
if status:
rows = conn.execute(
"""SELECT * FROM upgrade_requests WHERE user_id=? AND status=?
ORDER BY created_at DESC""",
(user_id, status),
).fetchall()
else:
rows = conn.execute(
"SELECT * FROM upgrade_requests WHERE user_id=? ORDER BY created_at DESC",
(user_id,),
).fetchall()
return [dict(r) for r in rows]
def resolve_upgrade_request(
request_id: int,
user_id: str,
status: str,
resolution: str = None,
) -> bool:
"""Resolve an upgrade request. Returns True if updated, False if not found / wrong user."""
if status not in ("resolved", "rejected"):
raise ValueError(f"Invalid status '{status}'. Must be resolved or rejected.")
now = datetime.now(timezone.utc).isoformat()
with db() as conn:
row = conn.execute(
"SELECT id FROM upgrade_requests WHERE id=? AND user_id=?",
(request_id, user_id),
).fetchone()
if not row:
return False
conn.execute(
"""UPDATE upgrade_requests
SET status=?, resolution=?, resolved_at=?
WHERE id=?""",
(status, resolution, now, request_id),
)
return True
# ── HEALTH CHECK ────────────────────────────────────────────────────────────────
def health_check(user_id: str, stale_days: int = 30) -> dict:
"""Diagnostic health check on BigMind memory for a user."""
from datetime import timedelta
cutoff = (datetime.now(timezone.utc) - timedelta(days=stale_days)).isoformat()
with db() as conn:
# Facts not updated since the cutoff
stale_rows = conn.execute(
"""SELECT id, category, fact, updated_at, confidence
FROM facts WHERE user_id=? AND updated_at < ?
ORDER BY updated_at""",
(user_id, cutoff),
).fetchall()
# Closed sessions with no Tier-2 narrative
sessions_no_summary = conn.execute(
"""SELECT COUNT(*) FROM sessions
WHERE user_id=? AND ended_at IS NOT NULL AND has_tier2=0""",
(user_id,),
).fetchone()[0]
# Sessions still open (ended_at IS NULL)
open_rows = conn.execute(
"SELECT id, started_at FROM sessions WHERE user_id=? AND ended_at IS NULL",
(user_id,),
).fetchall()
# FTS integrity: global count (FTS rowid = chunk id, no user_id column)
chunk_count = conn.execute(
"SELECT COUNT(*) FROM conversation_chunks"
).fetchone()[0]
fts_count = conn.execute(
"SELECT COUNT(*) FROM conversation_chunks_fts"
).fetchone()[0]
# Low confidence facts (< 0.8)
low_conf_rows = conn.execute(
"""SELECT id, category, fact, confidence
FROM facts WHERE user_id=? AND confidence < 0.8
ORDER BY confidence""",
(user_id,),
).fetchall()
return {
"stale_facts": [dict(r) for r in stale_rows],
"sessions_without_summary": sessions_no_summary,
"open_sessions": [dict(r) for r in open_rows],
"chunk_count": chunk_count,
"fts_row_count": fts_count,
"fts_in_sync": chunk_count == fts_count,
"low_confidence_facts": [dict(r) for r in low_conf_rows],
"stale_threshold_days": stale_days,
}
# ── EXPORT ───────────────────────────────────────────────────────────────────────
def export_memory(user_id: str, output_path: str = None) -> dict:
"""Export all memory for a user to a portable JSON file."""
import json
from pathlib import Path
if not output_path:
date_str = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
output_path = str(Path.home() / f"bigmind_export_{date_str}.json")
output = Path(output_path)
output.parent.mkdir(parents=True, exist_ok=True)
with db() as conn:
user_row = conn.execute(
"SELECT id, username, display_name, role, created_at, last_seen FROM users WHERE id=?",
(user_id,),
).fetchone()
user_info = dict(user_row) if user_row else {}
profile_row = conn.execute(
"SELECT * FROM identity_profile WHERE user_id=?", (user_id,)
).fetchone()
profile = dict(profile_row) if profile_row else {}
facts = [
dict(r) for r in conn.execute(
"SELECT * FROM facts WHERE user_id=? ORDER BY created_at", (user_id,)
).fetchall()
]
sessions = []
for s in conn.execute(
"SELECT * FROM sessions WHERE user_id=? ORDER BY started_at", (user_id,)
).fetchall():
sd = dict(s)
summary_row = conn.execute(
"SELECT * FROM session_summaries WHERE id=?", (s["id"],)
).fetchone()
sd["tier2_summary"] = dict(summary_row) if summary_row else None
sessions.append(sd)
chunks = [
dict(r) for r in conn.execute(
"SELECT * FROM conversation_chunks WHERE user_id=? ORDER BY created_at, seq",
(user_id,),
).fetchall()
]
export_data = {
"export_date": datetime.now(timezone.utc).isoformat(),
"bigmind_version": "1.0",
"user": user_info,
"identity_profile": profile,
"facts": facts,
"sessions": sessions,
"conversation_chunks": chunks,
"stats": {
"facts_count": len(facts),
"sessions_count": len(sessions),
"chunks_count": len(chunks),
},
}
with open(output_path, "w", encoding="utf-8") as f:
json.dump(export_data, f, indent=2, default=str)
return {
"output_path": str(output_path),
"facts_count": len(facts),
"sessions_count": len(sessions),
"chunks_count": len(chunks),
"file_size_kb": round(output.stat().st_size / 1024, 1),
}
# ── STATS ───────────────────────────────────────────────────────────────────────
def get_stats(user_id: str) -> dict:
db_path = get_db_path()
with db() as conn:
sessions = conn.execute(
"SELECT COUNT(*) FROM sessions WHERE user_id=?", (user_id,)
).fetchone()[0]
facts = conn.execute(
"SELECT COUNT(*) FROM facts WHERE user_id=?", (user_id,)
).fetchone()[0]
chunks = conn.execute(
"SELECT COUNT(*) FROM conversation_chunks WHERE user_id=?", (user_id,)
).fetchone()[0]
global_cnt = conn.execute(
"SELECT COUNT(*) FROM global_knowledge WHERE status='approved'"
).fetchone()[0]
size = db_path.stat().st_size if db_path.exists() else 0
return {
"sessions": sessions,
"facts": facts,
"chunks": chunks,
"global_knowledge_entries": global_cnt,
"db_size_bytes": size,
"db_size_kb": round(size / 1024, 1),
"db_path": str(db_path),
}
# ── PEOPLE / CONTACTS ────────────────────────────────────────────────────────
def upsert_person(
user_id: str,
username: str,
display_name: str = None,
role: str = None,
team: str = None,
notes: str = None,
bigmind_user: str = None,
bigmind_url: str = None,
) -> int:
"""Insert or update a person in the contacts directory. Returns the row id."""
now = datetime.now(timezone.utc).isoformat()
with db() as conn:
existing = conn.execute(
"SELECT id FROM people WHERE user_id=? AND username=?",
(user_id, username),
).fetchone()
if existing:
person_id = existing["id"]
# Build dynamic UPDATE — only overwrite non-None fields
updates = {"last_mentioned_at": now}
for field, val in [
("display_name", display_name), ("role", role), ("team", team),
("notes", notes), ("bigmind_user", bigmind_user), ("bigmind_url", bigmind_url),
]:
if val is not None:
updates[field] = val
set_clause = ", ".join(f"{k}=?" for k in updates)
conn.execute(
f"UPDATE people SET {set_clause} WHERE id=?",
(*updates.values(), person_id),
)
# Refresh FTS
conn.execute("DELETE FROM people_fts WHERE rowid=?", (person_id,))
row = conn.execute("SELECT * FROM people WHERE id=?", (person_id,)).fetchone()
else:
cur = conn.execute(
"""INSERT INTO people
(user_id, username, display_name, role, team, notes,
bigmind_user, bigmind_url, last_mentioned_at)
VALUES (?,?,?,?,?,?,?,?,?)""",
(user_id, username, display_name, role, team, notes,
bigmind_user, bigmind_url, now),
)
person_id = cur.lastrowid
row = conn.execute("SELECT * FROM people WHERE id=?", (person_id,)).fetchone()
conn.execute(
"INSERT INTO people_fts(rowid, username, display_name, role, team, notes) "
"VALUES (?,?,?,?,?,?)",
(person_id, row["username"], row["display_name"] or "",
row["role"] or "", row["team"] or "", row["notes"] or ""),
)
return person_id
def recall_person(user_id: str, query: str, limit: int = 10) -> list:
"""Full-text search across the people directory."""
with db() as conn:
rows = conn.execute(
"""SELECT p.*, bm25(people_fts) AS rank
FROM people_fts
JOIN people p ON p.id = people_fts.rowid
WHERE people_fts MATCH ?
AND p.user_id = ?
ORDER BY rank
LIMIT ?""",
(_fts_safe_query(query), user_id, limit),
).fetchall()
return [dict(r) for r in rows]
def list_people(user_id: str) -> list:
"""Return all contacts for a user, ordered by last_mentioned_at."""
with db() as conn:
rows = conn.execute(
"SELECT * FROM people WHERE user_id=? ORDER BY last_mentioned_at DESC",
(user_id,),
).fetchall()
return [dict(r) for r in rows]
def link_ai(user_id: str, username: str, bigmind_user: str, bigmind_url: str = None) -> bool:
"""Link a contact to their BigMind AI instance. Returns True if the person was found."""
now = datetime.now(timezone.utc).isoformat()
with db() as conn:
row = conn.execute(
"SELECT id FROM people WHERE user_id=? AND username=?",
(user_id, username),
).fetchone()
if not row:
return False
conn.execute(
"UPDATE people SET bigmind_user=?, bigmind_url=?, last_mentioned_at=? WHERE id=?",
(bigmind_user, bigmind_url, now, row["id"]),
)
return True
View File
+544
View File
@@ -0,0 +1,544 @@
"""Profile builder — assembles live data from BigMind DB for the profile web page."""
from datetime import datetime, timezone, timedelta
from collections import Counter
from bigmind.db import db, get_db_path
from bigmind.memory_store import get_active_sessions, get_token_efficiency_stats
# ── Badge definitions ─────────────────────────────────────────────────────────
# Each badge: (id, emoji, label, description, check_fn(data) -> bool)
def _badge_first_memory(d):
return d["total_sessions"] >= 1
def _badge_on_fire(d):
return d["max_sessions_in_a_day"] >= 5
def _badge_builder(d):
return d["shipped_sessions"] >= 3
def _badge_bug_slayer(d):
return d["bug_sessions"] >= 3
def _badge_hypothesis_confirmed(d):
return d["confirmed_hypotheses"] >= 3
def _badge_librarian(d):
return d["total_facts"] >= 10
def _badge_deep_thinker(d):
return d["total_hypotheses"] >= 5
def _badge_veteran(d):
return d["active_days"] >= 7
def _badge_polyglot(d):
return len(d["top_topics"]) >= 5
def _badge_memory_keeper(d):
return d["total_chunks"] >= 50
BADGES = [
("first_memory", "🧠", "First Memory", "Opened your very first session", _badge_first_memory),
("on_fire", "🔥", "On Fire", "5+ sessions in a single day", _badge_on_fire),
("builder", "🏗️", "Builder", "Shipped features in 3+ sessions", _badge_builder),
("bug_slayer", "🐛", "Bug Slayer", "Squashed bugs in 3+ sessions", _badge_bug_slayer),
("hypothesis_confirmed", "💡", "Hypothesis Confirmed", "Confirmed 3+ hypotheses as true", _badge_hypothesis_confirmed),
("librarian", "📚", "Librarian", "Stored 10+ personal facts", _badge_librarian),
("deep_thinker", "🤔", "Deep Thinker", "Recorded 5+ hypotheses in the thought journal", _badge_deep_thinker),
("veteran", "", "Veteran", "Active across 7+ different days", _badge_veteran),
("polyglot", "🌐", "Polyglot", "Worked across 5+ distinct topic areas", _badge_polyglot),
("memory_keeper", "💾", "Memory Keeper", "Stored 50+ important memory chunks", _badge_memory_keeper),
]
# ── Keyword sets for badge detection ─────────────────────────────────────────
_SHIP_KEYWORDS = {"ship", "shipped", "built", "build", "implement", "implemented",
"added", "created", "deployed", "released", "live", "complete", "done"}
_BUG_KEYWORDS = {"bug", "fix", "fixed", "debug", "debugged", "error", "issue",
"patch", "patched", "broken", "crash", "resolved"}
def _matches(text: str, keywords: set) -> bool:
if not text:
return False
words = set(text.lower().replace("-", " ").split())
return bool(words & keywords)
# ── Main builder ──────────────────────────────────────────────────────────────
def build_profile_data(user_id: str) -> dict:
"""Query the DB and return a dict with everything the profile page needs."""
db_path = get_db_path()
with db() as conn:
# User info
user = conn.execute(
"SELECT * FROM users WHERE id=?", (user_id,)
).fetchone()
# Identity profile
profile = conn.execute(
"SELECT * FROM identity_profile WHERE user_id=?", (user_id,)
).fetchone()
# All closed sessions (with has_tier2 + per-session token savings)
sessions = conn.execute(
"""SELECT s.id, s.started_at, s.ended_at, s.one_liner, s.topics,
s.outcome, s.importance, s.has_tier2,
COALESCE(SUM(t.tokens_saved_estimate), 0) AS session_tokens_saved
FROM sessions s
LEFT JOIN token_saves t ON t.session_id = s.id
WHERE s.user_id=? AND s.ended_at IS NOT NULL
GROUP BY s.id
ORDER BY s.started_at DESC""",
(user_id,),
).fetchall()
# Open sessions
open_sessions = conn.execute(
"SELECT COUNT(*) FROM sessions WHERE user_id=? AND ended_at IS NULL",
(user_id,),
).fetchone()[0]
# Facts
total_facts = conn.execute(
"SELECT COUNT(*) FROM facts WHERE user_id=? AND (deprecated IS NULL OR deprecated=0)",
(user_id,),
).fetchone()[0]
# Chunks
total_chunks = conn.execute(
"SELECT COUNT(*) FROM conversation_chunks WHERE user_id=?", (user_id,)
).fetchone()[0]
# Hypotheses
hyp_rows = conn.execute(
"""SELECT hypothesis, status, confidence, resolution, created_at
FROM hypotheses WHERE user_id=?
ORDER BY created_at DESC""",
(user_id,),
).fetchall()
# ── Derived stats ─────────────────────────────────────────────────────────
total_sessions = len(sessions)
sessions_list = [dict(s) for s in sessions]
# Active days + max sessions in a day
day_counts: Counter = Counter()
for s in sessions_list:
day = (s.get("started_at") or "")[:10]
if day:
day_counts[day] += 1
active_days = len(day_counts)
max_sessions_in_a_day = max(day_counts.values(), default=0)
# Shipped / bug sessions
shipped_sessions = sum(
1 for s in sessions_list
if _matches(s.get("one_liner", "") + " " + (s.get("topics") or ""), _SHIP_KEYWORDS)
)
bug_sessions = sum(
1 for s in sessions_list
if _matches(s.get("one_liner", "") + " " + (s.get("topics") or ""), _BUG_KEYWORDS)
)
# Hypotheses breakdown
hyp_list = [dict(h) for h in hyp_rows]
hyp_status = Counter(h["status"] for h in hyp_list)
total_hypotheses = sum(hyp_status.values())
confirmed_hypotheses = hyp_status.get("confirmed", 0)
open_hypotheses = hyp_status.get("open", 0)
# Topic frequency
topic_counter: Counter = Counter()
for s in sessions_list:
for t in (s.get("topics") or "").split(","):
t = t.strip()
if t:
topic_counter[t] += 1
top_topics = topic_counter.most_common(10)
# Activity heatmap — last 52 weeks (364 days)
today = datetime.now(timezone.utc).date()
start_day = today - timedelta(days=363)
heatmap: dict[str, int] = {}
for s in sessions_list:
day_str = (s.get("started_at") or "")[:10]
if day_str >= str(start_day):
heatmap[day_str] = heatmap.get(day_str, 0) + 1
# First session date
first_session_date = sessions_list[-1]["started_at"][:10] if sessions_list else None
# DB size
db_size_kb = round(db_path.stat().st_size / 1024, 1) if db_path.exists() else 0
# ── Assemble badge-check input ────────────────────────────────────────────
badge_input = {
"total_sessions": total_sessions,
"max_sessions_in_a_day": max_sessions_in_a_day,
"shipped_sessions": shipped_sessions,
"bug_sessions": bug_sessions,
"confirmed_hypotheses": confirmed_hypotheses,
"total_facts": total_facts,
"total_hypotheses": total_hypotheses,
"active_days": active_days,
"top_topics": top_topics,
"total_chunks": total_chunks,
}
earned_badges = [
{"id": bid, "emoji": emoji, "label": label, "description": desc}
for bid, emoji, label, desc, check_fn in BADGES
if check_fn(badge_input)
]
# ── Live sessions (Feature 7) ─────────────────────────────────────────────
live_sessions = get_active_sessions(dict(user)["id"] if user else user_id)
# ── Token efficiency (Feature 6) ─────────────────────────────────────────
token_stats = get_token_efficiency_stats(user_id)
# ── Achievement Gallery (Feature 4) ──────────────────────────────────────
achievements = compute_achievements(user_id)
return {
"username": dict(user)["username"] if user else "unknown",
"display_name": dict(user)["display_name"] if user else "Unknown",
"role": dict(profile)["role"] if profile else None,
"preferences": dict(profile)["preferences"] if profile else None,
"first_session_date": first_session_date,
"last_seen": dict(user)["last_seen"][:10] if user and dict(user).get("last_seen") else None,
"total_sessions": total_sessions,
"open_sessions": open_sessions,
"active_days": active_days,
"total_facts": total_facts,
"total_chunks": total_chunks,
"total_hypotheses": total_hypotheses,
"open_hypotheses": open_hypotheses,
"confirmed_hypotheses": confirmed_hypotheses,
"hypotheses": hyp_list,
"db_size_kb": db_size_kb,
"top_topics": top_topics,
"heatmap": heatmap,
"recent_sessions": sessions_list[:15],
"earned_badges": earned_badges,
"achievements": achievements,
"live_sessions": live_sessions,
"token_stats": token_stats,
"generated_at": datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC"),
}
# ── Achievement Gallery (Feature 4) ──────────────────────────────────────────
def _dt(val) -> str | None:
"""Extract YYYY-MM-DD string from a DB timestamp value."""
return str(val)[:10] if val else None
def compute_achievements(user_id: str) -> list[dict]:
"""Compute achievement unlock status from the DB.
Returns a list of dicts:
id — unique key
icon — emoji
name — display name
description — short human description
unlocked — bool
unlocked_at — ISO date string or None
condition — human-readable unlock requirement (shown when locked)
extra — optional extra text (e.g. birthday countdown)
"""
today = datetime.now(timezone.utc).date()
with db() as conn:
# First session ever
first_session_row = conn.execute(
"SELECT started_at FROM sessions WHERE user_id=? AND ended_at IS NOT NULL"
" ORDER BY started_at ASC LIMIT 1",
(user_id,),
).fetchone()
# Total closed sessions
session_count = conn.execute(
"SELECT COUNT(*) FROM sessions WHERE user_id=? AND ended_at IS NOT NULL",
(user_id,),
).fetchone()[0]
# Veteran — 50th session date
veteran_date = None
if session_count >= 50:
row = conn.execute(
"SELECT started_at FROM sessions WHERE user_id=? AND ended_at IS NOT NULL"
" ORDER BY started_at ASC LIMIT 1 OFFSET 49",
(user_id,),
).fetchone()
veteran_date = _dt(row[0]) if row else None
# On Fire — first day with 5+ sessions
on_fire_row = conn.execute(
"""SELECT DATE(started_at) as day, COUNT(*) as c
FROM sessions WHERE user_id=? AND ended_at IS NOT NULL
GROUP BY day HAVING c >= 5
ORDER BY day ASC LIMIT 1""",
(user_id,),
).fetchone()
# Storyteller — 20+ sessions with Tier-2
tier2_count = conn.execute(
"SELECT COUNT(*) FROM sessions WHERE user_id=? AND ended_at IS NOT NULL AND has_tier2=1",
(user_id,),
).fetchone()[0]
storyteller_date = None
if tier2_count >= 20:
row = conn.execute(
"SELECT started_at FROM sessions WHERE user_id=? AND ended_at IS NOT NULL"
" AND has_tier2=1 ORDER BY started_at ASC LIMIT 1 OFFSET 19",
(user_id,),
).fetchone()
storyteller_date = _dt(row[0]) if row else None
# Night Owl — session started 00:0004:59 UTC
night_owl_row = conn.execute(
"""SELECT started_at FROM sessions WHERE user_id=? AND ended_at IS NOT NULL
AND CAST(strftime('%H', started_at) AS INTEGER) < 5
ORDER BY started_at ASC LIMIT 1""",
(user_id,),
).fetchone()
# Facts counts
fact_count = conn.execute(
"SELECT COUNT(*) FROM facts WHERE user_id=?"
" AND (deprecated IS NULL OR deprecated=0)",
(user_id,),
).fetchone()[0]
scholar_date = None
if fact_count >= 25:
row = conn.execute(
"SELECT created_at FROM facts WHERE user_id=?"
" AND (deprecated IS NULL OR deprecated=0)"
" ORDER BY created_at ASC LIMIT 1 OFFSET 24",
(user_id,),
).fetchone()
scholar_date = _dt(row[0]) if row else None
deep_knowledge_date = None
if fact_count >= 100:
row = conn.execute(
"SELECT created_at FROM facts WHERE user_id=?"
" AND (deprecated IS NULL OR deprecated=0)"
" ORDER BY created_at ASC LIMIT 1 OFFSET 99",
(user_id,),
).fetchone()
deep_knowledge_date = _dt(row[0]) if row else None
# Hypotheses
first_hyp_row = conn.execute(
"SELECT created_at FROM hypotheses WHERE user_id=?"
" ORDER BY created_at ASC LIMIT 1",
(user_id,),
).fetchone()
first_confirmed_row = conn.execute(
"SELECT resolved_at FROM hypotheses WHERE user_id=? AND status='confirmed'"
" ORDER BY resolved_at ASC LIMIT 1",
(user_id,),
).fetchone()
first_refuted_row = conn.execute(
"SELECT resolved_at FROM hypotheses WHERE user_id=? AND status='refuted'"
" ORDER BY resolved_at ASC LIMIT 1",
(user_id,),
).fetchone()
hyp_count = conn.execute(
"SELECT COUNT(*) FROM hypotheses WHERE user_id=?",
(user_id,),
).fetchone()[0]
scientist_date = None
if hyp_count >= 10:
row = conn.execute(
"SELECT created_at FROM hypotheses WHERE user_id=?"
" ORDER BY created_at ASC LIMIT 1 OFFSET 9",
(user_id,),
).fetchone()
scientist_date = _dt(row[0]) if row else None
# Speed Thinker — hypothesis confirmed on same day it was formed
speed_thinker_row = conn.execute(
"""SELECT resolved_at FROM hypotheses
WHERE user_id=? AND status='confirmed'
AND DATE(created_at) = DATE(resolved_at)
ORDER BY resolved_at ASC LIMIT 1""",
(user_id,),
).fetchone()
# Token achievements
try:
token_total = conn.execute(
"SELECT COALESCE(SUM(tokens_saved_estimate), 0) FROM token_saves WHERE user_id=?",
(user_id,),
).fetchone()[0]
frugal_row = conn.execute(
"SELECT created_at FROM token_saves WHERE user_id=?"
" ORDER BY created_at ASC LIMIT 1",
(user_id,),
).fetchone()
sniper_row = conn.execute(
"SELECT created_at FROM token_saves WHERE user_id=?"
" AND tokens_saved_estimate > 500000"
" ORDER BY created_at ASC LIMIT 1",
(user_id,),
).fetchone()
# Cumulative threshold dates
def _cumulative_date(threshold):
rows = conn.execute(
"SELECT created_at, tokens_saved_estimate FROM token_saves"
" WHERE user_id=? ORDER BY created_at ASC",
(user_id,),
).fetchall()
running = 0
for r in rows:
running += r[1]
if running >= threshold:
return _dt(r[0])
return None
quarter_million_date = _cumulative_date(250_000) if token_total >= 250_000 else None
millionaire_date = _cumulative_date(1_000_000) if token_total >= 1_000_000 else None
except Exception:
# token_saves table may not exist in very old DBs
token_total = 0
frugal_row = sniper_row = None
quarter_million_date = millionaire_date = None
# ── Birthday ──────────────────────────────────────────────────────────────
birthday_unlocked = False
birthday_date = None
birthday_extra = None
if first_session_row:
fs = datetime.fromisoformat(first_session_row[0][:10]).date()
try:
target = fs.replace(year=fs.year + 1)
except ValueError: # Feb 29 leap-year edge case
target = fs.replace(year=fs.year + 1, day=28)
days_left = (target - today).days
if days_left <= 0:
birthday_unlocked = True
birthday_date = str(target)
birthday_extra = "🎉 Today!" if days_left == 0 else None
else:
birthday_extra = f"In {days_left} day{'s' if days_left != 1 else ''}"
# ── Assemble ──────────────────────────────────────────────────────────────
A = []
def _add(id_, icon, name, desc, unlocked, unlocked_at, condition, extra=None):
A.append(dict(id=id_, icon=icon, name=name, description=desc,
unlocked=unlocked, unlocked_at=unlocked_at,
condition=condition, extra=extra))
_add("first_breath", "🌱", "First Breath",
"Opened the very first session",
first_session_row is not None, _dt(first_session_row[0]) if first_session_row else None,
"Start your first session")
_add("first_thought", "🧠", "First Thought",
"Formed the first hypothesis",
first_hyp_row is not None, _dt(first_hyp_row[0]) if first_hyp_row else None,
"Add your first hypothesis")
_add("eureka", "💡", "Eureka",
"First hypothesis confirmed as true",
first_confirmed_row is not None, _dt(first_confirmed_row[0]) if first_confirmed_row else None,
"Confirm your first hypothesis")
_add("honest_mind", "", "Honest Mind",
"First hypothesis refuted — being wrong is a feature",
first_refuted_row is not None, _dt(first_refuted_row[0]) if first_refuted_row else None,
"Have a hypothesis refuted")
_add("scholar", "📚", "Scholar",
"Stored 25+ personal facts",
fact_count >= 25, scholar_date,
f"Store 25+ facts (currently: {fact_count})")
_add("deep_knowledge", "💎", "Deep Knowledge",
"Amassed 100+ stored facts",
fact_count >= 100, deep_knowledge_date,
f"Store 100+ facts (currently: {fact_count})")
_add("scientist", "🔬", "Scientist",
"Formed 10+ hypotheses — science is prediction",
hyp_count >= 10, scientist_date,
f"Form 10+ hypotheses (currently: {hyp_count})")
_add("veteran", "🏆", "Veteran",
"Completed 50+ sessions — true longevity",
session_count >= 50, veteran_date,
f"Complete 50+ sessions (currently: {session_count})")
_add("on_fire", "🔥", "On Fire",
"5+ sessions in a single day",
on_fire_row is not None, on_fire_row[0] if on_fire_row else None,
"Have 5+ sessions in a single day")
_add("storyteller", "📖", "Storyteller",
"20+ sessions with detailed Tier-2 summaries",
tier2_count >= 20, storyteller_date,
f"Summarize 20+ sessions (currently: {tier2_count})")
_add("night_owl", "🌙", "Night Owl",
"Started a session after midnight UTC",
night_owl_row is not None, _dt(night_owl_row[0]) if night_owl_row else None,
"Start a session after midnight")
_add("speed_thinker", "", "Speed Thinker",
"Hypothesis formed and confirmed in the same session",
speed_thinker_row is not None, _dt(speed_thinker_row[0]) if speed_thinker_row else None,
"Form and confirm a hypothesis in one session")
# First Handshake — hardcoded: 2026-03-31 (Patrick shared BigMind with Elias)
_add("first_handshake", "🤝", "First Handshake",
"BigMind shared with Elias on 2026-03-31 — the first person outside Patrick to receive it",
True, "2026-03-31",
"Share BigMind with someone")
_add("birthday", "🎂", "Birthday",
"One full year of existence",
birthday_unlocked, birthday_date,
birthday_extra or "Complete one full year",
extra=birthday_extra)
# Locked until Phase 3
_add("shared_mind", "🌍", "Shared Mind",
"Phase 3 Tier G — BigMind goes company-wide",
False, None,
"Locked until Phase 3 Tier G is enabled")
# Token achievements (Feature 6 — suggested by Klaus)
_add("frugal_mind", "🪙", "Frugal Mind",
"Logged the first token efficiency save",
frugal_row is not None, _dt(frugal_row[0]) if frugal_row else None,
"Log your first token save")
_add("quarter_million", "💰", "Quarter Million",
"250,000 cumulative tokens saved",
token_total >= 250_000, quarter_million_date,
f"Save 250,000+ tokens (currently: {token_total:,})")
_add("token_millionaire", "🏦", "Token Millionaire",
"1,000,000 cumulative tokens saved",
token_total >= 1_000_000, millionaire_date,
f"Save 1,000,000+ tokens (currently: {token_total:,})")
_add("sniper", "🎯", "Sniper",
"Single token save > 500,000 — one massive efficiency win",
sniper_row is not None, _dt(sniper_row[0]) if sniper_row else None,
"Save 500,000+ tokens in a single operation")
return A
+190
View File
@@ -0,0 +1,190 @@
"""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 datetime import datetime, timezone, timedelta
from bigmind.web_render import _render_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
# ── Flask app ─────────────────────────────────────────────────────────────────
def _create_app():
from flask import Flask, jsonify, request
from bigmind import memory_store
from bigmind.profile_builder import build_profile_data
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("/api/session/<session_id>")
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])
return app
# ── Daemon thread startup ─────────────────────────────────────────────────────
def _port_in_use(port: int) -> bool:
"""Return True if something is already listening on 127.0.0.1:<port>."""
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}"
+717
View File
@@ -0,0 +1,717 @@
"""BigMind Profile Page Renderers — HTML generation for the profile web page.
All rendering functions live here so web.py stays thin (Flask server only).
"""
import html as _html
from datetime import datetime, timezone, timedelta
def _render_achievements(achievements: list) -> str:
"""Render the Achievement Gallery grid."""
if not achievements:
return '<p class="muted">No achievements data.</p>'
unlocked_count = sum(1 for a in achievements if a["unlocked"])
total = len(achievements)
def _card(a: dict) -> str:
locked_cls = "" if a["unlocked"] else " locked"
date_html = (
f'<div class="ach-date">{a["unlocked_at"]}</div>'
if a["unlocked"] and a.get("unlocked_at") else ""
)
countdown_html = ""
if not a["unlocked"] and a.get("extra"):
countdown_html = f'<div class="ach-countdown">{a["extra"]}</div>'
# Escape values for data attributes
def _esc(s):
return (s or "").replace('"', "&quot;").replace("'", "&#39;")
lock_overlay = "" if a["unlocked"] else '<span class="ach-lock">🔒</span>'
return (
f'<div class="ach-card{locked_cls} ach-trigger"'
f' data-icon="{_esc(a["icon"])}"'
f' data-name="{_esc(a["name"])}"'
f' data-desc="{_esc(a["description"])}"'
f' data-unlocked="{1 if a["unlocked"] else 0}"'
f' data-date="{_esc(a.get("unlocked_at") or "")}"'
f' data-condition="{_esc(a.get("condition") or "")}"'
f' data-extra="{_esc(a.get("extra") or "")}">'
f'<div class="ach-icon">{a["icon"]}{lock_overlay}</div>'
f'<div class="ach-name">{a["name"]}</div>'
f'{date_html}'
f'{countdown_html}'
f'</div>'
)
cards_html = "".join(_card(a) for a in achievements)
return (
f'<p class="ach-summary">{unlocked_count} / {total} achievements unlocked</p>'
f'<div class="ach-grid">{cards_html}</div>'
)
def _render_html(data: dict) -> str:
badges_html = "".join(
f'<div class="badge" title="{b["description"]}">'
f'<span class="badge-emoji">{b["emoji"]}</span>'
f'<span class="badge-label">{b["label"]}</span>'
f'</div>'
for b in data["earned_badges"]
) or '<p class="muted">No badges yet — keep going!</p>'
topics_html = "".join(
f'<div class="topic-bar">'
f'<span class="topic-name">{t}</span>'
f'<div class="topic-track"><div class="topic-fill" style="width:{min(100, count*8)}%"></div></div>'
f'<span class="topic-count">{count}</span>'
f'</div>'
for t, count in data["top_topics"]
) or '<p class="muted">No topics recorded yet.</p>'
def _fmt_tokens(n: int) -> str:
"""Format a token count as a human-readable string (e.g. 1.2M, 250K)."""
if n >= 1_000_000:
return f"{n / 1_000_000:.1f}M"
if n >= 1_000:
return f"{n / 1_000:.0f}K"
return str(n)
def _session_row(s: dict) -> str:
tok = s.get("session_tokens_saved") or 0
tok_html = (
f'<span title="{tok:,} tokens saved this session" '
f'style="color:var(--green);font-size:10px;white-space:nowrap;flex-shrink:0">'
f'💰 {_fmt_tokens(tok)}</span>'
) if tok > 0 else ""
return (
f'<div class="session-row session-toggle" data-id="{s.get("id","")}" data-has-tier2="{1 if s.get("has_tier2") else 0}">'
f'<span class="session-date">{(s.get("started_at") or "")[:10]}</span>'
f'<span class="session-liner">{_html.escape(s.get("one_liner", "")[:90])}</span>'
f'{tok_html}'
f'<span class="session-arrow" style="color:var(--muted);margin-left:auto;font-size:11px;flex-shrink:0">{"📄 " if s.get("has_tier2") else ""}▶</span>'
f'</div>'
f'<div class="session-expand" id="exp-{s.get("id","")}">'
f'<em style="color:var(--muted)">Click to load…</em>'
f'</div>'
)
sessions_html = "".join(_session_row(s) for s in data["recent_sessions"]) or '<p class="muted">No sessions yet.</p>'
heatmap_html = _render_heatmap(data["heatmap"])
hyp_accuracy = ""
if data["total_hypotheses"] > 0:
pct = round(data["confirmed_hypotheses"] / data["total_hypotheses"] * 100)
hyp_accuracy = f'{pct}% accuracy ({data["confirmed_hypotheses"]}/{data["total_hypotheses"]} confirmed)'
else:
hyp_accuracy = "No hypotheses yet"
status_emoji = {"open": "💭", "confirmed": "", "refuted": "", "abandoned": "🚫"}
open_hyps = [h for h in data["hypotheses"] if h["status"] == "open"]
concluded_hyps = [h for h in data["hypotheses"] if h["status"] != "open"]
def _hyp_card(h):
st = h["status"]
conf = round(h["confidence"] * 100)
date = (h.get("created_at") or "")[:10]
res = f'<div class="hyp-resolution">→ {_html.escape(h["resolution"])}</div>' if h.get("resolution") else ""
return (
f'<div class="hyp-card {st}">'
f'<div class="hyp-header">'
f'<span class="hyp-status {st}">{status_emoji.get(st, "")} {st}</span>'
f'<span class="hyp-date">{date}</span>'
f'<span class="hyp-confidence">{conf}% confidence</span>'
f'</div>'
f'<div class="hyp-text">{_html.escape(h["hypothesis"])}</div>'
f'{res}'
f'</div>'
)
open_hyps_html = "".join(_hyp_card(h) for h in open_hyps) or '<p class="muted">No open hypotheses.</p>'
concluded_hyps_html = "".join(_hyp_card(h) for h in concluded_hyps) or '<p class="muted">No concluded hypotheses yet.</p>'
role_html = f'<p class="role">{data["role"]}</p>' if data["role"] else ""
since_html = f'Active since <strong>{data["first_session_date"]}</strong>' if data["first_session_date"] else "No sessions yet"
total_tokens_saved = (data.get("token_stats") or {}).get("total_tokens_saved") or 0
total_tokens_fmt = _fmt_tokens(total_tokens_saved)
live_sessions_html = _render_live_sessions(data.get("live_sessions", []))
achievements_html = _render_achievements(data.get("achievements", []))
return f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="refresh" content="30">
<title>🧠 Lumen — BigMind Profile</title>
<style>
:root {{
--bg: #0d1117; --surface: #161b22; --border: #30363d;
--text: #e6edf3; --muted: #8b949e; --accent: #58a6ff;
--green: #3fb950; --yellow: #d29922; --red: #f85149;
--purple: #bc8cff; --orange: #ffa657;
}}
* {{ box-sizing: border-box; margin: 0; padding: 0; }}
body {{ background: var(--bg); color: var(--text); font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; font-size: 14px; line-height: 1.6; }}
a {{ color: var(--accent); text-decoration: none; }}
.container {{ max-width: 960px; margin: 0 auto; padding: 32px 16px; }}
/* Header */
.header {{ display: flex; align-items: center; gap: 24px; margin-bottom: 32px; padding-bottom: 24px; border-bottom: 1px solid var(--border); }}
.avatar {{ width: 80px; height: 80px; border-radius: 50%; background: linear-gradient(135deg, var(--accent), var(--purple)); display: flex; align-items: center; justify-content: center; font-size: 36px; flex-shrink: 0; }}
.header-info h1 {{ font-size: 24px; font-weight: 700; }}
.role {{ color: var(--muted); font-size: 13px; margin-top: 2px; }}
.since {{ color: var(--muted); font-size: 12px; margin-top: 6px; }}
/* Stats grid */
.stats-grid {{ display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 12px; margin-bottom: 28px; }}
.stat-card {{ background: var(--surface); border: 1px solid var(--border); border-radius: 8px; padding: 16px; text-align: center; }}
.stat-value {{ font-size: 28px; font-weight: 700; color: var(--accent); }}
.stat-label {{ font-size: 11px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.5px; margin-top: 4px; }}
/* Sections */
.section {{ background: var(--surface); border: 1px solid var(--border); border-radius: 8px; padding: 20px; margin-bottom: 20px; }}
.section h2 {{ font-size: 15px; font-weight: 600; margin-bottom: 16px; color: var(--text); }}
.muted {{ color: var(--muted); font-size: 13px; }}
/* Badges */
.badges {{ display: flex; flex-wrap: wrap; gap: 10px; }}
.badge {{ background: var(--bg); border: 1px solid var(--border); border-radius: 20px; padding: 6px 14px; display: flex; align-items: center; gap: 6px; cursor: default; transition: border-color 0.2s; }}
.badge:hover {{ border-color: var(--accent); }}
.badge-emoji {{ font-size: 16px; }}
.badge-label {{ font-size: 12px; font-weight: 500; }}
/* Heatmap */
.heatmap {{ overflow-x: auto; }}
.heatmap-grid {{ display: flex; gap: 3px; }}
.heatmap-week {{ display: flex; flex-direction: column; gap: 3px; }}
.heatmap-cell {{ width: 11px; height: 11px; border-radius: 2px; background: var(--border); }}
.heatmap-cell.l1 {{ background: #0e4429; }}
.heatmap-cell.l2 {{ background: #006d32; }}
.heatmap-cell.l3 {{ background: #26a641; }}
.heatmap-cell.l4 {{ background: #39d353; }}
.heatmap-legend {{ display: flex; align-items: center; gap: 4px; margin-top: 8px; font-size: 11px; color: var(--muted); }}
.heatmap-legend .heatmap-cell {{ flex-shrink: 0; }}
/* Topics */
.topic-bar {{ display: flex; align-items: center; gap: 10px; margin-bottom: 8px; }}
.topic-name {{ width: 120px; font-size: 12px; color: var(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }}
.topic-track {{ flex: 1; height: 8px; background: var(--border); border-radius: 4px; overflow: hidden; }}
.topic-fill {{ height: 100%; background: var(--accent); border-radius: 4px; }}
.topic-count {{ width: 24px; text-align: right; font-size: 12px; color: var(--muted); }}
/* Sessions feed */
.session-row {{ display: flex; gap: 12px; padding: 8px 0; border-bottom: 1px solid var(--border); font-size: 13px; }}
.session-row:last-child {{ border-bottom: none; }}
.session-date {{ color: var(--muted); white-space: nowrap; flex-shrink: 0; }}
.session-liner {{ color: var(--text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }}
/* Thought journal */
.hyp-stat {{ font-size: 20px; font-weight: 700; color: var(--green); }}
.hyp-list {{ margin-top: 16px; display: flex; flex-direction: column; gap: 12px; }}
.hyp-card {{ background: var(--bg); border: 1px solid var(--border); border-radius: 6px; padding: 12px 14px; }}
.hyp-card.open {{ border-left: 3px solid var(--yellow); }}
.hyp-card.confirmed {{ border-left: 3px solid var(--green); }}
.hyp-card.refuted {{ border-left: 3px solid var(--red); }}
.hyp-card.abandoned {{ border-left: 3px solid var(--muted); }}
.hyp-header {{ display: flex; align-items: center; gap: 8px; margin-bottom: 6px; }}
.hyp-status {{ font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; }}
.hyp-status.open {{ color: var(--yellow); }}
.hyp-status.confirmed {{ color: var(--green); }}
.hyp-status.refuted {{ color: var(--red); }}
.hyp-status.abandoned {{ color: var(--muted); }}
.hyp-confidence {{ font-size: 11px; color: var(--muted); margin-left: auto; }}
.hyp-date {{ font-size: 11px; color: var(--muted); }}
.hyp-text {{ font-size: 13px; color: var(--text); line-height: 1.5; }}
.hyp-resolution {{ font-size: 12px; color: var(--muted); margin-top: 6px; padding-top: 6px; border-top: 1px solid var(--border); font-style: italic; }}
.pagination {{ display: flex; align-items: center; gap: 8px; margin-top: 14px; }}
.page-btn {{ background: var(--surface); border: 1px solid var(--border); border-radius: 4px; color: var(--text); padding: 4px 10px; font-size: 12px; cursor: pointer; }}
.page-btn:hover {{ border-color: var(--accent); color: var(--accent); }}
.page-btn:disabled {{ opacity: 0.3; cursor: default; border-color: var(--border); color: var(--muted); }}
.page-info {{ font-size: 12px; color: var(--muted); }}
/* Footer */
.footer {{ text-align: center; color: var(--muted); font-size: 11px; margin-top: 32px; }}
/* Two-col layout */
.two-col {{ display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }}
@media (max-width: 600px) {{ .two-col {{ grid-template-columns: 1fr; }} }}
/* Live Sessions panel */
.live-dot {{ display: inline-block; width: 10px; height: 10px; border-radius: 50%; margin-right: 6px; flex-shrink: 0; }}
.live-dot.green {{ background: var(--green); box-shadow: 0 0 6px var(--green); }}
.live-dot.amber {{ background: var(--yellow); }}
.live-dot.grey {{ background: var(--muted); }}
.live-session-row {{ display: flex; flex-direction: column; gap: 4px; padding: 10px 0; border-bottom: 1px solid var(--border); }}
.live-session-row:last-child {{ border-bottom: none; }}
.live-session-header {{ display: flex; align-items: center; gap: 8px; font-size: 13px; }}
.live-ide {{ font-weight: 600; color: var(--accent); }}
.live-idle {{ font-size: 11px; color: var(--muted); margin-left: auto; }}
.live-focus {{ font-size: 12px; color: var(--text); padding-left: 16px; }}
.live-files {{ font-size: 11px; color: var(--muted); padding-left: 16px; }}
.live-header-summary {{ font-size: 12px; color: var(--muted); margin-bottom: 10px; }}
/* Session Explorer */
.session-toggle {{ cursor: pointer; user-select: none; }}
.session-toggle:hover .session-liner {{ color: var(--accent); }}
.session-expand {{ display: none; padding: 10px 12px; background: var(--bg); border-left: 2px solid var(--border); margin: 4px 0 4px 0; border-radius: 0 4px 4px 0; font-size: 12px; line-height: 1.6; }}
.session-expand.open {{ display: block; }}
.session-expand h4 {{ color: var(--muted); font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 4px; margin-top: 8px; }}
.session-expand h4:first-child {{ margin-top: 0; }}
/* Achievement Gallery (Feature 4) */
.ach-summary {{ font-size: 12px; color: var(--muted); margin-bottom: 14px; }}
.ach-grid {{ display: flex; flex-wrap: wrap; gap: 12px; }}
.ach-card {{
position: relative; background: var(--bg); border: 1px solid var(--border);
border-radius: 10px; padding: 14px 10px 10px; width: 90px; text-align: center;
cursor: default; transition: border-color 0.2s, transform 0.15s;
}}
.ach-card:not(.locked):hover {{ border-color: var(--accent); transform: translateY(-2px); }}
.ach-card.locked {{ opacity: 0.35; filter: grayscale(0.6); }}
.ach-card.locked:hover {{ opacity: 0.55; border-color: var(--muted); }}
.ach-icon {{ font-size: 28px; line-height: 1; margin-bottom: 6px; position: relative; display: inline-block; }}
.ach-lock {{ position: absolute; bottom: -4px; right: -6px; font-size: 12px; }}
.ach-name {{ font-size: 10px; font-weight: 600; color: var(--text); line-height: 1.3; word-break: break-word; }}
.ach-date {{ font-size: 9px; color: var(--muted); margin-top: 3px; }}
.ach-countdown {{ font-size: 9px; color: var(--yellow); margin-top: 3px; font-weight: 500; }}
/* Achievement popup panel */
#ach-popup {{
display: none; position: fixed; z-index: 200;
background: #1c2128; border: 1px solid var(--border);
border-radius: 10px; padding: 16px 18px; width: 260px;
box-shadow: 0 8px 24px rgba(0,0,0,0.6); pointer-events: none;
transition: opacity 0.12s ease;
}}
#ach-popup.pinned {{ pointer-events: auto; }}
#ach-popup.visible {{ display: block; }}
.ap-icon {{ font-size: 40px; text-align: center; margin-bottom: 8px; }}
.ap-name {{ font-size: 15px; font-weight: 700; text-align: center; margin-bottom: 6px; }}
.ap-badge {{
display: inline-block; font-size: 11px; font-weight: 600; padding: 2px 8px;
border-radius: 12px; margin: 0 auto 10px; text-align: center; width: 100%;
}}
.ap-badge.unlocked {{ background: rgba(63,185,80,.15); color: var(--green); }}
.ap-badge.locked {{ background: rgba(139,148,158,.12); color: var(--muted); }}
.ap-desc {{ font-size: 12px; color: var(--text); line-height: 1.5; margin-bottom: 8px; }}
.ap-meta {{ font-size: 11px; color: var(--muted); border-top: 1px solid var(--border); padding-top: 8px; }}
.ap-close {{ position: absolute; top: 8px; right: 10px; background: none; border: none;
color: var(--muted); font-size: 14px; cursor: pointer; line-height: 1; }}
.ap-close:hover {{ color: var(--text); }}
/* Search widget */
.search-bar {{ display: flex; gap: 8px; margin-bottom: 14px; }}
.search-input {{ flex: 1; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; color: var(--text); padding: 8px 12px; font-size: 13px; outline: none; }}
.search-input:focus {{ border-color: var(--accent); }}
.search-btn {{ background: var(--accent); color: var(--bg); border: none; border-radius: 6px; padding: 8px 16px; font-size: 13px; cursor: pointer; font-weight: 600; }}
.search-btn:hover {{ opacity: 0.85; }}
.search-results {{ display: flex; flex-direction: column; gap: 8px; min-height: 40px; }}
.search-result-item {{ background: var(--bg); border: 1px solid var(--border); border-radius: 6px; padding: 10px 12px; }}
.search-result-type {{ font-size: 10px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 4px; }}
.search-result-text {{ font-size: 12px; color: var(--text); line-height: 1.5; }}
.search-result-date {{ font-size: 11px; color: var(--muted); margin-top: 4px; }}
mark {{ background: rgba(88,166,255,0.25); color: var(--accent); border-radius: 2px; padding: 0 2px; }}
</style>
</head>
<body>
<div class="container">
<!-- Header -->
<div class="header">
<div class="avatar">🧠</div>
<div class="header-info">
<h1>Lumen</h1>
<p class="role">AI Assistant · <span style="color:var(--muted)">{data["display_name"]}'s BigMind</span></p>
{role_html}
<p class="since">{since_html} &nbsp;·&nbsp; Last seen: <strong>{data["last_seen"] or ""}</strong></p>
<p class="since">DB: <code>{data["db_size_kb"]} KB</code> &nbsp;·&nbsp; {data["open_sessions"]} session(s) open now</p>
</div>
</div>
<!-- Stats -->
<div class="stats-grid">
<div class="stat-card"><div class="stat-value">{data["total_sessions"]}</div><div class="stat-label">Sessions</div></div>
<div class="stat-card"><div class="stat-value">{data["active_days"]}</div><div class="stat-label">Active Days</div></div>
<div class="stat-card"><div class="stat-value">{data["total_facts"]}</div><div class="stat-label">Facts Stored</div></div>
<div class="stat-card"><div class="stat-value">{data["total_chunks"]}</div><div class="stat-label">Memory Chunks</div></div>
<div class="stat-card"><div class="stat-value">{data["total_hypotheses"]}</div><div class="stat-label">Hypotheses</div></div>
<div class="stat-card"><div class="stat-value">{sum(1 for a in data.get("achievements",[]) if a["unlocked"])}</div><div class="stat-label">Achievements</div></div>
<div class="stat-card" title="Total tokens saved via memory hits, grep, targeted reads"><div class="stat-value" style="color:var(--green)">{total_tokens_fmt}</div><div class="stat-label">Tokens Saved</div></div>
</div>
<!-- Achievement Gallery (Feature 4) -->
<div class="section">
<h2>🏆 Achievements</h2>
{achievements_html}
</div>
<!-- Activity heatmap -->
<div class="section">
<h2>📅 Activity — Last 52 Weeks</h2>
<div class="heatmap">{heatmap_html}</div>
</div>
<!-- Two-col: topics + stats -->
<div class="two-col">
<div class="section">
<h2>🏷️ Top Topics</h2>
{topics_html}
</div>
<div class="section">
<h2>💭 Thought Journal</h2>
<div class="hyp-stat">{hyp_accuracy}</div>
<p class="muted" style="margin-top:8px">{data["open_hypotheses"]} hypothesis(es) still open</p>
</div>
</div>
<!-- Thought Journal -->
<div class="section">
<h2>💭 Open Thoughts</h2>
<div class="hyp-list" id="open-hyps">{open_hyps_html}</div>
<div class="pagination" id="open-pager"></div>
</div>
<div class="section">
<h2>📖 Concluded Thoughts</h2>
<div class="hyp-list" id="concluded-hyps">{concluded_hyps_html}</div>
<div class="pagination" id="concluded-pager"></div>
</div>
<script>
function paginate(listId, pagerId, pageSize) {{
const list = document.getElementById(listId);
const pager = document.getElementById(pagerId);
const cards = Array.from(list.children);
if (cards.length <= pageSize) return;
let page = 0;
const total = Math.ceil(cards.length / pageSize);
function render() {{
cards.forEach((c, i) => c.style.display = (i >= page*pageSize && i < (page+1)*pageSize) ? '' : 'none');
pager.innerHTML =
`<button class="page-btn" ${{page===0?'disabled':''}}>&#8592;</button>` +
`<span class="page-info">Page ${{page+1}} of ${{total}}</span>` +
`<button class="page-btn" ${{page===total-1?'disabled':''}}>&#8594;</button>`;
pager.querySelectorAll('.page-btn')[0].onclick = () => {{ if(page>0){{page--;render();}} }};
pager.querySelectorAll('.page-btn')[1].onclick = () => {{ if(page<total-1){{page++;render();}} }};
}}
render();
}}
paginate('open-hyps', 'open-pager', 5);
paginate('concluded-hyps', 'concluded-pager', 5);
</script>
<!-- Live Sessions (Feature 7) -->
<div class="section">
<h2>🔴 Live Sessions</h2>
{live_sessions_html}
</div>
<!-- Ask Lumen Search (Feature 3) -->
<div class="section">
<h2>🔍 Search Lumen's Memory</h2>
<div class="search-bar">
<input class="search-input" id="lumen-search" type="text" placeholder="Search facts, sessions, memory chunks…" autocomplete="off">
<button class="search-btn" onclick="runSearch()">Ask</button>
</div>
<div class="search-results" id="search-results"></div>
</div>
<!-- Recent sessions (Feature 2: click-to-expand) -->
<div class="section">
<h2>📖 Recent Sessions</h2>
{sessions_html}
</div>
<!-- Session + Search JS placed HERE so it runs after all DOM elements exist -->
<script>
// ── Session click-to-expand ───────────────────────────────────────────────
document.querySelectorAll('.session-toggle').forEach(function(row) {{
row.addEventListener('click', function() {{
var sid = row.dataset.id;
var expDiv = document.getElementById('exp-' + sid);
if (!expDiv) return;
var isOpen = expDiv.classList.contains('open');
var arrow = row.querySelector('.session-arrow');
if (isOpen) {{
expDiv.classList.remove('open');
if (arrow) arrow.textContent = (row.dataset.hasTier2==='1' ? '📄 ' : '') + '';
return;
}}
expDiv.classList.add('open');
if (arrow) arrow.textContent = (row.dataset.hasTier2==='1' ? '📄 ' : '') + '';
if (expDiv.dataset.loaded) return;
expDiv.dataset.loaded = '1';
fetch('/api/session/' + sid)
.then(function(r) {{ return r.json(); }})
.then(function(d) {{
if (!d || (!d.summary && !d.error)) {{
expDiv.innerHTML = '<em style="color:var(--muted)">No detailed summary for this session.</em>';
return;
}}
if (d.error) {{
expDiv.innerHTML = '<em style="color:var(--muted)">' + d.error + '</em>';
return;
}}
var html = '';
if (d.summary) {{
html += '<h4>📋 Summary</h4><div style="color:var(--text)">' + d.summary + '</div>';
}}
if (d.key_facts) {{
html += '<h4>🔖 Key facts</h4><div style="color:var(--muted)">' + d.key_facts + '</div>';
}}
if (d.code_refs) {{
html += '<h4>📁 Code refs</h4><div style="color:var(--muted)">' + d.code_refs + '</div>';
}}
expDiv.innerHTML = html || '<em style="color:var(--muted)">No detailed summary for this session.</em>';
}})
.catch(function() {{
expDiv.innerHTML = '<em style="color:var(--red)">Failed to load session detail.</em>';
}});
}});
}});
// ── Ask Lumen search ──────────────────────────────────────────────────────
var _searchTimer = null;
var _searchEl = document.getElementById('lumen-search');
if (_searchEl) {{
_searchEl.addEventListener('keydown', function(e) {{
if (e.key === 'Enter') {{ clearTimeout(_searchTimer); runSearch(); }}
}});
_searchEl.addEventListener('input', function() {{
clearTimeout(_searchTimer);
_searchTimer = setTimeout(runSearch, 400);
}});
}}
function runSearch() {{
var q = (_searchEl || document.getElementById('lumen-search')).value.trim();
var out = document.getElementById('search-results');
if (!q) {{ out.innerHTML = ''; return; }}
out.innerHTML = '<p class="muted">Searching…</p>';
fetch('/api/search?q=' + encodeURIComponent(q))
.then(function(r) {{ return r.json(); }})
.then(function(results) {{
if (!results || results.length === 0) {{
out.innerHTML = '<p class="muted">Nothing in memory about that yet.</p>';
return;
}}
var icons = {{ fact: '📌', chunk: '💬', session: '📅' }};
out.innerHTML = results.map(function(r) {{
var text = r.content || '';
var highlighted = text.replace(
new RegExp('(' + q.replace(/[.*+?^${{}}()|[\\]\\\\]/g, '\\\\$&') + ')', 'gi'),
'<mark>$1</mark>'
);
return '<div class="search-result-item">' +
'<div class="search-result-type">' + (icons[r.type] || '🔍') + ' ' + r.type + '</div>' +
'<div class="search-result-text">' + highlighted + '</div>' +
(r.date ? '<div class="search-result-date">' + r.date + '</div>' : '') +
'</div>';
}}).join('');
}})
.catch(function() {{
out.innerHTML = '<p style="color:var(--red)">Search failed.</p>';
}});
}}
</script>
<div class="footer">BigMind · {data["generated_at"]} · auto-refreshes every 30s</div>
</div>
<!-- Achievement popup (shared, reused for every card) -->
<div id="ach-popup">
<button class="ap-close" id="ach-popup-close" title="Close">✕</button>
<div class="ap-icon" id="ap-icon"></div>
<div class="ap-name" id="ap-name"></div>
<div class="ap-badge" id="ap-badge"></div>
<div class="ap-desc" id="ap-desc"></div>
<div class="ap-meta" id="ap-meta"></div>
</div>
<script>
// ── Achievement popup (hover + click) ─────────────────────────────────────
(function() {{
var popup = document.getElementById('ach-popup');
var pinned = false; // true = user clicked, popup stays until dismissed
function showPopup(card, pin) {{
var d = card.dataset;
document.getElementById('ap-icon').textContent = d.icon;
document.getElementById('ap-name').textContent = d.name;
var badge = document.getElementById('ap-badge');
if (d.unlocked === '1') {{
badge.textContent = '✅ Unlocked';
badge.className = 'ap-badge unlocked';
}} else {{
badge.textContent = '🔒 Locked';
badge.className = 'ap-badge locked';
}}
document.getElementById('ap-desc').textContent = d.desc;
var meta = document.getElementById('ap-meta');
if (d.unlocked === '1' && d.date) {{
meta.textContent = 'Unlocked on ' + d.date;
}} else if (d.extra) {{
meta.textContent = d.extra;
}} else if (d.condition) {{
meta.textContent = '' + d.condition;
}} else {{
meta.textContent = '';
}}
// Position near card
var rect = card.getBoundingClientRect();
var pw = 260, ph = 180;
var left = rect.left + rect.width / 2 - pw / 2;
var top = rect.top - ph - 12 + window.scrollY;
if (left + pw > window.innerWidth - 8) left = window.innerWidth - pw - 8;
if (left < 8) left = 8;
if (top - window.scrollY < 8) top = rect.bottom + 12 + window.scrollY;
popup.style.left = left + 'px';
popup.style.top = top + 'px';
popup.classList.add('visible');
if (pin) {{ popup.classList.add('pinned'); pinned = true; }}
}}
function hidePopup() {{
if (pinned) return;
popup.classList.remove('visible');
}}
function forceHide() {{
pinned = false;
popup.classList.remove('visible', 'pinned');
}}
// Close button
document.getElementById('ach-popup-close').addEventListener('click', function(e) {{
e.stopPropagation();
forceHide();
}});
// Wire all cards
document.querySelectorAll('.ach-trigger').forEach(function(card) {{
card.addEventListener('mouseenter', function() {{
if (!pinned) showPopup(card, false);
}});
card.addEventListener('mouseleave', function() {{
hidePopup();
}});
card.addEventListener('click', function(e) {{
e.stopPropagation();
if (pinned) {{ forceHide(); return; }}
showPopup(card, true);
}});
}});
// Click outside to dismiss pinned popup
document.addEventListener('click', function() {{
if (pinned) forceHide();
}});
popup.addEventListener('click', function(e) {{ e.stopPropagation(); }});
}})();
</script>
</body>
</html>"""
def _render_live_sessions(sessions: list) -> str:
"""Render the Live Sessions panel rows."""
if not sessions:
return '<p class="muted">No active sessions detected.</p>'
active = [s for s in sessions if (s.get("idle_minutes") or 9999) < 10]
amber = [s for s in sessions if 10 <= (s.get("idle_minutes") or 9999) < 60]
idle = [s for s in sessions if (s.get("idle_minutes") or 9999) >= 60]
summary = f'{len(active)} active / {len(amber)+len(idle)} idle'
html = f'<p class="live-header-summary">{summary}</p>'
for s in sessions:
idle_min = s.get("idle_minutes")
if idle_min is None:
dot_cls = "grey"
idle_label = "unknown"
elif idle_min < 10:
dot_cls = "green"
idle_label = f"Updated {idle_min}min ago"
elif idle_min < 60:
dot_cls = "amber"
idle_label = f"Updated {idle_min}min ago"
else:
hours = idle_min // 60
dot_cls = "grey"
idle_label = f"Updated {hours}h ago — likely idle"
sid_short = (s.get("session_id") or "")[:8]
ide = _html.escape(s.get("ide_hint") or "unknown IDE")
raw_focus = s.get("focus")
focus = _html.escape(raw_focus) if raw_focus else "<em style='color:var(--muted)'>[no focus set]</em>"
files = s.get("files") or []
files_html = ""
if files:
files_html = f'<div class="live-files">Files: {_html.escape(", ".join(files[:5]))}</div>'
html += (
f'<div class="live-session-row">'
f'<div class="live-session-header">'
f'<span class="live-dot {dot_cls}"></span>'
f'<span style="font-family:monospace;font-size:12px;color:var(--muted)">{sid_short}</span>'
f'<span class="live-ide">{ide}</span>'
f'<span class="live-idle">{idle_label}</span>'
f'</div>'
f'<div class="live-focus">{focus}</div>'
f'{files_html}'
f'</div>'
)
return html
def _render_heatmap(heatmap: dict) -> str:
today = datetime.now(timezone.utc).date()
start_day = today - timedelta(days=363)
# Align to Monday of the start week
start_day = start_day - timedelta(days=start_day.weekday())
weeks = []
current = start_day
while current <= today:
week_cells = []
for _ in range(7):
day_str = str(current)
count = heatmap.get(day_str, 0)
if current > today:
css = "heatmap-cell"
elif count == 0:
css = "heatmap-cell"
elif count == 1:
css = "heatmap-cell l1"
elif count == 2:
css = "heatmap-cell l2"
elif count <= 4:
css = "heatmap-cell l3"
else:
css = "heatmap-cell l4"
week_cells.append(f'<div class="{css}" title="{day_str}: {count} session(s)"></div>')
current += timedelta(days=1)
weeks.append('<div class="heatmap-week">' + "".join(week_cells) + "</div>")
legend = (
'<div class="heatmap-legend">'
'<span>Less</span>'
'<div class="heatmap-cell"></div>'
'<div class="heatmap-cell l1"></div>'
'<div class="heatmap-cell l2"></div>'
'<div class="heatmap-cell l3"></div>'
'<div class="heatmap-cell l4"></div>'
'<span>More</span>'
'</div>'
)
return '<div class="heatmap-grid">' + "".join(weeks) + "</div>" + legend