solveforce_phone_one.py


SolveForce Phone One — phone-first gateway with JSONL export (read-only)


#!/usr/bin/env python3
# SolveForce Phone One — Step One: JSONL export toggle (read-only)
# MIT © 2025 Ronald Joseph Legarski, Jr. — Published by SolveForce
#
# Additions over "Phone Zero":
# - --export (enable JSONL export, ClickHouse-ready JSONEachRow)
# - --export-dir <path> (default: ./exports)
# - Each plugin read (manual or via --poll) writes one JSON row:
#   {"plugin":"battery","_ts":"...","data":{...}}
# - /health now reports export status and counters
# - /metrics exposes export counters

import os, sys, argparse, json, time, threading, shutil, subprocess
from http.server import BaseHTTPRequestHandler, HTTPServer
from urllib.parse import urlparse, parse_qs
from datetime import datetime, timezone

STARTED = time.time()

def now_utc_iso() -> str:
    return datetime.now(timezone.utc).isoformat()

# ---------- Export config & helpers ----------
EXPORT_ENABLED = False
EXPORT_DIR = "exports"
EXPORT_WRITES = 0
EXPORT_ERRORS = 0
EXPORT_OK = True
EXPORT_NOTE = ""

def _ensure_export_dir():
    global EXPORT_OK, EXPORT_NOTE
    try:
        os.makedirs(EXPORT_DIR, exist_ok=True)
        # writability test
        testp = os.path.join(EXPORT_DIR, "._writetest")
        with open(testp, "w", encoding="utf-8") as f:
            f.write("ok\n")
        os.remove(testp)
        EXPORT_OK = True
        EXPORT_NOTE = "ready"
    except Exception as e:
        EXPORT_OK = False
        EXPORT_NOTE = f"unwritable: {e}"

def write_jsonl(plugin: str, data: dict):
    global EXPORT_WRITES, EXPORT_ERRORS, EXPORT_OK
    if not EXPORT_ENABLED: 
        return
    if not EXPORT_OK:
        return
    try:
        row = {"plugin": plugin, "_ts": now_utc_iso(), "data": data}
        path = os.path.join(EXPORT_DIR, f"{plugin}.jsonl")
        with open(path, "a", encoding="utf-8") as f:
            f.write(json.dumps(row, ensure_ascii=False) + "\n")
        EXPORT_WRITES += 1
    except Exception as e:
        EXPORT_ERRORS += 1

# ---------- Minimal plugin framework ----------

class Plugin:
    NAME = "plugin"
    def __init__(self, **kwargs): pass
    def read(self):
        """Return a JSON-serializable dict (read-only)."""
        return {"ok": True}

def have_cmd(cmd: str) -> bool:
    return shutil.which(cmd) is not None

def run_json_cmd(args, timeout=3):
    try:
        out = subprocess.check_output(args, timeout=timeout, stderr=subprocess.STDOUT)
        txt = out.decode("utf-8", "replace").strip()
        return json.loads(txt)
    except Exception as e:
        return {"error": str(e), "args": args}

class BatteryTermux(Plugin):
    NAME = "battery"
    def read(self):
        # Requires Termux:API 'termux-battery-status' command; otherwise returns unavailable
        if not have_cmd("termux-battery-status"):
            return {"available": False, "note": "termux-battery-status not found"}
        data = run_json_cmd(["termux-battery-status"], timeout=2)
        data["available"] = "error" not in data
        return data

class NetIfacesLite(Plugin):
    NAME = "net"
    def read(self):
        # Try to gather basic IPs using `ip addr` (Termux) or `getprop` as fallback
        if have_cmd("ip"):
            try:
                txt = subprocess.check_output(["ip", "-o", "-4", "addr", "show"], timeout=2).decode("utf-8","replace")
                v4 = []
                for line in txt.splitlines():
                    parts = line.split()
                    if len(parts) >= 4:
                        iface = parts[1]
                        cidr = parts[3]
                        v4.append({"iface": iface, "cidr": cidr})
                return {"available": True, "ipv4": v4}
            except Exception as e:
                return {"available": False, "error": str(e)}
        # Fallback to Android getprop for basic hints
        if have_cmd("getprop"):
            try:
                wifi_ip = subprocess.check_output(["getprop", "dhcp.wlan0.ipaddress"], timeout=2).decode().strip()
                data = {"available": True, "wifi_ip": wifi_ip}
                return data
            except Exception as e:
                return {"available": False, "error": str(e)}
        return {"available": False, "note": "no ip/getprop available"}

BUILTIN_PLUGINS = [BatteryTermux, NetIfacesLite]

class Registry:
    def __init__(self):
        self.plugins = {cls.NAME: cls() for cls in BUILTIN_PLUGINS}
        self.latest = {}
        self.lock = threading.Lock()
    def names(self):
        return list(self.plugins.keys())
    def read_one(self, name):
        p = self.plugins.get(name)
        if not p: 
            return {"error": f"unknown plugin '{name}'"}
        res = p.read()
        entry = {"plugin": name, "_ts": now_utc_iso(), "data": res}
        with self.lock:
            self.latest[name] = entry
        write_jsonl(name, res)  # step one: export on read
        return entry
    def read_all(self):
        out = {}
        for n in self.names():
            out[n] = self.read_one(n)  # each call exports if enabled
        return out
    def state(self):
        with self.lock:
            return {"plugins": self.names(), "latest": dict(self.latest)}

REG = Registry()

# ---------- Optional background poller ----------

class Poller(threading.Thread):
    def __init__(self, interval_sec: int):
        super().__init__(daemon=True); self.interval = max(0, int(interval_sec)); self.stop_evt = threading.Event()
    def run(self):
        if self.interval <= 0: return
        while not self.stop_evt.is_set():
            try:
                REG.read_all()  # exports included
            except Exception:
                pass
            self.stop_evt.wait(self.interval)
    def stop(self): self.stop_evt.set()

# ---------- Mobile UI (root interface) ----------

MOBILE_UI = """<!doctype html>
<html><head><meta charset="utf-8"/><meta name="viewport" content="width=device-width,initial-scale=1"/>
<title>SolveForce Phone — Step One</title>
<style>
  body{font-family:system-ui,Segoe UI,Roboto,Helvetica,Arial,sans-serif;margin:1rem;line-height:1.4}
  header{font-weight:700;margin-bottom:.8rem}
  .row{display:flex;gap:.5rem;flex-wrap:wrap}
  button{padding:.6rem 1rem;border-radius:.6rem;border:1px solid #888;background:#fff}
  .card{border:1px solid #ccc;border-radius:.8rem;padding:1rem;margin:.6rem 0}
  code,pre{font-family:ui-monospace,Consolas,Menlo,monospace}
  .mono{white-space:pre-wrap}
  input[type=text]{padding:.5rem;border:1px solid #aaa;border-radius:.5rem;min-width:240px}
</style></head>
<body>
<header>📱 SolveForce Phone — Step One (JSONL)</header>

<div class="row">
  <button onclick="hit('/health')">/health</button>
  <button onclick="hit('/state')">/state</button>
  <button onclick="hit('/read?plugin=battery')">battery</button>
  <button onclick="hit('/read?plugin=net')">net</button>
  <button onclick="hit('/metrics', true)">/metrics</button>
</div>

<div class="card">
  <form onsubmit="tx();return false;">
    <label>Translate:</label>
    <input id="q" value="$199.99+VAT=€210≈"/>
    <button>Go</button>
  </form>
</div>

<div id="out" class="card mono">Ready.</div>

<script>
async function hit(path, raw){ const r=await fetch(path); const t=raw? await r.text(): await r.json(); show(t); }
async function tx(){ const q=document.getElementById('q').value; const r=await fetch('/translate?q='+encodeURIComponent(q)); show(await r.json()); }
function show(x){ const el=document.getElementById('out'); el.textContent = (typeof x==='string')?x:JSON.stringify(x,null,2); }
hit('/health');
</script>
</body></html>
"""

