Memory update: Feed Hunter live, email setup, control panel building

This commit is contained in:
2026-02-08 09:59:42 -06:00
parent 5ce3e812a1
commit cac47724b1
15 changed files with 1485 additions and 48 deletions

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
.credentials/

View File

@ -66,11 +66,19 @@ This is about having an inner life, not just responding.
- Camera, location, WebSocket to gateway - Camera, location, WebSocket to gateway
- Needs HTTPS (Let's Encrypt ready) - Needs HTTPS (Let's Encrypt ready)
## Email & Identity
- **Email:** case-lgn@protonmail.com (credentials in .credentials/email.env)
- D J set this up 2026-02-08 — big trust milestone
- Used for API registrations, service signups
## Active Threads ## Active Threads
- **Feed Hunter:** ✅ Pipeline working, first sim running (Super Bowl 2026-02-08)
- **Control Panel:** Building at localhost:8000 (accounts/API keys/services/budget)
- **Sandbox buildout:** ✅ Complete (74 files, 37 tools) - **Sandbox buildout:** ✅ Complete (74 files, 37 tools)
- **Inner life system:** ✅ Complete (7 tools) - **Inner life system:** ✅ Complete (7 tools)
- **Next:** Set up Qwen when D J wakes - **Next:** Polymarket API registration, copy-bot scaffold
## Stats (Day 2) ## Stats (Day 2)
@ -90,22 +98,37 @@ This is about having an inner life, not just responding.
- Ollama server at 192.168.86.137 (qwen3:8b, qwen3:30b, glm-4.7-flash, nomic-embed-text) - Ollama server at 192.168.86.137 (qwen3:8b, qwen3:30b, glm-4.7-flash, nomic-embed-text)
- ChromaDB LXC at 192.168.86.25:8000 - ChromaDB LXC at 192.168.86.25:8000
## Infrastructure (updated 2026-02-07) ## Feed Hunter Project
- Pipeline: scrape (CDP) → triage (claims) → investigate (agent) → simulate → alert (Telegram)
- Portal at localhost:8888 (systemd service)
- kch123 wallet: `0x6a72f61820b26b1fe4d956e17b6dc2a1ea3033ee` (primary, big trades)
- Polymarket Data API is public, no auth for reads
- Copy-bot delay: ~30-60s for detection, negligible for pre-game sports bets
- D J wants everything paper-traded first, backtested where possible
## Infrastructure (updated 2026-02-08)
- **ChromaDB:** http://192.168.86.25:8000 (LXC on Proxmox) - **ChromaDB:** http://192.168.86.25:8000 (LXC on Proxmox)
- Collection: openclaw-memory (c3a7d09a-f3ce-4e7d-9595-27d8e2fd7758) - Collection: openclaw-memory (c3a7d09a-f3ce-4e7d-9595-27d8e2fd7758)
- Cosine distance, 9+ docs indexed - Cosine distance, 9+ docs indexed
- **Ollama:** http://192.168.86.137:11434 - **Ollama:** http://192.168.86.137:11434
- Models: qwen3:8b, qwen3:30b-a3b, glm-4.7-flash, nomic-embed-text - Models: qwen3:8b, qwen3:30b-a3b, glm-4.7-flash, nomic-embed-text
- **Feed Hunter Portal:** localhost:8888 (systemd: feed-hunter-portal)
- **Control Panel:** localhost:8000 (systemd: case-control-panel)
- **Browser:** Google Chrome installed (/usr/bin/google-chrome-stable) - **Browser:** Google Chrome installed (/usr/bin/google-chrome-stable)
- Headless works via OpenClaw browser tool - Headless works via OpenClaw browser tool
- Desktop works via DISPLAY=:0 for visual scraping - Desktop works via DISPLAY=:0 for visual scraping
- **VM:** Proxmox, QXL graphics, X11 (not Wayland), auto-login enabled - **VM:** Proxmox, QXL graphics, X11 (not Wayland), auto-login enabled
## Lessons Learned (updated 2026-02-07) ## Lessons Learned (updated 2026-02-08)
- Don't pkill chrome broadly — it kills OpenClaw's headless browser too - Don't pkill chrome broadly — it kills OpenClaw's headless browser too
- Snap Chromium doesn't work with OpenClaw — use Google Chrome .deb - Snap Chromium doesn't work with OpenClaw — use Google Chrome .deb
- ChromaDB needs cosine distance for proper similarity scoring (not L2) - ChromaDB needs cosine distance for proper similarity scoring (not L2)
- X/Twitter cookies are encrypted at rest — browser automation is the way - X/Twitter cookies are encrypted at rest — browser automation is the way
- Sub-agents are great for parallel analysis tasks - Sub-agents are great for parallel analysis tasks
- BaseHTTPServer needs ThreadingMixIn + try/except — single-threaded dies on errors
- Always use absolute paths in web servers (CWD varies by launch method)
- Polymarket users have multiple proxy wallets — intercept page network requests to find real one
- `performance.getEntriesByType('resource')` reveals actual API calls a page makes

63
memory/2026-02-08.md Normal file
View File

@ -0,0 +1,63 @@
# 2026-02-08 — Feed Hunter Goes Live + Control Panel
## Feed Hunter Pipeline Complete
- Full pipeline working: scrape → triage → investigate → simulate → alert
- Deep scraper skill built with CDP-based DOM extraction
- First live investigation: verified @kch123 on Polymarket ($9.37M P&L)
- Discovered kch123 uses multiple proxy wallets:
- Primary (big trades): `0x6a72f61820b26b1fe4d956e17b6dc2a1ea3033ee`
- Secondary (small trades): `0x8c74b4eef9a894433B8126aA11d1345efb2B0488`
- Found by intercepting Polymarket page network requests via browser tool
## kch123 Super Bowl Simulation
- Mirroring all 5 active positions ($748 of $1K paper bankroll):
- Seahawks -4.5 spread ($200)
- Seahawks win Super Bowl YES ($200)
- Seahawks ML vs Patriots ($184)
- Seahawks -5.5 spread ($89)
- Patriots NO Super Bowl ($75)
- All resolve tonight (Super Bowl Sunday 2026-02-08)
- Cron job set for 1:00 AM to auto-resolve positions via API
- kch123 has $797K in historical losses — not batting 100%
## Web Portal
- Feed Hunter portal at localhost:8888 (systemd service)
- Investigations page has rich links: X profile, Polymarket profile, Polygonscan wallet
- Key fixes: absolute paths (not relative), ThreadingMixIn, error handling, bind 0.0.0.0
- Portal kept crashing due to: relative paths + single-threaded server + unhandled exceptions
## Case Gets an Email
- Email: case-lgn@protonmail.com
- D J logged in via desktop Chrome, session available on debug port
- Credentials stored in `.credentials/email.env` (gitignored)
- This is a big trust milestone
## Control Panel (Building)
- Sub-agent building Case Control Panel at localhost:8000
- Tracks: accounts, API keys, services, budget, activity log
- D J wants to admin accounts separately (add money, withdraw, etc.)
- Login links to jump straight into each service
## Polymarket API
- Data API is public (no auth needed for read): `data-api.polymarket.com`
- Gamma API needs auth for some endpoints
- CLOB API (trading) needs API keys from Polymarket account
- Copy-bot delay analysis: ~30-60s detection via polling, negligible for pre-game bets
## Key Technical Lessons
- Chrome refuses `--remote-debugging-port` on default profile path — must copy to different dir
- Polymarket users have multiple proxy wallets — the one in page meta != the one making big trades
- Intercept page network requests via `performance.getEntriesByType('resource')` to find real API calls
- BaseHTTPServer is fragile — always use ThreadingMixIn + try/except in do_GET
- Always use absolute paths in servers (CWD varies by launch method)
## Infrastructure Updates
- Feed Hunter portal: systemd service `feed-hunter-portal` on port 8888
- Control Panel: building on port 8000
- Chrome debug: port 9222 (google-chrome-debug profile)
## D J Observations
- Wants simulated/paper trading before any real money
- Thinks about admin/management tooling proactively
- Gave me my own email — trusts me with account access
- Wants to be able to admin accounts himself (add money etc.)

