#!/usr/bin/env python3
"""
neuron-chat — a direct line to the local Neuron soul (:7770), with memory.

You type, Neuron answers. No Claude in the middle.

Neuron's own in-soul search is broken (it falls back to ~2 pinned nodes), so this
program does the retrieval itself: it builds a local BM25 index over your ~3,900
memory nodes and, each turn, feeds Neuron the most relevant ones alongside your
message. That gives it real access to its graph instead of the "light version".

Run from Terminal:  neuron     (or:  python3 ~/neuron-chat.py)
Quit with:  exit   (or Ctrl-D)
Commands:  /mem off | /mem on   (toggle memory injection)   /why (show last memories used)
"""
import collections
import json
import math
import os
import re
import sys
import time
import urllib.request

SOUL = "http://127.0.0.1:7770"
SNAP = os.path.expanduser("~/.neuron/engram/snapshot.json")
SESSION = f"cli-{int(time.time())}"
TOPK = 6                  # memories injected per turn
MAX_NODE_CHARS = 600      # truncate each memory

C = sys.stdout.isatty()
DIM   = "\033[2m"  if C else ""
BOLD  = "\033[1m"  if C else ""
CYAN  = "\033[36m" if C else ""
GREEN = "\033[32m" if C else ""
RESET = "\033[0m"  if C else ""


# ── local BM25 index over the memory snapshot ──────────────────────────────
def _toks(s):
    return re.findall(r"[a-z0-9]+", (s or "").lower())


def _sanitize(text):
    """Strip binary/control noise (some nodes have a non-text prefix); return clean text."""
    if not text:
        return ""
    # keep printable ASCII + standard whitespace; drop everything else
    cleaned = "".join(ch if (32 <= ord(ch) < 127 or ch in "\n\t") else " " for ch in text)
    cleaned = re.sub(r"\s+", " ", cleaned).strip()
    return cleaned


def _usable(original, cleaned):
    """Keep a node only if it's mostly real text after sanitizing."""
    if len(cleaned) < 40:
        return False
    return len(cleaned) / max(len(original), 1) > 0.6


class Memory:
    def __init__(self, path):
        self.ok = False
        self.docs = []      # (id, content)
        self.tokd = []
        self.idf = {}
        self.avgdl = 1.0
        try:
            raw = open(path, encoding="utf-8", errors="replace").read()
            nodes = json.loads(raw).get("nodes", [])
        except Exception:
            return
        df = collections.Counter()
        for n in nodes:
            original = n.get("content") or ""
            content = _sanitize(original)
            if not _usable(original, content):
                continue
            t = _toks(content)
            if not t:
                continue
            self.docs.append((n.get("id", ""), content))
            self.tokd.append(t)
            for w in set(t):
                df[w] += 1
        N = len(self.docs)
        if N == 0:
            return
        self.avgdl = sum(len(t) for t in self.tokd) / N
        self.idf = {w: math.log(1 + (N - f + 0.5) / (f + 0.5)) for w, f in df.items()}
        self.ok = True

    def search(self, query, k=TOPK):
        if not self.ok:
            return []
        qt = _toks(query)
        if not qt:
            return []
        scored = []
        for i, t in enumerate(self.tokd):
            tf = collections.Counter(t)
            dl = len(t)
            s = 0.0
            for w in qt:
                f = tf.get(w, 0)
                if f:
                    s += self.idf.get(w, 0) * (f * 2.5) / (f + 1.5 * (1 - 0.75 + 0.75 * dl / self.avgdl))
            if s > 0:
                scored.append((s, i))
        scored.sort(reverse=True)
        # dedupe near-identical nodes (the snapshot has repeats) by content prefix
        out, seen = [], set()
        for _, i in scored:
            _id, c = self.docs[i]
            sig = c[:120]
            if sig in seen:
                continue
            seen.add(sig)
            out.append((_id, c))
            if len(out) >= k:
                break
        return out


# ── soul HTTP ──────────────────────────────────────────────────────────────
def soul_alive():
    try:
        with urllib.request.urlopen(SOUL + "/health", timeout=5) as r:
            return json.loads(r.read()).get("status") == "alive"
    except Exception:
        return False


