67b8b44408
- web.py: add /profile-image route (serves most-recent gallery PNG)
add /gallery/image/<filename> route (per-image serving)
add /gallery route (renders gallery page from DB)
add _get_profile_image_path() helper
- web_render.py: replace emoji avatar with <img src=/profile-image>
onerror fallback to 🧠 emoji
add .nav bar with Profile/Gallery links to both pages
add _render_gallery_html() full gallery page renderer
add gallery CSS: .gal-grid, .gal-card, .gal-img, .gal-info, etc.
- db.py: bump SCHEMA_VERSION 7→8
add gallery_images table (id, filename, prompt, tags, model,
created_at, width, height, file_size_bytes)
add _migrate_v7_to_v8() migration function
add init_db() hook for v<8 migration
- tests: update test_schema_version_is_7→8 in test_db.py and
test_feature7_live_sessions.py; add gallery_images to expected tables
Storage strategy: Option B (filesystem + DB metadata)
Images in ~/.mcp/bigmind/gallery/, metadata in SQLite
Pre-populated with 5 lumen_profiles images (seeds 2409122067,
764633840, 1367851518, 3135233944, 568659042)
Tests: 297/297 passing
510 lines
20 KiB
Python
510 lines
20 KiB
Python
"""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 = 8
|
|
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'
|
|
)""",
|
|
|
|
# ── GALLERY IMAGES — AI-generated image archive ──────────────────────────
|
|
"""CREATE TABLE IF NOT EXISTS gallery_images (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
filename TEXT NOT NULL UNIQUE,
|
|
prompt TEXT,
|
|
tags TEXT,
|
|
model TEXT,
|
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
width INTEGER,
|
|
height INTEGER,
|
|
file_size_bytes INTEGER
|
|
)""",
|
|
|
|
"""CREATE INDEX IF NOT EXISTS idx_gallery_created
|
|
ON gallery_images(created_at DESC)""",
|
|
]
|
|
|
|
|
|
# ─── 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)
|
|
if current_version < 8:
|
|
_migrate_v7_to_v8(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 _migrate_v7_to_v8(conn: sqlite3.Connection) -> None:
|
|
"""v7 → v8: add gallery_images table for AI-generated image archive."""
|
|
conn.execute("""
|
|
CREATE TABLE IF NOT EXISTS gallery_images (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
filename TEXT NOT NULL UNIQUE,
|
|
prompt TEXT,
|
|
tags TEXT,
|
|
model TEXT,
|
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
width INTEGER,
|
|
height INTEGER,
|
|
file_size_bytes INTEGER
|
|
)
|
|
""")
|
|
conn.execute("""
|
|
CREATE INDEX IF NOT EXISTS idx_gallery_created
|
|
ON gallery_images(created_at DESC)
|
|
""")
|
|
logger.info("BigMind schema migrated v7 → v8 (gallery_images table)")
|
|
|
|
|
|
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()
|
|
|