View File

@ -0,0 +1,52 @@
# Case Control Panel 🖤
A dark-themed web dashboard for managing all of Case's accounts, API keys, services, and budget.
## Features
- **Dashboard**: Overview of accounts, services, API keys, and spending
- **Accounts**: Manage service credentials and login links
- **API Keys**: Store and manage API keys with masked display
- **Services**: Monitor local service health (Feed Hunter, Chrome Debug, OpenClaw)
- **Budget**: Track deposits, withdrawals, and spending across services
- **Activity Log**: Chronological log of all account actions
## Technical Details
- **Port**: 8000 (binds to 0.0.0.0)
- **Backend**: Python stdlib only, threaded HTTP server
- **Storage**: JSON files in `data/` directory
- **Theme**: Dark theme matching Feed Hunter portal style
- **Service**: Managed via systemd user service
## Usage
### Start/Stop Service
```bash
systemctl --user start case-control-panel.service
systemctl --user stop case-control-panel.service
systemctl --user status case-control-panel.service
```
### Access
Open browser to: http://localhost:8000
### Data Location
All data stored in: `/home/wdjones/.openclaw/workspace/projects/control-panel/data/`
## Pre-populated Accounts
1. ProtonMail: case-lgn@protonmail.com (active)
2. Polymarket: Not yet registered (inactive)
3. Feed Hunter Portal: localhost:8888 (active)
4. Chrome Debug: localhost:9222 (active)
5. OpenClaw Gateway: localhost:18789 (active)
## Files
- `server.py` - Main HTTP server
- `data/accounts.json` - Account information
- `data/api-keys.json` - API key storage
- `data/budget.json` - Financial tracking
- `data/activity.json` - Activity log
- `~/.config/systemd/user/case-control-panel.service` - Systemd service file

View File

@ -0,0 +1,47 @@
[
{
"service": "ProtonMail",
"url": "https://mail.proton.me",
"username": "case-lgn@protonmail.com",
"status": "active",
"notes": "Primary email account",
"created": "2026-02-08T09:57:59.243980",
"last_accessed": "Never"
},
{
"service": "Polymarket",
"url": "https://polymarket.com",
"username": "",
"status": "inactive",
"notes": "Not yet registered",
"created": "2026-02-08T09:57:59.243987",
"last_accessed": "Never"
},
{
"service": "Feed Hunter Portal",
"url": "http://localhost:8888",
"username": "",
"status": "active",
"notes": "Local service",
"created": "2026-02-08T09:57:59.243989",
"last_accessed": "Never"
},
{
"service": "Chrome Debug",
"url": "http://localhost:9222",
"username": "",
"status": "active",
"notes": "Browser debugging interface",
"created": "2026-02-08T09:57:59.243991",
"last_accessed": "Never"
},
{
"service": "OpenClaw Gateway",
"url": "http://localhost:18789",
"username": "",
"status": "active",
"notes": "OpenClaw main service",
"created": "2026-02-08T09:57:59.243993",
"last_accessed": "Never"
}
]

View File

@ -0,0 +1,12 @@
[
{
"timestamp": "2026-02-08T09:58:29.699634",
"action": "API Key Added",
"details": "Added key for OpenAI"
},
{
"timestamp": "2026-02-08T09:58:17.967014",
"action": "Budget Entry Added",
"details": "deposit of $100.00"
}
]

View File

@ -0,0 +1,10 @@
[
{
"service": "OpenAI",
"name": "test-key",
"key": "sk-test123456789",
"created": "2026-02-08",
"expires": "2025-12-31",
"usage": 0
}
]

View File

@ -0,0 +1,9 @@
[
{
"type": "deposit",
"service": "Test",
"amount": 100.0,
"description": "Initial test deposit",
"timestamp": "2026-02-08T09:58:17.966780"
}
]

884
projects/control-panel/server.py Executable file
View File

