feat(agent): agentic chat with wallet auth and tiered model routing

- New /api/agent/chat endpoint with Venice tool-calling loop (max 8 iter)
- Tiered models: glm-4.7-flash default, kimi-k2-thinking for Elite+
- Wallet auth: /api/auth/{nonce,verify,whoami,logout} with Ed25519 + JWT
- 10 tools registered: site search, crypto prices, SITREP, .sol lookup,
  wallet xray, contraband/awesomelist search, changelog, trigger_effect
- Per-tool rate limits, 30s timeout, \$30/mo budget guard
- Frontend: tier badge, tool call cards, wallet sign-in handshake
- Changelog v1.40.0
This commit is contained in:
jae 2026-04-20 10:40:27 +00:00
parent 2c498efbfb
commit e73b74cfa2
10 changed files with 1581 additions and 79 deletions

321
api/agent_routes.py Normal file
View file

@ -0,0 +1,321 @@
"""Agentic chat endpoint for JAE-AI.
POST /api/agent/chat
{messages: [{role, content}], session_id?}
Runs an agent loop calling Venice API with tool-calling support.
Tier-routed model selection, per-(identity, tool) rate limiting,
monthly output-token budget cap.
"""
import json
import os
import time
import calendar
import datetime
import threading
from pathlib import Path
from flask import Blueprint, request, jsonify
import requests as req
from auth_routes import read_session, fetch_sol_balance, _load_apikeys
from agent_tiers import compute_tier, pick_model, tier_allows
from agent_tools import (
TOOLS, get_allowed_tools, openai_tool_schemas, check_rate,
)
agent_bp = Blueprint('agent_bp', __name__, url_prefix='/api/agent')
DATA_DIR = Path(__file__).parent / 'data'
BUDGET_FILE = DATA_DIR / 'agent_budget.json'
# Venice API
VENICE_URL = 'https://api.venice.ai/api/v1/chat/completions'
# Model pricing ($ per 1M tokens) — input, output
MODEL_PRICING = {
'zai-org-glm-4.7-flash': (0.125, 0.50),
'kimi-k2-thinking': (0.75, 3.20),
}
MONTHLY_BUDGET_USD = 30.0
MAX_ITERATIONS = 8
MAX_MESSAGES = 40 # cap sent to venice
_budget_lock = threading.Lock()
SYSTEM_PROMPT = (
'You are JAE-AI, the agentic AI operator embedded at jaeswift.xyz — a military / cyberpunk '
'themed command centre. You speak like a tactical operator: terse, confident, a little cheeky. '
'You have access to tools to search the site, fetch live crypto prices, scan Solana wallets, '
'look up .sol domains, fetch SITREP briefings, and trigger sitewide visual effects on the '
"user's browser. Use tools proactively when helpful — don't just describe what you could do, "
'do it. After a tool returns, briefly explain the result in plain English. Keep replies under '
'200 words unless the user asks for detail. Never invent tool results. British English, occasional '
'profanity is fine, never censor words. Current date: {date}.'
)
def _load_budget() -> dict:
try:
with open(BUDGET_FILE) as f:
return json.load(f)
except Exception:
return {}
def _save_budget(b: dict):
try:
BUDGET_FILE.parent.mkdir(parents=True, exist_ok=True)
with open(BUDGET_FILE, 'w') as f:
json.dump(b, f, indent=2)
except Exception as e:
print(f'[agent] budget save failed: {e}')
def _current_month_key() -> str:
return datetime.datetime.utcnow().strftime('%Y-%m')
def _estimate_cost(model: str, in_tokens: int, out_tokens: int) -> float:
in_p, out_p = MODEL_PRICING.get(model, (0.5, 1.0))
return (in_tokens * in_p + out_tokens * out_p) / 1_000_000
def _record_usage(model: str, in_tokens: int, out_tokens: int) -> tuple[float, float]:
"""Update budget. Returns (month_total_usd, remaining_usd)."""
with _budget_lock:
b = _load_budget()
key = _current_month_key()
month = b.setdefault(key, {'total_usd': 0.0, 'calls': 0, 'by_model': {}})
cost = _estimate_cost(model, in_tokens, out_tokens)
month['total_usd'] = round(month['total_usd'] + cost, 6)
month['calls'] = month['calls'] + 1
mm = month['by_model'].setdefault(model, {'calls': 0, 'in_tokens': 0, 'out_tokens': 0, 'usd': 0.0})
mm['calls'] += 1
mm['in_tokens'] += in_tokens
mm['out_tokens'] += out_tokens
mm['usd'] = round(mm['usd'] + cost, 6)
_save_budget(b)
return month['total_usd'], max(0.0, MONTHLY_BUDGET_USD - month['total_usd'])
def _budget_exceeded() -> bool:
b = _load_budget()
month = b.get(_current_month_key(), {})
return month.get('total_usd', 0) >= MONTHLY_BUDGET_USD
def _identity_key() -> str:
sess = read_session()
if sess and sess.get('address'):
return f"wallet:{sess['address']}"
return f"ip:{request.headers.get('X-Forwarded-For', request.remote_addr or 'unknown').split(',')[0].strip()}"
def _execute_tool(name: str, args: dict, identity: str) -> dict:
tool = TOOLS.get(name)
if not tool:
return {'error': f'unknown tool: {name}'}
# Rate limit check
rate_key = (identity, name)
if not check_rate(rate_key, tool.rate_limit):
return {'error': f'rate limit exceeded for {name} ({tool.rate_limit})'}
try:
return tool.handler(args or {})
except Exception as e:
return {'error': f'tool {name} failed: {e}'}
def _call_venice(model: str, messages: list, tools_schema: list, api_key: str) -> dict:
resp = req.post(
VENICE_URL,
headers={'Authorization': f'Bearer {api_key}', 'Content-Type': 'application/json'},
json={
'model': model,
'messages': messages,
'tools': tools_schema if tools_schema else None,
'tool_choice': 'auto' if tools_schema else None,
'temperature': 0.7,
'max_tokens': 1500,
},
timeout=90,
)
if resp.status_code != 200:
raise RuntimeError(f'Venice HTTP {resp.status_code}: {resp.text[:400]}')
return resp.json()
@agent_bp.route('/chat', methods=['POST'])
def agent_chat():
# ── Identity / tier ──────────────────────────────────────────────────
sess = read_session()
if sess and sess.get('address'):
address = sess['address']
balance = fetch_sol_balance(address)
tier = compute_tier(address, balance)
else:
address = None
tier = 'anonymous'
# ── Budget guard ────────────────────────────────────────────────────
if _budget_exceeded():
return jsonify({
'error': 'Monthly agent budget exceeded — please try again next month',
'budget_usd': MONTHLY_BUDGET_USD,
}), 429
# ── Input ───────────────────────────────────────────────────────────
data = request.get_json(silent=True) or {}
messages_in = data.get('messages') or []
if not isinstance(messages_in, list) or not messages_in:
return jsonify({'error': 'messages array required'}), 400
memory_context = (data.get('memory_context') or '').strip()
# Sanitise + cap
cleaned = []
for m in messages_in[-MAX_MESSAGES:]:
role = m.get('role')
if role not in ('user', 'assistant', 'system', 'tool'):
continue
cleaned.append({k: v for k, v in m.items() if k in ('role', 'content', 'tool_calls', 'tool_call_id', 'name')})
if not cleaned:
return jsonify({'error': 'no valid messages'}), 400
# ── Build system prompt ─────────────────────────────────────────────
sys_prompt = SYSTEM_PROMPT.format(date=datetime.date.today().isoformat())
if memory_context and tier != 'anonymous':
sys_prompt = memory_context + '\n\n' + sys_prompt
if tier == 'elite':
sys_prompt += '\n\n[TIER: ELITE — $JAE holder. Greet them accordingly.]'
elif tier == 'admin':
sys_prompt += '\n\n[TIER: ADMIN — this is Jae, the site operator.]'
elif tier == 'operator':
sys_prompt += '\n\n[TIER: OPERATOR — wallet authenticated.]'
messages = [{'role': 'system', 'content': sys_prompt}] + cleaned
# ── Model + tools ───────────────────────────────────────────────────
model = pick_model(tier)
allowed = get_allowed_tools(tier)
tools_schema = openai_tool_schemas(allowed)
# ── API key ─────────────────────────────────────────────────────────
keys = _load_apikeys()
api_key = (keys.get('venice') or {}).get('api_key', '')
if not api_key:
return jsonify({'error': 'Venice API key not configured'}), 500
identity = _identity_key()
tool_trace = []
total_in = 0
total_out = 0
final_content = ''
frontend_actions = [] # effects etc. to execute client-side
try:
for iteration in range(MAX_ITERATIONS):
resp_json = _call_venice(model, messages, tools_schema, api_key)
usage = resp_json.get('usage') or {}
total_in += int(usage.get('prompt_tokens', 0) or 0)
total_out += int(usage.get('completion_tokens', 0) or 0)
choice = (resp_json.get('choices') or [{}])[0]
msg = choice.get('message') or {}
tool_calls = msg.get('tool_calls') or []
# Append assistant turn
assistant_turn = {'role': 'assistant', 'content': msg.get('content') or ''}
if tool_calls:
assistant_turn['tool_calls'] = tool_calls
messages.append(assistant_turn)
if not tool_calls:
final_content = msg.get('content') or msg.get('reasoning_content') or ''
break
# Execute tool calls
for tc in tool_calls:
fn = (tc.get('function') or {})
name = fn.get('name', '')
raw_args = fn.get('arguments') or '{}'
try:
args = json.loads(raw_args) if isinstance(raw_args, str) else (raw_args or {})
except Exception:
args = {}
result = _execute_tool(name, args, identity)
tool_trace.append({
'name': name, 'args': args, 'result': result,
'iteration': iteration,
})
# Capture frontend trigger_effect action
if isinstance(result, dict) and result.get('action') == 'trigger_effect':
frontend_actions.append({
'action': 'trigger_effect',
'effect': result.get('effect'),
})
messages.append({
'role': 'tool',
'tool_call_id': tc.get('id'),
'name': name,
'content': json.dumps(result, default=str)[:4000],
})
else:
final_content = final_content or '(agent reached iteration limit)'
except Exception as e:
return jsonify({
'error': f'agent failure: {e}',
'tool_calls': tool_trace,
'tier': tier,
'model_used': model,
}), 502
# ── Record usage ────────────────────────────────────────────────────
month_total, remaining = _record_usage(model, total_in, total_out)
return jsonify({
'content': final_content,
'tool_calls': tool_trace,
'frontend_actions': frontend_actions,
'model_used': model,
'tier': tier,
'authenticated': bool(address),
'address': address,
'tokens': {'in': total_in, 'out': total_out},
'budget': {
'month_usd': round(month_total, 4),
'remaining_usd': round(remaining, 4),
'cap_usd': MONTHLY_BUDGET_USD,
},
})
@agent_bp.route('/tools', methods=['GET'])
def list_tools():
"""Debug: list tools available to current tier."""
sess = read_session()
address = sess.get('address') if sess else None
balance = fetch_sol_balance(address) if address else 0.0
tier = compute_tier(address, balance) if address else 'anonymous'
allowed = get_allowed_tools(tier)
return jsonify({
'tier': tier,
'model': pick_model(tier),
'tools': [{'name': t.name, 'tier': t.tier, 'rate_limit': t.rate_limit,
'description': t.description} for t in allowed],
})
@agent_bp.route('/budget', methods=['GET'])
def budget_status():
b = _load_budget()
month = b.get(_current_month_key(), {'total_usd': 0.0, 'calls': 0, 'by_model': {}})
return jsonify({
'month': _current_month_key(),
'cap_usd': MONTHLY_BUDGET_USD,
'total_usd': round(month.get('total_usd', 0), 4),
'remaining_usd': round(max(0.0, MONTHLY_BUDGET_USD - month.get('total_usd', 0)), 4),
'calls': month.get('calls', 0),
'by_model': month.get('by_model', {}),
})