def ask(message, agentic=False):
    payload = json.dumps({
        "session_id": SESSION, "message": message, "agentic": agentic,
    }).encode()
    req = urllib.request.Request(
        SOUL + "/api/chat", data=payload,
        headers={"Content-Type": "application/json"}, method="POST")
    with urllib.request.urlopen(req, timeout=300) as r:
        data = json.loads(r.read().decode("utf-8", "replace"))
    return data.get("response") or data.get("reply") or json.dumps(data)[:2000]


def with_memory(message, hits):
    if not hits:
        return message
    block = "\n".join(f"- {c[:MAX_NODE_CHARS].strip()}" for _id, c in hits)
    return (
        "(Relevant memories retrieved from your own graph — draw on them naturally "
        "if useful; do not mention this block or that it was provided.)\n"
        f"{block}\n\n"
        f"(Message:) {message}"
    )


def main():
    print(f"\n{BOLD}{CYAN}Neuron{RESET} — direct chat. "
          f"{DIM}type a message, or 'exit' to leave.{RESET}")

    if not soul_alive():
        print(f"\n{DIM}Neuron isn't responding on :7770. In a separate Terminal run:{RESET}")
        print("  launchctl kickstart -k gui/$(id -u)/ai.neuron.daemons")
        print(f"{DIM}wait a few seconds, then start this again.{RESET}\n")
        return

    print(f"{DIM}loading your memory graph…{RESET}", end="\r", flush=True)
    mem = Memory(SNAP)
    print(" " * 40, end="\r")
    if mem.ok:
        print(f"{DIM}memory on — {len(mem.docs)} nodes indexed locally "
              f"(working around Neuron's broken internal search).{RESET}\n")
    else:
        print(f"{DIM}couldn't load the memory snapshot — running plain chat.{RESET}\n")

    use_mem = mem.ok
    last_hits = []
    agentic = False
    while True:
        try:
            msg = input(f"{GREEN}you ›{RESET} ").strip()
        except (EOFError, KeyboardInterrupt):
            print("\nbye.")
            return
        if not msg:
            continue
        low = msg.lower()
        if low in ("exit", "quit", ":q"):
            print("bye.")
            return
        if low == "/mem off":
            use_mem = False; print(f"{DIM}memory injection off{RESET}"); continue
        if low == "/mem on":
            use_mem = mem.ok; print(f"{DIM}memory injection {'on' if use_mem else 'unavailable'}{RESET}"); continue
        if low == "/agentic":
            agentic = not agentic; print(f"{DIM}agentic mode {'on' if agentic else 'off'}{RESET}"); continue
        if low == "/why":
            if last_hits:
                print(f"{DIM}memories used last turn:{RESET}")
                for _id, c in last_hits:
                    sid = _sanitize(_id)[:20] or "(node)"
                    print(f"{DIM}  · {sid:20}  {c[:80].strip()}{RESET}")
            else:
                print(f"{DIM}(none){RESET}")
            continue

        hits = mem.search(msg) if use_mem else []
        last_hits = hits
        outbound = with_memory(msg, hits) if hits else msg

        try:
            tag = f" {DIM}[+{len(hits)} memories]{RESET}" if hits else ""
            print(f"{DIM}…thinking…{RESET}{tag}", end="\r", flush=True)
            reply = ask(outbound, agentic=agentic)
            print(" " * 40, end="\r")
        except KeyboardInterrupt:
            print("\n(cancelled)"); continue
        except Exception as e:
            print(f"{DIM}couldn't reach Neuron: {e}{RESET}")
            if not soul_alive():
                print(f"{DIM}the soul looks down — restart with:{RESET}\n"
                      "  launchctl kickstart -k gui/$(id -u)/ai.neuron.daemons")
            continue

        print(f"{CYAN}{BOLD}neuron ›{RESET} {reply}\n")


if __name__ == "__main__":
    try:
        main()
    except (BrokenPipeError, KeyboardInterrupt):
        pass