# ---------- Translator (operators + currencies) ----------
MATH_OPS = {
    "+": "+", "+": "+",
    "-": "-", "−": "-", "–": "-", "—": "-",
    "×": "*", "∙": "*", "·": "*", "⋅": "*", "*": "*",
    "÷": "/", "∕": "/", "/": "/",
    "=": "=", "≈": "~", "≃": "~", "≅": "~",
    "<": "<", "≤": "<=", "⩽": "<=",
    ">": ">", "≥": ">=", "⩾": ">=",
    "^": "^", "%": "%"
}
CURRENCIES = {
  "A$": "AUD", "C$": "CAD", "NZ$": "NZD", "HK$": "HKD", "S$": "SGD", "R$": "BRL", "MX$": "MXN",
  "$": "USD", "€": "EUR", "£": "GBP", "¥": "JPY", "₩": "KRW", "₹": "INR", "₽": "RUS", "₺": "TRY",
  "₫": "VND", "₦": "NGN", "₱": "PHP", "฿": "THB", "₵": "GHS", "₡": "CRC", "₭": "LAK",
  "₴": "UAH", "₲": "PYG", "₮": "MNT", "₼": "AZN", "₸": "KZT", "₾": "GEL", "₿": "BTC"
}
CURR_KEYS = sorted(CURRENCIES.keys(), key=lambda k: len(k), reverse=True)

def translate_query(q: str):
    i=0; tokens=[]; out=[]
    while i < len(q):
        matched=False
        for key in CURR_KEYS:
            if q.startswith(key, i):
                iso=CURRENCIES[key]; tokens.append({"type":"currency","raw":key,"iso":iso})
                out.append(f"<{iso}>"); i+=len(key); matched=True; break
        if matched: continue
        ch=q[i]
        if ch in MATH_OPS:
            canon=MATH_OPS[ch]; tokens.append({"type":"operator","raw":ch,"op":canon}); out.append(canon); i+=1; continue
        if ch.isdigit() or (ch=="." and i+1<len(q) and q[i+1].isdigit()):
            j=i+1
            while j<len(q) and (q[j].isdigit() or q[j] in ".,_"): j+=1
            num=q[i:j]; tokens.append({"type":"number","raw":num}); out.append(num.replace(",","")); i=j; continue
        if ch.isalpha():
            j=i+1
            while j<len(q) and (q[j].isalpha() or q[j] in "-_/"): j+=1
            word=q[i:j]; tokens.append({"type":"text","raw":word}); out.append(word); i=j; continue
        out.append(ch); i+=1
    return {"input": q, "normalized": "".join(out), "tokens": tokens}

# ---------- HTTP surface ----------

MANAGER_POLL = None

class Handler(BaseHTTPRequestHandler):
    server_version = "SolveForcePhoneOne/0.1"
    def _send(self, code, payload, ct="application/json; charset=utf-8"):
        data = payload if isinstance(payload, str) else json.dumps(payload, ensure_ascii=False, separators=(",",":"))
        body = data.encode("utf-8")
        self.send_response(code); self.send_header("Content-Type", ct)
        self.send_header("Content-Length", str(len(body))); self.end_headers(); self.wfile.write(body)
    def log_message(self, fmt, *args): return
    def do_GET(self):
        parsed = urlparse(self.path)
        if parsed.path == "/ui":
            self._send(200, MOBILE_UI, ct="text/html; charset=utf-8"); return
        if parsed.path == "/ping":
            self._send(200, {"pong": True, "time": now_utc_iso()}); return
        if parsed.path == "/health":
            self._send(200, {
                "status":"ok",
                "time":now_utc_iso(),
                "uptime_sec": int(time.time()-STARTED),
                "export": {"enabled": EXPORT_ENABLED, "dir": EXPORT_DIR, "ok": EXPORT_OK, "note": EXPORT_NOTE, "writes": EXPORT_WRITES, "errors": EXPORT_ERRORS}
            }); 
            return
        if parsed.path == "/state":
            self._send(200, REG.state()); return
        if parsed.path == "/read":
            name = parse_qs(parsed.query).get("plugin", [""])[0]
            if name == "all":
                self._send(200, REG.read_all()); return
            self._send(200, REG.read_one(name)); return
        if parsed.path == "/translate":
            q = parse_qs(parsed.query).get("q", [""])[0]
            self._send(200, translate_query(q)); return
        if parsed.path == "/metrics":
            up = int(time.time() - STARTED)
            names = REG.names()
            lines = [f"solveforce_up {up}",
                     f"solveforce_plugins {len(names)}",
                     f"solveforce_export_enabled {1 if EXPORT_ENABLED else 0}",
                     f"solveforce_export_writes_total {EXPORT_WRITES}",
                     f"solveforce_export_errors_total {EXPORT_ERRORS}"]
            self._send(200, "\n".join(lines)+"\n", ct="text/plain; version=0.0.4; charset=utf-8"); return
        self._send(404, {"error":"not found","path": parsed.path})

def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--host", default="127.0.0.1")
    ap.add_argument("--port", default="8080")
    ap.add_argument("--poll", default="0", help="poll interval seconds; 0 = no background poll")
    ap.add_argument("--export", action="store_true", help="enable JSONL export")
    ap.add_argument("--export-dir", default="exports", help="export directory")
    args = ap.parse_args()

    # configure export
    global EXPORT_ENABLED, EXPORT_DIR
    EXPORT_ENABLED = bool(args.export)
    EXPORT_DIR = args.export_dir
    if EXPORT_ENABLED:
        _ensure_export_dir()

    # start poller
    poll_int = int(args.poll)
    global MANAGER_POLL
    MANAGER_POLL = Poller(interval_sec=poll_int)
    MANAGER_POLL.start()

    # HTTP
    host, port = args.host, int(args.port)
    httpd = HTTPServer((host, port), Handler)
    print(f"[SolveForce] Phone One UI → http://{host}:{port}/ui")
    try:
        httpd.serve_forever()
    except KeyboardInterrupt:
        pass
    finally:
        MANAGER_POLL.stop()
        httpd.server_close()