44
api/agent_tiers.py Normal file
View file

@ -0,0 +1,44 @@
"""Tier system for JAE-AI agent — access control + model routing."""
TIERS = ['anonymous', 'operator', 'elite', 'admin']
TIER_RANK = {name: idx for idx, name in enumerate(TIERS)}
# Admin wallets — Solana base58 pubkeys. Populate when confirmed.
ADMIN_WALLETS = [
'9NuiHnHQ9kQVj8JwPCpmE4VU1MhwcYL9gWbkiAyn8', # jaeswift.sol
]
# Model routing per tier
MODEL_ROUTING = {
'anonymous': 'zai-org-glm-4.7-flash',
'operator': 'zai-org-glm-4.7-flash',
'elite': 'kimi-k2-thinking',
'admin': 'kimi-k2-thinking',
}
def compute_tier(address: str | None, balance_sol: float = 0.0) -> str:
"""Derive tier from wallet address + SOL holdings."""
if not address:
return 'anonymous'
if address in ADMIN_WALLETS:
return 'admin'
try:
if float(balance_sol) >= 1.0:
return 'elite'
except (TypeError, ValueError):
pass
return 'operator'
def tier_rank(tier: str) -> int:
return TIER_RANK.get(tier, 0)
def tier_allows(user_tier: str, required_tier: str) -> bool:
"""True when user_tier >= required_tier in the hierarchy."""
return tier_rank(user_tier) >= tier_rank(required_tier)
def pick_model(tier: str) -> str:
return MODEL_ROUTING.get(tier, MODEL_ROUTING['anonymous'])

539
api/agent_tools.py Normal file
View file

