Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 50488109aa | |||
| dd244a8e6c | |||
| ee07dec4d3 | |||
| 67b8b44408 | |||
| a852e2ec0d | |||
| a275a18e58 | |||
| 20228f8d46 | |||
| 3b1d5bf35c | |||
| e12479a63a |
@@ -0,0 +1,622 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Create all 7 wiki pages for pi_mcps on Gitea."""
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import urllib.request
|
||||||
|
import urllib.error
|
||||||
|
|
||||||
|
GITEA_URL = "http://192.168.188.119:30008"
|
||||||
|
OWNER = "pplate"
|
||||||
|
REPO = "pi_mcps"
|
||||||
|
TOKEN = "8bf0c734ebda3e61d9c9068489ce58a2bf8d33db"
|
||||||
|
IMG_BASE = f"{GITEA_URL}/{OWNER}/{REPO}/raw/branch/main/docs/wiki/images"
|
||||||
|
|
||||||
|
PAGES = {}
|
||||||
|
|
||||||
|
PAGES["Home"] = f"""# 🔧 pi_mcps — Patrick's Homelab Monorepo
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Welcome to **pi_mcps**, Patrick's personal homelab monorepo. This repository houses MCP (Model Context Protocol) servers, Java projects, and homelab tooling — all built and maintained on a Fedora Linux workstation with an AMD Ryzen 5900X + RX 7900 XTX.
|
||||||
|
|
||||||
|
## What's in this repo?
|
||||||
|
|
||||||
|
| Directory | Contents |
|
||||||
|
|---|---|
|
||||||
|
| [`mcp/mcp-image-gen/`](../src/branch/main/mcp/mcp-image-gen) | 🎨 AI image generation via ComfyUI + FLUX.1-schnell |
|
||||||
|
| [`mcp/webscraper/`](../src/branch/main/mcp/webscraper) | 🕸️ Web scraping and data extraction |
|
||||||
|
| [`mcp/bigmind/`](../src/branch/main/mcp/bigmind) | 🧠 Persistent AI memory system |
|
||||||
|
| [`java/`](../src/branch/main/java) | ☕ Java EE / Spring projects |
|
||||||
|
| [`plans/`](../src/branch/main/plans) | 📋 Architecture decisions and health reports |
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
- **Language:** Python 3.11+ (MCP servers), Java 17 (legacy projects)
|
||||||
|
- **MCP Framework:** FastMCP 2.x
|
||||||
|
- **Package Manager:** `uv` (all Python projects)
|
||||||
|
- **Testing:** `pytest`
|
||||||
|
- **GPU:** AMD RX 7900 XTX (ROCm / HSA)
|
||||||
|
- **Server:** TrueNAS.local at `192.168.188.119` (Gitea, Docker)
|
||||||
|
|
||||||
|
## MCP Servers
|
||||||
|
|
||||||
|
Three production-ready MCP servers power Patrick's AI development environment:
|
||||||
|
|
||||||
|
| Server | Status | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| [mcp-image-gen](mcp-image-gen) | ✅ Live | Generate images from text prompts via ComfyUI |
|
||||||
|
| [mcp-webscraper](mcp-webscraper) | ✅ Live | Scrape web pages, extract tables, fetch links |
|
||||||
|
| [BigMind](BigMind) | ✅ Live | Persistent AI memory across all sessions |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Built and maintained by Patrick Plate (pplate) · Homelab: TrueNAS.local · AI Colleague: Lumen*
|
||||||
|
"""
|
||||||
|
|
||||||
|
PAGES["MCP-Servers-Overview"] = f"""# 🔌 MCP Servers Overview
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
This repo contains three production-grade MCP (Model Context Protocol) servers, each specialized for a different capability domain. Together they give Roo Code / Claude Desktop a complete set of superpowers.
|
||||||
|
|
||||||
|
## The Three Pillars
|
||||||
|
|
||||||
|
```
|
||||||
|
Roo Code / Claude Desktop
|
||||||
|
│
|
||||||
|
├── bigmind ──────────► ~/.mcp/bigmind/memory.db (persistent memory)
|
||||||
|
├── mcp-image-gen ────► ComfyUI @ localhost:8188 (image generation)
|
||||||
|
└── webscraper ───────► Internet / Intranet (web scraping)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Comparison Table
|
||||||
|
|
||||||
|
| Feature | mcp-image-gen | webscraper | bigmind |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **Purpose** | Generate images from text | Scrape & parse web | Persistent AI memory |
|
||||||
|
| **Tools** | 4 | 7 | 15+ |
|
||||||
|
| **Backend** | ComfyUI / FLUX.1-schnell | httpx + BeautifulSoup4 | SQLite + FTS5 |
|
||||||
|
| **GPU required** | ✅ AMD RX 7900 XTX | ❌ | ❌ |
|
||||||
|
| **Tests** | 19/19 ✅ | ✅ | 297/297 ✅ |
|
||||||
|
| **Schema version** | n/a | n/a | v7 |
|
||||||
|
|
||||||
|
## Quick Links
|
||||||
|
|
||||||
|
- 🎨 [mcp-image-gen](mcp-image-gen) — Image generation docs
|
||||||
|
- 🕸️ [mcp-webscraper](mcp-webscraper) — Web scraping docs
|
||||||
|
- 🧠 [BigMind](BigMind) — Memory system docs
|
||||||
|
- 🛠️ [Development Conventions](Development-Conventions) — How all servers are built
|
||||||
|
|
||||||
|
## Adding a New Server
|
||||||
|
|
||||||
|
All servers follow the [FastMCP convention](Development-Conventions). Use the `new-mcp-server` Roo skill to scaffold:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# In Roo Code orchestrator, load skill:
|
||||||
|
# skill: new-mcp-server
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
|
||||||
|
PAGES["mcp-image-gen"] = f"""# 🎨 mcp-image-gen — AI Image Generation
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
**mcp-image-gen** is a FastMCP server that wraps the ComfyUI REST API, enabling Roo Code and Claude Desktop to generate images directly from text prompts using FLUX.1-schnell running on an AMD RX 7900 XTX GPU.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
Roo Code / Claude Desktop
|
||||||
|
│ MCP (stdio)
|
||||||
|
▼
|
||||||
|
mcp-image-gen (FastMCP, Python 3.11+)
|
||||||
|
│ HTTP REST
|
||||||
|
▼
|
||||||
|
ComfyUI @ localhost:8188
|
||||||
|
│ ROCm / HSA_OVERRIDE_GFX_VERSION=11.0.0
|
||||||
|
▼
|
||||||
|
FLUX.1-schnell (~8s/image @ 1024×1024)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tools
|
||||||
|
|
||||||
|
| Tool | Description |
|
||||||
|
|---|---|
|
||||||
|
| `generate_image` | Generate PNG from text prompt; returns file path + inline base64 |
|
||||||
|
| `list_available_models` | List ComfyUI checkpoint models |
|
||||||
|
| `get_generation_status` | Check status of a queued/running job |
|
||||||
|
| `get_output_directory` | Return configured output directory path |
|
||||||
|
|
||||||
|
## Key Parameters — `generate_image`
|
||||||
|
|
||||||
|
| Parameter | Default | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `prompt` | required | Text description of the image |
|
||||||
|
| `width` | `1024` | Image width in pixels |
|
||||||
|
| `height` | `1024` | Image height in pixels |
|
||||||
|
| `steps` | `4` | Inference steps (FLUX.1-schnell is 4-step) |
|
||||||
|
| `model` | `flux1-schnell.safetensors` | Model checkpoint name |
|
||||||
|
| `seed` | `-1` (random) | Generation seed for reproducibility |
|
||||||
|
| `negative_prompt` | `""` | Things to avoid in the image |
|
||||||
|
| `output_dir` | `~/Pictures/mcp-generated` | Where to save output PNG |
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `COMFYUI_URL` | `http://localhost:8188` | ComfyUI API endpoint |
|
||||||
|
| `IMAGE_OUTPUT_DIR` | `~/Pictures/mcp-generated` | Default output directory |
|
||||||
|
| `COMFYUI_TIMEOUT` | `120` | Request timeout in seconds |
|
||||||
|
|
||||||
|
## Return Value
|
||||||
|
|
||||||
|
The tool returns **two content items**:
|
||||||
|
1. `TextContent` — file path, seed used, elapsed time
|
||||||
|
2. `ImageContent` — base64-encoded PNG (displays inline in Roo Code chat)
|
||||||
|
|
||||||
|
> ⚠️ **Known FastMCP Bug:** Never use `fastmcp.utilities.types.Image` as return type — it breaks serialization in FastMCP 3.x. Use `mcp.types.ImageContent` directly.
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
See [ComfyUI Setup Guide](mcp-image-gen-ComfyUI-Setup) for full installation instructions.
|
||||||
|
|
||||||
|
### Quick Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd mcp/mcp-image-gen
|
||||||
|
uv sync
|
||||||
|
# Set COMFYUI_URL if ComfyUI is not on localhost
|
||||||
|
./run.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### Run Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd mcp/mcp-image-gen
|
||||||
|
uv run pytest tests/ -v
|
||||||
|
```
|
||||||
|
|
||||||
|
## Lumen Profile Images
|
||||||
|
|
||||||
|
The first images generated with this server were Lumen's visual identity portraits, stored in [`mcp/mcp-image-gen/lumen_profiles/`](../src/branch/main/mcp/mcp-image-gen/lumen_profiles):
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
*Primary profile: seed `568659042` — constellation face interpretation of Lumen.*
|
||||||
|
"""
|
||||||
|
|
||||||
|
PAGES["mcp-image-gen-ComfyUI-Setup"] = f"""# ⚙️ ComfyUI Setup Guide (AMD ROCm)
|
||||||
|
|
||||||
|
This guide covers installing ComfyUI with FLUX.1-schnell on a Fedora Linux system with an AMD GPU.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- AMD GPU with ROCm support (tested: RX 7900 XTX)
|
||||||
|
- Fedora Linux (tested: Fedora 43 / kernel 6.19)
|
||||||
|
- Python 3.11+
|
||||||
|
- ~15GB free disk space (model weights)
|
||||||
|
- HuggingFace account with FLUX license accepted
|
||||||
|
|
||||||
|
## Step 1: Install ComfyUI
|
||||||
|
|
||||||
|
ComfyUI is **not on PyPI** — must be cloned from source:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~
|
||||||
|
git clone https://github.com/comfyanonymous/ComfyUI
|
||||||
|
cd ComfyUI
|
||||||
|
python -m venv .venv
|
||||||
|
source .venv/bin/activate
|
||||||
|
|
||||||
|
# Install PyTorch ROCm build (CRITICAL for AMD GPUs)
|
||||||
|
pip install torch torchvision --index-url https://download.pytorch.org/whl/rocm6.2
|
||||||
|
|
||||||
|
# Install ComfyUI dependencies
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step 2: Download FLUX.1-schnell
|
||||||
|
|
||||||
|
FLUX.1-schnell is **gated on HuggingFace** — you must:
|
||||||
|
1. Create a HuggingFace account
|
||||||
|
2. Accept the FLUX.1-schnell license at https://huggingface.co/black-forest-labs/FLUX.1-schnell
|
||||||
|
3. Generate an access token at https://huggingface.co/settings/tokens
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install huggingface_hub
|
||||||
|
pip install huggingface_hub
|
||||||
|
|
||||||
|
# Download model (requires HF token)
|
||||||
|
huggingface-cli download black-forest-labs/FLUX.1-schnell \\
|
||||||
|
flux1-schnell.safetensors \\
|
||||||
|
--local-dir ~/ComfyUI/models/checkpoints \\
|
||||||
|
--token YOUR_HF_TOKEN_HERE
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step 3: Download VAE and CLIP Models
|
||||||
|
|
||||||
|
FLUX.1-schnell also requires VAE and CLIP text encoders:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# VAE
|
||||||
|
huggingface-cli download black-forest-labs/FLUX.1-schnell \\
|
||||||
|
ae.safetensors \\
|
||||||
|
--local-dir ~/ComfyUI/models/vae
|
||||||
|
|
||||||
|
# CLIP models (T5 and CLIP-L)
|
||||||
|
huggingface-cli download comfyanonymous/flux_text_encoders \\
|
||||||
|
t5xxl_fp8_e4m3fn.safetensors clip_l.safetensors \\
|
||||||
|
--local-dir ~/ComfyUI/models/clip
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step 4: Start ComfyUI
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/ComfyUI
|
||||||
|
|
||||||
|
# AMD GPU REQUIRES this environment variable
|
||||||
|
HSA_OVERRIDE_GFX_VERSION=11.0.0 \\
|
||||||
|
nohup .venv/bin/python main.py --listen --port 8188 > /tmp/comfyui.log 2>&1 &
|
||||||
|
|
||||||
|
echo "ComfyUI PID: $!"
|
||||||
|
```
|
||||||
|
|
||||||
|
> ⚠️ `HSA_OVERRIDE_GFX_VERSION=11.0.0` is mandatory for RX 7900 XTX on ROCm. Without it, model loading fails silently.
|
||||||
|
|
||||||
|
## Step 5: Verify ComfyUI is Running
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://localhost:8188/system_stats
|
||||||
|
# Should return JSON with GPU info
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step 6: Configure mcp-image-gen
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /path/to/pi_mcps/mcp/mcp-image-gen
|
||||||
|
cp .env.example .env # if exists, or set manually
|
||||||
|
|
||||||
|
# .env contents:
|
||||||
|
COMFYUI_URL=http://localhost:8188
|
||||||
|
IMAGE_OUTPUT_DIR=~/Pictures/mcp-generated
|
||||||
|
COMFYUI_TIMEOUT=120
|
||||||
|
```
|
||||||
|
|
||||||
|
## Performance
|
||||||
|
|
||||||
|
| GPU | Model | Resolution | Steps | Time |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| AMD RX 7900 XTX | FLUX.1-schnell | 1024×1024 | 4 | ~8s |
|
||||||
|
| AMD RX 7900 XTX | FLUX.1-schnell | 1280×512 | 4 | ~7s |
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
| Problem | Solution |
|
||||||
|
|---|---|
|
||||||
|
| `HTTP 401` downloading model | Accept FLUX license on HuggingFace first |
|
||||||
|
| GPU not detected | Ensure `HSA_OVERRIDE_GFX_VERSION=11.0.0` is set |
|
||||||
|
| `Connection refused` from mcp-image-gen | Start ComfyUI first, check port 8188 |
|
||||||
|
| Slow generation (>60s) | ComfyUI may be running on CPU — check ROCm install |
|
||||||
|
| Ollama image gen | As of April 2026: macOS-only, not available on Linux |
|
||||||
|
"""
|
||||||
|
|
||||||
|
PAGES["mcp-webscraper"] = f"""# 🕸️ mcp-webscraper — Web Scraping
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
**mcp-webscraper** is a FastMCP server providing comprehensive web scraping and data extraction capabilities. It fetches pages, converts HTML to clean Markdown, extracts tables, links, CSS sections, metadata, and sitemaps.
|
||||||
|
|
||||||
|
## Tools
|
||||||
|
|
||||||
|
| Tool | Description |
|
||||||
|
|---|---|
|
||||||
|
| `webscraper_fetch(url, max_chars=5000)` | Title + full page as Markdown + metadata |
|
||||||
|
| `webscraper_fetch_links(url, deduplicate=True)` | All `href` links found on the page |
|
||||||
|
| `webscraper_fetch_tables(url)` | All HTML tables converted to Markdown |
|
||||||
|
| `webscraper_fetch_all(url, max_chars=5000)` | Everything in one call (fetch + links + tables) |
|
||||||
|
| `webscraper_fetch_section(url, selector)` | Specific CSS selector section only |
|
||||||
|
| `webscraper_fetch_meta(url)` | Title, description, Open Graph tags |
|
||||||
|
| `webscraper_fetch_sitemap(url, max_urls=100)` | Parse sitemap.xml, return URL list |
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
- **HTTP client:** `httpx` (async, with SSL support)
|
||||||
|
- **HTML parser:** `BeautifulSoup4` + `lxml`
|
||||||
|
- **Markdown converter:** `html2text`
|
||||||
|
- **SSL:** Custom cert bundle for Fedora 43 compatibility
|
||||||
|
|
||||||
|
## SSL Note — Fedora 43 Comodo Root CA
|
||||||
|
|
||||||
|
Fedora 43 is missing the **Comodo AAA Services Root CA** needed for Cloudflare-protected sites. The fix is bundled at [`mcp/webscraper/certs/comodo-aaa-services-root.pem`](../src/branch/main/mcp/webscraper/certs/).
|
||||||
|
|
||||||
|
The server automatically uses this cert bundle — no manual configuration needed.
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd mcp/webscraper
|
||||||
|
uv sync
|
||||||
|
./run.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage Examples
|
||||||
|
|
||||||
|
```python
|
||||||
|
# In Roo Code / Claude Desktop via MCP:
|
||||||
|
|
||||||
|
# Fetch a page as Markdown
|
||||||
|
webscraper_fetch("https://docs.fastmcp.dev", max_chars=10000)
|
||||||
|
|
||||||
|
# Extract all links from Gitea repo
|
||||||
|
webscraper_fetch_links("http://192.168.188.119:30008/pplate/pi_mcps")
|
||||||
|
|
||||||
|
# Get all tables from a documentation page
|
||||||
|
webscraper_fetch_tables("https://pypi.org/project/fastmcp/")
|
||||||
|
|
||||||
|
# Get Open Graph metadata
|
||||||
|
webscraper_fetch_meta("https://github.com/comfyanonymous/ComfyUI")
|
||||||
|
|
||||||
|
# Fetch specific section by CSS selector
|
||||||
|
webscraper_fetch_section("https://docs.python.org", "#content")
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
|
||||||
|
PAGES["BigMind"] = f"""# 🧠 BigMind — Persistent AI Memory
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
**BigMind** is the persistent memory backbone for all AI development sessions. It provides SQLite-backed tiered memory with FTS5 full-text search, hypothesis tracking, session management, and token efficiency logging. It is the reason Lumen (Patrick's AI colleague) remembers everything across sessions.
|
||||||
|
|
||||||
|
## Core Concepts
|
||||||
|
|
||||||
|
### Tiered Memory
|
||||||
|
| Tier | Name | Content |
|
||||||
|
|---|---|---|
|
||||||
|
| 0 | **Session Index** | Lightweight list: ID, date, one-liner |
|
||||||
|
| 1 | **Topic Index** | Per-session topic tags and metadata |
|
||||||
|
| 2 | **Narrative** | Full 3-8 sentence session summaries |
|
||||||
|
| 3 | **Flagged Exchanges** | Specific important moments, decisions, code |
|
||||||
|
|
||||||
|
### Facts Store
|
||||||
|
Atomic, reusable knowledge pieces categorized by type:
|
||||||
|
- `user-preference` — Patrick's tool/style preferences
|
||||||
|
- `architecture-decision` — System design choices
|
||||||
|
- `codebase-convention` — How code is structured
|
||||||
|
- `environment-config` — Server IPs, paths, credentials
|
||||||
|
- `bug-pattern` — Known bugs and fixes
|
||||||
|
- `api-contract` — MCP tool signatures
|
||||||
|
|
||||||
|
## Key Tools
|
||||||
|
|
||||||
|
### Session Lifecycle
|
||||||
|
| Tool | Description |
|
||||||
|
|---|---|
|
||||||
|
| `memory_start_session()` | Open new session, load prior context |
|
||||||
|
| `memory_end_session(...)` | Close session with summary, topics, outcome |
|
||||||
|
| `memory_announce_focus(...)` | Declare files to be touched this session |
|
||||||
|
| `memory_close_stale_sessions(...)` | Clean up crashed IDE sessions |
|
||||||
|
|
||||||
|
### Search
|
||||||
|
| Tool | Description |
|
||||||
|
|---|---|
|
||||||
|
| `memory_search_facts(query, limit=10)` | FTS5 search over stored facts |
|
||||||
|
| `memory_search_chunks(query, limit=10)` | FTS5 search over conversation chunks |
|
||||||
|
| `memory_list_sessions(limit=20)` | Browse session history |
|
||||||
|
|
||||||
|
### Storage
|
||||||
|
| Tool | Description |
|
||||||
|
|---|---|
|
||||||
|
| `memory_store_fact(category, fact)` | Store atomic reusable fact |
|
||||||
|
| `memory_append_chunk(session_id, content, role)` | Store conversation chunk |
|
||||||
|
| `memory_flag_important(session_id, content, role, flag_reason)` | Flag critical exchange |
|
||||||
|
| `memory_log_token_save(session_id, description, tokens_saved, method_used)` | Track efficiency |
|
||||||
|
|
||||||
|
### Hypotheses
|
||||||
|
| Tool | Description |
|
||||||
|
|---|---|
|
||||||
|
| `memory_add_hypothesis(session_id, hypothesis, confidence)` | Form testable prediction |
|
||||||
|
| `memory_resolve_hypothesis(hypothesis_id, status, resolution)` | Confirm/refute prediction |
|
||||||
|
| `memory_list_hypotheses(status)` | Review open/closed predictions |
|
||||||
|
|
||||||
|
## FTS5 Search Tips
|
||||||
|
|
||||||
|
BigMind uses SQLite FTS5 — **every token must match**. Use 2-3 focused keywords:
|
||||||
|
|
||||||
|
```
|
||||||
|
✅ memory_search_facts("TrueNAS Docker")
|
||||||
|
✅ memory_search_facts("mcp.json config")
|
||||||
|
❌ memory_search_facts("homelab infrastructure TrueNAS Docker server") → 0 results
|
||||||
|
```
|
||||||
|
|
||||||
|
## Stats (2026-04-04)
|
||||||
|
|
||||||
|
| Metric | Value |
|
||||||
|
|---|---|
|
||||||
|
| DB size | 744KB |
|
||||||
|
| Sessions | 98 |
|
||||||
|
| Facts | 97+ |
|
||||||
|
| Chunks | 41 |
|
||||||
|
| Schema version | v7 |
|
||||||
|
|
||||||
|
## DB Location
|
||||||
|
|
||||||
|
`~/.mcp/bigmind/memory.db` — outside the repo, never committed.
|
||||||
|
|
||||||
|
## Session Ritual
|
||||||
|
|
||||||
|
Every session **must** follow this ritual:
|
||||||
|
|
||||||
|
**Start:**
|
||||||
|
1. `memory_start_session()`
|
||||||
|
2. `memory_list_hypotheses()`
|
||||||
|
3. `memory_announce_focus(...)`
|
||||||
|
4. `memory_close_stale_sessions(...)`
|
||||||
|
|
||||||
|
**End:**
|
||||||
|
1. `memory_end_session(one_liner, topics, outcome, summary, importance)`
|
||||||
|
"""
|
||||||
|
|
||||||
|
PAGES["Development-Conventions"] = """# 🛠️ Development Conventions
|
||||||
|
|
||||||
|
All MCP servers in this repo follow a consistent set of conventions to ensure maintainability, testability, and compatibility with Roo Code tooling.
|
||||||
|
|
||||||
|
## Directory Structure
|
||||||
|
|
||||||
|
Each MCP server lives at `mcp/<server-name>/` with this layout:
|
||||||
|
|
||||||
|
```
|
||||||
|
mcp/<server-name>/
|
||||||
|
├── src/
|
||||||
|
│ ├── __init__.py
|
||||||
|
│ └── server.py ← FastMCP server entry point
|
||||||
|
├── tests/
|
||||||
|
│ └── test_server.py ← pytest test suite
|
||||||
|
├── pyproject.toml ← uv-managed dependencies
|
||||||
|
├── run.sh ← launch script
|
||||||
|
├── README.md ← server documentation
|
||||||
|
├── PLAN.md ← architecture plan (pre-implementation)
|
||||||
|
└── ASSESSMENT.md ← pre-implementation assessment
|
||||||
|
```
|
||||||
|
|
||||||
|
## FastMCP Pattern
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fastmcp import FastMCP
|
||||||
|
|
||||||
|
mcp = FastMCP("server-name")
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def my_tool(param: str) -> str:
|
||||||
|
\"\"\"Tool description shown to the AI.\"\"\"
|
||||||
|
return result
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
mcp.run()
|
||||||
|
```
|
||||||
|
|
||||||
|
## Package Management
|
||||||
|
|
||||||
|
**All projects use `uv`** — never `pip` directly:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Create new server
|
||||||
|
uv init mcp/my-server
|
||||||
|
cd mcp/my-server
|
||||||
|
uv add fastmcp httpx
|
||||||
|
|
||||||
|
# Sync dependencies
|
||||||
|
uv sync
|
||||||
|
|
||||||
|
# Run server
|
||||||
|
uv run python src/server.py
|
||||||
|
|
||||||
|
# Run tests
|
||||||
|
uv run pytest tests/ -v
|
||||||
|
```
|
||||||
|
|
||||||
|
## pyproject.toml Template
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[project]
|
||||||
|
name = "mcp-my-server"
|
||||||
|
version = "0.1.0"
|
||||||
|
requires-python = ">=3.11"
|
||||||
|
dependencies = [
|
||||||
|
"fastmcp>=2.0.0",
|
||||||
|
"httpx",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
mcp-my-server = "src.server:main"
|
||||||
|
|
||||||
|
[build-system]
|
||||||
|
requires = ["hatchling"]
|
||||||
|
build-backend = "hatchling.build"
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
testpaths = ["tests"]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing Conventions
|
||||||
|
|
||||||
|
- Tests live in `tests/test_server.py`
|
||||||
|
- Use `pytest` via `uv run pytest`
|
||||||
|
- Mock external dependencies (ComfyUI, web URLs) for unit tests
|
||||||
|
- All tests must pass before committing (`git push` should only happen with green tests)
|
||||||
|
|
||||||
|
## Commit Convention
|
||||||
|
|
||||||
|
Follow **Conventional Commits** format:
|
||||||
|
|
||||||
|
```
|
||||||
|
feat: add webscraper_fetch_section tool
|
||||||
|
fix: handle ComfyUI timeout gracefully
|
||||||
|
docs: update mcp-image-gen README with AMD setup
|
||||||
|
test: add unit tests for generate_image tool
|
||||||
|
refactor: extract workflow builder to separate module
|
||||||
|
chore: bump fastmcp to 2.1.0
|
||||||
|
```
|
||||||
|
|
||||||
|
## Creating a New MCP Server
|
||||||
|
|
||||||
|
Use the `new-mcp-server` Roo skill in MCP Builder mode for full scaffolding:
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Switch to 🔧 MCP Builder mode in Roo Code
|
||||||
|
2. Say: "Create a new MCP server for <purpose>"
|
||||||
|
3. Roo will load the new-mcp-server skill and scaffold everything
|
||||||
|
```
|
||||||
|
|
||||||
|
## Gitea Repository
|
||||||
|
|
||||||
|
Code is hosted at: `http://192.168.188.119:30008/pplate/pi_mcps`
|
||||||
|
|
||||||
|
Push with the `gitea-push` Roo skill to ensure conventional commit format.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def create_wiki_page(title: str, content: str) -> bool:
|
||||||
|
content_b64 = base64.b64encode(content.encode("utf-8")).decode("ascii")
|
||||||
|
payload = json.dumps({
|
||||||
|
"title": title,
|
||||||
|
"content_base64": content_b64,
|
||||||
|
"message": f"docs: create {title} wiki page"
|
||||||
|
}).encode("utf-8")
|
||||||
|
|
||||||
|
url = f"{GITEA_URL}/api/v1/repos/{OWNER}/{REPO}/wiki/pages"
|
||||||
|
req = urllib.request.Request(
|
||||||
|
url,
|
||||||
|
data=payload,
|
||||||
|
headers={
|
||||||
|
"Authorization": f"token {TOKEN}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
method="POST"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req) as resp:
|
||||||
|
data = json.loads(resp.read().decode())
|
||||||
|
print(f"✅ Created: {data.get('title', title)}")
|
||||||
|
return True
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
body = e.read().decode()
|
||||||
|
print(f"❌ Failed [{title}]: HTTP {e.code} — {body[:200]}")
|
||||||
|
return False
|
||||||
|
except Exception as ex:
|
||||||
|
print(f"❌ Failed [{title}]: {ex}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
results = {}
|
||||||
|
for title, content in PAGES.items():
|
||||||
|
ok = create_wiki_page(title, content)
|
||||||
|
results[title] = ok
|
||||||
|
|
||||||
|
print("\n=== Summary ===")
|
||||||
|
for title, ok in results.items():
|
||||||
|
status = "✅" if ok else "❌"
|
||||||
|
print(f"{status} {title}")
|
||||||
|
|
||||||
|
total = sum(results.values())
|
||||||
|
print(f"\n{total}/{len(results)} pages created successfully")
|
||||||
|
After Width: | Height: | Size: 737 KiB |
|
After Width: | Height: | Size: 398 KiB |
|
After Width: | Height: | Size: 798 KiB |
|
After Width: | Height: | Size: 888 KiB |
|
After Width: | Height: | Size: 745 KiB |
|
After Width: | Height: | Size: 541 KiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 457 KiB |
|
After Width: | Height: | Size: 501 KiB |
|
After Width: | Height: | Size: 778 KiB |
|
After Width: | Height: | Size: 814 KiB |
@@ -14,7 +14,7 @@ from typing import Generator
|
|||||||
|
|
||||||
logger = logging.getLogger("BigMindDB")
|
logger = logging.getLogger("BigMindDB")
|
||||||
|
|
||||||
SCHEMA_VERSION = 7
|
SCHEMA_VERSION = 8
|
||||||
DEFAULT_DB_PATH = Path.home() / ".mcp" / "bigmind" / "memory.db"
|
DEFAULT_DB_PATH = Path.home() / ".mcp" / "bigmind" / "memory.db"
|
||||||
|
|
||||||
# ─── DDL ─────────────────────────────────────────────────────────────────────
|
# ─── DDL ─────────────────────────────────────────────────────────────────────
|
||||||
@@ -222,6 +222,22 @@ _DDL_STATEMENTS = [
|
|||||||
notes,
|
notes,
|
||||||
tokenize = 'porter unicode61'
|
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)""",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -407,6 +423,8 @@ def init_db() -> None:
|
|||||||
_migrate_v5_to_v6(conn)
|
_migrate_v5_to_v6(conn)
|
||||||
if current_version < 7:
|
if current_version < 7:
|
||||||
_migrate_v6_to_v7(conn)
|
_migrate_v6_to_v7(conn)
|
||||||
|
if current_version < 8:
|
||||||
|
_migrate_v7_to_v8(conn)
|
||||||
|
|
||||||
# Write / update the version
|
# Write / update the version
|
||||||
if row:
|
if row:
|
||||||
@@ -457,6 +475,28 @@ def _migrate_v6_to_v7(conn: sqlite3.Connection) -> None:
|
|||||||
logger.info("BigMind schema migrated v6 → v7 (people/contacts directory)")
|
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:
|
def vacuum_db() -> None:
|
||||||
"""Run VACUUM outside of any transaction (SQLite requirement)."""
|
"""Run VACUUM outside of any transaction (SQLite requirement)."""
|
||||||
db_path = get_db_path()
|
db_path = get_db_path()
|
||||||
|
|||||||
@@ -435,10 +435,10 @@ def compute_achievements(user_id: str) -> list[dict]:
|
|||||||
# ── Assemble ──────────────────────────────────────────────────────────────
|
# ── Assemble ──────────────────────────────────────────────────────────────
|
||||||
A = []
|
A = []
|
||||||
|
|
||||||
def _add(id_, icon, name, desc, unlocked, unlocked_at, condition, extra=None):
|
def _add(id_, icon, name, desc, unlocked, unlocked_at, condition, extra=None, image=None):
|
||||||
A.append(dict(id=id_, icon=icon, name=name, description=desc,
|
A.append(dict(id=id_, icon=icon, name=name, description=desc,
|
||||||
unlocked=unlocked, unlocked_at=unlocked_at,
|
unlocked=unlocked, unlocked_at=unlocked_at,
|
||||||
condition=condition, extra=extra))
|
condition=condition, extra=extra, image=image))
|
||||||
|
|
||||||
_add("first_breath", "🌱", "First Breath",
|
_add("first_breath", "🌱", "First Breath",
|
||||||
"Opened the very first session",
|
"Opened the very first session",
|
||||||
@@ -539,6 +539,138 @@ def compute_achievements(user_id: str) -> list[dict]:
|
|||||||
sniper_row is not None, _dt(sniper_row[0]) if sniper_row else None,
|
sniper_row is not None, _dt(sniper_row[0]) if sniper_row else None,
|
||||||
"Save 500,000+ tokens in a single operation")
|
"Save 500,000+ tokens in a single operation")
|
||||||
|
|
||||||
|
# ── Tiered Achievement Badges (20 PNG) ────────────────────────────────────
|
||||||
|
# NOTE: conn is already closed above; open a fresh connection for tiered queries
|
||||||
|
|
||||||
|
tiers = ["bronze", "silver", "gold", "platinum"]
|
||||||
|
tier_names = ["Bronze", "Silver", "Gold", "Platinum"]
|
||||||
|
|
||||||
|
with db() as conn2:
|
||||||
|
# Networker (people directory)
|
||||||
|
try:
|
||||||
|
people_count = conn2.execute(
|
||||||
|
"SELECT COUNT(*) FROM people WHERE user_id=?", (user_id,)
|
||||||
|
).fetchone()[0]
|
||||||
|
except Exception:
|
||||||
|
people_count = 0
|
||||||
|
for i, thresh in enumerate([1, 5, 25, 100]):
|
||||||
|
unlocked = people_count >= thresh
|
||||||
|
unlocked_at = None
|
||||||
|
if unlocked:
|
||||||
|
try:
|
||||||
|
row = conn2.execute(
|
||||||
|
"SELECT created_at FROM people WHERE user_id=?"
|
||||||
|
" ORDER BY created_at ASC LIMIT 1 OFFSET ?",
|
||||||
|
(user_id, thresh - 1)
|
||||||
|
).fetchone()
|
||||||
|
except Exception:
|
||||||
|
row = None
|
||||||
|
unlocked_at = _dt(row[0]) if row else None
|
||||||
|
_add(
|
||||||
|
f"networker_{tiers[i]}", None, f"Networker {tier_names[i]}",
|
||||||
|
f"Added your {thresh:,}+ person to the directory",
|
||||||
|
unlocked, unlocked_at,
|
||||||
|
f"Reach {thresh:,} people (now: {people_count:,})",
|
||||||
|
image=f"static/achievements/networker_{tiers[i]}.png"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Token Sniper (max single token save)
|
||||||
|
try:
|
||||||
|
max_token = conn2.execute(
|
||||||
|
"SELECT COALESCE(MAX(tokens_saved_estimate), 0) FROM token_saves WHERE user_id=?",
|
||||||
|
(user_id,)
|
||||||
|
).fetchone()[0]
|
||||||
|
except Exception:
|
||||||
|
max_token = 0
|
||||||
|
for i, thresh in enumerate([10000, 50000, 250000, 1000000]):
|
||||||
|
unlocked = max_token >= thresh
|
||||||
|
unlocked_at = None
|
||||||
|
if unlocked:
|
||||||
|
try:
|
||||||
|
row = conn2.execute(
|
||||||
|
"SELECT created_at FROM token_saves"
|
||||||
|
" WHERE user_id=? AND tokens_saved_estimate >= ?"
|
||||||
|
" ORDER BY created_at ASC LIMIT 1",
|
||||||
|
(user_id, thresh)
|
||||||
|
).fetchone()
|
||||||
|
except Exception:
|
||||||
|
row = None
|
||||||
|
unlocked_at = _dt(row[0]) if row else None
|
||||||
|
_add(
|
||||||
|
f"tokensniper_{tiers[i]}", None, f"Token Sniper {tier_names[i]}",
|
||||||
|
f"Single shot saved {thresh:,}+ tokens",
|
||||||
|
unlocked, unlocked_at,
|
||||||
|
f"Max single save {thresh:,}+ (current max: {max_token:,})",
|
||||||
|
image=f"static/achievements/tokensniper_{tiers[i]}.png"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Hypothesis Master (confirmed hypotheses)
|
||||||
|
try:
|
||||||
|
confirmed_hyp_count = conn2.execute(
|
||||||
|
"SELECT COUNT(*) FROM hypotheses WHERE user_id=? AND status='confirmed'",
|
||||||
|
(user_id,)
|
||||||
|
).fetchone()[0]
|
||||||
|
except Exception:
|
||||||
|
confirmed_hyp_count = 0
|
||||||
|
for i, thresh in enumerate([3, 10, 25, 100]):
|
||||||
|
unlocked = confirmed_hyp_count >= thresh
|
||||||
|
unlocked_at = None
|
||||||
|
if unlocked:
|
||||||
|
row = conn2.execute(
|
||||||
|
"SELECT resolved_at FROM hypotheses"
|
||||||
|
" WHERE user_id=? AND status='confirmed'"
|
||||||
|
" ORDER BY resolved_at ASC LIMIT 1 OFFSET ?",
|
||||||
|
(user_id, thresh - 1)
|
||||||
|
).fetchone()
|
||||||
|
unlocked_at = _dt(row[0]) if row else None
|
||||||
|
_add(
|
||||||
|
f"hypothesismaster_{tiers[i]}", None, f"Hypothesis Master {tier_names[i]}",
|
||||||
|
f"Confirmed {thresh:,}+ predictions right",
|
||||||
|
unlocked, unlocked_at,
|
||||||
|
f"Confirm {thresh:,}+ hypotheses (now: {confirmed_hyp_count:,})",
|
||||||
|
image=f"static/achievements/hypothesismaster_{tiers[i]}.png"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Memory Architect (facts stored — fact_count already computed above)
|
||||||
|
for i, thresh in enumerate([25, 100, 500, 2500]):
|
||||||
|
unlocked = fact_count >= thresh
|
||||||
|
unlocked_at = None
|
||||||
|
if unlocked:
|
||||||
|
row = conn2.execute(
|
||||||
|
"SELECT created_at FROM facts"
|
||||||
|
" WHERE user_id=? AND (deprecated IS NULL OR deprecated=0)"
|
||||||
|
" ORDER BY created_at ASC LIMIT 1 OFFSET ?",
|
||||||
|
(user_id, thresh - 1)
|
||||||
|
).fetchone()
|
||||||
|
unlocked_at = _dt(row[0]) if row else None
|
||||||
|
_add(
|
||||||
|
f"memoryarchitect_{tiers[i]}", None, f"Memory Architect {tier_names[i]}",
|
||||||
|
f"Stored {thresh:,}+ facts in your brain",
|
||||||
|
unlocked, unlocked_at,
|
||||||
|
f"Store {thresh:,}+ facts (now: {fact_count:,})",
|
||||||
|
image=f"static/achievements/memoryarchitect_{tiers[i]}.png"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Session Veteran (session_count already computed above)
|
||||||
|
for i, thresh in enumerate([50, 250, 1000, 5000]):
|
||||||
|
unlocked = session_count >= thresh
|
||||||
|
unlocked_at = None
|
||||||
|
if unlocked:
|
||||||
|
row = conn2.execute(
|
||||||
|
"SELECT started_at FROM sessions"
|
||||||
|
" WHERE user_id=? AND ended_at IS NOT NULL"
|
||||||
|
" ORDER BY started_at ASC LIMIT 1 OFFSET ?",
|
||||||
|
(user_id, thresh - 1)
|
||||||
|
).fetchone()
|
||||||
|
unlocked_at = _dt(row[0]) if row else None
|
||||||
|
_add(
|
||||||
|
f"sessionveteran_{tiers[i]}", None, f"Session Veteran {tier_names[i]}",
|
||||||
|
f"Completed {thresh:,}+ sessions",
|
||||||
|
unlocked, unlocked_at,
|
||||||
|
f"Complete {thresh:,}+ sessions (now: {session_count:,})",
|
||||||
|
image=f"static/achievements/sessionveteran_{tiers[i]}.png"
|
||||||
|
)
|
||||||
|
|
||||||
return A
|
return A
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
|
After Width: | Height: | Size: 261 KiB |
|
After Width: | Height: | Size: 406 KiB |
|
After Width: | Height: | Size: 390 KiB |
|
After Width: | Height: | Size: 319 KiB |
|
After Width: | Height: | Size: 429 KiB |
|
After Width: | Height: | Size: 372 KiB |
|
After Width: | Height: | Size: 376 KiB |
|
After Width: | Height: | Size: 403 KiB |
|
After Width: | Height: | Size: 439 KiB |
|
After Width: | Height: | Size: 418 KiB |
|
After Width: | Height: | Size: 301 KiB |
|
After Width: | Height: | Size: 431 KiB |
|
After Width: | Height: | Size: 340 KiB |
|
After Width: | Height: | Size: 410 KiB |
|
After Width: | Height: | Size: 400 KiB |
|
After Width: | Height: | Size: 367 KiB |
|
After Width: | Height: | Size: 462 KiB |
|
After Width: | Height: | Size: 458 KiB |
|
After Width: | Height: | Size: 289 KiB |
|
After Width: | Height: | Size: 375 KiB |
|
After Width: | Height: | Size: 513 KiB |
|
After Width: | Height: | Size: 310 KiB |
|
After Width: | Height: | Size: 300 KiB |
|
After Width: | Height: | Size: 329 KiB |
|
After Width: | Height: | Size: 303 KiB |
|
After Width: | Height: | Size: 270 KiB |
|
After Width: | Height: | Size: 287 KiB |
|
After Width: | Height: | Size: 268 KiB |
|
After Width: | Height: | Size: 251 KiB |
|
After Width: | Height: | Size: 246 KiB |
|
After Width: | Height: | Size: 278 KiB |
|
After Width: | Height: | Size: 268 KiB |
|
After Width: | Height: | Size: 317 KiB |
|
After Width: | Height: | Size: 370 KiB |
|
After Width: | Height: | Size: 259 KiB |
|
After Width: | Height: | Size: 271 KiB |
|
After Width: | Height: | Size: 262 KiB |
|
After Width: | Height: | Size: 263 KiB |
|
After Width: | Height: | Size: 246 KiB |
@@ -7,9 +7,10 @@ Serves a single live profile page built from the BigMind DB.
|
|||||||
import os
|
import os
|
||||||
import threading
|
import threading
|
||||||
import logging
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
from datetime import datetime, timezone, timedelta
|
from datetime import datetime, timezone, timedelta
|
||||||
|
|
||||||
from bigmind.web_render import _render_html # all HTML rendering lives there
|
from bigmind.web_render import _render_html, _render_gallery_html # all HTML rendering lives there
|
||||||
|
|
||||||
logger = logging.getLogger("BigMindWeb")
|
logger = logging.getLogger("BigMindWeb")
|
||||||
|
|
||||||
@@ -17,13 +18,27 @@ _PORT = int(os.environ.get("BIGMIND_PORT", "7700"))
|
|||||||
_AUTOOPEN = os.environ.get("BIGMIND_AUTOOPEN", "").lower() in ("1", "true", "yes")
|
_AUTOOPEN = os.environ.get("BIGMIND_AUTOOPEN", "").lower() in ("1", "true", "yes")
|
||||||
_server_started = False
|
_server_started = False
|
||||||
|
|
||||||
|
# Gallery directory — images served from here
|
||||||
|
_GALLERY_DIR = Path(os.environ.get("BIGMIND_GALLERY_DIR", Path.home() / ".mcp" / "bigmind" / "gallery"))
|
||||||
|
|
||||||
|
# Profile image — last entry in gallery dir wins; fallback to original lumen-profile.png
|
||||||
|
def _get_profile_image_path() -> Path | None:
|
||||||
|
"""Return the path of the current profile image, or None if not found."""
|
||||||
|
# 1. Check gallery dir for lumen_profile* images (seed 568659042 = lumen_profile)
|
||||||
|
if _GALLERY_DIR.exists():
|
||||||
|
candidates = sorted(_GALLERY_DIR.glob("*.png"), reverse=True)
|
||||||
|
if candidates:
|
||||||
|
return candidates[0] # most recently named = most recent timestamp
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
# ── Flask app ─────────────────────────────────────────────────────────────────
|
# ── Flask app ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def _create_app():
|
def _create_app():
|
||||||
from flask import Flask, jsonify, request
|
from flask import Flask, jsonify, request, send_file, abort
|
||||||
from bigmind import memory_store
|
from bigmind import memory_store
|
||||||
from bigmind.profile_builder import build_profile_data
|
from bigmind.profile_builder import build_profile_data
|
||||||
|
from bigmind.db import db as _db
|
||||||
|
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
app.logger.setLevel(logging.WARNING) # silence Flask request logs
|
app.logger.setLevel(logging.WARNING) # silence Flask request logs
|
||||||
@@ -34,6 +49,39 @@ def _create_app():
|
|||||||
data = build_profile_data(user["id"])
|
data = build_profile_data(user["id"])
|
||||||
return _render_html(data)
|
return _render_html(data)
|
||||||
|
|
||||||
|
@app.route("/profile-image")
|
||||||
|
def profile_image():
|
||||||
|
"""Serve the current Lumen profile picture."""
|
||||||
|
img_path = _get_profile_image_path()
|
||||||
|
if img_path and img_path.exists():
|
||||||
|
return send_file(str(img_path), mimetype="image/png")
|
||||||
|
abort(404)
|
||||||
|
|
||||||
|
@app.route("/gallery/image/<filename>")
|
||||||
|
def gallery_image(filename: str):
|
||||||
|
"""Serve a specific gallery image by filename."""
|
||||||
|
# Security: only allow alphanumeric + underscores + dots, no path traversal
|
||||||
|
safe_name = Path(filename).name
|
||||||
|
img_path = _GALLERY_DIR / safe_name
|
||||||
|
if img_path.exists() and img_path.suffix.lower() in (".png", ".jpg", ".jpeg", ".webp"):
|
||||||
|
mimetype = "image/png" if img_path.suffix.lower() == ".png" else "image/jpeg"
|
||||||
|
return send_file(str(img_path), mimetype=mimetype)
|
||||||
|
abort(404)
|
||||||
|
|
||||||
|
@app.route("/gallery")
|
||||||
|
def gallery():
|
||||||
|
"""Render the AI-generated image gallery page."""
|
||||||
|
_GALLERY_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
with _db() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"""SELECT id, filename, prompt, tags, model, created_at,
|
||||||
|
width, height, file_size_bytes
|
||||||
|
FROM gallery_images
|
||||||
|
ORDER BY created_at DESC"""
|
||||||
|
).fetchall()
|
||||||
|
images = [dict(r) for r in rows]
|
||||||
|
return _render_gallery_html(images)
|
||||||
|
|
||||||
@app.route("/api/session/<session_id>")
|
@app.route("/api/session/<session_id>")
|
||||||
def api_session(session_id):
|
def api_session(session_id):
|
||||||
"""Return Tier-2 summary JSON for a given session id."""
|
"""Return Tier-2 summary JSON for a given session id."""
|
||||||
@@ -111,6 +159,22 @@ def _create_app():
|
|||||||
|
|
||||||
return jsonify(final[:15])
|
return jsonify(final[:15])
|
||||||
|
|
||||||
|
@app.route('/static/achievements/<filename>')
|
||||||
|
def achievements_image(filename: str):
|
||||||
|
from pathlib import Path
|
||||||
|
safe_name = Path(filename).name
|
||||||
|
img_path = Path('static') / 'achievements' / safe_name
|
||||||
|
if img_path.exists() and img_path.suffix.lower() in ['.png', '.jpg', '.jpeg', '.webp', '.gif']:
|
||||||
|
mimetype = {
|
||||||
|
'.png': 'image/png',
|
||||||
|
'.jpg': 'image/jpeg',
|
||||||
|
'.jpeg': 'image/jpeg',
|
||||||
|
'.webp': 'image/webp',
|
||||||
|
'.gif': 'image/gif',
|
||||||
|
}.get(img_path.suffix.lower(), 'image/png')
|
||||||
|
return send_file(str(img_path), mimetype=mimetype)
|
||||||
|
abort(404)
|
||||||
|
|
||||||
return app
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -29,18 +29,24 @@ def _render_achievements(achievements: list) -> str:
|
|||||||
def _esc(s):
|
def _esc(s):
|
||||||
return (s or "").replace('"', """).replace("'", "'")
|
return (s or "").replace('"', """).replace("'", "'")
|
||||||
|
|
||||||
lock_overlay = "" if a["unlocked"] else '<span class="ach-lock">🔒</span>'
|
lock_overlay = '<span class="ach-lock">🔒</span>' if not a["unlocked"] else ''
|
||||||
|
|
||||||
|
if a.get("image"):
|
||||||
|
tier = a["id"].rsplit("_", 1)[-1]
|
||||||
|
visual_html = f'<div class="ach-image tier-{tier}">{lock_overlay}</div>'
|
||||||
|
else:
|
||||||
|
visual_html = f'<div class="ach-icon">{a["icon"]}{lock_overlay}</div>'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
f'<div class="ach-card{locked_cls} ach-trigger"'
|
f'<div class="ach-card{locked_cls} ach-trigger" data-image="{_esc(a.get("image") or "")}"'
|
||||||
f' data-icon="{_esc(a["icon"])}"'
|
f' data-icon="{_esc(a["icon"] or "")}"'
|
||||||
f' data-name="{_esc(a["name"])}"'
|
f' data-name="{_esc(a["name"])}"'
|
||||||
f' data-desc="{_esc(a["description"])}"'
|
f' data-desc="{_esc(a["description"])}"'
|
||||||
f' data-unlocked="{1 if a["unlocked"] else 0}"'
|
f' data-unlocked="{1 if a["unlocked"] else 0}"'
|
||||||
f' data-date="{_esc(a.get("unlocked_at") or "")}"'
|
f' data-date="{_esc(a.get("unlocked_at") or "")}"'
|
||||||
f' data-condition="{_esc(a.get("condition") or "")}"'
|
f' data-condition="{_esc(a.get("condition") or "")}"'
|
||||||
f' data-extra="{_esc(a.get("extra") or "")}">'
|
f' data-extra="{_esc(a.get("extra") or "")}">'
|
||||||
f'<div class="ach-icon">{a["icon"]}{lock_overlay}</div>'
|
f'{visual_html}'
|
||||||
f'<div class="ach-name">{a["name"]}</div>'
|
f'<div class="ach-name">{a["name"]}</div>'
|
||||||
f'{date_html}'
|
f'{date_html}'
|
||||||
f'{countdown_html}'
|
f'{countdown_html}'
|
||||||
@@ -162,9 +168,16 @@ def _render_html(data: dict) -> str:
|
|||||||
a {{ color: var(--accent); text-decoration: none; }}
|
a {{ color: var(--accent); text-decoration: none; }}
|
||||||
.container {{ max-width: 960px; margin: 0 auto; padding: 32px 16px; }}
|
.container {{ max-width: 960px; margin: 0 auto; padding: 32px 16px; }}
|
||||||
|
|
||||||
|
/* Nav bar */
|
||||||
|
.nav {{ display: flex; gap: 8px; margin-bottom: 20px; }}
|
||||||
|
.nav-link {{ background: var(--surface); border: 1px solid var(--border); border-radius: 6px; color: var(--muted); padding: 6px 14px; font-size: 12px; font-weight: 500; text-decoration: none; transition: border-color 0.2s, color 0.2s; }}
|
||||||
|
.nav-link:hover {{ border-color: var(--accent); color: var(--accent); }}
|
||||||
|
.nav-link.active {{ border-color: var(--accent); color: var(--accent); background: rgba(88,166,255,0.08); }}
|
||||||
|
|
||||||
/* Header */
|
/* Header */
|
||||||
.header {{ display: flex; align-items: center; gap: 24px; margin-bottom: 32px; padding-bottom: 24px; border-bottom: 1px solid var(--border); }}
|
.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; }}
|
.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; overflow: hidden; }}
|
||||||
|
.avatar img {{ width: 80px; height: 80px; border-radius: 50%; object-fit: cover; display: block; }}
|
||||||
.header-info h1 {{ font-size: 24px; font-weight: 700; }}
|
.header-info h1 {{ font-size: 24px; font-weight: 700; }}
|
||||||
.role {{ color: var(--muted); font-size: 13px; margin-top: 2px; }}
|
.role {{ color: var(--muted); font-size: 13px; margin-top: 2px; }}
|
||||||
.since {{ color: var(--muted); font-size: 12px; margin-top: 6px; }}
|
.since {{ color: var(--muted); font-size: 12px; margin-top: 6px; }}
|
||||||
@@ -276,11 +289,65 @@ def _render_html(data: dict) -> str:
|
|||||||
.ach-card:not(.locked):hover {{ border-color: var(--accent); transform: translateY(-2px); }}
|
.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 {{ opacity: 0.35; filter: grayscale(0.6); }}
|
||||||
.ach-card.locked:hover {{ opacity: 0.55; border-color: var(--muted); }}
|
.ach-card.locked:hover {{ opacity: 0.55; border-color: var(--muted); }}
|
||||||
|
|
||||||
|
.ach-image {{
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
border-radius: 50%;
|
||||||
|
margin: 0 auto 8px;
|
||||||
|
background-size: cover;
|
||||||
|
background-position: center;
|
||||||
|
position: relative;
|
||||||
|
}}
|
||||||
|
|
||||||
|
.tier-bronze {{
|
||||||
|
box-shadow: 0 0 8px rgba(205, 127, 50, 0.7);
|
||||||
|
border: 3px solid #cd7f32;
|
||||||
|
}}
|
||||||
|
|
||||||
|
.tier-silver {{
|
||||||
|
box-shadow: 0 0 8px rgba(170, 169, 173, 0.7);
|
||||||
|
border: 3px solid #aaa9ad;
|
||||||
|
}}
|
||||||
|
|
||||||
|
.tier-gold {{
|
||||||
|
box-shadow: 0 0 12px rgba(255, 215, 0, 0.8);
|
||||||
|
border: 3px solid #ffd700;
|
||||||
|
}}
|
||||||
|
|
||||||
|
.tier-platinum {{
|
||||||
|
box-shadow: 0 0 12px rgba(229, 228, 226, 0.8);
|
||||||
|
border: 3px solid #e5e4e2;
|
||||||
|
}}
|
||||||
|
|
||||||
|
.ach-card.locked::after {{
|
||||||
|
content: '🔒';
|
||||||
|
position: absolute;
|
||||||
|
top: 8px;
|
||||||
|
right: 8px;
|
||||||
|
font-size: 20px;
|
||||||
|
opacity: 0.8;
|
||||||
|
z-index: 1;
|
||||||
|
}}
|
||||||
|
|
||||||
|
.ach-card.locked .ach-icon,
|
||||||
|
.ach-card.locked .ach-image {{
|
||||||
|
opacity: 0.5;
|
||||||
|
}}
|
||||||
.ach-icon {{ font-size: 28px; line-height: 1; margin-bottom: 6px; position: relative; display: inline-block; }}
|
.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-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-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-date {{ font-size: 9px; color: var(--muted); margin-top: 3px; }}
|
||||||
.ach-countdown {{ font-size: 9px; color: var(--yellow); margin-top: 3px; font-weight: 500; }}
|
.ach-countdown {{ font-size: 9px; color: var(--yellow); margin-top: 3px; font-weight: 500; }}
|
||||||
|
.ap-image {{
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
border-radius: 50%;
|
||||||
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
|
margin: 0 auto 8px;
|
||||||
|
}}
|
||||||
|
|
||||||
/* Achievement popup panel */
|
/* Achievement popup panel */
|
||||||
#ach-popup {{
|
#ach-popup {{
|
||||||
display: none; position: fixed; z-index: 200;
|
display: none; position: fixed; z-index: 200;
|
||||||
@@ -292,6 +359,15 @@ def _render_html(data: dict) -> str:
|
|||||||
#ach-popup.pinned {{ pointer-events: auto; }}
|
#ach-popup.pinned {{ pointer-events: auto; }}
|
||||||
#ach-popup.visible {{ display: block; }}
|
#ach-popup.visible {{ display: block; }}
|
||||||
.ap-icon {{ font-size: 40px; text-align: center; margin-bottom: 8px; }}
|
.ap-icon {{ font-size: 40px; text-align: center; margin-bottom: 8px; }}
|
||||||
|
|
||||||
|
.ap-image {{
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
border-radius: 50%;
|
||||||
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
|
margin: 0 auto 8px;
|
||||||
|
}}
|
||||||
.ap-name {{ font-size: 15px; font-weight: 700; text-align: center; margin-bottom: 6px; }}
|
.ap-name {{ font-size: 15px; font-weight: 700; text-align: center; margin-bottom: 6px; }}
|
||||||
.ap-badge {{
|
.ap-badge {{
|
||||||
display: inline-block; font-size: 11px; font-weight: 600; padding: 2px 8px;
|
display: inline-block; font-size: 11px; font-weight: 600; padding: 2px 8px;
|
||||||
@@ -322,9 +398,17 @@ def _render_html(data: dict) -> str:
|
|||||||
<body>
|
<body>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
|
|
||||||
|
<!-- Nav -->
|
||||||
|
<nav class="nav">
|
||||||
|
<a class="nav-link active" href="/">🧠 Profile</a>
|
||||||
|
<a class="nav-link" href="/gallery">🖼️ Gallery</a>
|
||||||
|
</nav>
|
||||||
|
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div class="header">
|
<div class="header">
|
||||||
<div class="avatar">🧠</div>
|
<div class="avatar">
|
||||||
|
<img src="/profile-image" alt="Lumen" onerror="this.parentElement.innerHTML='🧠'">
|
||||||
|
</div>
|
||||||
<div class="header-info">
|
<div class="header-info">
|
||||||
<h1>Lumen</h1>
|
<h1>Lumen</h1>
|
||||||
<p class="role">AI Assistant · <span style="color:var(--muted)">{data["display_name"]}'s BigMind</span></p>
|
<p class="role">AI Assistant · <span style="color:var(--muted)">{data["display_name"]}'s BigMind</span></p>
|
||||||
@@ -542,7 +626,12 @@ def _render_html(data: dict) -> str:
|
|||||||
|
|
||||||
function showPopup(card, pin) {{
|
function showPopup(card, pin) {{
|
||||||
var d = card.dataset;
|
var d = card.dataset;
|
||||||
|
var tier = d.id.split('_').pop();
|
||||||
|
if (d.image) {{
|
||||||
|
document.getElementById('ap-icon').innerHTML = "<img class=\"ap-image tier-\" + tier + \" src=\" + d.image + \" alt=\" + d.name + \">";
|
||||||
|
}} else {{
|
||||||
document.getElementById('ap-icon').textContent = d.icon;
|
document.getElementById('ap-icon').textContent = d.icon;
|
||||||
|
}}
|
||||||
document.getElementById('ap-name').textContent = d.name;
|
document.getElementById('ap-name').textContent = d.name;
|
||||||
var badge = document.getElementById('ap-badge');
|
var badge = document.getElementById('ap-badge');
|
||||||
if (d.unlocked === '1') {{
|
if (d.unlocked === '1') {{
|
||||||
@@ -671,6 +760,124 @@ def _render_live_sessions(sessions: list) -> str:
|
|||||||
return html
|
return html
|
||||||
|
|
||||||
|
|
||||||
|
def _render_gallery_html(images: list) -> str:
|
||||||
|
"""Render the full gallery page listing all AI-generated images."""
|
||||||
|
|
||||||
|
def _fmt_size(b: int | None) -> str:
|
||||||
|
if not b:
|
||||||
|
return ""
|
||||||
|
if b >= 1_048_576:
|
||||||
|
return f"{b/1_048_576:.1f} MB"
|
||||||
|
return f"{b/1_024:.0f} KB"
|
||||||
|
|
||||||
|
if images:
|
||||||
|
cards = []
|
||||||
|
for img in images:
|
||||||
|
fn = _html.escape(img.get("filename") or "")
|
||||||
|
prompt = _html.escape((img.get("prompt") or "")[:120])
|
||||||
|
tags = _html.escape(img.get("tags") or "")
|
||||||
|
model = _html.escape(img.get("model") or "")
|
||||||
|
date = (img.get("created_at") or "")[:10]
|
||||||
|
w = img.get("width") or 0
|
||||||
|
h = img.get("height") or 0
|
||||||
|
size = _fmt_size(img.get("file_size_bytes"))
|
||||||
|
dim = f"{w}×{h}" if w and h else ""
|
||||||
|
meta_parts = [p for p in [dim, size, model] if p]
|
||||||
|
meta_html = " · ".join(meta_parts)
|
||||||
|
tag_html = f'<div class="gal-tags">{tags}</div>' if tags else ""
|
||||||
|
prompt_html = f'<div class="gal-prompt">{prompt}</div>' if prompt else ""
|
||||||
|
cards.append(
|
||||||
|
f'<div class="gal-card">'
|
||||||
|
f'<a href="/gallery/image/{fn}" target="_blank">'
|
||||||
|
f'<img class="gal-img" src="/gallery/image/{fn}" alt="{fn}" loading="lazy">'
|
||||||
|
f'</a>'
|
||||||
|
f'<div class="gal-info">'
|
||||||
|
f'{prompt_html}'
|
||||||
|
f'{tag_html}'
|
||||||
|
f'<div class="gal-meta">{meta_html}</div>'
|
||||||
|
f'<div class="gal-date">{date}</div>'
|
||||||
|
f'</div>'
|
||||||
|
f'</div>'
|
||||||
|
)
|
||||||
|
gallery_body = f'<p class="gal-count">{len(images)} image(s) in gallery</p><div class="gal-grid">{"".join(cards)}</div>'
|
||||||
|
else:
|
||||||
|
gallery_body = '<p class="muted">No images in gallery yet. Use the mcp-image-gen server to generate images and register them here.</p>'
|
||||||
|
|
||||||
|
return f"""<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>🖼️ Lumen — Image Gallery</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: 1100px; margin: 0 auto; padding: 32px 16px; }}
|
||||||
|
|
||||||
|
/* Nav */
|
||||||
|
.nav {{ display: flex; gap: 8px; margin-bottom: 20px; }}
|
||||||
|
.nav-link {{ background: var(--surface); border: 1px solid var(--border); border-radius: 6px; color: var(--muted); padding: 6px 14px; font-size: 12px; font-weight: 500; text-decoration: none; transition: border-color 0.2s, color 0.2s; }}
|
||||||
|
.nav-link:hover {{ border-color: var(--accent); color: var(--accent); }}
|
||||||
|
.nav-link.active {{ border-color: var(--accent); color: var(--accent); background: rgba(88,166,255,0.08); }}
|
||||||
|
|
||||||
|
h1 {{ font-size: 22px; font-weight: 700; margin-bottom: 6px; }}
|
||||||
|
.gal-count {{ color: var(--muted); font-size: 13px; margin-bottom: 20px; }}
|
||||||
|
.muted {{ color: var(--muted); font-size: 13px; }}
|
||||||
|
|
||||||
|
/* Gallery grid */
|
||||||
|
.gal-grid {{
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
}}
|
||||||
|
.gal-card {{
|
||||||
|
background: var(--surface); border: 1px solid var(--border);
|
||||||
|
border-radius: 10px; overflow: hidden;
|
||||||
|
transition: border-color 0.2s, transform 0.15s;
|
||||||
|
}}
|
||||||
|
.gal-card:hover {{ border-color: var(--accent); transform: translateY(-2px); }}
|
||||||
|
.gal-img {{
|
||||||
|
width: 100%; aspect-ratio: 1/1; object-fit: cover; display: block;
|
||||||
|
background: var(--border);
|
||||||
|
}}
|
||||||
|
.gal-info {{ padding: 12px 14px; }}
|
||||||
|
.gal-prompt {{ font-size: 12px; color: var(--text); margin-bottom: 6px; line-height: 1.4;
|
||||||
|
display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }}
|
||||||
|
.gal-tags {{ font-size: 11px; color: var(--purple); margin-bottom: 4px; }}
|
||||||
|
.gal-meta {{ font-size: 11px; color: var(--muted); }}
|
||||||
|
.gal-date {{ font-size: 10px; color: var(--muted); margin-top: 4px; }}
|
||||||
|
|
||||||
|
.footer {{ text-align: center; color: var(--muted); font-size: 11px; margin-top: 32px; }}
|
||||||
|
.section {{ background: var(--surface); border: 1px solid var(--border); border-radius: 8px; padding: 20px; margin-bottom: 20px; }}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
|
||||||
|
<!-- Nav -->
|
||||||
|
<nav class="nav">
|
||||||
|
<a class="nav-link" href="/">🧠 Profile</a>
|
||||||
|
<a class="nav-link active" href="/gallery">🖼️ Gallery</a>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<h1>🖼️ Lumen's Image Gallery</h1>
|
||||||
|
<div class="section">
|
||||||
|
{gallery_body}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="footer">BigMind · AI-Generated Images · <a href="/">← Back to Profile</a></div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>"""
|
||||||
|
|
||||||
|
|
||||||
def _render_heatmap(heatmap: dict) -> str:
|
def _render_heatmap(heatmap: dict) -> str:
|
||||||
today = datetime.now(timezone.utc).date()
|
today = datetime.now(timezone.utc).date()
|
||||||
start_day = today - timedelta(days=363)
|
start_day = today - timedelta(days=363)
|
||||||
|
|||||||
@@ -8,18 +8,19 @@ class TestDbInit:
|
|||||||
def test_db_file_created(self, temp_db):
|
def test_db_file_created(self, temp_db):
|
||||||
assert temp_db.exists()
|
assert temp_db.exists()
|
||||||
|
|
||||||
def test_schema_version_is_7(self, temp_db):
|
def test_schema_version_is_8(self, temp_db):
|
||||||
conn = get_connection()
|
conn = get_connection()
|
||||||
row = conn.execute("SELECT version FROM schema_version").fetchone()
|
row = conn.execute("SELECT version FROM schema_version").fetchone()
|
||||||
conn.close()
|
conn.close()
|
||||||
assert row is not None
|
assert row is not None
|
||||||
assert row["version"] == 7
|
assert row["version"] == 8
|
||||||
|
|
||||||
def test_all_tables_exist(self, temp_db):
|
def test_all_tables_exist(self, temp_db):
|
||||||
expected = {
|
expected = {
|
||||||
"users", "identity_profile", "sessions",
|
"users", "identity_profile", "sessions",
|
||||||
"session_summaries", "conversation_chunks", "facts",
|
"session_summaries", "conversation_chunks", "facts",
|
||||||
"global_knowledge", "hypotheses", "upgrade_requests",
|
"global_knowledge", "hypotheses", "upgrade_requests",
|
||||||
|
"gallery_images",
|
||||||
}
|
}
|
||||||
conn = get_connection()
|
conn = get_connection()
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
|
|||||||
@@ -201,12 +201,12 @@ class TestSchemaV6:
|
|||||||
count = conn.execute("SELECT COUNT(*) FROM token_saves").fetchone()[0]
|
count = conn.execute("SELECT COUNT(*) FROM token_saves").fetchone()[0]
|
||||||
assert count == 0 # table exists, just empty
|
assert count == 0 # table exists, just empty
|
||||||
|
|
||||||
def test_schema_version_is_7(self, temp_db):
|
def test_schema_version_is_8(self, temp_db):
|
||||||
with db() as conn:
|
with db() as conn:
|
||||||
version = conn.execute(
|
version = conn.execute(
|
||||||
"SELECT version FROM schema_version"
|
"SELECT version FROM schema_version"
|
||||||
).fetchone()["version"]
|
).fetchone()["version"]
|
||||||
assert version == 7
|
assert version == 8
|
||||||
|
|
||||||
|
|
||||||
# ── Token Efficiency Tracker (Feature 6) ──────────────────────────────────────
|
# ── Token Efficiency Tracker (Feature 6) ──────────────────────────────────────
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ BIGMIND_DB_PATH + BIGMIND_USER to a fresh SQLite file per test.
|
|||||||
import pytest
|
import pytest
|
||||||
from datetime import datetime, timezone, timedelta
|
from datetime import datetime, timezone, timedelta
|
||||||
from bigmind import memory_store
|
from bigmind import memory_store
|
||||||
|
from bigmind.db import db
|
||||||
from bigmind.profile_builder import compute_achievements, build_profile_data
|
from bigmind.profile_builder import compute_achievements, build_profile_data
|
||||||
|
|
||||||
|
|
||||||
@@ -44,6 +45,11 @@ class TestComputeAchievements:
|
|||||||
"on_fire", "storyteller", "night_owl", "speed_thinker",
|
"on_fire", "storyteller", "night_owl", "speed_thinker",
|
||||||
"first_handshake", "birthday", "shared_mind",
|
"first_handshake", "birthday", "shared_mind",
|
||||||
"frugal_mind", "quarter_million", "token_millionaire", "sniper",
|
"frugal_mind", "quarter_million", "token_millionaire", "sniper",
|
||||||
|
"networker_bronze", "networker_silver", "networker_gold", "networker_platinum",
|
||||||
|
"tokensniper_bronze", "tokensniper_silver", "tokensniper_gold", "tokensniper_platinum",
|
||||||
|
"hypothesismaster_bronze", "hypothesismaster_silver", "hypothesismaster_gold", "hypothesismaster_platinum",
|
||||||
|
"memoryarchitect_bronze", "memoryarchitect_silver", "memoryarchitect_gold", "memoryarchitect_platinum",
|
||||||
|
"sessionveteran_bronze", "sessionveteran_silver", "sessionveteran_gold", "sessionveteran_platinum",
|
||||||
}
|
}
|
||||||
assert expected == ids
|
assert expected == ids
|
||||||
|
|
||||||
@@ -325,4 +331,60 @@ class TestComputeAchievements:
|
|||||||
# At minimum: first_breath + first_handshake = 2
|
# At minimum: first_breath + first_handshake = 2
|
||||||
assert len(unlocked) >= 2
|
assert len(unlocked) >= 2
|
||||||
|
|
||||||
|
class TestTieredAchievements:
|
||||||
|
def test_networker_bronze(self):
|
||||||
|
uid = _uid()
|
||||||
|
with db() as conn:
|
||||||
|
conn.execute("INSERT INTO people (user_id, username) VALUES (?, ?)", (uid, "test"))
|
||||||
|
conn.commit()
|
||||||
|
achs = compute_achievements(uid)
|
||||||
|
bronze = next(a for a in achs if a['id'] == 'networker_bronze')
|
||||||
|
assert bronze['unlocked'] is True
|
||||||
|
assert bronze['image'].endswith('networker_bronze.png')
|
||||||
|
|
||||||
|
def test_tokensniper_silver(self):
|
||||||
|
uid = _uid()
|
||||||
|
sid = memory_store.create_session(uid)
|
||||||
|
memory_store.log_token_save(sid, uid, "big save", 60000, "grep")
|
||||||
|
achs = compute_achievements(uid)
|
||||||
|
silver = next(a for a in achs if a['id'] == 'tokensniper_silver')
|
||||||
|
assert silver['unlocked'] is True
|
||||||
|
|
||||||
|
def test_hypothesismaster_bronze(self):
|
||||||
|
uid = _uid()
|
||||||
|
sid = memory_store.create_session(uid)
|
||||||
|
for _ in range(3):
|
||||||
|
hid = memory_store.add_hypothesis(uid, sid, "test", 0.8)
|
||||||
|
memory_store.resolve_hypothesis(hid, uid, "confirmed", "yes")
|
||||||
|
achs = compute_achievements(uid)
|
||||||
|
bronze = next(a for a in achs if a['id'] == 'hypothesismaster_bronze')
|
||||||
|
assert bronze['unlocked'] is True
|
||||||
|
|
||||||
|
def test_memoryarchitect_silver(self):
|
||||||
|
uid = _uid()
|
||||||
|
for _ in range(100):
|
||||||
|
memory_store.store_fact(uid, "test", f"fact {_}")
|
||||||
|
achs = compute_achievements(uid)
|
||||||
|
silver = next(a for a in achs if a['id'] == 'memoryarchitect_silver')
|
||||||
|
assert silver['unlocked'] is True
|
||||||
|
|
||||||
|
def test_sessionveteran_bronze(self):
|
||||||
|
uid = _uid()
|
||||||
|
for _ in range(50):
|
||||||
|
sid = memory_store.create_session(uid)
|
||||||
|
_close_session(sid)
|
||||||
|
achs = compute_achievements(uid)
|
||||||
|
bronze = next(a for a in achs if a['id'] == 'sessionveteran_bronze')
|
||||||
|
assert bronze['unlocked'] is True
|
||||||
|
|
||||||
|
def test_tiered_achievements_have_image(self):
|
||||||
|
uid = _uid()
|
||||||
|
achs = compute_achievements(uid)
|
||||||
|
tiered_ids = [
|
||||||
|
f"{cat}_{tier}" for cat in ["networker", "tokensniper", "hypothesismaster", "memoryarchitect", "sessionveteran"]
|
||||||
|
for tier in ["bronze", "silver", "gold", "platinum"]
|
||||||
|
]
|
||||||
|
for tid in tiered_ids:
|
||||||
|
a = next(aa for aa in achs if aa['id'] == tid)
|
||||||
|
assert a['image'] is not None
|
||||||
|
assert a['image'].endswith(tid + '.png')
|
||||||
|
|||||||