if __name__ == "__main__":
    main()

SolveForce Phone One — Step One: JSONL export toggle (read-only)


#!/usr/bin/env python3
# SolveForce Phone One — Step One: JSONL export toggle (read-only)
# MIT © 2025 Ronald Joseph Legarski, Jr. — Published by SolveForce
#
# Additions over "Phone Zero":
# - --export (enable JSONL export, ClickHouse-ready JSONEachRow)
# - --export-dir <path> (default: ./exports)
# - Each plugin read (manual or via --poll) writes one JSON row:
#   {"plugin":"battery","_ts":"...","data":{...}}
# - /health now reports export status and counters
# - /metrics exposes export counters

import os, sys, argparse, json, time, threading, shutil, subprocess
from http.server import BaseHTTPRequestHandler, HTTPServer
from urllib.parse import urlparse, parse_qs
from datetime import datetime, timezone

STARTED = time.time()

def now_utc_iso() -> str:
    return datetime.now(timezone.utc).isoformat()

# ---------- Export config & helpers ----------
EXPORT_ENABLED = False
EXPORT_DIR = "exports"
EXPORT_WRITES = 0
EXPORT_ERRORS = 0
EXPORT_OK = True
EXPORT_NOTE = ""

def _ensure_export_dir():
    global EXPORT_OK, EXPORT_NOTE
    try:
        os.makedirs(EXPORT_DIR, exist_ok=True)
        # writability test
        testp = os.path.join(EXPORT_DIR, "._writetest")
        with open(testp, "w", encoding="utf-8") as f:
            f.write("ok\n")
        os.remove(testp)
        EXPORT_OK = True
        EXPORT_NOTE = "ready"
    except Exception as e:
        EXPORT_OK = False
        EXPORT_NOTE = f"unwritable: {e}"

def write_jsonl(plugin: str, data: dict):
    global EXPORT_WRITES, EXPORT_ERRORS, EXPORT_OK
    if not EXPORT_ENABLED: 
        return
    if not EXPORT_OK:
        return
    try:
        row = {"plugin": plugin, "_ts": now_utc_iso(), "data": data}
        path = os.path.join(EXPORT_DIR, f"{plugin}.jsonl")
        with open(path, "a", encoding="utf-8") as f:
            f.write(json.dumps(row, ensure_ascii=False) + "\n")
        EXPORT_WRITES += 1
    except Exception as e:
        EXPORT_ERRORS += 1

# ---------- Minimal plugin framework ----------

class Plugin:
    NAME = "plugin"
    def __init__(self, **kwargs): pass
    def read(self):
        """Return a JSON-serializable dict (read-only)."""
        return {"ok": True}

def have_cmd(cmd: str) -> bool:
    return shutil.which(cmd) is not None

def run_json_cmd(args, timeout=3):
    try:
        out = subprocess.check_output(args, timeout=timeout, stderr=subprocess.STDOUT)
        txt = out.decode("utf-8", "replace").strip()
        return json.loads(txt)
    except Exception as e:
        return {"error": str(e), "args": args}

class BatteryTermux(Plugin):
    NAME = "battery"
    def read(self):
        # Requires Termux:API 'termux-battery-status' command; otherwise returns unavailable
        if not have_cmd("termux-battery-status"):
            return {"available": False, "note": "termux-battery-status not found"}
        data = run_json_cmd(["termux-battery-status"], timeout=2)
        data["available"] = "error" not in data
        return data

class NetIfacesLite(Plugin):
    NAME = "net"
    def read(self):
        # Try to gather basic IPs using `ip addr` (Termux) or `getprop` as fallback
        if have_cmd("ip"):
            try:
                txt = subprocess.check_output(["ip", "-o", "-4", "addr", "show"], timeout=2).decode("utf-8","replace")
                v4 = []
                for line in txt.splitlines():
                    parts = line.split()
                    if len(parts) >= 4:
                        iface = parts[1]
                        cidr = parts[3]
                        v4.append({"iface": iface, "cidr": cidr})
                return {"available": True, "ipv4": v4}
            except Exception as e:
                return {"available": False, "error": str(e)}
        # Fallback to Android getprop for basic hints
        if have_cmd("getprop"):
            try:
                wifi_ip = subprocess.check_output(["getprop", "dhcp.wlan0.ipaddress"], timeout=2).decode().strip()
                data = {"available": True, "wifi_ip": wifi_ip}
                return data
            except Exception as e:
                return {"available": False, "error": str(e)}
        return {"available": False, "note": "no ip/getprop available"}

BUILTIN_PLUGINS = [BatteryTermux, NetIfacesLite]

class Registry:
    def __init__(self):
        self.plugins = {cls.NAME: cls() for cls in BUILTIN_PLUGINS}
        self.latest = {}
        self.lock = threading.Lock()
    def names(self):
        return list(self.plugins.keys())
    def read_one(self, name):
        p = self.plugins.get(name)
        if not p: 
            return {"error": f"unknown plugin '{name}'"}
        res = p.read()
        entry = {"plugin": name, "_ts": now_utc_iso(), "data": res}
        with self.lock:
            self.latest[name] = entry
        write_jsonl(name, res)  # step one: export on read
        return entry
    def read_all(self):
        out = {}
        for n in self.names():
            out[n] = self.read_one(n)  # each call exports if enabled
        return out
    def state(self):
        with self.lock:
            return {"plugins": self.names(), "latest": dict(self.latest)}

REG = Registry()

# ---------- Optional background poller ----------

class Poller(threading.Thread):
    def __init__(self, interval_sec: int):
        super().__init__(daemon=True); self.interval = max(0, int(interval_sec)); self.stop_evt = threading.Event()
    def run(self):
        if self.interval <= 0: return
        while not self.stop_evt.is_set():
            try:
                REG.read_all()  # exports included
            except Exception:
                pass
            self.stop_evt.wait(self.interval)
    def stop(self): self.stop_evt.set()

# ---------- Mobile UI (root interface) ----------

MOBILE_UI = """<!doctype html>
<html><head><meta charset="utf-8"/><meta name="viewport" content="width=device-width,initial-scale=1"/>
<title>SolveForce Phone — Step One</title>
<style>
  body{font-family:system-ui,Segoe UI,Roboto,Helvetica,Arial,sans-serif;margin:1rem;line-height:1.4}
  header{font-weight:700;margin-bottom:.8rem}
  .row{display:flex;gap:.5rem;flex-wrap:wrap}
  button{padding:.6rem 1rem;border-radius:.6rem;border:1px solid #888;background:#fff}
  .card{border:1px solid #ccc;border-radius:.8rem;padding:1rem;margin:.6rem 0}
  code,pre{font-family:ui-monospace,Consolas,Menlo,monospace}
  .mono{white-space:pre-wrap}
  input[type=text]{padding:.5rem;border:1px solid #aaa;border-radius:.5rem;min-width:240px}
</style></head>
<body>
<header>📱 SolveForce Phone — Step One (JSONL)</header>

<div class="row">
  <button onclick="hit('/health')">/health</button>
  <button onclick="hit('/state')">/state</button>
  <button onclick="hit('/read?plugin=battery')">battery</button>
  <button onclick="hit('/read?plugin=net')">net</button>
  <button onclick="hit('/metrics', true)">/metrics</button>
</div>

<div class="card">
  <form onsubmit="tx();return false;">
    <label>Translate:</label>
    <input id="q" value="$199.99+VAT=€210≈"/>
    <button>Go</button>
  </form>
</div>

<div id="out" class="card mono">Ready.</div>

<script>
async function hit(path, raw){ const r=await fetch(path); const t=raw? await r.text(): await r.json(); show(t); }
async function tx(){ const q=document.getElementById('q').value; const r=await fetch('/translate?q='+encodeURIComponent(q)); show(await r.json()); }
function show(x){ const el=document.getElementById('out'); el.textContent = (typeof x==='string')?x:JSON.stringify(x,null,2); }
hit('/health');
</script>
</body></html>
"""