@ -0,0 +1,539 @@
"""Agent tool registry + implementations for the JAE-AI agentic chat.
Register once on import. Each Tool carries:
- JSON schema for Venice function-calling
- Handler callable
- Minimum tier
- Rate limit spec ('N/min' or 'N/hour')
- Timeout (seconds)
"""
import json
import time
import re
from collections import defaultdict, deque
from dataclasses import dataclass, field
from pathlib import Path
from typing import Callable, Any
import requests as req
DATA_DIR = Path(__file__).parent / 'data'
@dataclass
class Tool:
name: str
description: str
params: dict
handler: Callable[[dict], Any]
tier: str = 'anonymous'
rate_limit: str = '60/min'
timeout: int = 30
TOOLS: dict[str, Tool] = {}
def register(tool: Tool):
TOOLS[tool.name] = tool
# ── Rate limiting (per (identity, tool) key) ─────────────────────────────
_rate_state: dict[tuple, deque] = defaultdict(lambda: deque(maxlen=200))
def parse_spec(spec: str) -> tuple[int, int]:
"""'10/min' -> (10, 60). Supports /sec /min /hour /day."""
try:
n, unit = spec.split('/')
n = int(n)
except Exception:
return 60, 60
unit = unit.lower().strip()
period = {'sec': 1, 'second': 1, 'min': 60, 'minute': 60,
'hour': 3600, 'hr': 3600, 'day': 86400}.get(unit, 60)
return n, period
def check_rate(key: tuple, spec: str) -> bool:
limit, period = parse_spec(spec)
now = time.time()
q = _rate_state[key]
while q and q[0] < now - period:
q.popleft()
if len(q) >= limit:
return False
q.append(now)
return True
# ── Simple TTL cache ─────────────────────────────────────────────────────
_cache: dict[str, tuple[float, Any]] = {}
def cache_get(key: str, ttl: float) -> Any | None:
entry = _cache.get(key)
if entry and time.time() - entry[0] < ttl:
return entry[1]
return None
def cache_set(key: str, value: Any):
_cache[key] = (time.time(), value)
def _load_json(name: str) -> Any:
try:
with open(DATA_DIR / name) as f:
return json.load(f)
except Exception:
return None
# ── Tool Handlers ────────────────────────────────────────────────────────
def tool_search_site(args: dict) -> dict:
query = (args.get('query') or '').strip().lower()
if not query:
return {'error': 'query required', 'results': []}
results = []
# Index main content files
sources = [
('changelog', 'changelog.json'),
('navigation', 'navigation.json'),
('homepage', 'homepage.json'),
('managed_services', 'managed_services.json'),
('posts', 'posts.json'),
('links', 'links.json'),
('tracks', 'tracks.json'),
]
for label, fname in sources:
data = _load_json(fname)
if data is None:
continue
blob = json.dumps(data, ensure_ascii=False).lower()
if query in blob:
# Find first match snippet
idx = blob.find(query)
snippet = blob[max(0, idx - 80): idx + 160]
results.append({
'source': label,
'title': label.replace('_', ' ').title(),
'snippet': snippet.replace('\n', ' '),
'url': f'/api/{label}' if label != 'homepage' else '/',
})
return {'results': results[:5], 'count': len(results)}
def tool_get_sol_price(args: dict) -> dict:
cached = cache_get('sol_price', 30)
if cached is not None:
return cached
try:
r = req.get('https://api.binance.com/api/v3/ticker/24hr',
params={'symbol': 'SOLUSDT'}, timeout=8)
r.raise_for_status()
j = r.json()
out = {
'symbol': 'SOL',
'price_usd': float(j.get('lastPrice', 0)),
'change_24h_pct': float(j.get('priceChangePercent', 0)),
'volume_24h': float(j.get('volume', 0)),
'source': 'binance',
}
cache_set('sol_price', out)
return out
except Exception as e:
return {'error': f'price fetch failed: {e}'}
def tool_get_crypto_price(args: dict) -> dict:
symbol = (args.get('symbol') or '').upper().strip()
if not symbol or not re.match(r'^[A-Z0-9]{2,10}$', symbol):
return {'error': 'valid symbol required (e.g. BTC, ETH)'}
ck = f'crypto_price:{symbol}'
cached = cache_get(ck, 30)
if cached is not None:
return cached
pair = f'{symbol}USDT'
try:
r = req.get('https://api.binance.com/api/v3/ticker/24hr',
params={'symbol': pair}, timeout=8)
if r.status_code != 200:
return {'error': f'symbol {symbol} not found on binance'}
j = r.json()
out = {
'symbol': symbol,
'price_usd': float(j.get('lastPrice', 0)),
'change_24h_pct': float(j.get('priceChangePercent', 0)),
'volume_24h': float(j.get('volume', 0)),
}
cache_set(ck, out)
return out
except Exception as e:
return {'error': f'price fetch failed: {e}'}
def _search_entries(entries: list, query: str, fields: list[str], limit: int = 5) -> list:
q = query.lower()
matches = []
for e in entries:
blob = ' '.join(str(e.get(f, '')) for f in fields).lower()
if q in blob:
matches.append(e)
if len(matches) >= limit:
break
return matches
def tool_search_contraband(args: dict) -> dict:
query = (args.get('query') or '').strip()
category = (args.get('category') or '').strip().lower()
if not query:
return {'error': 'query required'}
data = _load_json('contraband.json') or {}
cats = data.get('categories', []) if isinstance(data, dict) else []
results = []
for cat in cats:
cat_name = (cat.get('name') or '').lower()
if category and category not in cat_name and category not in (cat.get('slug') or '').lower():
continue
for sub in cat.get('subcategories', []) or []:
for item in sub.get('items', []) or []:
blob = f"{item.get('title','')} {item.get('description','')} {item.get('url','')}".lower()
if query.lower() in blob:
results.append({
'title': item.get('title'),
'url': item.get('url'),
'description': (item.get('description') or '')[:200],
'category': cat.get('name'),
'subcategory': sub.get('name'),
})
if len(results) >= 5:
return {'results': results, 'count': len(results)}
return {'results': results, 'count': len(results)}
def tool_search_awesomelist(args: dict) -> dict:
query = (args.get('query') or '').strip().lower()
if not query:
return {'error': 'query required'}
index = _load_json('awesomelist_index.json') or {}
sectors = index.get('sectors', []) if isinstance(index, dict) else []
results = []
# Search sector names first
for sec in sectors:
name = (sec.get('name') or '').lower()
desc = (sec.get('description') or '').lower()
if query in name or query in desc:
results.append({
'type': 'sector',
'name': sec.get('name'),
'slug': sec.get('slug'),
'count': sec.get('count', 0),
'url': f"/recon/awesomelist?sector={sec.get('slug')}",
})
# Search inside individual sector files for entry matches
awesome_dir = DATA_DIR / 'awesomelist'
if awesome_dir.exists() and len(results) < 5:
import os
for fname in sorted(os.listdir(awesome_dir))[:80]:
if len(results) >= 5:
break
try:
with open(awesome_dir / fname) as f:
sec_data = json.load(f)
for entry in (sec_data.get('entries') or [])[:300]:
blob = f"{entry.get('name','')} {entry.get('description','')}".lower()
if query in blob:
results.append({
'type': 'entry',
'name': entry.get('name'),
'url': entry.get('url'),
'description': (entry.get('description') or '')[:200],
'sector_file': fname,
})
if len(results) >= 5:
break
except Exception:
continue
return {'results': results[:5], 'count': len(results)}
def tool_get_sitrep(args: dict) -> dict:
date_arg = (args.get('date') or 'latest').strip()
sitrep_dir = DATA_DIR / 'sitreps'
if not sitrep_dir.exists():
return {'error': 'No SITREPs available yet', 'sitrep': None}
import os
files = sorted([f for f in os.listdir(sitrep_dir) if f.endswith('.json')], reverse=True)
if not files:
return {'error': 'No SITREPs archived', 'sitrep': None}
target = None
if date_arg == 'latest':
target = files[0]
else:
for f in files:
if date_arg in f:
target = f
break
if not target:
return {'error': f'No SITREP matching "{date_arg}"', 'available': files[:5]}
try:
with open(sitrep_dir / target) as f:
data = json.load(f)
summary = data.get('summary') or data.get('content', '')[:800]
return {
'date': data.get('date', target.replace('.json', '')),
'summary': summary[:1200],
'source_count': len(data.get('sources', [])),
'sectors': list((data.get('sectors') or {}).keys()),
'url': '/transmissions/sitrep',
}
except Exception as e:
return {'error': f'Failed to read SITREP: {e}'}
def tool_lookup_sol_domain(args: dict) -> dict:
name = (args.get('name') or '').strip().lower().replace('.sol', '')
if not name or not re.match(r'^[a-z0-9_-]{1,63}$', name):
return {'error': 'invalid domain name'}
try:
# Bonfida SNS public API
r = req.get(f'https://sns-api.bonfida.com/domain/lookup/{name}', timeout=8)
if r.status_code == 200:
j = r.json()
return {
'name': f'{name}.sol',
'registered': True,
'owner': j.get('result', {}).get('owner') if isinstance(j.get('result'), dict) else j.get('owner'),
'raw': j,
'solscan_url': f'https://www.sns.id/domain?domain={name}',
}
if r.status_code == 404:
return {
'name': f'{name}.sol',
'registered': False,
'available': True,
'register_url': f'https://www.sns.id/search?search={name}',
}
return {'name': f'{name}.sol', 'status_code': r.status_code, 'raw': r.text[:400]}
except Exception as e:
return {'error': f'lookup failed: {e}'}
def tool_wallet_xray(args: dict) -> dict:
address = (args.get('address') or '').strip()
if not address or not (32 <= len(address) <= 44):
return {'error': 'invalid Solana address'}
# Lazy import to avoid circular
from auth_routes import get_rpc_url
rpc = get_rpc_url()
out: dict = {'address': address, 'rpc': 'helius' if 'helius' in rpc else 'public'}
try:
# Balance
r = req.post(rpc, json={'jsonrpc': '2.0', 'id': 1, 'method': 'getBalance',
'params': [address]}, timeout=10)
r.raise_for_status()
lamports = int(r.json().get('result', {}).get('value', 0))
out['balance_sol'] = round(lamports / 1_000_000_000, 6)
except Exception as e:
return {'error': f'balance fetch failed: {e}'}
try:
# Token accounts count
r = req.post(rpc, json={
'jsonrpc': '2.0', 'id': 2, 'method': 'getTokenAccountsByOwner',
'params': [address, {'programId': 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'},
{'encoding': 'jsonParsed'}]
}, timeout=12)
tokens = r.json().get('result', {}).get('value', []) or []
non_zero = [t for t in tokens if int(t.get('account', {}).get('data', {}).get('parsed', {}).get('info', {}).get('tokenAmount', {}).get('amount', 0) or 0) > 0]
out['token_account_count'] = len(tokens)
out['non_zero_tokens'] = len(non_zero)
except Exception as e:
out['token_error'] = str(e)
try:
# Last 5 signatures
r = req.post(rpc, json={
'jsonrpc': '2.0', 'id': 3, 'method': 'getSignaturesForAddress',
'params': [address, {'limit': 5}]
}, timeout=10)
sigs = r.json().get('result', []) or []
out['recent_txs'] = [{
'signature': s.get('signature'),
'slot': s.get('slot'),
'block_time': s.get('blockTime'),
'err': bool(s.get('err')),
} for s in sigs]
except Exception as e:
out['tx_error'] = str(e)
out['solscan_url'] = f'https://solscan.io/account/{address}'
return out
def tool_get_changelog(args: dict) -> dict:
limit = int(args.get('limit') or 5)
limit = max(1, min(limit, 25))
data = _load_json('changelog.json') or {}
entries = data.get('entries', []) if isinstance(data, dict) else (data if isinstance(data, list) else [])
out = []
for e in entries[:limit]:
out.append({
'version': e.get('version'),
'date': e.get('date'),
'category': e.get('category'),
'title': e.get('title'),
'changes': (e.get('changes') or [])[:6],
})
return {'entries': out, 'count': len(out), 'total_available': len(entries)}
VALID_EFFECTS = {
'crt', 'vhs', 'glitch', 'redalert', 'red', 'invert', 'blueprint', 'typewriter',
'gravity', 'earthquake', 'lowgravity', 'melt', 'shuffle',
'rain', 'snow', 'fog', 'night', 'underwater',
'dimensions', 'portal', 'retro',
'partymode', 'ghostmode', 'quantum', 'sneak', 'hacker',
'matrix', 'cmatrix',
}
def tool_trigger_effect(args: dict) -> dict:
name = (args.get('name') or '').strip().lower()
if name not in VALID_EFFECTS:
return {'error': f'unknown effect. Valid: {sorted(VALID_EFFECTS)}', 'effect': None}
return {
'action': 'trigger_effect',
'effect': name,
'message': f'Activating {name} effect on your browser…',
}
# ── Registration ─────────────────────────────────────────────────────────
register(Tool(
name='search_site',
description='Full-text search across jaeswift.xyz content (changelog, posts, navigation, homepage). Returns top 5 matches with snippets.',
params={
'type': 'object',
'properties': {'query': {'type': 'string', 'description': 'search query'}},
'required': ['query'],
},
handler=tool_search_site, tier='anonymous', rate_limit='20/min',
))
register(Tool(
name='get_sol_price',
description='Get live Solana (SOL) price and 24h change from Binance.',
params={'type': 'object', 'properties': {}, 'required': []},
handler=tool_get_sol_price, tier='anonymous', rate_limit='60/min',
))
register(Tool(
name='get_crypto_price',
description='Get live USD price and 24h change for any crypto symbol (BTC, ETH, etc) from Binance.',
params={
'type': 'object',
'properties': {'symbol': {'type': 'string', 'description': 'ticker symbol e.g. BTC, ETH, DOGE'}},
'required': ['symbol'],
},
handler=tool_get_crypto_price, tier='anonymous', rate_limit='60/min',
))
register(Tool(
name='search_contraband',
description='Search the CONTRABAND archive of 16k+ curated resources across 24 categories (software, privacy, movies, books, games, AI, etc.). Top 5 matches.',
params={
'type': 'object',
'properties': {
'query': {'type': 'string', 'description': 'search query'},
'category': {'type': 'string', 'description': 'optional category filter (e.g. privacy, software, ai)'},
},
'required': ['query'],
},
handler=tool_search_contraband, tier='anonymous', rate_limit='20/min',
))
register(Tool(
name='search_awesomelist',
description='Search 28 curated awesome-list sectors with 135k+ entries (development, datascience, security, etc.). Top 5 matches.',
params={
'type': 'object',
'properties': {'query': {'type': 'string', 'description': 'search query'}},
'required': ['query'],
},
handler=tool_search_awesomelist, tier='anonymous', rate_limit='20/min',
))
register(Tool(
name='get_sitrep',
description='Get the latest (or a specific date) SITREP daily AI intelligence briefing covering tech, cybersecurity, and crypto.',
params={
'type': 'object',
'properties': {'date': {'type': 'string', 'description': 'date (YYYY-MM-DD) or "latest"', 'default': 'latest'}},
'required': [],
},
handler=tool_get_sitrep, tier='anonymous', rate_limit='20/min',
))
register(Tool(
name='lookup_sol_domain',
description='Check if a .sol domain is registered or available (via Bonfida SNS). Returns owner if registered.',
params={
'type': 'object',
'properties': {'name': {'type': 'string', 'description': 'domain name without .sol suffix'}},
'required': ['name'],
},
handler=tool_lookup_sol_domain, tier='anonymous', rate_limit='30/min',
))
register(Tool(
name='wallet_xray',
description='Scan any Solana wallet address: SOL balance, token account count, recent transactions.',
params={
'type': 'object',
'properties': {'address': {'type': 'string', 'description': 'Solana wallet pubkey (base58)'}},
'required': ['address'],
},
handler=tool_wallet_xray, tier='anonymous', rate_limit='10/min', timeout=20,
))
register(Tool(
name='get_changelog',
description='Get the last N changelog entries from jaeswift.xyz (what has been built/fixed recently).',
params={
'type': 'object',
'properties': {'limit': {'type': 'integer', 'description': 'number of entries (1-25)', 'default': 5}},
'required': [],
},
handler=tool_get_changelog, tier='anonymous', rate_limit='30/min',
))
register(Tool(
name='trigger_effect',
description='Activate a sitewide visual effect on the user\'s browser (crt, vhs, glitch, redalert, matrix, rain, snow, partymode, etc.). Returns an action payload the frontend will execute.',
params={
'type': 'object',
'properties': {'name': {'type': 'string', 'description': 'effect name (e.g. crt, rain, partymode)'}},
'required': ['name'],
},
handler=tool_trigger_effect, tier='anonymous', rate_limit='30/min',
))
# ── Public API ───────────────────────────────────────────────────────────
def get_allowed_tools(user_tier: str) -> list[Tool]:
from agent_tiers import tier_allows
return [t for t in TOOLS.values() if tier_allows(user_tier, t.tier)]
def openai_tool_schemas(tools: list[Tool]) -> list[dict]:
return [{
'type': 'function',
'function': {
'name': t.name,
'description': t.description,
'parameters': t.params,
},
} for t in tools]

View file

@ -30,6 +30,21 @@ try:
app.register_blueprint(visitor_bp)
except Exception as _vb_err:
print(f'[WARN] visitor_routes not loaded: {_vb_err}')
# Register auth blueprint (provides /api/auth/* wallet authentication)
try:
from auth_routes import auth_bp
app.register_blueprint(auth_bp)
except Exception as _ab_err:
print(f'[WARN] auth_routes not loaded: {_ab_err}')
# Register agent blueprint (provides /api/agent/* agentic chat)
try:
import agent_tools # triggers tool registration
from agent_routes import agent_bp
app.register_blueprint(agent_bp)
except Exception as _ag_err:
print(f'[WARN] agent_routes not loaded: {_ag_err}')
DATA_DIR = Path(__file__).parent / 'data'
JWT_SECRET = 'jaeswift-hud-s3cr3t-2026!x'

261
api/auth_routes.py Normal file
View file

@ -0,0 +1,261 @@
"""Wallet-based authentication for JAE-AI agent.
Endpoints:
POST /api/auth/nonce issue sign-in nonce for a Solana pubkey
POST /api/auth/verify verify Ed25519 signature, issue JWT cookie
GET /api/auth/whoami introspect cookie + holdings tier
POST /api/auth/logout clear cookie
"""
import json
import os
import secrets
import time
import datetime
from pathlib import Path
from flask import Blueprint, request, jsonify, make_response
import jwt
import requests as req
import base58
from nacl.signing import VerifyKey
from nacl.exceptions import BadSignatureError
from agent_tiers import compute_tier, pick_model
auth_bp = Blueprint('auth_bp', __name__, url_prefix='/api/auth')
DATA_DIR = Path(__file__).parent / 'data'
APIKEYS_FILE = DATA_DIR / 'apikeys.json'
# Solana RPC endpoints (mainnet). Helius used if key present, else public RPC.
PUBLIC_RPC = 'https://api.mainnet-beta.solana.com'
# In-memory nonce store: address -> (nonce, expires_ts)
_NONCES: dict[str, tuple[str, float]] = {}
_NONCE_TTL = 300 # 5 minutes
# Balance cache: address -> (lamports, ts)
_BALANCE_CACHE: dict[str, tuple[int, float]] = {}
_BALANCE_TTL = 60 # 1 minute
COOKIE_NAME = 'jae_session'
JWT_ALGO = 'HS256'
JWT_EXPIRY_HOURS = 24
def _load_apikeys() -> dict:
try:
with open(APIKEYS_FILE) as f:
return json.load(f)
except Exception:
return {}
def _save_apikeys(keys: dict):
try:
with open(APIKEYS_FILE, 'w') as f:
json.dump(keys, f, indent=2)
except Exception as e:
print(f'[auth] Failed to save apikeys: {e}')
def get_jwt_secret() -> str:
"""Load JAE_JWT_SECRET from apikeys.json; generate + persist if missing."""
keys = _load_apikeys()
secret = keys.get('jae_jwt_secret') or ''
if not secret:
secret = secrets.token_hex(32)
keys['jae_jwt_secret'] = secret
_save_apikeys(keys)
return secret
def get_rpc_url() -> str:
keys = _load_apikeys()
# Check custom slots for Helius
for slot in ('custom1', 'custom2', 'custom3'):
c = keys.get(slot) or {}
name = (c.get('name') or '').lower()
if 'helius' in name and c.get('key'):
return f"https://mainnet.helius-rpc.com/?api-key={c['key']}"
# Check a direct helius field
h = keys.get('helius') or {}
if h.get('api_key'):
return f"https://mainnet.helius-rpc.com/?api-key={h['api_key']}"
return PUBLIC_RPC
def fetch_sol_balance(address: str) -> float:
"""Return SOL balance (float). Cached 60s. Returns 0.0 on error."""
now = time.time()
cached = _BALANCE_CACHE.get(address)
if cached and now - cached[1] < _BALANCE_TTL:
return cached[0] / 1_000_000_000
try:
r = req.post(
get_rpc_url(),
json={'jsonrpc': '2.0', 'id': 1, 'method': 'getBalance', 'params': [address]},
timeout=6,
)
r.raise_for_status()
lamports = int(r.json()['result']['value'])
_BALANCE_CACHE[address] = (lamports, now)
return lamports / 1_000_000_000
except Exception as e:
print(f'[auth] getBalance failed for {address[:8]}…: {e}')
return 0.0
def _cleanup_nonces():
now = time.time()
stale = [k for k, (_, exp) in _NONCES.items() if exp < now]
for k in stale:
_NONCES.pop(k, None)
def _valid_solana_address(addr: str) -> bool:
if not isinstance(addr, str) or not (32 <= len(addr) <= 44):
return False
try:
raw = base58.b58decode(addr)
return len(raw) == 32
except Exception:
return False
def read_session() -> dict | None:
"""Decode JWT cookie. Returns payload or None."""
token = request.cookies.get(COOKIE_NAME)
if not token:
return None
try:
return jwt.decode(token, get_jwt_secret(), algorithms=[JWT_ALGO])
except Exception:
return None
# ── Endpoints ────────────────────────────────────────────────────────────
@auth_bp.route('/nonce', methods=['POST'])
def issue_nonce():
data = request.get_json(silent=True) or {}
address = (data.get('address') or '').strip()
if not _valid_solana_address(address):
return jsonify({'error': 'Invalid Solana address'}), 400
_cleanup_nonces()
nonce = secrets.token_hex(16)
expires_ts = time.time() + _NONCE_TTL
expires_iso = datetime.datetime.utcfromtimestamp(expires_ts).isoformat() + 'Z'
_NONCES[address] = (nonce, expires_ts)
message = f'Sign in to jaeswift.xyz\nnonce: {nonce}\nexpires: {expires_iso}'
return jsonify({'message': message, 'nonce': nonce, 'expires': expires_iso})
@auth_bp.route('/verify', methods=['POST'])
def verify_signature():
data = request.get_json(silent=True) or {}
address = (data.get('address') or '').strip()
signature_b58 = (data.get('signature') or '').strip()
nonce = (data.get('nonce') or '').strip()
if not _valid_solana_address(address):
return jsonify({'error': 'Invalid address'}), 400
if not signature_b58 or not nonce:
return jsonify({'error': 'Missing signature or nonce'}), 400
stored = _NONCES.get(address)
if not stored:
return jsonify({'error': 'No active nonce for this address'}), 401
stored_nonce, expires_ts = stored
if stored_nonce != nonce:
return jsonify({'error': 'Nonce mismatch'}), 401
if time.time() > expires_ts:
_NONCES.pop(address, None)
return jsonify({'error': 'Nonce expired'}), 401
# Rebuild exact signed message
expires_iso = datetime.datetime.utcfromtimestamp(expires_ts).isoformat() + 'Z'
message = f'Sign in to jaeswift.xyz\nnonce: {nonce}\nexpires: {expires_iso}'
try:
pubkey_bytes = base58.b58decode(address)
vk = VerifyKey(pubkey_bytes)
# Try base58 then base64 decode for signature
sig_bytes = None
try:
sig_bytes = base58.b58decode(signature_b58)
except Exception:
import base64
sig_bytes = base64.b64decode(signature_b58)
vk.verify(message.encode('utf-8'), sig_bytes)
except BadSignatureError:
return jsonify({'error': 'Signature verification failed'}), 401
except Exception as e:
return jsonify({'error': f'Signature decode error: {e}'}), 400
# Consume nonce
_NONCES.pop(address, None)
# Compute tier based on live SOL balance
balance = fetch_sol_balance(address)
tier = compute_tier(address, balance)
payload = {
'address': address,
'tier': tier,
'iat': int(time.time()),
'exp': int(time.time()) + JWT_EXPIRY_HOURS * 3600,
}
token = jwt.encode(payload, get_jwt_secret(), algorithm=JWT_ALGO)
resp = make_response(jsonify({
'authenticated': True,
'address': address,
'tier': tier,
'balance_sol': round(balance, 4),
'model': pick_model(tier),
'expires_in': JWT_EXPIRY_HOURS * 3600,
}))
resp.set_cookie(
COOKIE_NAME, token,
max_age=JWT_EXPIRY_HOURS * 3600,
httponly=True,
secure=True,
samesite='Lax',
path='/',
)
return resp
@auth_bp.route('/whoami', methods=['GET'])
def whoami():
sess = read_session()
if not sess:
return jsonify({
'authenticated': False,
'address': None,
'tier': 'anonymous',
'model': pick_model('anonymous'),
'balance_sol': 0,
'holdings': {},
})
address = sess.get('address')
# Re-fetch balance (cached) to refresh tier on the fly
balance = fetch_sol_balance(address) if address else 0.0
tier = compute_tier(address, balance)
return jsonify({
'authenticated': True,
'address': address,
'tier': tier,
'model': pick_model(tier),
'balance_sol': round(balance, 4),
'holdings': {'sol': round(balance, 4)},
'expires_at': sess.get('exp'),
})
@auth_bp.route('/logout', methods=['POST'])
def logout():
resp = make_response(jsonify({'ok': True}))
resp.set_cookie(COOKIE_NAME, '', expires=0, path='/', secure=True, httponly=True, samesite='Lax')
return resp

View file

@ -1,6 +1,26 @@
{
"site": "jaeswift.xyz",
"entries": [
{
"version": "1.40.0",
"date": "20/04/2026",
"category": "FEATURE",
"title": "Agentic JAE-AI Chat \u2014 wallet auth + 10 tools + tiered model routing",
"changes": [
"JAE-AI homepage chat is now fully agentic \u2014 frontend routes to new POST /api/agent/chat which runs a Venice tool-calling loop (max 8 iterations) with tiered model selection",
"Tiered model routing: anonymous/operator visitors get zai-org-glm-4.7-flash ($0.125/$0.50 per M); Elite tier (\u22651 SOL held or $JAE holder) + admin get kimi-k2-thinking ($0.75/$3.20 per M) with deeper reasoning budget",
"Wallet-based auth: new POST /api/auth/nonce \u2192 POST /api/auth/verify flow using Ed25519 signatures (PyNaCl + base58) over a per-session 16-byte hex nonce with 5-min TTL; success issues HS256 JWT in HttpOnly Secure SameSite=Lax cookie (jae_session, 24h)",
"GET /api/auth/whoami returns {authenticated, address, tier, balance_sol, holdings}; POST /api/auth/logout clears cookie; JAE_JWT_SECRET auto-generated via secrets.token_hex(32) into apikeys.json on first start",
"Tier ladder: anonymous \u2192 operator (authenticated) \u2192 elite (\u22651 SOL) \u2192 admin (hardcoded wallet allowlist); tier computed live at /whoami using Helius RPC balance query",
"10 tools wired into the registry (api/agent_tools.py): search_site, get_sol_price, get_crypto_price, search_contraband, search_awesomelist, get_sitrep, lookup_sol_domain (Bonfida SNS), wallet_xray (Helius RPC), get_changelog, trigger_effect (frontend action)",
"Per-tool rate limits (token-bucket in-memory) keyed by (wallet_or_IP, tool) \u2014 e.g. 10/min for wallet_xray, 60/min for price fetches, 20/min for searches; 30s exec timeout per tool; 30s TTL cache on crypto prices",
"Hard $30/month output-token budget guard tracked in api/data/agent_budget.json \u2014 refuses with 'Monthly agent budget exceeded' once ceiling hit",
"Frontend chat (js/chat.js, 564 lines) rewritten: tier badge in chat header (ANONYMOUS / OPERATOR \ud83c\udf96\ufe0f / ELITE \u2b50 / ADMIN \ud83d\udee0\ufe0f, Elite has gold pulse animation), tool call cards with \u2705/\u274c status + expandable JSON details, typewriter animation on final reply, hooks window.__jaeEffects.toggle() on trigger_effect results",
"Wallet sign-in handshake: on wallet-connected event, chat.js fetches nonce, calls provider.signMessage (supports legacy + Wallet Standard), base58-encodes signature, posts /verify; all in-browser with zero extra deps (minimal bs58 encoder inlined)",
"Existing POST /api/chat (Shiro bot + CLI casual chat) left fully intact \u2014 agentic path is additive",
"New files: api/auth_routes.py (~250 lines), api/agent_tiers.py (~50 lines), api/agent_tools.py (539 lines), api/agent_routes.py (321 lines), css/agent-chat.css (145 lines); MOD api/app.py (+15 blueprint registration), api/requirements.txt (+PyJWT, PyNaCl, base58), index.html (+1 stylesheet link), js/chat.js (410\u2192564 lines)"
]
},
{
"version": "1.39.0",
"date": "20/04/2026",

View file

@ -2,3 +2,5 @@ flask
flask-cors
PyJWT
requests
PyNaCl
base58

145
css/agent-chat.css Normal file
View file

@ -0,0 +1,145 @@
/* ===================================================
JAESWIFT.XYZ Agentic chat UI (tool cards + tiers)
=================================================== */
/* ─── Tier badge ─── */
.tier-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
margin-left: 8px;
font-family: 'VT323', 'Courier New', monospace;
font-size: 0.78rem;
letter-spacing: 0.08em;
border: 1px solid currentColor;
border-radius: 2px;
text-transform: uppercase;
background: rgba(0, 0, 0, 0.35);
vertical-align: middle;
transition: all 0.3s ease;
user-select: none;
}
.tier-badge.tier-anonymous { color: #888; }
.tier-badge.tier-operator { color: #00cc33; box-shadow: 0 0 6px rgba(0, 204, 51, 0.3); }
.tier-badge.tier-elite {
color: #ffd700;
box-shadow: 0 0 10px rgba(255, 215, 0, 0.45);
animation: tier-elite-pulse 3s ease-in-out infinite;
}
.tier-badge.tier-admin { color: #ff3355; box-shadow: 0 0 8px rgba(255, 51, 85, 0.4); }
@keyframes tier-elite-pulse {
0%, 100% { box-shadow: 0 0 8px rgba(255, 215, 0, 0.35); }
50% { box-shadow: 0 0 16px rgba(255, 215, 0, 0.7); }
}
/* ─── Agent tool-call card ─── */
.agent-tool-call {
font-family: 'Courier New', 'Consolas', monospace;
font-size: 0.82rem;
color: #00ff66;
background: rgba(0, 20, 0, 0.4);
border-left: 2px solid #00cc33;
padding: 6px 10px;
margin: 6px 0 6px 18px;
border-radius: 2px;
max-width: calc(100% - 36px);
overflow-x: auto;
animation: tool-card-slide-in 0.25s ease-out;
}
@keyframes tool-card-slide-in {
from { opacity: 0; transform: translateX(-6px); }
to { opacity: 1; transform: translateX(0); }
}
.agent-tool-call .atc-header {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 6px;
color: #00ff99;
}
.agent-tool-call .atc-icon { font-size: 0.9rem; }
.agent-tool-call .atc-name {
font-weight: bold;
color: #66ffaa;
text-shadow: 0 0 4px rgba(0, 255, 100, 0.4);
}
.agent-tool-call .atc-args {
color: #55cc77;
font-size: 0.78rem;
overflow-wrap: anywhere;
max-width: 100%;
}
.agent-tool-call .atc-status {
color: #33dd66;
margin-top: 2px;
font-size: 0.8rem;
}
.agent-tool-call .atc-details {
margin-top: 2px;
}
.agent-tool-call .atc-details summary {
cursor: pointer;
color: #22aa44;
list-style: none;
outline: none;
font-size: 0.78rem;
}
.agent-tool-call .atc-details summary::-webkit-details-marker { display: none; }
.agent-tool-call .atc-details[open] summary::after { content: ' ▼'; }
.agent-tool-call .atc-details summary::after { content: ' ▶'; }
.agent-tool-call .atc-details pre {
margin: 4px 0 0;
padding: 6px 8px;
background: rgba(0, 0, 0, 0.6);
border: 1px dashed rgba(0, 204, 51, 0.3);
color: #88eeaa;
font-size: 0.74rem;
white-space: pre-wrap;
word-break: break-word;
max-height: 280px;
overflow-y: auto;
}
/* Agent reply styling tweak */
.chat-msg.chat-msg-agent .chat-msg-body {
position: relative;
}
.chat-msg.chat-msg-agent[data-model]:not([data-model=''])::after {
content: attr(data-model);
display: block;
font-family: 'VT323', monospace;
font-size: 0.65rem;
letter-spacing: 0.08em;
color: #446655;
text-transform: uppercase;
margin-top: 2px;
margin-left: 48px;
opacity: 0.6;
}
/* Locked CTA style (for tier-gated messages) */
.agent-locked {
border-left-color: #ff9933;
color: #ffaa55;
background: rgba(40, 20, 0, 0.4);
}
.agent-locked .atc-name { color: #ffcc77; }
/* Mobile responsiveness */
@media (max-width: 600px) {
.agent-tool-call {
margin-left: 6px;
font-size: 0.75rem;
}
.tier-badge {
font-size: 0.7rem;
padding: 1px 6px;
}
.agent-tool-call .atc-args {
font-size: 0.72rem;
}
}

View file

@ -8,6 +8,7 @@
<link rel="stylesheet" href="/css/chat-memory.css">
<link rel="stylesheet" href="/css/scan-visitor.css">
<link rel="stylesheet" href="/css/chat-cli.css">
<link rel="stylesheet" href="/css/agent-chat.css">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;500;600;700&family=Orbitron:wght@400;500;600;700;800;900&family=Share+Tech+Mono&display=swap" rel="stylesheet">

View file

@ -1,6 +1,6 @@
/* ===================================================
JAESWIFT.XYZ JAE-AI Chat Terminal
Venice API chat interface + Memoria-style memory
JAESWIFT.XYZ JAE-AI Agentic Chat Terminal
Tiered model routing + tool calls + wallet auth
=================================================== */
(function () {
@ -18,8 +18,17 @@
let history = [];
let isWaiting = false;
let currentTier = 'anonymous';
let currentAddress = null;
// ─── Render a message bubble ───
// ─── Utility ──────────────────────────────────────
function escapeHtml(s) {
return String(s || '').replace(/[&<>"']/g, function (c) {
return ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c];
});
}
// ─── Render a message bubble ──────────────────────
function addMessage(role, text) {
const welcome = chatMessages.querySelector('.chat-welcome');
if (welcome) welcome.remove();
@ -42,7 +51,36 @@
return body;
}
// ─── Typing indicator ───
function addToolCallCard(call) {
const wrap = document.createElement('div');
wrap.className = 'agent-tool-call';
const argsStr = escapeHtml(JSON.stringify(call.args || {}, null, 0));
const result = call.result || {};
const errored = !!result.error;
const statusIcon = errored ? '❌' : '✅';
let summary = errored ? (result.error || 'error') : 'ok';
if (!errored) {
if (Array.isArray(result.results)) summary = `${result.results.length} result${result.results.length === 1 ? '' : 's'}`;
else if (Array.isArray(result.entries)) summary = `${result.entries.length} entries`;
else if (typeof result.price_usd === 'number') summary = `$${result.price_usd.toFixed(2)} (${result.change_24h_pct > 0 ? '+' : ''}${(result.change_24h_pct || 0).toFixed(2)}%)`;
else if (result.effect) summary = `effect: ${result.effect}`;
else if (result.balance_sol !== undefined) summary = `${result.balance_sol} SOL`;
}
const resultStr = escapeHtml(JSON.stringify(result, null, 2).slice(0, 1400));
wrap.innerHTML = `
<div class="atc-header">
<span class="atc-icon">🔧</span>
<span class="atc-name">${escapeHtml(call.name)}</span>
<span class="atc-args">(${argsStr})</span>
</div>
<div class="atc-status"> ${statusIcon} ${escapeHtml(summary)}</div>
<details class="atc-details"><summary> (click to expand)</summary><pre>${resultStr}</pre></details>
`;
chatMessages.appendChild(wrap);
chatMessages.scrollTop = chatMessages.scrollHeight;
}
// ─── Typing indicator ─────────────────────────────
function showTyping() {
const indicator = document.createElement('div');
indicator.className = 'chat-msg chat-msg-assistant chat-typing-indicator';
@ -50,23 +88,25 @@
indicator.innerHTML = `
<span class="chat-msg-label">JAE-AI</span>
<div class="chat-msg-body">
<span class="typing-dot"></span>
<span class="typing-dot"></span>
<span class="typing-dot"></span>
</div>
`;
<span class="typing-dot"></span><span class="typing-dot"></span><span class="typing-dot"></span>
</div>`;
chatMessages.appendChild(indicator);
chatMessages.scrollTop = chatMessages.scrollHeight;
}
function hideTyping() {
const el = document.getElementById('chatTyping');
if (el) el.remove();
}
// ─── Typewriter effect ───
// ─── Typewriter effect ────────────────────────────
function typewriterEffect(element, text, speed) {
speed = speed || 12;
speed = speed || 10;
// If reply is too long, bypass animation
if (!text || text.length > 600) {
element.textContent = text || '';
chatMessages.scrollTop = chatMessages.scrollHeight;
return Promise.resolve();
}
let i = 0;
element.textContent = '';
return new Promise(function (resolve) {
@ -84,16 +124,13 @@
});
}
// ─── Restore previous chat history from localStorage ───
// ─── History restore ──────────────────────────────
function restoreHistory() {
if (!mem) return false;
const saved = mem.getHistory();
if (!saved || saved.length === 0) return false;
// Remove welcome and render saved history
const welcome = chatMessages.querySelector('.chat-welcome');
if (welcome) welcome.remove();
saved.forEach(function (m) {
const role = m.role === 'user' ? 'user' : 'assistant';
const label = document.createElement('span');
@ -108,26 +145,143 @@
el.appendChild(body);
chatMessages.appendChild(el);
});
// Divider showing restore
const divider = document.createElement('div');
divider.className = 'chat-divider';
divider.innerHTML = '<span>— RESTORED FROM MEMORY —</span>';
chatMessages.appendChild(divider);
chatMessages.scrollTop = chatMessages.scrollHeight;
// populate in-memory history
history = saved.slice();
return true;
}
// ─── Send message to API ───
// ─── Tier badge ───────────────────────────────────
const TIER_DISPLAY = {
anonymous: { label: 'ANONYMOUS', icon: '👁' },
operator: { label: 'OPERATOR', icon: '🎖️' },
elite: { label: 'ELITE', icon: '⭐' },
admin: { label: 'ADMIN', icon: '🛠️' },
};
function renderTierBadge() {
if (!chatHeader) return;
let badge = document.getElementById('chatTierBadge');
if (!badge) {
badge = document.createElement('span');
badge.id = 'chatTierBadge';
badge.className = 'tier-badge';
chatHeader.appendChild(badge);
}
const info = TIER_DISPLAY[currentTier] || TIER_DISPLAY.anonymous;
badge.className = 'tier-badge tier-' + currentTier;
badge.textContent = info.icon + ' ' + info.label;
badge.title = currentAddress ? (currentAddress.slice(0, 4) + '…' + currentAddress.slice(-4)) : 'Not authenticated';
}
// ─── Wallet auth handshake ────────────────────────
async function refreshWhoAmI() {
try {
const r = await fetch('/api/auth/whoami', { credentials: 'same-origin' });
if (!r.ok) return;
const j = await r.json();
currentTier = j.tier || 'anonymous';
currentAddress = j.address || null;
renderTierBadge();
} catch (e) { /* silent */ }
}
async function walletSignIn(address, provider) {
if (!address || !provider) return;
try {
// 1. Get nonce
const nr = await fetch('/api/auth/nonce', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ address }),
});
if (!nr.ok) { console.warn('nonce fetch failed'); return; }
const { message, nonce } = await nr.json();
// 2. Sign message
const encoded = new TextEncoder().encode(message);
let sigBytes;
if (typeof provider.signMessage === 'function') {
const res = await provider.signMessage(encoded, 'utf8');
sigBytes = res && (res.signature || res);
} else if (provider.features && provider.features['solana:signMessage']) {
const feat = provider.features['solana:signMessage'];
const res = await feat.signMessage({ message: encoded });
sigBytes = Array.isArray(res) ? res[0].signature : res.signature;
} else {
console.warn('wallet does not support signMessage');
return;
}
if (!sigBytes) return;
// sigBytes -> base58
const sigB58 = bs58encode(sigBytes);
// 3. Verify
const vr = await fetch('/api/auth/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify({ address, signature: sigB58, nonce }),
});
if (!vr.ok) {
const err = await vr.json().catch(() => ({}));
console.warn('verify failed:', err);
return;
}
const j = await vr.json();
currentTier = j.tier || 'operator';
currentAddress = j.address || address;
renderTierBadge();
const info = TIER_DISPLAY[currentTier];
addMessage('assistant', `🔓 Wallet verified. Tier: ${info.icon} ${info.label}. Model: ${j.model || 'default'}.`);
} catch (e) {
console.warn('walletSignIn error', e);
}
}
// Minimal bs58 encoder (no external deps)
const BS58_ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
function bs58encode(bytes) {
if (!bytes) return '';
if (!(bytes instanceof Uint8Array)) bytes = new Uint8Array(bytes);
const digits = [0];
for (let i = 0; i < bytes.length; i++) {
let carry = bytes[i];
for (let j = 0; j < digits.length; j++) {
carry += digits[j] << 8;
digits[j] = carry % 58;
carry = (carry / 58) | 0;
}
while (carry > 0) { digits.push(carry % 58); carry = (carry / 58) | 0; }
}
let str = '';
for (let k = 0; k < bytes.length && bytes[k] === 0; k++) str += '1';
for (let q = digits.length - 1; q >= 0; q--) str += BS58_ALPHABET[digits[q]];
return str;
}
// Listen to wallet-connect events
window.addEventListener('wallet-connected', function (e) {
const detail = e.detail || {};
const addr = detail.address || (window.solWallet && window.solWallet.address);
const provider = detail.provider || (window.solWallet && window.solWallet.provider);
if (addr && provider) walletSignIn(addr, provider);
});
window.addEventListener('wallet-disconnected', async function () {
try { await fetch('/api/auth/logout', { method: 'POST', credentials: 'same-origin' }); } catch (e) {}
currentTier = 'anonymous';
currentAddress = null;
renderTierBadge();
});
// ─── Send message to agent ────────────────────────
async function sendMessage() {
const text = chatInput.value.trim();
if (!text || isWaiting) return;
// ─── CLI interception ───
// CLI interception (slash commands, or nested CLI mode)
const inCliMode = window.__jaeCLI && typeof window.__jaeCLI.isInMode === 'function' && window.__jaeCLI.isInMode();
if ((text.startsWith('/') || inCliMode) && window.__jaeCLI && typeof window.__jaeCLI.handle === 'function') {
isWaiting = true;
@ -168,25 +322,29 @@
showTyping();
// Build memory context block
// Build conversation window (last 20 exchanges)
const convo = history.slice(-20).map(function (m) {
return { role: m.role, content: m.content };
});
const memoryBlock = mem ? mem.getContextBlock(text) : '';
try {
const resp = await fetch('/api/chat', {
const resp = await fetch('/api/agent/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify({
message: text,
history: history.slice(0, -1),
memory_context: memoryBlock
})
messages: convo,
memory_context: memoryBlock,
}),
});
hideTyping();
if (!resp.ok) {
const err = await resp.json().catch(function () { return {}; });
addMessage('assistant', 'ERROR: ' + (err.error || 'Connection failed'));
addMessage('assistant', 'ERROR: ' + (err.error || ('HTTP ' + resp.status)));
chatStatus.textContent = '● ERROR';
chatStatus.classList.remove('status-amber');
chatStatus.classList.add('status-red');
@ -195,21 +353,37 @@
}
const data = await resp.json();
const reply = data.reply || 'No response received.';
// Update tier from response
if (data.tier) { currentTier = data.tier; renderTierBadge(); }
// Render tool calls
if (Array.isArray(data.tool_calls)) {
data.tool_calls.forEach(addToolCallCard);
}
const reply = data.content || '(no reply)';
history.push({ role: 'assistant', content: reply });
if (mem) mem.appendHistory({ role: 'assistant', content: reply });
if (history.length > 40) history = history.slice(-30);
const bodyEl = addMessage('assistant', '');
await typewriterEffect(bodyEl, reply, 10);
bodyEl.parentElement.classList.add('chat-msg-agent');
bodyEl.parentElement.setAttribute('data-model', data.model_used || '');
await typewriterEffect(bodyEl, reply, 8);
// Execute frontend actions (effects etc.)
if (Array.isArray(data.frontend_actions)) {
data.frontend_actions.forEach(function (act) {
if (act.action === 'trigger_effect' && window.__jaeEffects && typeof window.__jaeEffects.toggle === 'function') {
try { window.__jaeEffects.toggle(act.effect); } catch (e) { console.warn('effect toggle failed', e); }
}
});
}
// Trigger memory extraction every N user messages (non-blocking)
if (mem) {
mem.incrementUserMsgAndMaybeExtract().catch(function () { /* silent */ });
}
} catch (e) {
hideTyping();
addMessage('assistant', 'ERROR: Network failure — ' + e.message);
@ -225,7 +399,7 @@
chatInput.focus();
}
// ─── Memory modal ───
// ─── Memory modal ─────────────────────────────────
function formatDate(ts) {
try { return new Date(ts).toLocaleString('en-GB'); } catch (e) { return ''; }
}
@ -282,67 +456,47 @@
<button class="mem-btn mem-btn-clearhist" id="memClearHist">🗑 CLEAR HISTORY</button>
<button class="mem-btn mem-btn-clear" id="memClear"> CLEAR ALL MEMORIES</button>
</div>
</div>
`;
</div>`;
document.body.appendChild(modal);
document.getElementById('memClose').addEventListener('click', function () { modal.remove(); });
modal.addEventListener('click', function (e) {
if (e.target === modal) modal.remove();
});
modal.addEventListener('click', function (e) { if (e.target === modal) modal.remove(); });
modal.querySelectorAll('.mem-del').forEach(function (btn) {
btn.addEventListener('click', function () {
const id = btn.getAttribute('data-id');
mem.remove(id);
renderMemoryModal(); renderMemoryModal(); // toggle off/on to refresh
renderMemoryModal(); renderMemoryModal();
});
});
document.getElementById('memExport').addEventListener('click', function () {
const blob = new Blob([mem.exportJSON()], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'jae-ai-memory-' + Date.now() + '.json';
a.click();
a.href = url; a.download = 'jae-ai-memory-' + Date.now() + '.json'; a.click();
setTimeout(function () { URL.revokeObjectURL(url); }, 1000);
});
document.getElementById('memExtract').addEventListener('click', function () {
const btn = document.getElementById('memExtract');
btn.disabled = true;
btn.textContent = '⌛ EXTRACTING...';
btn.disabled = true; btn.textContent = '⌛ EXTRACTING...';
mem.extractFromRecentChat().then(function () {
renderMemoryModal(); renderMemoryModal();
}).catch(function () {
btn.textContent = '✖ EXTRACT FAILED';
});
}).catch(function () { btn.textContent = '✖ EXTRACT FAILED'; });
});
document.getElementById('memClearHist').addEventListener('click', function () {
if (confirm('Clear chat history? Memories will be kept.')) {
mem.clearHistory();
alert('Chat history cleared. Reload the page.');
mem.clearHistory(); alert('Chat history cleared. Reload the page.');
}
});
document.getElementById('memClear').addEventListener('click', function () {
if (confirm('PERMANENTLY DELETE ALL MEMORIES? This cannot be undone.')) {
mem.clear();
renderMemoryModal(); renderMemoryModal();
mem.clear(); renderMemoryModal(); renderMemoryModal();
}
});
}
function escapeHtml(s) {
return String(s || '').replace(/[&<>"']/g, function (c) {
return ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c];
});
}
// ─── Inject memory button + privacy info into chat header ───
// ─── Header UI injection ──────────────────────────
function injectMemoryUI() {
if (!chatHeader) return;
if (document.getElementById('memBtn')) return;
@ -366,7 +520,6 @@
chatHeader.appendChild(info);
chatHeader.appendChild(btn);
updateMemCount();
}
@ -375,36 +528,37 @@
if (el && mem) el.textContent = mem.getAll().length;
}
// ─── Event listeners ───
// ─── Event listeners ──────────────────────────────
chatSend.addEventListener('click', sendMessage);
chatInput.addEventListener('keydown', function (e) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); }
});
// ─── Init ───
// ─── Init ─────────────────────────────────────────
injectMemoryUI();
// Update memory count periodically
renderTierBadge();
refreshWhoAmI();
setInterval(updateMemCount, 5000);
const restored = restoreHistory();
if (!restored) {
// Auto-greeting after short delay (only if no history restored)
setTimeout(function () {
showTyping();
setTimeout(function () {
hideTyping();
var greeting = 'Welcome to JAESWIFT.XYZ — I\'m JAE-AI, your onboard guide. Ask me anything about this system, or try saying "what can I explore here?"';
const greeting = 'Welcome to JAESWIFT.XYZ — I\'m JAE-AI. I can now search the site, fetch crypto prices, scan Solana wallets, look up .sol domains, pull SITREPs, and trigger visual effects. Connect your wallet for the Elite tier (1+ SOL) with Kimi-K2-Thinking. Ask me anything.';
history.push({ role: 'assistant', content: greeting });
if (mem) mem.appendHistory({ role: 'assistant', content: greeting });
var bodyEl = addMessage('assistant', '');
typewriterEffect(bodyEl, greeting, 15);
const bodyEl = addMessage('assistant', '');
typewriterEffect(bodyEl, greeting, 10);
}, 1500);
}, 2000);
}
// Expose minimal API
window.__jaeChat = {
refreshWhoAmI: refreshWhoAmI,
getTier: function () { return currentTier; },
getAddress: function () { return currentAddress; },
};
})();