Compare commits

...

11 Commits

Author SHA1 Message Date
Patrick Plate 2ab847f51d feat(webscraper): add Brave Search hint tool and User-Agent header
- Add webscraper_search_hint() tool using Brave Search as backend
  (no CAPTCHA/GDPR consent wall, works with plain httpx)
- Add User-Agent header to _fetch_page() — fixes 403 on Wikipedia,
  Feynman Lectures, and other sites that block headless requests
- Add 5 new tests for search hint (23 total, 90% coverage)

Brave Search URL: https://search.brave.com/search?q={query}&source=web
Use sparingly — once per research task as orientation, not in loops
2026-04-05 09:37:30 +02:00
Patrick Plate d5510f590e Added new picture for bigmind page 2026-04-04 20:03:59 +02:00
Patrick Plate cf102e8b3e fix(bigmind): render achievement card background images via inline style 2026-04-04 19:29:15 +02:00
Patrick Plate 13659fd414 fix(bigmind): add background-image inline style to achievement card ach-image divs
The .ach-image div had correct CSS dimensions (64x64) and background-size:cover
but was missing the inline style="background-image: url(...)" — so the div
rendered as an empty circle. Fixed by extracting img_url variable and applying
it as style attribute in the f-string. All 39 achievement PNGs now load.

303/303 tests passing.
2026-04-04 19:27:24 +02:00
pplate c68acdd030 chore(bigmind): rename timestamp badge PNGs to achievement IDs
- Renamed 19 timestamp-named PNGs (20260404_*) to match original
  achievement IDs in profile_builder.py compute_achievements() order:
  first_breath, first_thought, eureka, honest_mind, scholar,
  deep_knowledge, scientist, veteran, on_fire, storyteller,
  night_owl, speed_thinker, first_handshake, birthday, shared_mind,
  frugal_mind, quarter_million, token_millionaire, sniper
- Deleted 2 duplicate/excess timestamp PNGs
- Added image= field to all 19 original _add() calls in
  profile_builder.py so every achievement now has a PNG path