# ---------- Translator (operators + currencies) ----------
MATH_OPS = {
    "+": "+", "+": "+",
    "-": "-", "−": "-", "–": "-", "—": "-",
    "×": "*", "∙": "*", "·": "*", "⋅": "*", "*": "*",
    "÷": "/", "∕": "/", "/": "/",
    "=": "=", "≈": "~", "≃": "~", "≅": "~",
    "<": "<", "≤": "<=", "⩽": "<=",
    ">": ">", "≥": ">=", "⩾": ">=",
    "^": "^", "%": "%"
}
CURRENCIES = {
  "A$": "AUD", "C$": "CAD", "NZ$": "NZD", "HK$": "HKD", "S$": "SGD", "R$": "BRL", "MX$": "MXN",
  "$": "USD", "€": "EUR", "£": "GBP", "¥": "JPY", "₩": "KRW", "₹": "INR", "₽": "RUS", "₺": "TRY",
  "₫": "VND", "₦": "NGN", "₱": "PHP", "฿": "THB", "₵": "GHS", "₡": "CRC", "₭": "LAK",
  "₴": "UAH", "₲": "PYG", "₮": "MNT", "₼": "AZN", "₸": "KZT", "₾": "GEL", "₿": "BTC"
}
CURR_KEYS = sorted(CURRENCIES.keys(), key=lambda k: len(k), reverse=True)

def translate_query(q: str):
    i=0; tokens=[]; out=[]
    while i < len(q):
        matched=False
        for key in CURR_KEYS:
            if q.startswith(key, i):
                iso=CURRENCIES[key]; tokens.append({"type":"currency","raw":key,"iso":iso})
                out.append(f"<{iso}>"); i+=len(key); matched=True; break
        if matched: continue
        ch=q[i]
        if ch in MATH_OPS:
            canon=MATH_OPS[ch]; tokens.append({"type":"operator","raw":ch,"op":canon}); out.append(canon); i+=1; continue
        if ch.isdigit() or (ch=="." and i+1<len(q) and q[i+1].isdigit()):
            j=i+1
            while j<len(q) and (q[j].isdigit() or q[j] in ".,_"): j+=1
            num=q[i:j]; tokens.append({"type":"number","raw":num}); out.append(num.replace(",","")); i=j; continue
        if ch.isalpha():
            j=i+1
            while j<len(q) and (q[j].isalpha() or q[j] in "-_/"): j+=1
            word=q[i:j]; tokens.append({"type":"text","raw":word}); out.append(word); i=j; continue
        out.append(ch); i+=1
    return {"input": q, "normalized": "".join(out), "tokens": tokens}

# ---------- HTTP surface ----------

MANAGER_POLL = None

class Handler(BaseHTTPRequestHandler):
    server_version = "SolveForcePhoneOne/0.1"
    def _send(self, code, payload, ct="application/json; charset=utf-8"):
        data = payload if isinstance(payload, str) else json.dumps(payload, ensure_ascii=False, separators=(",",":"))
        body = data.encode("utf-8")
        self.send_response(code); self.send_header("Content-Type", ct)
        self.send_header("Content-Length", str(len(body))); self.end_headers(); self.wfile.write(body)
    def log_message(self, fmt, *args): return
    def do_GET(self):
        parsed = urlparse(self.path)
        if parsed.path == "/ui":
            self._send(200, MOBILE_UI, ct="text/html; charset=utf-8"); return
        if parsed.path == "/ping":
            self._send(200, {"pong": True, "time": now_utc_iso()}); return
        if parsed.path == "/health":
            self._send(200, {
                "status":"ok",
                "time":now_utc_iso(),
                "uptime_sec": int(time.time()-STARTED),
                "export": {"enabled": EXPORT_ENABLED, "dir": EXPORT_DIR, "ok": EXPORT_OK, "note": EXPORT_NOTE, "writes": EXPORT_WRITES, "errors": EXPORT_ERRORS}
            }); 
            return
        if parsed.path == "/state":
            self._send(200, REG.state()); return
        if parsed.path == "/read":
            name = parse_qs(parsed.query).get("plugin", [""])[0]
            if name == "all":
                self._send(200, REG.read_all()); return
            self._send(200, REG.read_one(name)); return
        if parsed.path == "/translate":
            q = parse_qs(parsed.query).get("q", [""])[0]
            self._send(200, translate_query(q)); return
        if parsed.path == "/metrics":
            up = int(time.time() - STARTED)
            names = REG.names()
            lines = [f"solveforce_up {up}",
                     f"solveforce_plugins {len(names)}",
                     f"solveforce_export_enabled {1 if EXPORT_ENABLED else 0}",
                     f"solveforce_export_writes_total {EXPORT_WRITES}",
                     f"solveforce_export_errors_total {EXPORT_ERRORS}"]
            self._send(200, "\n".join(lines)+"\n", ct="text/plain; version=0.0.4; charset=utf-8"); return
        self._send(404, {"error":"not found","path": parsed.path})

def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--host", default="127.0.0.1")
    ap.add_argument("--port", default="8080")
    ap.add_argument("--poll", default="0", help="poll interval seconds; 0 = no background poll")
    ap.add_argument("--export", action="store_true", help="enable JSONL export")
    ap.add_argument("--export-dir", default="exports", help="export directory")
    args = ap.parse_args()

    # configure export
    global EXPORT_ENABLED, EXPORT_DIR
    EXPORT_ENABLED = bool(args.export)
    EXPORT_DIR = args.export_dir
    if EXPORT_ENABLED:
        _ensure_export_dir()

    # start poller
    poll_int = int(args.poll)
    global MANAGER_POLL
    MANAGER_POLL = Poller(interval_sec=poll_int)
    MANAGER_POLL.start()

    # HTTP
    host, port = args.host, int(args.port)
    httpd = HTTPServer((host, port), Handler)
    print(f"[SolveForce] Phone One UI → http://{host}:{port}/ui")
    try:
        httpd.serve_forever()
    except KeyboardInterrupt:
        pass
    finally:
        MANAGER_POLL.stop()
        httpd.server_close()

if __name__ == "__main__":
    main()

