Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| da90781cad | |||
| 2ab847f51d | |||
| d5510f590e | |||
| cf102e8b3e | |||
| 13659fd414 | |||
| c68acdd030 | |||
| e61c9c98f5 | |||
| 50488109aa | |||
| dd244a8e6c | |||
| ee07dec4d3 | |||
| 67b8b44408 | |||
| a852e2ec0d |
@@ -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"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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,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
|
||||||
|
|
||||||
|
|||||||
|
After Width: | Height: | Size: 410 KiB |
|
After Width: | Height: | Size: 372 KiB |
|
After Width: | Height: | Size: 390 KiB |
|
After Width: | Height: | Size: 261 KiB |
|
After Width: | Height: | Size: 340 KiB |
|
After Width: | Height: | Size: 406 KiB |
|
After Width: | Height: | Size: 367 KiB |
|
After Width: | Height: | Size: 319 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: 251 KiB |
|
After Width: | Height: | Size: 246 KiB |
|
After Width: | Height: | Size: 278 KiB |
|
After Width: | Height: | Size: 294 KiB |
|
After Width: | Height: | Size: 301 KiB |
|
After Width: | Height: | Size: 439 KiB |
|
After Width: | Height: | Size: 462 KiB |
|
After Width: | Height: | Size: 429 KiB |
|
After Width: | Height: | Size: 376 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: 400 KiB |
|
After Width: | Height: | Size: 289 KiB |
|
After Width: | Height: | Size: 431 KiB |
|
After Width: | Height: | Size: 418 KiB |
|
After Width: | Height: | Size: 458 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 |
|
After Width: | Height: | Size: 403 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(__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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -29,18 +29,25 @@ 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]
|
||||||
|
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;
|
||||||
|
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 +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)
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
|
After Width: | Height: | Size: 2.0 MiB |
|
After Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 1.9 MiB |
|
After Width: | Height: | Size: 459 KiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 1.7 MiB |
|
After Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 1.7 MiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 1.3 MiB |
@@ -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")
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||