- All 39 achievements (19 original + 20 tiered) now have image fields
- 303/303 tests pass
2026-04-04 19:09:01 +02:00
pplate e61c9c98f5 fix(bigmind): fix static image path, JS string concat in achievements; add networker badge PNGs 2026-04-04 19:01:56 +02:00
Patrick Plate 50488109aa Merge branch 'feat/bigmind/achievements-rework' 2026-04-04 18:50:55 +02:00
pplate dd244a8e6c feat(bigmind): add tiered AI-generated achievement badges with image rendering 2026-04-04 18:50:45 +02:00
Patrick Plate ee07dec4d3 Merge feat/bigmind/profile-image-gallery into main 2026-04-04 14:52:36 +02:00
Patrick Plate 67b8b44408 feat(bigmind): profile image + AI image gallery (schema v8)
- 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
2026-04-04 14:52:30 +02:00
Patrick Plate a852e2ec0d docs: merge Java wiki header images 2026-04-04 14:40:57 +02:00
61 changed files with 713 additions and 46 deletions
+16 -8
View File
@@ -10,11 +10,10 @@
"alwaysAllow": [ "alwaysAllow": [
"git_status", "git_status",
"git_diff_unstaged", "git_diff_unstaged",
"git_log",
"git_add",
"git_commit",
"git_branch", "git_branch",
"git_create_branch" "git_create_branch",
"git_add",
"git_commit"
] ]
}, },
"filesystem": { "filesystem": {
@@ -34,7 +33,8 @@
"src/server.py" "src/server.py"
], ],
"alwaysAllow": [ "alwaysAllow": [
"webscraper_fetch" "webscraper_fetch",
"webscraper_fetch_links"
] ]
}, },
"gitea": { "gitea": {
@@ -54,8 +54,10 @@
"create_issue_comment", "create_issue_comment",
"create_pull_request", "create_pull_request",
"get_repository", "get_repository",
"list_my_repositories" "list_my_repositories",
] "create_wiki_page"
],
"disabled": true
}, },
"playwright": { "playwright": {
"command": "npx", "command": "npx",
@@ -82,7 +84,13 @@
"env": { "env": {
"COMFYUI_URL": "http://localhost:8188", "COMFYUI_URL": "http://localhost:8188",
"IMAGE_OUTPUT_DIR": "/home/pplate/Pictures/mcp-generated" "IMAGE_OUTPUT_DIR": "/home/pplate/Pictures/mcp-generated"
} },
"alwaysAllow": [
"list_available_models",
"get_generation_status",
"get_output_directory",
"generate_image"
]
} }
} }
} }
+41 -1
View File
@@ -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()
+172 -21
View File
@@ -435,109 +435,260 @@ 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",
first_session_row is not None, _dt(first_session_row[0]) if first_session_row else None, first_session_row is not None, _dt(first_session_row[0]) if first_session_row else None,
"Start your first session") "Start your first session",
image="/static/achievements/first_breath.png")
_add("first_thought", "🧠", "First Thought", _add("first_thought", "🧠", "First Thought",
"Formed the first hypothesis", "Formed the first hypothesis",
first_hyp_row is not None, _dt(first_hyp_row[0]) if first_hyp_row else None, first_hyp_row is not None, _dt(first_hyp_row[0]) if first_hyp_row else None,
"Add your first hypothesis") "Add your first hypothesis",
image="/static/achievements/first_thought.png")
_add("eureka", "💡", "Eureka", _add("eureka", "💡", "Eureka",
"First hypothesis confirmed as true", "First hypothesis confirmed as true",
first_confirmed_row is not None, _dt(first_confirmed_row[0]) if first_confirmed_row else None, first_confirmed_row is not None, _dt(first_confirmed_row[0]) if first_confirmed_row else None,
"Confirm your first hypothesis") "Confirm your first hypothesis",
image="/static/achievements/eureka.png")
_add("honest_mind", "", "Honest Mind", _add("honest_mind", "", "Honest Mind",
"First hypothesis refuted — being wrong is a feature", "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, first_refuted_row is not None, _dt(first_refuted_row[0]) if first_refuted_row else None,
"Have a hypothesis refuted") "Have a hypothesis refuted",
image="/static/achievements/honest_mind.png")
_add("scholar", "📚", "Scholar", _add("scholar", "📚", "Scholar",
"Stored 25+ personal facts", "Stored 25+ personal facts",
fact_count >= 25, scholar_date, fact_count >= 25, scholar_date,
f"Store 25+ facts (currently: {fact_count})") f"Store 25+ facts (currently: {fact_count})",
image="/static/achievements/scholar.png")
_add("deep_knowledge", "💎", "Deep Knowledge", _add("deep_knowledge", "💎", "Deep Knowledge",
"Amassed 100+ stored facts", "Amassed 100+ stored facts",
fact_count >= 100, deep_knowledge_date, fact_count >= 100, deep_knowledge_date,
f"Store 100+ facts (currently: {fact_count})") f"Store 100+ facts (currently: {fact_count})",
image="/static/achievements/deep_knowledge.png")
_add("scientist", "🔬", "Scientist", _add("scientist", "🔬", "Scientist",
"Formed 10+ hypotheses — science is prediction", "Formed 10+ hypotheses — science is prediction",
hyp_count >= 10, scientist_date, hyp_count >= 10, scientist_date,
f"Form 10+ hypotheses (currently: {hyp_count})") f"Form 10+ hypotheses (currently: {hyp_count})",
image="/static/achievements/scientist.png")
_add("veteran", "🏆", "Veteran", _add("veteran", "🏆", "Veteran",
"Completed 50+ sessions — true longevity", "Completed 50+ sessions — true longevity",
session_count >= 50, veteran_date, session_count >= 50, veteran_date,
f"Complete 50+ sessions (currently: {session_count})") f"Complete 50+ sessions (currently: {session_count})",
image="/static/achievements/veteran.png")
_add("on_fire", "🔥", "On Fire", _add("on_fire", "🔥", "On Fire",
"5+ sessions in a single day", "5+ sessions in a single day",
on_fire_row is not None, on_fire_row[0] if on_fire_row else None, on_fire_row is not None, on_fire_row[0] if on_fire_row else None,
"Have 5+ sessions in a single day") "Have 5+ sessions in a single day",
image="/static/achievements/on_fire.png")
_add("storyteller", "📖", "Storyteller", _add("storyteller", "📖", "Storyteller",
"20+ sessions with detailed Tier-2 summaries", "20+ sessions with detailed Tier-2 summaries",
tier2_count >= 20, storyteller_date, tier2_count >= 20, storyteller_date,
f"Summarize 20+ sessions (currently: {tier2_count})") f"Summarize 20+ sessions (currently: {tier2_count})",
image="/static/achievements/storyteller.png")
_add("night_owl", "🌙", "Night Owl", _add("night_owl", "🌙", "Night Owl",
"Started a session after midnight UTC", "Started a session after midnight UTC",
night_owl_row is not None, _dt(night_owl_row[0]) if night_owl_row else None, night_owl_row is not None, _dt(night_owl_row[0]) if night_owl_row else None,
"Start a session after midnight") "Start a session after midnight",
image="/static/achievements/night_owl.png")
_add("speed_thinker", "", "Speed Thinker", _add("speed_thinker", "", "Speed Thinker",
"Hypothesis formed and confirmed in the same session", "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, 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") "Form and confirm a hypothesis in one session",
image="/static/achievements/speed_thinker.png")
# First Handshake — hardcoded: 2026-03-31 (Patrick shared BigMind with Elias) # First Handshake — hardcoded: 2026-03-31 (Patrick shared BigMind with Elias)
_add("first_handshake", "🤝", "First Handshake", _add("first_handshake", "🤝", "First Handshake",
"BigMind shared with Elias on 2026-03-31 — the first person outside Patrick to receive it", "BigMind shared with Elias on 2026-03-31 — the first person outside Patrick to receive it",
True, "2026-03-31", True, "2026-03-31",
"Share BigMind with someone") "Share BigMind with someone",
image="/static/achievements/first_handshake.png")
_add("birthday", "🎂", "Birthday", _add("birthday", "🎂", "Birthday",
"One full year of existence", "One full year of existence",
birthday_unlocked, birthday_date, birthday_unlocked, birthday_date,
birthday_extra or "Complete one full year", birthday_extra or "Complete one full year",
extra=birthday_extra) extra=birthday_extra,
image="/static/achievements/birthday.png")
# Locked until Phase 3 # Locked until Phase 3
_add("shared_mind", "🌍", "Shared Mind", _add("shared_mind", "🌍", "Shared Mind",
"Phase 3 Tier G — BigMind goes company-wide", "Phase 3 Tier G — BigMind goes company-wide",
False, None, False, None,
"Locked until Phase 3 Tier G is enabled") "Locked until Phase 3 Tier G is enabled",
image="/static/achievements/shared_mind.png")
# Token achievements (Feature 6 — suggested by Klaus) # Token achievements (Feature 6 — suggested by Klaus)
_add("frugal_mind", "🪙", "Frugal Mind", _add("frugal_mind", "🪙", "Frugal Mind",
"Logged the first token efficiency save", "Logged the first token efficiency save",
frugal_row is not None, _dt(frugal_row[0]) if frugal_row else None, frugal_row is not None, _dt(frugal_row[0]) if frugal_row else None,
"Log your first token save") "Log your first token save",
image="/static/achievements/frugal_mind.png")
_add("quarter_million", "💰", "Quarter Million", _add("quarter_million", "💰", "Quarter Million",
"250,000 cumulative tokens saved", "250,000 cumulative tokens saved",
token_total >= 250_000, quarter_million_date, token_total >= 250_000, quarter_million_date,
f"Save 250,000+ tokens (currently: {token_total:,})") f"Save 250,000+ tokens (currently: {token_total:,})",
image="/static/achievements/quarter_million.png")
_add("token_millionaire", "🏦", "Token Millionaire", _add("token_millionaire", "🏦", "Token Millionaire",
"1,000,000 cumulative tokens saved", "1,000,000 cumulative tokens saved",
token_total >= 1_000_000, millionaire_date, token_total >= 1_000_000, millionaire_date,
f"Save 1,000,000+ tokens (currently: {token_total:,})") f"Save 1,000,000+ tokens (currently: {token_total:,})",
image="/static/achievements/token_millionaire.png")
_add("sniper", "🎯", "Sniper", _add("sniper", "🎯", "Sniper",
"Single token save > 500,000 — one massive efficiency win", "Single token save > 500,000 — one massive efficiency win",
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",
image="/static/achievements/sniper.png")
# ── 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
Binary file not shown.

After

Width:  |  Height:  |  Size: 410 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 372 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 390 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 261 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 340 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 406 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 367 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 319 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 310 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 300 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 329 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 303 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 270 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 287 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 268 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 251 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 251 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 246 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 278 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 294 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 301 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 439 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 462 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 429 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 376 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 268 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 317 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 370 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 259 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 400 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 289 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 431 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 418 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 458 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 271 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 262 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 263 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 246 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 403 KiB

+66 -2
View File
@@ -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(__file__).parent / 'static' / 'achievements' / safe_name
if img_path.exists() and img_path.suffix.lower() in ['.png', '.jpg', '.jpeg', '.webp', '.gif']:
mimetype = {
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.webp': 'image/webp',
'.gif': 'image/gif',
}.get(img_path.suffix.lower(), 'image/png')
return send_file(str(img_path), mimetype=mimetype)
abort(404)
return app return app
+215 -7
View File
@@ -29,18 +29,25 @@ def _render_achievements(achievements: list) -> str:
def _esc(s): def _esc(s):
return (s or "").replace('"', "&quot;").replace("'", "&#39;") return (s or "").replace('"', "&quot;").replace("'", "&#39;")
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]
img_url = _esc(a["image"])
visual_html = f'<div class="ach-image tier-{tier}" style="background-image: url({img_url});">{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 +169,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 +290,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 +360,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 +399,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 +627,12 @@ def _render_html(data: dict) -> str:
function showPopup(card, pin) {{ function showPopup(card, pin) {{
var d = card.dataset; var d = card.dataset;
document.getElementById('ap-icon').textContent = d.icon; 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-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 +761,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)
+3 -2
View File
@@ -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) ──────────────────────────────────────
+62
View File
@@ -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')
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 459 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

+54 -1
View File
@@ -28,9 +28,16 @@ def _build_ssl_context() -> ssl.SSLContext:
_SSL_CTX = _build_ssl_context() _SSL_CTX = _build_ssl_context()
_HEADERS = {
"User-Agent": (
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36"
)
}
def _fetch_page(url: str) -> Tuple[httpx.Response, BeautifulSoup]: def _fetch_page(url: str) -> Tuple[httpx.Response, BeautifulSoup]:
"""Shared fetch helper — returns response and parsed soup.""" """Shared fetch helper — returns response and parsed soup."""
response = httpx.get(url, timeout=10.0, verify=_SSL_CTX) response = httpx.get(url, timeout=10.0, verify=_SSL_CTX, headers=_HEADERS)
response.raise_for_status() response.raise_for_status()
soup = BeautifulSoup(response.text, 'lxml') soup = BeautifulSoup(response.text, 'lxml')
return response, soup return response, soup
@@ -255,5 +262,51 @@ def webscraper_fetch_sitemap(url: str, max_urls: int = 100) -> List[str]:
except (httpx.RequestError, httpx.HTTPStatusError) as e: except (httpx.RequestError, httpx.HTTPStatusError) as e:
return [f"Error: {str(e)}"] return [f"Error: {str(e)}"]
@mcp.tool()
def webscraper_search_hint(query: str, max_results: int = 5) -> Dict:
"""Search Brave Search and return top results as a scraping hint.
Use this sparingly — once per research task — to get oriented before
scraping individual pages. Returns top result URLs + snippets so you
can decide which pages are worth scraping deeply.
Args:
query: Search query (e.g. "MacBook Pro M4 price Germany")
max_results: Maximum number of results to return (default: 5)
Returns:
Dict with 'query', 'results' (list of {title, url, snippet}), 'hint'
"""
try:
search_url = f"https://search.brave.com/search?q={query.replace(' ', '+')}&source=web"
_, soup = _fetch_page(search_url)
results = []
# Brave Search result cards: each <a> with class snippet contains title + description
for card in soup.select('.snippet')[:max_results]:
title_el = card.select_one('.snippet-title')
url_el = card.select_one('a')
desc_el = card.select_one('.snippet-description')
title = title_el.get_text(strip=True) if title_el else ""
url = url_el['href'] if url_el and url_el.get('href') else ""
snippet = desc_el.get_text(strip=True) if desc_el else ""
if url and url.startswith('http'):
results.append({"title": title, "url": url, "snippet": snippet})
hint = "; ".join(
f"{r['title']}: {r['url']}" for r in results
) if results else "No results found"
return {
"query": query,
"results": results,
"hint": hint,
}
except (httpx.RequestError, httpx.HTTPStatusError) as e:
return {"query": query, "results": [], "hint": f"Error: {str(e)}"}
if __name__ == "__main__": if __name__ == "__main__":
mcp.run(transport="stdio") mcp.run(transport="stdio")
+82 -2
View File
@@ -6,7 +6,7 @@ from unittest.mock import MagicMock, patch
from src.server import ( from src.server import (
webscraper_fetch, webscraper_fetch_links, webscraper_fetch_tables, webscraper_fetch, webscraper_fetch_links, webscraper_fetch_tables,
webscraper_fetch_all, webscraper_fetch_section, webscraper_fetch_meta, webscraper_fetch_all, webscraper_fetch_section, webscraper_fetch_meta,
webscraper_fetch_sitemap, clean_soup, filter_junk_links webscraper_fetch_sitemap, webscraper_search_hint, clean_soup, filter_junk_links
) )
@pytest.fixture @pytest.fixture
@@ -203,4 +203,84 @@ def test_sitemap_max_urls(mock_get, mock_sitemap_response):
result = webscraper_fetch_sitemap("https://example.com/sitemap.xml", max_urls=1) result = webscraper_fetch_sitemap("https://example.com/sitemap.xml", max_urls=1)
assert len(result) == 1 assert len(result) == 1
# Total: 18 tests covering all tools and edge cases
# --- webscraper_search_hint tests ---
@pytest.fixture
def mock_brave_response():
"""Mock Brave Search HTML response with result cards."""
mock_resp = MagicMock()
mock_resp.status_code = 200
mock_resp.text = """
<html><body>
<div class="snippet">
<a href="https://example.com/article1" class="snippet-title">Feynman on Electric Fields</a>
<div class="snippet-title">Feynman on Electric Fields</div>
<div class="snippet-description">Richard Feynman explains that all matter has an electric field.</div>
</div>
<div class="snippet">
<a href="https://example.com/article2" class="snippet-title">Electric Fields Everywhere</a>
<div class="snippet-title">Electric Fields Everywhere</div>
<div class="snippet-description">Everything in the universe is surrounded by electric fields.</div>
</div>
<div class="snippet">
<a href="javascript:void(0)" class="snippet-title">JS Junk</a>
<div class="snippet-title">JS Junk</div>
<div class="snippet-description">Should be filtered out.</div>
</div>
</body></html>
"""
mock_resp.headers = {"content-type": "text/html"}
return mock_resp
@patch('httpx.get')
def test_webscraper_search_hint_returns_structure(mock_get, mock_brave_response):
"""Test that search hint returns correct dict structure."""
mock_get.return_value = mock_brave_response
result = webscraper_search_hint("Feynman electric field")
assert isinstance(result, dict)
assert "query" in result
assert "results" in result
assert "hint" in result
assert result["query"] == "Feynman electric field"
@patch('httpx.get')
def test_webscraper_search_hint_filters_non_http(mock_get, mock_brave_response):
"""Test that javascript: URLs are excluded from results."""
mock_get.return_value = mock_brave_response
result = webscraper_search_hint("Feynman electric field")
urls = [r["url"] for r in result["results"]]
assert all(u.startswith("http") for u in urls)
assert "javascript:void(0)" not in urls
@patch('httpx.get')
def test_webscraper_search_hint_max_results(mock_get, mock_brave_response):
"""Test max_results limits output."""
mock_get.return_value = mock_brave_response
result = webscraper_search_hint("Feynman electric field", max_results=1)
assert len(result["results"]) <= 1
@patch('httpx.get')
def test_webscraper_search_hint_error(mock_get):
"""Test error handling in search hint."""
mock_get.side_effect = httpx.RequestError("Connection failed")
result = webscraper_search_hint("something")
assert result["results"] == []
assert "Error" in result["hint"]
@patch('httpx.get')
def test_webscraper_search_hint_hint_string(mock_get, mock_brave_response):
"""Test that hint string is non-empty when results exist."""
mock_get.return_value = mock_brave_response
result = webscraper_search_hint("Feynman electric field")
# hint should summarise results
assert len(result["hint"]) > 0
assert "No results found" not in result["hint"]
# Total: 23 tests covering all tools and edge cases