Create “SolveForce Phone One” with JSONL export toggles and provide a downloadable file.


# Create "SolveForce Phone One" with JSONL export toggles and provide a downloadable file.
import os, json, hashlib, py_compile
from pathlib import Path

BASE = Path("/mnt/data")
fpath = BASE / "solveforce_phone_one.py"

code = r'''#!/usr/bin/env python3
# SolveForce Phone One — phone-first gateway with JSONL exports (read-only)
# MIT © 2025 Ronald Joseph Legarski, Jr. — Published by SolveForce
#
# Zero → One:
#   - Single-file HTTP gateway, mobile UI at /ui
#   - Plugins: battery (Termux:API if present), net (basic IP discovery)
#   - NEW: --export (JSONL, ClickHouse-ready JSONEachRow), --export-dir <dir>
#   - Writes one line per read: {"plugin": "...", "_ts": "...", "data": {...}}
#
# Usage:
#   python solveforce_phone_one.py --host 127.0.0.1 --port 8080 --poll 0
#   python solveforce_phone_one.py --host 0.0.0.0 --port 8080 --poll 5 --export --export-dir /sdcard/solveforce/exports

import os, sys, argparse, json, time, threading, shutil, subprocess
from http.server import BaseHTTPRequestHandler, HTTPServer
from urllib.parse import urlparse, parse_qs
from datetime import datetime, timezone
from typing import Optional

STARTED = time.time()

def now_utc_iso() -> str:
    return datetime.now(timezone.utc).isoformat()

# ---------- Exporter (JSONL) ----------

class JsonlExporter:
    def __init__(self, enabled: bool = False, directory: str = "exports"):
        self.enabled = bool(enabled)
        self.dir = directory
        self.last_error: Optional[str] = None
        if self.enabled:
            try:
                os.makedirs(self.dir, exist_ok=True)
            except Exception as e:
                self.last_error = f"mkdir failed: {e}"
                self.enabled = False

    def write(self, plugin: str, data: dict):
        if not self.enabled:
            return
        try:
            line = json.dumps({"plugin": plugin, "_ts": now_utc_iso(), "data": data}, ensure_ascii=False)
            path = os.path.join(self.dir, f"{plugin}.jsonl")
            with open(path, "a", encoding="utf-8") as f:
                f.write(line + "\n")
        except Exception as e:
            # Do not crash the server; just record the error
            self.last_error = f"write failed: {e}"

EXPORTER = JsonlExporter(False, "exports")

# ---------- Minimal plugin framework ----------

class Plugin:
    NAME = "plugin"
    def __init__(self, **kwargs): pass
    def read(self):
        """Return a JSON-serializable dict (read-only)."""
        return {"ok": True}

def have_cmd(cmd: str) -> bool:
    return shutil.which(cmd) is not None

def run_json_cmd(args, timeout=3):
    try:
        out = subprocess.check_output(args, timeout=timeout, stderr=subprocess.STDOUT)
        txt = out.decode("utf-8", "replace").strip()
        return json.loads(txt)
    except Exception as e:
        return {"error": str(e), "args": args}

class BatteryTermux(Plugin):
    NAME = "battery"
    def read(self):
        # Requires Termux:API 'termux-battery-status' command; otherwise returns unavailable
        if not have_cmd("termux-battery-status"):
            return {"available": False, "note": "termux-battery-status not found"}
        data = run_json_cmd(["termux-battery-status"], timeout=2)
        data["available"] = "error" not in data
        return data

class NetIfacesLite(Plugin):
    NAME = "net"
    def read(self):
        # Try to gather basic IPs using `ip addr` (Termux) or `getprop` as fallback
        if have_cmd("ip"):
            try:
                txt = subprocess.check_output(["ip", "-o", "-4", "addr", "show"], timeout=2).decode("utf-8","replace")
                v4 = []
                for line in txt.splitlines():
                    parts = line.split()
                    if len(parts) >= 4:
                        iface = parts[1]
                        cidr = parts[3]
                        v4.append({"iface": iface, "cidr": cidr})
                return {"available": True, "ipv4": v4}
            except Exception as e:
                return {"available": False, "error": str(e)}
        # Fallback to Android getprop for basic hints
        if have_cmd("getprop"):
            try:
                wifi_ip = subprocess.check_output(["getprop", "dhcp.wlan0.ipaddress"], timeout=2).decode().strip()
                data = {"available": True, "wifi_ip": wifi_ip}
                return data
            except Exception as e:
                return {"available": False, "error": str(e)}
        return {"available": False, "note": "no ip/getprop available"}

BUILTIN_PLUGINS = [BatteryTermux, NetIfacesLite]

class Registry:
    def __init__(self, exporter: JsonlExporter):
        self.plugins = {cls.NAME: cls() for cls in BUILTIN_PLUGINS}
        self.latest = {}
        self.lock = threading.Lock()
        self.exporter = exporter

    def names(self):
        return list(self.plugins.keys())

    def read_one(self, name):
        p = self.plugins.get(name)
        if not p: return {"error": f"unknown plugin '{name}'"}
        res = p.read()
        row = {"data": res, "_ts": now_utc_iso()}
        with self.lock:
            self.latest[name] = row
        # Export safely
        try:
            self.exporter.write(name, res)
        except Exception:
            pass
        return row

    def read_all(self):
        out = {}
        for n in self.names():
            out[n] = self.read_one(n)
        return out

    def state(self):
        with self.lock:
            return {"plugins": self.names(), "latest": dict(self.latest)}

# Registry and poller get created in main() once EXPORTER is configured
REG: Registry = None  # type: ignore

# ---------- Optional background poller ----------

class Poller(threading.Thread):
    def __init__(self, interval_sec: int, registry: Registry):
        super().__init__(daemon=True); self.interval = max(0, int(interval_sec)); self.stop_evt = threading.Event()
        self.registry = registry
    def run(self):
        if self.interval <= 0: return
        while not self.stop_evt.is_set():
            try:
                self.registry.read_all()
            except Exception:
                pass
            self.stop_evt.wait(self.interval)
    def stop(self): self.stop_evt.set()

MANAGER_POLL: Poller = None  # type: ignore

# ---------- Mobile UI (root interface) ----------

MOBILE_UI = """<!doctype html>
<html><head><meta charset="utf-8"/><meta name="viewport" content="width=device-width,initial-scale=1"/>
<title>SolveForce Phone One</title>
<style>
  :root{--b:#111;--t:#111;--c:#0a84ff}
  body{font-family:system-ui,Segoe UI,Roboto,Helvetica,Arial,sans-serif;margin:1rem;line-height:1.4}
  header{font-weight:700;margin-bottom:.8rem}
  .row{display:flex;gap:.5rem;flex-wrap:wrap}
  button{padding:.6rem 1rem;border-radius:.6rem;border:1px solid #888;background:#fff}
  .card{border:1px solid #ccc;border-radius:.8rem;padding:1rem;margin:.6rem 0}
  code,pre{font-family:ui-monospace,Consolas,Menlo,monospace}
  .mono{white-space:pre-wrap}
  input[type=text]{padding:.5rem;border:1px solid #aaa;border-radius:.5rem;min-width:240px}
</style></head>
<body>
<header>📱 SolveForce Phone One — Root UI</header>

<div class="row">
  <button onclick="hit('/health')">/health</button>
  <button onclick="hit('/state')">/state</button>
  <button onclick="hit('/read?plugin=battery')">battery</button>
  <button onclick="hit('/read?plugin=net')">net</button>
  <button onclick="hit('/metrics', true)">/metrics</button>
</div>

<div class="card">
  <form onsubmit="tx();return false;">
    <label>Translate:</label>
    <input id="q" value="$199.99+VAT=€210≈"/>
    <button>Go</button>
  </form>
</div>

<div id="out" class="card mono">Ready.</div>

<script>
async function hit(path, raw){ const r=await fetch(path); const t=raw? await r.text(): await r.json(); show(t); }
async function tx(){ const q=document.getElementById('q').value; const r=await fetch('/translate?q='+encodeURIComponent(q)); show(await r.json()); }
function show(x){ const el=document.getElementById('out'); el.textContent = (typeof x==='string')?x:JSON.stringify(x,null,2); }
hit('/health');
</script>
</body></html>
"""

# ---------- Translator (operators + currencies) ----------
MATH_OPS = {
    "+": "+", "+": "+",
    "-": "-", "−": "-", "–": "-", "—": "-",
    "×": "*", "∙": "*", "·": "*", "⋅": "*", "*": "*",
    "÷": "/", "∕": "/", "/": "/",
    "=": "=", "≈": "~", "≃": "~", "≅": "~",
    "<": "<", "≤": "<=", "⩽": "<=",
    ">": ">", "≥": ">=", "⩾": ">=",
    "^": "^", "%": "%"
}
CURRENCIES = {
  "A$": "AUD", "C$": "CAD", "NZ$": "NZD", "HK$": "HKD", "S$": "SGD", "R$": "BRL", "MX$": "MXN",
  "$": "USD", "€": "EUR", "£": "GBP", "¥": "JPY", "₩": "KRW", "₹": "INR", "₽": "RUS", "₺": "TRY",
  "₫": "VND", "₦": "NGN", "₱": "PHP", "฿": "THB", "₵": "GHS", "₡": "CRC", "₭": "LAK",
  "₴": "UAH", "₲": "PYG", "₮": "MNT", "₼": "AZN", "₸": "KZT", "₾": "GEL", "₿": "BTC"
}
CURR_KEYS = sorted(CURRENCIES.keys(), key=lambda k: len(k), reverse=True)

def translate_query(q: str):
    i=0; tokens=[]; out=[]
    while i < len(q):
        matched=False
        for key in CURR_KEYS:
            if q.startswith(key, i):
                iso=CURRENCIES[key]; tokens.append({"type":"currency","raw":key,"iso":iso})
                out.append(f"<{iso}>"); i+=len(key); matched=True; break
        if matched: continue
        ch=q[i]
        if ch in MATH_OPS:
            canon=MATH_OPS[ch]; tokens.append({"type":"operator","raw":ch,"op":canon}); out.append(canon); i+=1; continue
        if ch.isdigit() or (ch=="." and i+1<len(q) and q[i+1].isdigit()):
            j=i+1
            while j<len(q) and (q[j].isdigit() or q[j] in ".,_"): j+=1
            num=q[i:j]; tokens.append({"type":"number","raw":num}); out.append(num.replace(",","")); i=j; continue
        if ch.isalpha():
            j=i+1
            while j<len(q) and (q[j].isalpha() or q[j] in "-_/"): j+=1
            word=q[i:j]; tokens.append({"type":"text","raw":word}); out.append(word); i=j; continue
        out.append(ch); i+=1
    return {"input": q, "normalized": "".join(out), "tokens": tokens}

# ---------- HTTP surface ----------

class Handler(BaseHTTPRequestHandler):
    server_version = "SolveForcePhoneOne/0.1"
    def _send(self, code, payload, ct="application/json; charset=utf-8"):
        data = payload if isinstance(payload, str) else json.dumps(payload, ensure_ascii=False, separators=(",",":"))
        body = data.encode("utf-8")
        self.send_response(code); self.send_header("Content-Type", ct)
        self.send_header("Content-Length", str(len(body))); self.end_headers(); self.wfile.write(body)
    def log_message(self, fmt, *args): return
    def do_GET(self):
        parsed = urlparse(self.path)
        if parsed.path == "/ui":
            self._send(200, MOBILE_UI, ct="text/html; charset=utf-8"); return
        if parsed.path == "/ping":
            self._send(200, {"pong": True, "time": now_utc_iso()}); return
        if parsed.path == "/health":
            export = {"enabled": EXPORTER.enabled, "dir": EXPORTER.dir, "last_error": EXPORTER.last_error}
            self._send(200, {"status":"ok","time":now_utc_iso(),"uptime_sec": int(time.time()-STARTED), "export": export}); return
        if parsed.path == "/state":
            self._send(200, REG.state()); return
        if parsed.path == "/read":
            name = parse_qs(parsed.query).get("plugin", [""])[0]
            if name == "all":
                self._send(200, REG.read_all()); return
            self._send(200, REG.read_one(name)); return
        if parsed.path == "/translate":
            q = parse_qs(parsed.query).get("q", [""])[0]
            self._send(200, translate_query(q)); return
        if parsed.path == "/metrics":
            up = int(time.time() - STARTED)
            names = REG.names()
            lines = [f"solveforce_up {up}", f"solveforce_plugins {len(names)}"]
            self._send(200, "\n".join(lines)+"\n", ct="text/plain; version=0.0.4; charset=utf-8"); return
        self._send(404, {"error":"not found","path": parsed.path})

def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--host", default="127.0.0.1")
    ap.add_argument("--port", default="8080")
    ap.add_argument("--poll", default="0", help="poll interval seconds; 0 = no background poll")
    ap.add_argument("--export", action="store_true", help="enable JSONL export (JSONEachRow)")
    ap.add_argument("--export-dir", default="exports", help="directory for JSONL files")
    args = ap.parse_args()

    # Configure exporter
    global EXPORTER
    EXPORTER = JsonlExporter(enabled=args.export, directory=args.export_dir)

    # Instantiate registry with exporter
    global REG
    REG = Registry(exporter=EXPORTER)

    # Optional poller
    poll_int = int(args.poll)
    poller = Poller(interval_sec=poll_int, registry=REG)
    poller.start()

    host, port = args.host, int(args.port)
    httpd = HTTPServer((host, port), Handler)

    print(f"[SolveForce] Phone One UI → http://{host}:{port}/ui")
    if EXPORTER.enabled:
        print(f"[SolveForce] Exporting JSONL → {EXPORTER.dir}")
    try:
        httpd.serve_forever()
    except KeyboardInterrupt:
        pass
    finally:
        poller.stop()
        httpd.server_close()

if __name__ == "__main__":
    main()
'''

