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:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
@@ -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:00–04: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
|
||||
|
||||
|
||||
@@ -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}"
|
||||
@@ -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('"', """).replace("'", "'")
|
||||
|
||||
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} · Last seen: <strong>{data["last_seen"] or "—"}</strong></p>
|
||||
<p class="since">DB: <code>{data["db_size_kb"]} KB</code> · {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':''}}>←</button>` +
|
||||
`<span class="page-info">Page ${{page+1}} of ${{total}}</span>` +
|
||||
`<button class="page-btn" ${{page===total-1?'disabled':''}}>→</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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user