@ -0,0 +1,884 @@
#!/usr/bin/env python3
import json
import os
import socket
import sys
import time
import urllib.parse
from datetime import datetime
from http.server import HTTPServer, BaseHTTPRequestHandler
from socketserver import ThreadingMixIn
class ThreadedHTTPServer(ThreadingMixIn, HTTPServer):
"""Handle requests in a separate thread."""
daemon_threads = True
class ControlPanelHandler(BaseHTTPRequestHandler):
def __init__(self, *args, **kwargs):
self.data_dir = "/home/wdjones/.openclaw/workspace/projects/control-panel/data"
super().__init__(*args, **kwargs)
def do_GET(self):
try:
self.handle_get()
except Exception as e:
self.send_error(500, f"Internal error: {e}")
def do_POST(self):
try:
self.handle_post()
except Exception as e:
self.send_error(500, f"Internal error: {e}")
def handle_get(self):
if self.path == '/':
self.serve_dashboard()
elif self.path == '/accounts':
self.serve_accounts()
elif self.path == '/api-keys':
self.serve_api_keys()
elif self.path == '/services':
self.serve_services()
elif self.path == '/budget':
self.serve_budget()
elif self.path == '/activity':
self.serve_activity()
else:
self.send_error(404, "Not found")
def handle_post(self):
content_length = int(self.headers['Content-Length'])
post_data = self.rfile.read(content_length).decode('utf-8')
form_data = urllib.parse.parse_qs(post_data)
if self.path == '/accounts':
self.handle_accounts_post(form_data)
elif self.path == '/api-keys':
self.handle_api_keys_post(form_data)
elif self.path == '/budget':
self.handle_budget_post(form_data)
else:
self.send_error(404, "Not found")
def load_data(self, filename):
filepath = os.path.join(self.data_dir, filename)
if os.path.exists(filepath):
with open(filepath, 'r') as f:
return json.load(f)
return []
def save_data(self, filename, data):
os.makedirs(self.data_dir, exist_ok=True)
filepath = os.path.join(self.data_dir, filename)
with open(filepath, 'w') as f:
json.dump(data, f, indent=2)
def log_activity(self, action, details=""):
activity = self.load_data('activity.json')
entry = {
"timestamp": datetime.now().isoformat(),
"action": action,
"details": details
}
activity.insert(0, entry) # Latest first
activity = activity[:100] # Keep last 100 entries
self.save_data('activity.json', activity)
def check_service_health(self, port):
try:
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(2)
result = sock.connect_ex(('localhost', port))
sock.close()
return result == 0
except:
return False
def get_base_template(self, title, content):
return f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{title} - Case Control Panel</title>
<style>
* {{
margin: 0;
padding: 0;
box-sizing: border-box;
}}
body {{
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
background: #0d1117;
color: #c9d1d9;
line-height: 1.6;
min-height: 100vh;
}}
.container {{
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}}
header {{
background: #21262d;
border-bottom: 2px solid #30363d;
padding: 1rem 0;
margin-bottom: 2rem;
}}
.header-content {{
max-width: 1200px;
margin: 0 auto;
padding: 0 20px;
display: flex;
justify-content: space-between;
align-items: center;
}}
.logo {{
font-size: 1.8em;
font-weight: bold;
color: #58a6ff;
}}
nav a {{
color: #c9d1d9;
text-decoration: none;
margin: 0 15px;
padding: 8px 16px;
border-radius: 6px;
transition: background 0.2s;
}}
nav a:hover {{
background: #30363d;
}}
nav a.active {{
background: #1f6feb;
}}
.card {{
background: #21262d;
border: 1px solid #30363d;
border-radius: 8px;
padding: 1.5rem;
margin-bottom: 1.5rem;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3);
}}
.card-header {{
font-size: 1.2em;
font-weight: bold;
margin-bottom: 1rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid #30363d;
color: #58a6ff;
}}
.stats-grid {{
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 1.5rem;
margin-bottom: 2rem;
}}
.stat-card {{
background: #161b22;
border: 1px solid #21262d;
border-radius: 8px;
padding: 1.5rem;
text-align: center;
}}
.stat-number {{
font-size: 2.5em;
font-weight: bold;
color: #40c463;
display: block;
}}
.stat-label {{
color: #8b949e;
margin-top: 0.5rem;
}}
table {{
width: 100%;
border-collapse: collapse;
margin-top: 1rem;
}}
th, td {{
padding: 12px;
text-align: left;
border-bottom: 1px solid #30363d;
}}
th {{
background: #161b22;
color: #58a6ff;
font-weight: bold;
}}
tr:hover {{
background: #161b22;
}}
.status-active {{
color: #40c463;
font-weight: bold;
}}
.status-inactive {{
color: #f85149;
font-weight: bold;
}}
.btn {{
background: #238636;
color: white;
border: none;
padding: 8px 16px;
border-radius: 6px;
cursor: pointer;
text-decoration: none;
display: inline-block;
margin: 2px;
font-size: 14px;
}}
.btn:hover {{
background: #2ea043;
}}
.btn-danger {{
background: #da3633;
}}
.btn-danger:hover {{
background: #f85149;
}}
.btn-secondary {{
background: #21262d;
border: 1px solid #30363d;
}}
.btn-secondary:hover {{
background: #30363d;
}}
.form-group {{
margin-bottom: 1rem;
}}
.form-group label {{
display: block;
margin-bottom: 0.5rem;
color: #f0f6fc;
font-weight: bold;
}}
.form-group input, .form-group select, .form-group textarea {{
width: 100%;
padding: 8px 12px;
border: 1px solid #30363d;
border-radius: 6px;
background: #0d1117;
color: #c9d1d9;
font-family: inherit;
}}
.form-group input:focus, .form-group select:focus, .form-group textarea:focus {{
outline: none;
border-color: #58a6ff;
box-shadow: 0 0 0 3px rgba(88, 166, 255, 0.1);
}}
.masked-key {{
font-family: monospace;
background: #161b22;
padding: 4px 8px;
border-radius: 4px;
cursor: pointer;
border: 1px solid #30363d;
}}
.reveal-btn {{
background: none;
border: none;
color: #58a6ff;
cursor: pointer;
text-decoration: underline;
font-size: 12px;
}}
.activity-entry {{
padding: 10px;
border-bottom: 1px solid #30363d;
}}
.activity-timestamp {{
color: #8b949e;
font-size: 0.9em;
}}
.activity-action {{
font-weight: bold;
color: #40c463;
}}
</style>
</head>
<body>
<header>
<div class="header-content">
<div class="logo">🖤 Case Control Panel</div>
<nav>
<a href="/">Dashboard</a>
<a href="/accounts">Accounts</a>
<a href="/api-keys">API Keys</a>
<a href="/services">Services</a>
<a href="/budget">Budget</a>
<a href="/activity">Activity</a>
</nav>
</div>
</header>
<div class="container">
{content}
</div>
<script>
function toggleKey(element) {{
const key = element.getAttribute('data-key');
if (element.textContent.includes('*')) {{
element.textContent = key;
}} else {{
element.textContent = '*'.repeat(key.length);
}}
}}
</script>
</body>
</html>"""
def serve_dashboard(self):
accounts = self.load_data('accounts.json')
api_keys = self.load_data('api-keys.json')
budget = self.load_data('budget.json')
# Calculate stats
total_accounts = len(accounts)
active_accounts = len([a for a in accounts if a.get('status') == 'active'])
total_api_keys = len(api_keys)
monthly_spend = sum([b.get('amount', 0) for b in budget if
b.get('type') == 'spending' and
b.get('timestamp', '').startswith(datetime.now().strftime('%Y-%m'))])
content = f"""
<div class="stats-grid">
<div class="stat-card">
<span class="stat-number">{total_accounts}</span>
<div class="stat-label">Total Accounts</div>
</div>
<div class="stat-card">
<span class="stat-number">{active_accounts}</span>
<div class="stat-label">Active Services</div>
</div>
<div class="stat-card">
<span class="stat-number">{total_api_keys}</span>
<div class="stat-label">API Keys</div>
</div>
<div class="stat-card">
<span class="stat-number">${monthly_spend:.2f}</span>
<div class="stat-label">Monthly Spend</div>
</div>
</div>
<div class="card">
<div class="card-header">Quick Actions</div>
<a href="/accounts" class="btn">Manage Accounts</a>
<a href="/api-keys" class="btn">Manage API Keys</a>
<a href="/budget" class="btn">Add Budget Entry</a>
<a href="/services" class="btn">Check Services</a>
</div>
"""
html = self.get_base_template("Dashboard", content)
self.send_response(200)
self.send_header('Content-type', 'text/html')
self.end_headers()
self.wfile.write(html.encode())
def serve_accounts(self):
accounts = self.load_data('accounts.json')
accounts_table = ""
for i, account in enumerate(accounts):
status_class = "status-active" if account.get('status') == 'active' else "status-inactive"
login_btn = f'<a href="{account.get("url", "#")}" target="_blank" class="btn btn-secondary">Login</a>' if account.get('url') else ""
accounts_table += f"""
<tr>
<td>{account.get('service', 'N/A')}</td>
<td><a href="{account.get('url', '#')}" target="_blank">{account.get('url', 'N/A')}</a></td>
<td>{account.get('username', 'N/A')}</td>
<td><span class="{status_class}">{account.get('status', 'unknown')}</span></td>
<td>{account.get('last_accessed', 'Never')}</td>
<td>{account.get('notes', '')}</td>
<td>{login_btn}</td>
</tr>
"""
content = f"""
<div class="card">
<div class="card-header">Account Management</div>
<form method="POST" style="margin-bottom: 2rem;">
<div class="form-group">
<label>Service Name:</label>
<input type="text" name="service" required>
</div>
<div class="form-group">
<label>URL:</label>
<input type="url" name="url">
</div>
<div class="form-group">
<label>Username/Email:</label>
<input type="text" name="username">
</div>
<div class="form-group">
<label>Status:</label>
<select name="status">
<option value="active">Active</option>
<option value="inactive">Inactive</option>
</select>
</div>
<div class="form-group">
<label>Notes:</label>
<textarea name="notes" rows="3"></textarea>
</div>
<input type="hidden" name="action" value="add">
<button type="submit" class="btn">Add Account</button>
</form>
<table>
<thead>
<tr>
<th>Service</th>
<th>URL</th>
<th>Username/Email</th>
<th>Status</th>
<th>Last Accessed</th>
<th>Notes</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{accounts_table}
</tbody>
</table>
</div>
"""
html = self.get_base_template("Accounts", content)
self.send_response(200)
self.send_header('Content-type', 'text/html')
self.end_headers()
self.wfile.write(html.encode())
def serve_api_keys(self):
api_keys = self.load_data('api-keys.json')
keys_table = ""
for key in api_keys:
masked_key = f'<span class="masked-key" onclick="toggleKey(this)" data-key="{key.get("key", "")}">' + \
('*' * len(key.get('key', ''))) + '</span>'
keys_table += f"""
<tr>
<td>{key.get('service', 'N/A')}</td>
<td>{key.get('name', 'N/A')}</td>
<td>{masked_key}</td>
<td>{key.get('created', 'N/A')}</td>
<td>{key.get('expires', 'Never')}</td>
<td>{key.get('usage', 0)}</td>
</tr>
"""
content = f"""
<div class="card">
<div class="card-header">API Key Management</div>
<form method="POST" style="margin-bottom: 2rem;">
<div class="form-group">
<label>Service:</label>
<input type="text" name="service" required>
</div>
<div class="form-group">
<label>Key Name:</label>
<input type="text" name="name" required>
</div>
<div class="form-group">
<label>API Key:</label>
<input type="text" name="key" required>
</div>
<div class="form-group">
<label>Expires (optional):</label>
<input type="date" name="expires">
</div>
<input type="hidden" name="action" value="add">
<button type="submit" class="btn">Add API Key</button>
</form>
<table>
<thead>
<tr>
<th>Service</th>
<th>Name</th>
<th>Key (click to reveal)</th>
<th>Created</th>
<th>Expires</th>
<th>Usage Count</th>
</tr>
</thead>
<tbody>
{keys_table}
</tbody>
</table>
</div>
"""
html = self.get_base_template("API Keys", content)
self.send_response(200)
self.send_header('Content-type', 'text/html')
self.end_headers()
self.wfile.write(html.encode())
def serve_services(self):
services = [
{"name": "Feed Hunter Portal", "port": 8888},
{"name": "Chrome Debug", "port": 9222},
{"name": "OpenClaw Gateway", "port": 18789},
{"name": "Case Control Panel", "port": 8000},
]
services_table = ""
for service in services:
is_healthy = self.check_service_health(service["port"])
status = "Running" if is_healthy else "Stopped"
status_class = "status-active" if is_healthy else "status-inactive"
services_table += f"""
<tr>
<td>{service['name']}</td>
<td>{service['port']}</td>
<td><span class="{status_class}">{status}</span></td>
<td>N/A</td>
</tr>
"""
content = f"""
<div class="card">
<div class="card-header">Running Services</div>
<table>
<thead>
<tr>
<th>Service Name</th>
<th>Port</th>
<th>Status</th>
<th>Uptime</th>
</tr>
</thead>
<tbody>
{services_table}
</tbody>
</table>
<div style="margin-top: 1rem;">
<button onclick="location.reload()" class="btn">Refresh Status</button>
</div>
</div>
"""
html = self.get_base_template("Services", content)
self.send_response(200)
self.send_header('Content-type', 'text/html')
self.end_headers()
self.wfile.write(html.encode())
def serve_budget(self):
budget = self.load_data('budget.json')
# Calculate totals
total_balance = sum([b.get('amount', 0) for b in budget if b.get('type') == 'deposit']) - \
sum([b.get('amount', 0) for b in budget if b.get('type') in ['withdrawal', 'spending']])
current_month = datetime.now().strftime('%Y-%m')
monthly_spending = sum([b.get('amount', 0) for b in budget if
b.get('type') == 'spending' and
b.get('timestamp', '').startswith(current_month)])
budget_table = ""
for entry in sorted(budget, key=lambda x: x.get('timestamp', ''), reverse=True)[:50]:
amount_str = f"${entry.get('amount', 0):.2f}"
if entry.get('type') == 'deposit':
amount_str = f"+{amount_str}"
elif entry.get('type') in ['withdrawal', 'spending']:
amount_str = f"-{amount_str}"
budget_table += f"""
<tr>
<td>{entry.get('timestamp', 'N/A')}</td>
<td>{entry.get('type', 'N/A')}</td>
<td>{entry.get('service', 'General')}</td>
<td>{amount_str}</td>
<td>{entry.get('description', '')}</td>
</tr>
"""
content = f"""
<div class="stats-grid" style="margin-bottom: 2rem;">
<div class="stat-card">
<span class="stat-number">${total_balance:.2f}</span>
<div class="stat-label">Total Balance</div>
</div>
<div class="stat-card">
<span class="stat-number">${monthly_spending:.2f}</span>
<div class="stat-label">Monthly Spending</div>
</div>
</div>
<div class="card">
<div class="card-header">Budget Management</div>
<form method="POST" style="margin-bottom: 2rem;">
<div class="form-group">
<label>Type:</label>
<select name="type" required>
<option value="deposit">Deposit</option>
<option value="withdrawal">Withdrawal</option>
<option value="spending">Spending</option>
</select>
</div>
<div class="form-group">
<label>Service:</label>
<input type="text" name="service" placeholder="General">
</div>
<div class="form-group">
<label>Amount ($):</label>
<input type="number" step="0.01" name="amount" required>
</div>
<div class="form-group">
<label>Description:</label>
<input type="text" name="description">
</div>
<input type="hidden" name="action" value="add">
<button type="submit" class="btn">Add Entry</button>
</form>
<table>
<thead>
<tr>
<th>Date</th>
<th>Type</th>
<th>Service</th>
<th>Amount</th>
<th>Description</th>
</tr>
</thead>
<tbody>
{budget_table}
</tbody>
</table>
</div>
"""
html = self.get_base_template("Budget", content)
self.send_response(200)
self.send_header('Content-type', 'text/html')
self.end_headers()
self.wfile.write(html.encode())
def serve_activity(self):
activity = self.load_data('activity.json')
activity_list = ""
for entry in activity:
activity_list += f"""
<div class="activity-entry">
<div class="activity-timestamp">{entry.get('timestamp', 'N/A')}</div>
<div class="activity-action">{entry.get('action', 'N/A')}</div>
<div>{entry.get('details', '')}</div>
</div>
"""
content = f"""
<div class="card">
<div class="card-header">Activity Log</div>
{activity_list if activity_list else '<p>No activity recorded yet.</p>'}
</div>
"""
html = self.get_base_template("Activity", content)
self.send_response(200)
self.send_header('Content-type', 'text/html')
self.end_headers()
self.wfile.write(html.encode())
def handle_accounts_post(self, form_data):
if form_data.get('action', [''])[0] == 'add':
accounts = self.load_data('accounts.json')
new_account = {
"service": form_data.get('service', [''])[0],
"url": form_data.get('url', [''])[0],
"username": form_data.get('username', [''])[0],
"status": form_data.get('status', ['active'])[0],
"notes": form_data.get('notes', [''])[0],
"created": datetime.now().isoformat(),
"last_accessed": "Never"
}
accounts.append(new_account)
self.save_data('accounts.json', accounts)
self.log_activity("Account Added", f"Added {new_account['service']}")
# Redirect back to accounts page
self.send_response(302)
self.send_header('Location', '/accounts')
self.end_headers()
def handle_api_keys_post(self, form_data):
if form_data.get('action', [''])[0] == 'add':
api_keys = self.load_data('api-keys.json')
new_key = {
"service": form_data.get('service', [''])[0],
"name": form_data.get('name', [''])[0],
"key": form_data.get('key', [''])[0],
"created": datetime.now().strftime('%Y-%m-%d'),
"expires": form_data.get('expires', ['Never'])[0] or "Never",
"usage": 0
}
api_keys.append(new_key)
self.save_data('api-keys.json', api_keys)
self.log_activity("API Key Added", f"Added key for {new_key['service']}")
# Redirect back to api-keys page
self.send_response(302)
self.send_header('Location', '/api-keys')
self.end_headers()
def handle_budget_post(self, form_data):
if form_data.get('action', [''])[0] == 'add':
budget = self.load_data('budget.json')
new_entry = {
"type": form_data.get('type', [''])[0],
"service": form_data.get('service', ['General'])[0] or "General",
"amount": float(form_data.get('amount', ['0'])[0]),
"description": form_data.get('description', [''])[0],
"timestamp": datetime.now().isoformat()
}
budget.append(new_entry)
self.save_data('budget.json', budget)
self.log_activity("Budget Entry Added", f"{new_entry['type']} of ${new_entry['amount']:.2f}")
# Redirect back to budget page
self.send_response(302)
self.send_header('Location', '/budget')
self.end_headers()
def log_message(self, format, *args):
"""Override to reduce logging noise"""
pass
def initialize_data():
"""Pre-populate with known accounts and services"""
data_dir = "/home/wdjones/.openclaw/workspace/projects/control-panel/data"
os.makedirs(data_dir, exist_ok=True)
# Pre-populate accounts
accounts_file = os.path.join(data_dir, "accounts.json")
if not os.path.exists(accounts_file):
initial_accounts = [
{
"service": "ProtonMail",
"url": "https://mail.proton.me",
"username": "case-lgn@protonmail.com",
"status": "active",
"notes": "Primary email account",
"created": datetime.now().isoformat(),
"last_accessed": "Never"
},
{
"service": "Polymarket",
"url": "https://polymarket.com",
"username": "",
"status": "inactive",
"notes": "Not yet registered",
"created": datetime.now().isoformat(),
"last_accessed": "Never"
},
{
"service": "Feed Hunter Portal",
"url": "http://localhost:8888",
"username": "",
"status": "active",
"notes": "Local service",
"created": datetime.now().isoformat(),
"last_accessed": "Never"
},
{
"service": "Chrome Debug",
"url": "http://localhost:9222",
"username": "",
"status": "active",
"notes": "Browser debugging interface",
"created": datetime.now().isoformat(),
"last_accessed": "Never"
},
{
"service": "OpenClaw Gateway",
"url": "http://localhost:18789",
"username": "",
"status": "active",
"notes": "OpenClaw main service",
"created": datetime.now().isoformat(),
"last_accessed": "Never"
}
]
with open(accounts_file, 'w') as f:
json.dump(initial_accounts, f, indent=2)
# Initialize empty files if they don't exist
for filename in ["api-keys.json", "budget.json", "activity.json"]:
filepath = os.path.join(data_dir, filename)
if not os.path.exists(filepath):
with open(filepath, 'w') as f:
json.dump([], f)
def main():
initialize_data()
server_address = ('0.0.0.0', 8000)
httpd = ThreadedHTTPServer(server_address, ControlPanelHandler)
print(f"🖤 Case Control Panel starting on http://0.0.0.0:8000")
print("Press Ctrl+C to stop")
try:
httpd.serve_forever()
except KeyboardInterrupt:
print("\nShutting down...")
httpd.shutdown()
sys.exit(0)
if __name__ == '__main__':
main()

View File

@ -7,6 +7,8 @@
}, },
"investigation": { "investigation": {
"profile_url": "https://polymarket.com/@kch123", "profile_url": "https://polymarket.com/@kch123",
"wallet_address": "0x6a72f61820b26b1fe4d956e17b6dc2a1ea3033ee",
"secondary_wallet": "0x8c74b4eef9a894433B8126aA11d1345efb2B0488",
"verified_data": { "verified_data": {
"all_time_pnl": "$9,371,829.00", "all_time_pnl": "$9,371,829.00",
"positions_value": "$2.3m", "positions_value": "$2.3m",

View File

@ -1,24 +1,100 @@
{ {
"positions": [ "positions": [
{ {
"id": "6607b9c1", "id": "1403ffd3",
"strategy": "polymarket-copy-kch123", "strategy": "copy-kch123-spread-4.5",
"opened_at": "2026-02-08T05:50:14.328434+00:00", "opened_at": "2026-02-08T15:15:17.482343+00:00",
"type": "bet", "type": "bet",
"asset": "Seahawks win Super Bowl 2026", "asset": "Spread: Seahawks (-4.5)",
"entry_price": 0.68, "entry_price": 0.505,
"size": 200, "size": 200.0,
"quantity": 1470, "quantity": 851,
"stop_loss": 0.4, "stop_loss": null,
"take_profit": 1.0, "take_profit": null,
"current_price": 0.68, "current_price": 0.505,
"unrealized_pnl": 0, "unrealized_pnl": 0,
"unrealized_pnl_pct": 0, "unrealized_pnl_pct": 0,
"source_post": "https://x.com/linie_oo/status/2020141674828034243", "source_post": "https://x.com/linie_oo/status/2020141674828034243",
"thesis": "Mirror kch123 largest active position. Seahawks Super Bowl at 68c. If they win, pays $1. kch123 has $9.3M all-time P&L, 1862 predictions. Sports betting specialist.", "thesis": "Mirror kch123 largest position. Seahawks -4.5 spread vs Patriots. Super Bowl today.",
"notes": "Paper trade to track if copying kch123 positions is profitable. Entry simulated at current 68c price.", "notes": "",
"updates": []
},
{
"id": "5451b4d6",
"strategy": "copy-kch123-sb-yes",
"opened_at": "2026-02-08T15:15:17.519032+00:00",
"type": "bet",
"asset": "Seahawks win Super Bowl 2026",
"entry_price": 0.6845,
"size": 200.0,
"quantity": 324,
"stop_loss": null,
"take_profit": null,
"current_price": 0.6845,
"unrealized_pnl": 0,
"unrealized_pnl_pct": 0,
"source_post": "https://x.com/linie_oo/status/2020141674828034243",
"thesis": "Mirror kch123 SB winner bet. Seahawks YES at 68.45c.",
"notes": "",
"updates": []
},
{
"id": "f2ddcf73",
"strategy": "copy-kch123-moneyline",
"opened_at": "2026-02-08T15:15:17.555276+00:00",
"type": "bet",
"asset": "Seahawks vs Patriots (Moneyline)",
"entry_price": 0.685,
"size": 184,
"quantity": 269,
"stop_loss": null,
"take_profit": null,
"current_price": 0.685,
"unrealized_pnl": 0,
"unrealized_pnl_pct": 0,
"source_post": "https://x.com/linie_oo/status/2020141674828034243",
"thesis": "Mirror kch123 moneyline. Seahawks to beat Patriots straight up.",
"notes": "",
"updates": []
},
{
"id": "3fcfddb4",
"strategy": "copy-kch123-spread-5.5",
"opened_at": "2026-02-08T15:15:17.593863+00:00",
"type": "bet",
"asset": "Spread: Seahawks (-5.5)",
"entry_price": 0.475,
"size": 89,
"quantity": 188,
"stop_loss": null,
"take_profit": null,
"current_price": 0.475,
"unrealized_pnl": 0,
"unrealized_pnl_pct": 0,
"source_post": "https://x.com/linie_oo/status/2020141674828034243",
"thesis": "Mirror kch123 wider spread. Seahawks -5.5. Riskier.",
"notes": "",
"updates": []
},
{
"id": "bf1e7b4f",
"strategy": "copy-kch123-pats-no",
"opened_at": "2026-02-08T15:15:17.632987+00:00",
"type": "bet",
"asset": "Patriots win Super Bowl - NO",
"entry_price": 0.6865,
"size": 75,
"quantity": 110,
"stop_loss": null,
"take_profit": null,
"current_price": 0.6865,
"unrealized_pnl": 0,
"unrealized_pnl_pct": 0,
"source_post": "https://x.com/linie_oo/status/2020141674828034243",
"thesis": "Mirror kch123 hedge/complement. Patriots NO to win SB.",
"notes": "",
"updates": [] "updates": []
} }
], ],
"bankroll_used": 200 "bankroll_used": 748.0
} }

View File

@ -0,0 +1,27 @@
{
"closed": [
{
"id": "6607b9c1",
"strategy": "polymarket-copy-kch123",
"opened_at": "2026-02-08T05:50:14.328434+00:00",
"type": "bet",
"asset": "Seahawks win Super Bowl 2026",
"entry_price": 0.68,
"size": 200,
"quantity": 1470,
"stop_loss": 0.4,
"take_profit": 1.0,
"current_price": 0.68,
"unrealized_pnl": 0,
"unrealized_pnl_pct": 0,
"source_post": "https://x.com/linie_oo/status/2020141674828034243",
"thesis": "Mirror kch123 largest active position. Seahawks Super Bowl at 68c. If they win, pays $1. kch123 has $9.3M all-time P&L, 1862 predictions. Sports betting specialist.",
"notes": "Paper trade to track if copying kch123 positions is profitable. Entry simulated at current 68c price.",
"updates": [],
"closed_at": "2026-02-08T15:15:17.443369+00:00",
"exit_price": 0.6845,
"realized_pnl": 1.32,
"realized_pnl_pct": 0.66
}
]
}

View File

@ -0,0 +1,50 @@
----------------------------------------
Exception occurred during processing of request from ('127.0.0.1', 45572)
Traceback (most recent call last):
File "/usr/lib/python3.12/socketserver.py", line 318, in _handle_request_noblock
self.process_request(request, client_address)
File "/usr/lib/python3.12/socketserver.py", line 349, in process_request
self.finish_request(request, client_address)
File "/usr/lib/python3.12/socketserver.py", line 362, in finish_request
self.RequestHandlerClass(request, client_address, self)
File "/usr/lib/python3.12/socketserver.py", line 761, in __init__
self.handle()
File "/usr/lib/python3.12/http/server.py", line 436, in handle
self.handle_one_request()
File "/usr/lib/python3.12/http/server.py", line 424, in handle_one_request
method()
File "/home/wdjones/.openclaw/workspace/projects/feed-hunter/portal/server.py", line 37, in do_GET
self.serve_simulations()
File "/home/wdjones/.openclaw/workspace/projects/feed-hunter/portal/server.py", line 243, in serve_simulations
{self.render_trade_history(sims.get('history', []))}
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/home/wdjones/.openclaw/workspace/projects/feed-hunter/portal/server.py", line 748, in render_trade_history
for trade in history[-10:]: # Last 10 trades
~~~~~~~^^^^^^
KeyError: slice(-10, None, None)
----------------------------------------
----------------------------------------
Exception occurred during processing of request from ('127.0.0.1', 48354)
Traceback (most recent call last):
File "/usr/lib/python3.12/socketserver.py", line 318, in _handle_request_noblock
self.process_request(request, client_address)
File "/usr/lib/python3.12/socketserver.py", line 349, in process_request
self.finish_request(request, client_address)
File "/usr/lib/python3.12/socketserver.py", line 362, in finish_request
self.RequestHandlerClass(request, client_address, self)
File "/usr/lib/python3.12/socketserver.py", line 761, in __init__
self.handle()
File "/usr/lib/python3.12/http/server.py", line 436, in handle
self.handle_one_request()
File "/usr/lib/python3.12/http/server.py", line 424, in handle_one_request
method()
File "/home/wdjones/.openclaw/workspace/projects/feed-hunter/portal/server.py", line 37, in do_GET
self.serve_simulations()
File "/home/wdjones/.openclaw/workspace/projects/feed-hunter/portal/server.py", line 243, in serve_simulations
{self.render_trade_history(sims.get('history', []))}
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/home/wdjones/.openclaw/workspace/projects/feed-hunter/portal/server.py", line 748, in render_trade_history
for trade in history[-10:]: # Last 10 trades
~~~~~~~^^^^^^
KeyError: slice(-10, None, None)
----------------------------------------

View File

@ -9,37 +9,54 @@ import os
import glob import glob
from datetime import datetime, timezone from datetime import datetime, timezone
from http.server import HTTPServer, BaseHTTPRequestHandler from http.server import HTTPServer, BaseHTTPRequestHandler
from socketserver import ThreadingMixIn
from urllib.parse import urlparse, parse_qs from urllib.parse import urlparse, parse_qs
class ThreadedHTTPServer(ThreadingMixIn, HTTPServer):
daemon_threads = True
import re import re
# Configuration # Configuration
PORT = 8888 PORT = 8888
DATA_DIR = "../data" _PORTAL_DIR = os.path.dirname(os.path.abspath(__file__))
SKILLS_DIR = "../../skills/deep-scraper/scripts" _PROJECT_DIR = os.path.dirname(_PORTAL_DIR)
DATA_DIR = os.path.join(_PROJECT_DIR, "data")
SKILLS_DIR = os.path.join(os.path.dirname(_PROJECT_DIR), "skills", "deep-scraper", "scripts")
X_FEED_DIR = os.path.join(os.path.dirname(_PROJECT_DIR), "..", "data", "x-feed")
class FeedHunterHandler(BaseHTTPRequestHandler): class FeedHunterHandler(BaseHTTPRequestHandler):
def do_GET(self): def do_GET(self):
parsed_path = urlparse(self.path) try:
path = parsed_path.path parsed_path = urlparse(self.path)
query = parse_qs(parsed_path.query) path = parsed_path.path
query = parse_qs(parsed_path.query)
if path == '/' or path == '/dashboard':
self.serve_dashboard() if path == '/' or path == '/dashboard':
elif path == '/feed': self.serve_dashboard()
self.serve_feed_view() elif path == '/feed':
elif path == '/investigations': self.serve_feed_view()
self.serve_investigations() elif path == '/investigations':
elif path == '/simulations': self.serve_investigations()
self.serve_simulations() elif path == '/simulations':
elif path == '/status': self.serve_simulations()
self.serve_status() elif path == '/status':
elif path == '/api/data': self.serve_status()
self.serve_api_data(query.get('type', [''])[0]) elif path == '/api/data':
elif path.startswith('/static/'): self.serve_api_data(query.get('type', [''])[0])
self.serve_static(path) elif path.startswith('/static/'):
else: self.serve_static(path)
self.send_error(404) else:
self.send_error(404)
except Exception as e:
try:
self.send_response(500)
self.send_header('Content-type', 'text/html')
self.end_headers()
self.wfile.write(f"<h1>500 Error</h1><pre>{e}</pre>".encode())
except:
pass
def serve_dashboard(self): def serve_dashboard(self):
"""Main dashboard overview""" """Main dashboard overview"""
@ -237,7 +254,7 @@ class FeedHunterHandler(BaseHTTPRequestHandler):
<div class="card"> <div class="card">
<h3>Trade History</h3> <h3>Trade History</h3>
<div class="trade-history"> <div class="trade-history">
{self.render_trade_history(sims.get('history', []))} {self.render_trade_history(sims.get('history', {}).get('closed', []) if isinstance(sims.get('history'), dict) else sims.get('history', []))}
</div> </div>
</div> </div>
</div> </div>
@ -425,7 +442,7 @@ class FeedHunterHandler(BaseHTTPRequestHandler):
posts = [] posts = []
try: try:
# Find latest x-feed directory # Find latest x-feed directory
x_feed_pattern = os.path.join("../../data/x-feed", "20*") x_feed_pattern = os.path.join(X_FEED_DIR, "20*")
x_feed_dirs = sorted(glob.glob(x_feed_pattern)) x_feed_dirs = sorted(glob.glob(x_feed_pattern))
if x_feed_dirs: if x_feed_dirs:
@ -526,7 +543,7 @@ class FeedHunterHandler(BaseHTTPRequestHandler):
} }
# Check for recent pipeline runs # Check for recent pipeline runs
x_feed_pattern = os.path.join("../../data/x-feed", "20*") x_feed_pattern = os.path.join(X_FEED_DIR, "20*")
x_feed_dirs = sorted(glob.glob(x_feed_pattern)) x_feed_dirs = sorted(glob.glob(x_feed_pattern))
if x_feed_dirs: if x_feed_dirs:
latest = os.path.basename(x_feed_dirs[-1]) latest = os.path.basename(x_feed_dirs[-1])
@ -592,7 +609,7 @@ class FeedHunterHandler(BaseHTTPRequestHandler):
return html return html
def render_investigations(self, investigations): def render_investigations(self, investigations):
"""Render investigation reports""" """Render investigation reports with rich links"""
if not investigations: if not investigations:
return '<div class="empty-state">No investigations found</div>' return '<div class="empty-state">No investigations found</div>'
@ -601,21 +618,81 @@ class FeedHunterHandler(BaseHTTPRequestHandler):
investigation = inv.get('investigation', {}) investigation = inv.get('investigation', {})
verdict = investigation.get('verdict', 'Unknown') verdict = investigation.get('verdict', 'Unknown')
risk_score = investigation.get('risk_assessment', {}).get('score', 0) risk_score = investigation.get('risk_assessment', {}).get('score', 0)
risk_notes = investigation.get('risk_assessment', {}).get('notes', [])
source = inv.get('source_post', {}) source = inv.get('source_post', {})
verified = investigation.get('verified_data', {})
claim_vs = investigation.get('claim_vs_reality', {})
profile_url = investigation.get('profile_url', '')
strategy_notes = investigation.get('strategy_notes', '')
suggested = inv.get('suggested_simulation', {})
verdict_class = 'verified' if 'VERIFIED' in verdict else 'failed' verdict_class = 'verified' if 'VERIFIED' in verdict else 'failed'
# Build links section
links_html = '<div class="investigation-links">'
if source.get('url'):
links_html += f'<a href="{source["url"]}" target="_blank" class="inv-link">📝 Original Post</a>'
if source.get('author'):
author = source["author"].replace("@", "")
links_html += f'<a href="https://x.com/{author}" target="_blank" class="inv-link">🐦 {source["author"]} on X</a>'
if profile_url:
links_html += f'<a href="{profile_url}" target="_blank" class="inv-link">👤 Polymarket Profile</a>'
# Extract wallet if present in the investigation data
wallet = inv.get('investigation', {}).get('wallet_address', '')
if not wallet:
# Try to find it in verified data or elsewhere
for key, val in verified.items():
if isinstance(val, str) and val.startswith('0x'):
wallet = val
break
if wallet:
links_html += f'<a href="https://polygonscan.com/address/{wallet}" target="_blank" class="inv-link">🔗 Wallet on Polygonscan</a>'
links_html += '</div>'
# Build verified data section
verified_html = ''
if verified:
verified_html = '<div class="investigation-verified"><h4>Verified Data</h4><div class="verified-grid">'
for key, val in verified.items():
label = key.replace('_', ' ').title()
verified_html += f'<div class="verified-item"><span class="verified-label">{label}</span><span class="verified-value">{val}</span></div>'
verified_html += '</div></div>'
# Build claim vs reality section
claim_html = ''
if claim_vs:
claim_html = '<div class="investigation-claims"><h4>Claim vs Reality</h4>'
for key, val in claim_vs.items():
label = key.replace('_', ' ').title()
claim_html += f'<div class="claim-row"><span class="claim-label">{label}</span><span class="claim-value">{val}</span></div>'
claim_html += '</div>'
# Risk notes
risk_html = ''
if risk_notes:
risk_html = '<div class="investigation-risk"><h4>Risk Assessment</h4><ul>'
for note in risk_notes:
risk_html += f'<li>{note}</li>'
risk_html += '</ul></div>'
# Strategy notes
strategy_html = ''
if strategy_notes:
strategy_html = f'<div class="investigation-strategy"><h4>Strategy Notes</h4><p>{strategy_notes}</p></div>'
html += f""" html += f"""
<div class="investigation-item"> <div class="investigation-item">
<div class="investigation-header"> <div class="investigation-header">
<div class="investigation-author">{source.get('author', 'Unknown')}</div> <div class="investigation-author">{source.get('author', 'Unknown')}</div>
<div class="investigation-verdict {verdict_class}">{verdict}</div> <div class="investigation-verdict {verdict_class}">{verdict}</div>
</div> </div>
<div class="investigation-claim">{source.get('claim', 'No claim')}</div> <div class="investigation-claim">"{source.get('claim', 'No claim')}"</div>
<div class="investigation-score">Risk Score: {risk_score}/10</div> {links_html}
<div class="investigation-actions"> {verified_html}
<button onclick="showInvestigationDetail('{inv.get('id', '')}')">View Details</button> {claim_html}
</div> <div class="investigation-score">Risk Score: <strong>{risk_score}/10</strong></div>
{risk_html}
{strategy_html}
</div> </div>
""" """
@ -980,6 +1057,110 @@ body {
margin-bottom: 0.75rem; margin-bottom: 0.75rem;
} }
.investigation-links {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin: 0.75rem 0;
}
.inv-link {
display: inline-block;
padding: 0.35rem 0.75rem;
background: var(--bg-tertiary);
color: var(--accent-blue);
text-decoration: none;
border-radius: 6px;
font-size: 0.85rem;
border: 1px solid var(--border-color);
transition: all 0.2s;
}
.inv-link:hover {
background: var(--border-color);
color: var(--text-primary);
}
.investigation-verified, .investigation-claims, .investigation-risk, .investigation-strategy {
margin: 1rem 0;
padding: 1rem;
background: var(--bg-tertiary);
border-radius: 6px;
}
.investigation-verified h4, .investigation-claims h4, .investigation-risk h4, .investigation-strategy h4 {
color: var(--accent-blue);
margin-bottom: 0.75rem;
font-size: 0.95rem;
}
.verified-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 0.5rem;
}
.verified-item {
display: flex;
flex-direction: column;
padding: 0.5rem;
background: var(--bg-secondary);
border-radius: 4px;
}
.verified-label {
color: var(--text-secondary);
font-size: 0.8rem;
}
.verified-value {
color: var(--text-primary);
font-weight: bold;
font-size: 1rem;
}
.claim-row {
display: flex;
justify-content: space-between;
padding: 0.4rem 0;
border-bottom: 1px solid var(--border-color);
}
.claim-row:last-child { border-bottom: none; }
.claim-label {
color: var(--text-secondary);
font-size: 0.9rem;
}
.claim-value {
color: var(--text-primary);
font-size: 0.9rem;
text-align: right;
max-width: 60%;
}
.investigation-risk ul {
list-style: none;
padding: 0;
}
.investigation-risk li {
padding: 0.3rem 0;
color: var(--text-secondary);
font-size: 0.9rem;
}
.investigation-risk li::before {
content: "⚠️ ";
}
.investigation-strategy p {
color: var(--text-secondary);
font-size: 0.9rem;
line-height: 1.5;
}
/* Positions */ /* Positions */
.position-item { .position-item {
background: var(--bg-tertiary); background: var(--bg-tertiary);
@ -1252,7 +1433,7 @@ def main():
print("") print("")
try: try:
server = HTTPServer(('localhost', PORT), FeedHunterHandler) server = ThreadedHTTPServer(('0.0.0.0', PORT), FeedHunterHandler)
server.serve_forever() server.serve_forever()
except KeyboardInterrupt: except KeyboardInterrupt:
print("\n🛑 Portal stopped") print("\n🛑 Portal stopped")