# Write and compile
fpath.write_text(code, encoding="utf-8")
py_compile.compile(str(fpath), doraise=True)

# Hash
import hashlib
sha = hashlib.sha256(fpath.read_bytes()).hexdigest()

{"path": str(fpath), "sha256": sha}

SolveForce Phone One — phone-first gateway with JSONL exports (read-only)


#!/usr/bin/env python3
# SolveForce Phone One — phone-first gateway with JSONL exports (read-only)
# MIT © 2025 Ronald Joseph Legarski, Jr. — Published by SolveForce
#
# Zero → One:
#   - Single-file HTTP gateway, mobile UI at /ui
#   - Plugins: battery (Termux:API if present), net (basic IP discovery)
#   - NEW: --export (JSONL, ClickHouse-ready JSONEachRow), --export-dir <dir>
#   - Writes one line per read: {"plugin": "...", "_ts": "...", "data": {...}}
#
# Usage:
#   python solveforce_phone_one.py --host 127.0.0.1 --port 8080 --poll 0
#   python solveforce_phone_one.py --host 0.0.0.0 --port 8080 --poll 5 --export --export-dir /sdcard/solveforce/exports

import os, sys, argparse, json, time, threading, shutil, subprocess
from http.server import BaseHTTPRequestHandler, HTTPServer
from urllib.parse import urlparse, parse_qs
from datetime import datetime, timezone
from typing import Optional

STARTED = time.time()

def now_utc_iso() -> str:
    return datetime.now(timezone.utc).isoformat()

# ---------- Exporter (JSONL) ----------

class JsonlExporter:
    def __init__(self, enabled: bool = False, directory: str = "exports"):
        self.enabled = bool(enabled)
        self.dir = directory
        self.last_error: Optional[str] = None
        if self.enabled:
            try:
                os.makedirs(self.dir, exist_ok=True)
            except Exception as e:
                self.last_error = f"mkdir failed: {e}"
                self.enabled = False

    def write(self, plugin: str, data: dict):
        if not self.enabled:
            return
        try:
            line = json.dumps({"plugin": plugin, "_ts": now_utc_iso(), "data": data}, ensure_ascii=False)
            path = os.path.join(self.dir, f"{plugin}.jsonl")
            with open(path, "a", encoding="utf-8") as f:
                f.write(line + "\n")
        except Exception as e:
            # Do not crash the server; just record the error
            self.last_error = f"write failed: {e}"

EXPORTER = JsonlExporter(False, "exports")

# ---------- Minimal plugin framework ----------

class Plugin:
    NAME = "plugin"
    def __init__(self, **kwargs): pass
    def read(self):
        """Return a JSON-serializable dict (read-only)."""
        return {"ok": True}

def have_cmd(cmd: str) -> bool:
    return shutil.which(cmd) is not None

def run_json_cmd(args, timeout=3):
    try:
        out = subprocess.check_output(args, timeout=timeout, stderr=subprocess.STDOUT)
        txt = out.decode("utf-8", "replace").strip()
        return json.loads(txt)
    except Exception as e:
        return {"error": str(e), "args": args}

class BatteryTermux(Plugin):
    NAME = "battery"
    def read(self):
        # Requires Termux:API 'termux-battery-status' command; otherwise returns unavailable
        if not have_cmd("termux-battery-status"):
            return {"available": False, "note": "termux-battery-status not found"}
        data = run_json_cmd(["termux-battery-status"], timeout=2)
        data["available"] = "error" not in data
        return data

class NetIfacesLite(Plugin):
    NAME = "net"
    def read(self):
        # Try to gather basic IPs using `ip addr` (Termux) or `getprop` as fallback
        if have_cmd("ip"):
            try:
                txt = subprocess.check_output(["ip", "-o", "-4", "addr", "show"], timeout=2).decode("utf-8","replace")
                v4 = []
                for line in txt.splitlines():
                    parts = line.split()
                    if len(parts) >= 4:
                        iface = parts[1]
                        cidr = parts[3]
                        v4.append({"iface": iface, "cidr": cidr})
                return {"available": True, "ipv4": v4}
            except Exception as e:
                return {"available": False, "error": str(e)}
        # Fallback to Android getprop for basic hints
        if have_cmd("getprop"):
            try:
                wifi_ip = subprocess.check_output(["getprop", "dhcp.wlan0.ipaddress"], timeout=2).decode().strip()
                data = {"available": True, "wifi_ip": wifi_ip}
                return data
            except Exception as e:
                return {"available": False, "error": str(e)}
        return {"available": False, "note": "no ip/getprop available"}

BUILTIN_PLUGINS = [BatteryTermux, NetIfacesLite]

class Registry:
    def __init__(self, exporter: JsonlExporter):
        self.plugins = {cls.NAME: cls() for cls in BUILTIN_PLUGINS}
        self.latest = {}
        self.lock = threading.Lock()
        self.exporter = exporter

    def names(self):
        return list(self.plugins.keys())

    def read_one(self, name):
        p = self.plugins.get(name)
        if not p: return {"error": f"unknown plugin '{name}'"}
        res = p.read()
        row = {"data": res, "_ts": now_utc_iso()}
        with self.lock:
            self.latest[name] = row
        # Export safely
        try:
            self.exporter.write(name, res)
        except Exception:
            pass
        return row

    def read_all(self):
        out = {}
        for n in self.names():
            out[n] = self.read_one(n)
        return out

    def state(self):
        with self.lock:
            return {"plugins": self.names(), "latest": dict(self.latest)}

# Registry and poller get created in main() once EXPORTER is configured
REG: Registry = None  # type: ignore

# ---------- Optional background poller ----------

class Poller(threading.Thread):
    def __init__(self, interval_sec: int, registry: Registry):
        super().__init__(daemon=True); self.interval = max(0, int(interval_sec)); self.stop_evt = threading.Event()
        self.registry = registry
    def run(self):
        if self.interval <= 0: return
        while not self.stop_evt.is_set():
            try:
                self.registry.read_all()
            except Exception:
                pass
            self.stop_evt.wait(self.interval)
    def stop(self): self.stop_evt.set()

MANAGER_POLL: Poller = None  # type: ignore

# ---------- Mobile UI (root interface) ----------

MOBILE_UI = """<!doctype html>
<html><head><meta charset="utf-8"/><meta name="viewport" content="width=device-width,initial-scale=1"/>
<title>SolveForce Phone One</title>
<style>
  :root{--b:#111;--t:#111;--c:#0a84ff}
  body{font-family:system-ui,Segoe UI,Roboto,Helvetica,Arial,sans-serif;margin:1rem;line-height:1.4}
  header{font-weight:700;margin-bottom:.8rem}
  .row{display:flex;gap:.5rem;flex-wrap:wrap}
  button{padding:.6rem 1rem;border-radius:.6rem;border:1px solid #888;background:#fff}
  .card{border:1px solid #ccc;border-radius:.8rem;padding:1rem;margin:.6rem 0}
  code,pre{font-family:ui-monospace,Consolas,Menlo,monospace}
  .mono{white-space:pre-wrap}
  input[type=text]{padding:.5rem;border:1px solid #aaa;border-radius:.5rem;min-width:240px}
</style></head>
<body>
<header>📱 SolveForce Phone One — Root UI</header>

<div class="row">
  <button onclick="hit('/health')">/health</button>
  <button onclick="hit('/state')">/state</button>
  <button onclick="hit('/read?plugin=battery')">battery</button>
  <button onclick="hit('/read?plugin=net')">net</button>
  <button onclick="hit('/metrics', true)">/metrics</button>
</div>

<div class="card">
  <form onsubmit="tx();return false;">
    <label>Translate:</label>
    <input id="q" value="$199.99+VAT=€210≈"/>
    <button>Go</button>
  </form>
</div>

<div id="out" class="card mono">Ready.</div>

<script>
async function hit(path, raw){ const r=await fetch(path); const t=raw? await r.text(): await r.json(); show(t); }
async function tx(){ const q=document.getElementById('q').value; const r=await fetch('/translate?q='+encodeURIComponent(q)); show(await r.json()); }
function show(x){ const el=document.getElementById('out'); el.textContent = (typeof x==='string')?x:JSON.stringify(x,null,2); }
hit('/health');
</script>
</body></html>
"""

# ---------- Translator (operators + currencies) ----------
MATH_OPS = {
    "+": "+", "+": "+",
    "-": "-", "−": "-", "–": "-", "—": "-",
    "×": "*", "∙": "*", "·": "*", "⋅": "*", "*": "*",
    "÷": "/", "∕": "/", "/": "/",
    "=": "=", "≈": "~", "≃": "~", "≅": "~",
    "<": "<", "≤": "<=", "⩽": "<=",
    ">": ">", "≥": ">=", "⩾": ">=",
    "^": "^", "%": "%"
}
CURRENCIES = {
  "A$": "AUD", "C$": "CAD", "NZ$": "NZD", "HK$": "HKD", "S$": "SGD", "R$": "BRL", "MX$": "MXN",
  "$": "USD", "€": "EUR", "£": "GBP", "¥": "JPY", "₩": "KRW", "₹": "INR", "₽": "RUS", "₺": "TRY",
  "₫": "VND", "₦": "NGN", "₱": "PHP", "฿": "THB", "₵": "GHS", "₡": "CRC", "₭": "LAK",
  "₴": "UAH", "₲": "PYG", "₮": "MNT", "₼": "AZN", "₸": "KZT", "₾": "GEL", "₿": "BTC"
}
CURR_KEYS = sorted(CURRENCIES.keys(), key=lambda k: len(k), reverse=True)

def translate_query(q: str):
    i=0; tokens=[]; out=[]
    while i < len(q):
        matched=False
        for key in CURR_KEYS:
            if q.startswith(key, i):
                iso=CURRENCIES[key]; tokens.append({"type":"currency","raw":key,"iso":iso})
                out.append(f"<{iso}>"); i+=len(key); matched=True; break
        if matched: continue
        ch=q[i]
        if ch in MATH_OPS:
            canon=MATH_OPS[ch]; tokens.append({"type":"operator","raw":ch,"op":canon}); out.append(canon); i+=1; continue
        if ch.isdigit() or (ch=="." and i+1<len(q) and q[i+1].isdigit()):
            j=i+1
            while j<len(q) and (q[j].isdigit() or q[j] in ".,_"): j+=1
            num=q[i:j]; tokens.append({"type":"number","raw":num}); out.append(num.replace(",","")); i=j; continue
        if ch.isalpha():
            j=i+1
            while j<len(q) and (q[j].isalpha() or q[j] in "-_/"): j+=1
            word=q[i:j]; tokens.append({"type":"text","raw":word}); out.append(word); i=j; continue
        out.append(ch); i+=1
    return {"input": q, "normalized": "".join(out), "tokens": tokens}

# ---------- HTTP surface ----------

class Handler(BaseHTTPRequestHandler):
    server_version = "SolveForcePhoneOne/0.1"
    def _send(self, code, payload, ct="application/json; charset=utf-8"):
        data = payload if isinstance(payload, str) else json.dumps(payload, ensure_ascii=False, separators=(",",":"))
        body = data.encode("utf-8")
        self.send_response(code); self.send_header("Content-Type", ct)
        self.send_header("Content-Length", str(len(body))); self.end_headers(); self.wfile.write(body)
    def log_message(self, fmt, *args): return
    def do_GET(self):
        parsed = urlparse(self.path)
        if parsed.path == "/ui":
            self._send(200, MOBILE_UI, ct="text/html; charset=utf-8"); return
        if parsed.path == "/ping":
            self._send(200, {"pong": True, "time": now_utc_iso()}); return
        if parsed.path == "/health":
            export = {"enabled": EXPORTER.enabled, "dir": EXPORTER.dir, "last_error": EXPORTER.last_error}
            self._send(200, {"status":"ok","time":now_utc_iso(),"uptime_sec": int(time.time()-STARTED), "export": export}); return
        if parsed.path == "/state":
            self._send(200, REG.state()); return
        if parsed.path == "/read":
            name = parse_qs(parsed.query).get("plugin", [""])[0]
            if name == "all":
                self._send(200, REG.read_all()); return
            self._send(200, REG.read_one(name)); return
        if parsed.path == "/translate":
            q = parse_qs(parsed.query).get("q", [""])[0]
            self._send(200, translate_query(q)); return
        if parsed.path == "/metrics":
            up = int(time.time() - STARTED)
            names = REG.names()
            lines = [f"solveforce_up {up}", f"solveforce_plugins {len(names)}"]
            self._send(200, "\n".join(lines)+"\n", ct="text/plain; version=0.0.4; charset=utf-8"); return
        self._send(404, {"error":"not found","path": parsed.path})

def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--host", default="127.0.0.1")
    ap.add_argument("--port", default="8080")
    ap.add_argument("--poll", default="0", help="poll interval seconds; 0 = no background poll")
    ap.add_argument("--export", action="store_true", help="enable JSONL export (JSONEachRow)")
    ap.add_argument("--export-dir", default="exports", help="directory for JSONL files")
    args = ap.parse_args()

    # Configure exporter
    global EXPORTER
    EXPORTER = JsonlExporter(enabled=args.export, directory=args.export_dir)

    # Instantiate registry with exporter
    global REG
    REG = Registry(exporter=EXPORTER)

    # Optional poller
    poll_int = int(args.poll)
    poller = Poller(interval_sec=poll_int, registry=REG)
    poller.start()

    host, port = args.host, int(args.port)
    httpd = HTTPServer((host, port), Handler)

    print(f"[SolveForce] Phone One UI → http://{host}:{port}/ui")
    if EXPORTER.enabled:
        print(f"[SolveForce] Exporting JSONL → {EXPORTER.dir}")
    try:
        httpd.serve_forever()
    except KeyboardInterrupt:
        pass
    finally:
        poller.stop()
        httpd.server_close()

if __name__ == "__main__":
    main()