#!/usr/bin/env python3
""

import json
import os
import shutil
import subprocess
import sys

sys.path.insert(0, os.path.dirname(os.path.realpath(__file__)))
_KIT = os.path.dirname(os.path.realpath(__file__))
os.environ["PATH"] = os.pathsep.join(
    [
        os.path.join(_KIT, "adapter", "node_modules", ".bin"),
        os.path.join(_KIT, "node", "bin"),
        os.environ.get("PATH", ""),
    ]
)
import acp_lib
import sexpr_lib
import thomas_config

PERSONA_KEYS = (":name", ":pronouns", ":voice", ":personality", ":image")

USAGE = """thomas -- your assistant: one brain, one persona, configured in ~/.thomas/thomas.sexpr

  thomas login                        sign in to Claude with your own paid account (once)
  thomas login --free                 or start free: a free OpenRouter key, no card (text answers only)
  thomas doctor                       check that everything Thomas needs is installed, with the fix for each gap
  thomas config                       the merged configuration (shipped defaults < ~/.thomas/thomas.sexpr < env)
  thomas brain                        the brains Thomas can use (claude, codex, gemini, free) and which is chosen
  thomas persona                      who your assistant is
  thomas persona set :KEY VALUE       design it (:name :pronouns :voice :personality :image)
  thomas chat [--brain K]             talk back and forth: type a message, read the answer, type again
  thomas ask [--brain K] [--new] "PROMPT"  one message; --new ignores the conversation so far
  thomas check                        put the last answer to a fresh session that did not write it, and see what it doubts
  thomas remember "NOTE"              something every answer should know about you (kept in ~/.thomas/memory.md)
  thomas forget                       delete the kept conversation (~/.thomas/conversation.jsonl)
  thomas telegram setup | run         talk to it from your phone, through a Telegram bot you create
  thomas telegram install             keep it answering in the background (after pairing); uninstall stops it
  thomas idioms [--json] [--project D] Claude Code's commands, keys and agents, for an editor sidebar

Thomas runs on your machine; your prompts go to the brain's provider under your own login.
So that it remembers you, Thomas keeps your recent turns and your notes in two files only you can read,
and each message sends them to the brain too. (memory :keep nil) in ~/.thomas/thomas.sexpr stops keeping and
sending them; files already written stay until you delete them.
With the default settings Thomas runs only the Claude brain, in an empty scratch folder, and refuses
every request it is asked to approve -- reading, writing or running anything. Claude may still run a few
read-only commands such as `whoami` and `pwd` without asking, so the brain sees your prompt, your persona
settings, your account name and a temporary folder path, along with what Claude itself sends its provider
(such as your platform and the date). Codex and Gemini read files outside that folder
even in their asking modes, so Thomas runs them only after you set `:autonomy t`, which lets the brain read,
write and run commands anywhere your user account can. Logins stay yours: Thomas never ships a credential,
and reading another app's stored key is off unless you turn it on."""

ADAPTER_FIX = {
    "claude-agent-acp": "run install.sh in the Thomas folder again: it installs the adapter there (no admin rights)",
    "codex-acp": "npm install -g @agentclientprotocol/codex-acp   then run `codex login`",
    "gemini": "npm install -g @google/gemini-cli   then run `gemini` once to log in",
}


def doctor_checks(f):
    ""
    out = []
    py = f.get("py") or (0, 0)
    out.append((py >= (3, 10), f"python {py[0]}.{py[1]}", "install Python 3.10 or newer"))
    out.append(
        (
            bool(f.get("redis_mod")),
            "the redis package for Python",
            "run install.sh in the Thomas folder: it makes a private .venv beside thomas with redis in it",
        )
    )
    out.append(
        (
            bool(f.get("config")),
            "the shipped defaults (thomas.sexpr)",
            "keep thomas.sexpr next to the thomas script",
        )
    )
    chosen = f.get("chosen")
    out.append(
        (
            chosen in (f.get("agents") or ()),
            f"a chosen brain ({chosen or 'none'})",
            "set one in ~/.thomas/thomas.sexpr: (brain :kind claude)",
        )
    )
    if chosen == FREE:
        out.append(
            (
                bool(f.get("free_ready", True)),
                "a free OpenRouter key and model list",
                "run `thomas login --free` -- it explains how to get a free key from openrouter.ai/keys",
            )
        )
    adapter = f.get("adapter")
    if adapter:
        out.append(
            (
                bool(f.get("adapter_on_path")),
                f"the {adapter} adapter",
                ADAPTER_FIX.get(adapter, f"install {adapter}"),
            )
        )
        if adapter == "claude-agent-acp":
            node = f.get("node")
            out.append(
                (
                    node is not None and node >= 22,
                    f"Node {node if node is not None else 'not found'} (the claude adapter needs 22+)",
                    "run install.sh in the Thomas folder again: it fetches Node 22 into that folder (no admin rights)",
                )
            )
            out.append(
                (
                    bool(f.get("claude_login")),
                    "logged in to Claude",
                    (
                        "run `thomas login` -- it opens Claude's sign-in page (or prints a link). You need a paid Claude account (Pro,"
                        " Max, Team or Enterprise -- the free plan does not include it) or a Claude Console account;"
                        " or set ANTHROPIC_API_KEY. No paid account? `thomas login --free` starts free"
                    ),
                )
            )
    out.append(
        (
            bool(f.get("probe_ok")),
            "acp-probe can launch",
            "run install.sh in the Thomas folder again: it points acp-probe's first line at the folder's own python",
        )
    )
    return out


def _facts(cfg):
    ""
    import importlib.util

    here = os.path.dirname(os.path.realpath(__file__))
    chosen = cfg.get("brain", {}).get(":kind")
    spec = acp_lib.AGENTS.get(chosen) or {}
    adapter = (spec.get("cmd") or [None])[0]
    node = None
    if shutil.which("node"):
        try:
            v = subprocess.run(
                ["node", "--version"], capture_output=True, text=True, timeout=10, check=False
            ).stdout
            node = int(v.strip().lstrip("v").split(".")[0])
        except (OSError, ValueError, subprocess.SubprocessError) as e:
            print(
                f"thomas: node version unreadable ({type(e).__name__}); reported as unknown",
                file=sys.stderr,
            )
            node = None
    probe = os.path.join(here, "acp-probe")
    probe_ok = False
    try:
        with open(probe) as fh:
            first = fh.readline()
        interp = first[2:].split()[0] if first.startswith("#!") else ""
        probe_ok = bool(interp) and (
            os.path.isfile(interp)
            if interp != "/usr/bin/env"
            else bool(shutil.which(first.split()[1]))
        )
    except (OSError, IndexError) as e:
        print(
            f"thomas: acp-probe unreadable at {probe} ({type(e).__name__}); reported as not ok",
            file=sys.stderr,
        )
        probe_ok = False
    return {
        "py": sys.version_info[:2],
        "redis_mod": importlib.util.find_spec("redis") is not None,
        "config": thomas_config.DEFAULT if os.path.isfile(thomas_config.DEFAULT) else None,
        "chosen": chosen,
        "agents": tuple(acp_lib.AGENTS) + (FREE,),
        "free_ready": chosen != FREE or bool(_free_ready()),
        "adapter": adapter,
        "adapter_on_path": bool(adapter and shutil.which(adapter)),
        "node": node,
        "probe_ok": probe_ok,
        "claude_login": bool(
            os.environ.get("ANTHROPIC_API_KEY")
            or os.environ.get("CLAUDE_CODE_OAUTH_TOKEN")
            or (adapter == "claude-agent-acp" and _login_known_ok())
        ),
    }


def sdk_claude_paths(adapter_real):
    ""
    pkg = os.path.dirname(os.path.dirname(adapter_real))  # .../claude-agent-acp
    hoisted = os.path.dirname(
        os.path.dirname(pkg)
    )  # .../node_modules (above the @agentclientprotocol scope)
    return [
        os.path.join(pkg, "node_modules", "@anthropic-ai"),
        os.path.join(hoisted, "@anthropic-ai"),
    ]


def claude_binary():
    ""
    acp = shutil.which("claude-agent-acp")
    if acp:
        for d in sdk_claude_paths(os.path.realpath(acp)):
            try:
                names = sorted(os.listdir(d))
            except (
                OSError
            ):  # a candidate SDK path that is not a directory on this install; the next is tried
                continue
            for n in names:
                b = os.path.join(d, n, "claude")
                if n.startswith("claude-agent-sdk-") and os.access(b, os.X_OK):
                    return b
    return shutil.which("claude")


def logged_in(binary, _run=subprocess.run, kids=None):
    ""
    if not binary:
        return None
    try:
        if kids is None:
            out = _run(
                [binary, "auth", "status", "--json"],
                capture_output=True,
                text=True,
                timeout=30,
                check=False,
            ).stdout
        else:
            p = subprocess.Popen(
                [binary, "auth", "status", "--json"], stdout=subprocess.PIPE, text=True
            )
            kids.append(p)
            try:
                out, _ = p.communicate(timeout=30)
            except subprocess.TimeoutExpired:
                print(
                    "thomas: `auth status` did not answer in 30 s; killed, login state UNREADABLE",
                    file=sys.stderr,
                )
                p.kill()
                p.communicate()
                return None
            finally:
                kids.remove(p)
        d = json.loads(out)
    except (OSError, ValueError, subprocess.SubprocessError) as e:
        print(
            f"thomas: `auth status` gave no readable JSON ({type(e).__name__}); login state UNREADABLE",
            file=sys.stderr,
        )
        return None
    v = d.get("loggedIn") if isinstance(d, dict) else None
    return v if isinstance(v, bool) else None


def _login_known_ok():
    ""
    b = claude_binary()
    return b is not None and logged_in(b) is not False


FREE = "free"  # the free brain: OpenRouter's free models, text only (thomas_free.py) -- no paid account needed
FREE_MODEL_FILE = os.path.expanduser(
    "~/.thomas/free-models"
)  # the ranked fallback list, one model id per line


def _free_ready():
    ""
    import thomas_free

    return bool(thomas_free.read_key()) and os.path.isfile(FREE_MODEL_FILE)


def try_cmd(which=shutil.which, argv0=None):
    ""
    if which("thomas"):
        return "thomas"
    return os.path.abspath(argv0 or sys.argv[0])


def free_login(
    cfg_path=thomas_config.USER, _getpass=None, _check=None, key_path=None, model_path=None
):
    ""
    import getpass

    import thomas_free

    print(
        "Start Thomas free, with OpenRouter's free models (no card, no paid plan):\n"
        "  1. On any device, open openrouter.ai and sign in (Google or GitHub works).\n"
        "  2. Open openrouter.ai/keys, press 'Create API Key', and copy the key.\n"
        "  3. Paste it here (it is not shown on screen).\n"
        "Privacy: free models are run by outside companies that may keep, train on, or publish what you send --\n"
        "including your notes and recent conversation, which Thomas sends with every question. Do not send anything\n"
        "private. OpenRouter may ask you to allow this in openrouter.ai/settings/privacy before free models work.\n"
        "For a paid brain use `thomas login`.\n",
        flush=True,
    )
    try:
        key = (_getpass or getpass.getpass)("OpenRouter key: ").strip()
    except (EOFError, KeyboardInterrupt):
        print("\nCancelled. Run `thomas login --free` when you are ready.", file=sys.stderr)
        return 130
    if not key:
        print("thomas: no key pasted -- nothing saved.", file=sys.stderr)
        return 1
    models, err = (_check or thomas_free.check_key)(key)
    if not models:
        print("thomas: " + err, file=sys.stderr)
        return 1
    thomas_free.save_key(key, key_path or thomas_free.KEY_FILE)
    _private_write(model_path or FREE_MODEL_FILE, "\n".join(models))
    if os.environ.get("OPENROUTER_API_KEY"):
        print(
            "Note: OPENROUTER_API_KEY is set in your environment; Thomas uses it instead of the key you just saved."
        )
    set_brain_kind(FREE, cfg_path)
    print(
        f'Ready: Thomas now answers with free models ({models[0]} first). Try: {try_cmd()} ask "hello"'
    )
    return 0


def set_brain_kind(kind, cfg_path=thomas_config.USER):
    ""
    forms = []
    if os.path.exists(cfg_path):
        with open(cfg_path, encoding="utf-8") as fh:
            forms = [f for f in sexpr_lib.read_all(fh.read()) if isinstance(f, list) and f]
    forms = set_form(forms, "brain", ":kind", kind)
    os.makedirs(os.path.dirname(cfg_path), exist_ok=True)
    with open(cfg_path + ".tmp", "w", encoding="utf-8") as fh:
        fh.write(dump_forms(forms))
    os.replace(cfg_path + ".tmp", cfg_path)


def _private_write(path, text):
    os.makedirs(os.path.dirname(path), exist_ok=True)
    fd = os.open(path + ".tmp", os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
    os.fchmod(fd, 0o600)  # os.open's mode applies only at creation
    with os.fdopen(fd, "w", encoding="utf-8") as fh:
        fh.write(text + "\n")
    os.replace(path + ".tmp", path)


def rotated(models, rotate):
    ""
    return models[1:] + models[:1] if rotate and len(models) > 1 else list(models)


def free_turn(prompt, _ask=None, rotate=False):
    ""
    import thomas_free

    key = thomas_free.read_key()
    try:
        with open(FREE_MODEL_FILE, encoding="utf-8") as fh:
            models = fh.read().split()
    except (
        FileNotFoundError
    ):  # no model file is the normal pre-login state; the login hint below is the message
        models = []
    except OSError as e:
        print(
            f"thomas: free-model list unreadable at {FREE_MODEL_FILE} ({e.strerror}); treated as empty",
            file=sys.stderr,
        )
        models = []
    if not key or not models:
        return 1, "", "the free brain needs a key first: run `thomas login --free`"
    models = rotated(models, rotate)
    rc, reply, err = (_ask or thomas_free.ask)(prompt, key, models)
    return rc, reply, (err + "\n") if err else ""


def login_cmd(cfg):
    ""
    kind = cfg.get("brain", {}).get(":kind") or "claude"
    if kind != "claude":
        how = {
            "codex": "codex login",
            "gemini": "gemini",
        }.get(kind)
        if not how:
            print(
                f"thomas: no brain called {kind!r}; pick claude, codex, gemini or free in ~/.thomas/thomas.sexpr",
                file=sys.stderr,
            )
            return 2
        print(
            f"Log in to {kind} with its own command: `{how}`"
            + (" (it asks you to sign in the first time)" if kind == "gemini" else "")
        )
        return 0
    import signal

    kids: list[subprocess.Popen] = []

    def _stop(_sig, _frame):
        for k in kids:
            k.terminate()
        sys.exit(143)

    signal.signal(signal.SIGTERM, _stop)
    try:
        return _login_claude(kids)
    except (
        KeyboardInterrupt
    ):  # Ctrl-C anywhere (the status checks too): children terminated, exit 130, no traceback
        for k in kids:
            k.terminate()
        print("\nCancelled. Run `thomas login` when you are ready.", file=sys.stderr)
        return 130


def keychain_home_mismatch(platform, home, account_home):
    ""
    if platform != "darwin" or not home or not account_home:
        return None
    if os.path.realpath(home) == os.path.realpath(account_home):
        return None
    return (
        f"HOME is {home}, but this account's home is {account_home}; the Claude sign-in keeps its token in your"
        " login keychain, which lives there. Run `thomas login` without changing HOME."
    )


def _account_home():
    import pwd

    try:
        return pwd.getpwuid(os.getuid()).pw_dir
    except (
        KeyError
    ):  # no passwd entry for this uid (a container); '' makes the caller fall back to $HOME
        return ""


def _login_claude(kids):
    ""
    why = keychain_home_mismatch(sys.platform, os.environ.get("HOME", ""), _account_home())
    if why:
        print("thomas: " + why, file=sys.stderr)
        return 2
    b = claude_binary()
    if not b:
        print(
            "thomas: the Claude adapter is not installed yet -- run install.sh in the Thomas folder again",
            file=sys.stderr,
        )
        return 1
    if logged_in(b, kids=kids):
        print(f'You are already logged in to Claude. Try: {try_cmd()} ask "hello"')
        return 0
    print(
        "Signing you in to Claude: a sign-in page opens in your browser, or -- on a computer without one -- a link to\n"
        "open on any device and a code to paste back here. You need a PAID account (Pro, Max, Team or Enterprise), or\n"
        "a Claude Console account billed by use -- the free claude.ai plan does not include this. Thomas never sees\n"
        "your password; the login is stored by Claude Code on this computer.\n",
        flush=True,
    )  # before the child writes
    child = subprocess.Popen([b, "auth", "login"])
    kids.append(child)
    rc = child.wait()
    if logged_in(b, kids=kids):
        print('\nLogged in. Try: thomas ask "hello"')
        return 0
    print(
        "\nNot logged in yet. Run `thomas login` again, or set ANTHROPIC_API_KEY to a Claude Console key.",
        file=sys.stderr,
    )
    return rc or 1


def auth_hint(kind, text):
    ""
    if "Authentication required" not in text and "authentication_error" not in text:
        return None
    how = {
        "claude": "thomas login",
        "gemini": "gemini (run it once and sign in)",
        "codex": "codex login",
    }
    return f"your {kind} brain needs YOUR login first: run `{how.get(kind, kind + ' login')}`, then ask again."


def next_steps(f, cmd):
    ""
    chosen = f.get("chosen")
    signed = {
        FREE: f.get("free_ready"),
        "claude": f.get("claude_login"),
        None: f.get("claude_login"),
    }.get(chosen)
    gaps = [w for ok, w, _fix in doctor_checks(f) if not ok]
    name = f"{cmd} persona set :name YourName"
    hello = f'{cmd} ask "hello"'
    if signed and not gaps:
        return [
            "Next: you are already signed in, so give your assistant a name:  " + name,
            "      and say hello:                                              " + hello,
        ]
    if signed:
        return [
            "Next: fix what is marked MISSING above (each line says how), then give your assistant a name:  "
            + name,
            "      and say hello:  " + hello,
        ]
    if chosen in ("codex", "gemini"):
        return [
            f"Next: sign in to your {chosen} brain with your own account:  {cmd} login",
            "      then give your assistant a name:  " + name,
            "      and say hello:                    " + hello,
        ]
    return [
        "Next: sign in with your own paid Claude account (a sign-in page opens, or a link to open):",
        f"      {cmd} login",
        f"      no paid account? start free instead (an OpenRouter key, no card):  {cmd} login --free",
        "      then give your assistant a name:  " + name,
        "      and say hello:                    " + hello,
    ]


def print_doctor(checks, out=print):
    ""
    gaps = 0
    for ok, what, fix in checks:
        out(f"  {'ok  ' if ok else 'MISSING'}  {what}" + ("" if ok else f"\n           fix: {fix}"))
        gaps += not ok
    out("  ready." if not gaps else f"  {gaps} thing(s) to fix before `thomas ask` will work.")
    return gaps


SAFE_MODES = {"claude": "default", "codex": "read-only", "gemini": "default"}
CONFINED_BRAINS = ("claude",)


def confinement_refusal(brain, kind):
    ""
    if (
        kind == FREE
    ):  # text only -- one request, one reply; it can read and run nothing, so there is nothing to confine
        return None
    if kind not in acp_lib.AGENTS:
        return f"Thomas does not know a brain called {kind!r}: pick claude, codex, gemini or free"
    if brain.get(":autonomy") is True or kind in CONFINED_BRAINS:
        return None
    return (
        f"the {kind} brain reads files outside its folder even in its asking mode, so Thomas runs it only with"
        f" autonomy on: add (brain :autonomy t) to ~/.thomas/thomas.sexpr knowingly, or use --brain claude"
    )


ASKS = os.path.expanduser("~/.thomas/asks.jsonl")


def record_ask(ok, prompt_chars, reply_chars, brain, path=ASKS, now=None):
    ""
    import json
    import time

    row = {
        "t": round(now if now is not None else time.time()),
        "brain": brain,
        "ok": bool(ok),
        "prompt_chars": prompt_chars,
        "reply_chars": reply_chars,
    }
    try:
        os.makedirs(os.path.dirname(path), exist_ok=True)
        with open(path, "a") as fh:
            fh.write(json.dumps(row) + "\n")
    except OSError as e:
        print(f"thomas: could not record this ask in {path} ({e})", file=sys.stderr)
    return row


def ask_flags(brain, kind, scratch=None):
    ""
    flags = []
    if brain.get(":autonomy") is not True:
        flags += ["--mode", SAFE_MODES.get(kind, "default")]
        if scratch:
            flags += ["--cwd", scratch]
    if brain.get(":borrow-credentials") is not True:
        flags.append("--no-borrow")
    if brain.get(":ledger") is not True:
        flags.append("--no-ledger")
    return flags


def persona_preamble(p):
    ""
    lines = [f"You are {p.get(':name') or 'Thomas'}, the user's assistant."]
    if p.get(":pronouns"):
        lines.append(f"Pronouns: {p[':pronouns']}.")
    if p.get(":personality"):
        lines.append(f"Personality: {p[':personality']}")
    if p.get(":voice"):
        lines.append(f"Voice: {p[':voice']}")
    return "\n".join(lines)


CONVO = os.path.expanduser("~/.thomas/conversation.jsonl")
NOTES = os.path.expanduser("~/.thomas/memory.md")
HISTORY_CHARS = 8000  # the most recalled conversation one turn carries; older turns drop first


def history_block(turns, max_turns=8, max_chars=HISTORY_CHARS):
    ""
    keep = (
        [t for t in turns if t.get("you") and t.get("thomas")][-max_turns:] if max_turns > 0 else []
    )
    while keep:
        text = "\n\n".join(f"User: {t['you']}\nYou: {t['thomas']}" for t in keep)
        if len(text) <= max_chars:
            return "Our conversation so far (oldest first):\n\n" + text
        keep = keep[1:]
    return ""


NOTES_CHARS = (
    16000  # notes beyond this are left out (oldest first): the prompt rides argv, which the OS caps
)


def clean_text(s):
    ""
    return str(s).replace("\x00", "").encode("utf-8", "replace").decode("utf-8")


def compose_prompt(preamble, notes, history, prompt):
    ""
    notes = clean_text(notes).strip()[-NOTES_CHARS:]
    parts = [clean_text(preamble)]
    if notes:
        parts.append("What the user wants you to remember:\n" + notes)
    if history:
        parts.append(clean_text(history))
    parts.append(("The user now says:\n" if history or notes else "") + clean_text(prompt))
    return "\n\n".join(parts)


def _private_append(path, line):
    ""
    os.makedirs(os.path.dirname(path), exist_ok=True)
    fd = os.open(path, os.O_WRONLY | os.O_APPEND | os.O_CREAT, 0o600)
    with os.fdopen(fd, "a") as fh:
        fh.write(line)


def load_turns(path=CONVO):
    ""
    import json

    try:
        with open(path, encoding="utf-8", errors="replace") as fh:
            lines = fh.readlines()
    except (
        OSError
    ):  # no asks file yet is the normal first state: nothing was ever asked
        return []
    out = []
    for ln in lines:
        try:
            row = json.loads(ln)
        except (
            ValueError,
            RecursionError,
        ):  # a torn or hostile line in the asks file; a deeply nested one raises RecursionError, not ValueError
            continue
        if (
            isinstance(row, dict)
            and isinstance(row.get("you"), str)
            and isinstance(row.get("thomas"), str)
        ):
            out.append(row)
    return out


def _deep_ok():
    ""
    import tempfile

    with tempfile.NamedTemporaryFile("w", suffix=".jsonl", delete=False) as fh:
        fh.write("[" * 100000 + "]" * 100000 + '\n{"you": "q", "thomas": "a"}\n')
    try:
        return load_turns(fh.name) == [{"you": "q", "thomas": "a"}]
    finally:
        os.remove(fh.name)


def read_notes(path=NOTES):
    ""
    try:
        with open(path, encoding="utf-8", errors="replace") as fh:
            return fh.read()
    except OSError:  # no notes file yet means no notes; '' is that fact
        return ""


def brains(agents, have):
    ""
    return [(n, bool(have(spec["cmd"][0])), " ".join(spec["cmd"])) for n, spec in agents.items()]


def set_form(forms, section, key, value):
    ""
    out, done = [], False
    for f in forms:
        if isinstance(f, list) and f and f[0] == section and not done:
            body = list(f[1:])
            for i in range(0, len(body) - 1, 2):
                if body[i] == key:
                    body[i + 1] = value
                    break
            else:
                body += [key, value]
            out.append([section, *body])
            done = True
        else:
            out.append(f)
    if not done:
        out.append([section, key, value])
    return out


def _quote(v):
    ""
    if v in ("nil", "t"):
        return v
    return '"' + str(v).replace("\\", "\\\\").replace('"', '\\"') + '"'


def dump_forms(forms):
    ""
    rows = []
    for f in forms:
        head, body = f[0], f[1:]
        pairs = " ".join(f"{k} {_quote(v)}" for k, v in zip(body[0::2], body[1::2]))
        rows.append(f"({head} {pairs})")
    return (
        ";; machine-written by thomas (persona set, login); the documented defaults are thomas.sexpr beside the thomas program\n"
        + "\n".join(rows)
        + "\n"
    )


def persona_set(key, value, path=thomas_config.USER):
    if key not in PERSONA_KEYS:
        raise SystemExit(f"thomas persona set: unknown key {key} (one of {' '.join(PERSONA_KEYS)})")
    if (
        key == ":image"
        and value not in ("nil", "")
        and not os.path.isfile(os.path.expanduser(value))
    ):
        raise SystemExit(f"thomas persona set: no image at {value}")
    forms = []
    if os.path.exists(path):
        with open(path, encoding="utf-8") as fh:
            forms = [f for f in sexpr_lib.read_all(fh.read()) if isinstance(f, list) and f]
    forms = set_form(forms, "persona", key, value or "nil")
    os.makedirs(os.path.dirname(path), exist_ok=True)
    tmp = path + ".tmp"
    with open(tmp, "w", encoding="utf-8") as fh:
        fh.write(dump_forms(forms))
    os.replace(tmp, path)


def main(argv):
    if "--selftest" in argv:
        return selftest()
    cmd = argv[1] if len(argv) > 1 else "config"
    cfg = thomas_config.load()
    if cmd in ("--help", "-h", "help"):
        print(USAGE)
        return 0
    if cmd == "login":
        if "--free" in argv[2:]:
            return free_login()
        if cfg.get("brain", {}).get(":kind") == FREE:
            rc = login_cmd({**cfg, "brain": {**cfg.get("brain", {}), ":kind": "claude"}})
            if rc == 0:
                set_brain_kind("claude")
                print("Switched Thomas to the Claude brain.")
            return rc
        return login_cmd(cfg)
    if cmd == "doctor":
        f = _facts(cfg)
        gaps = print_doctor(doctor_checks(f))
        if "--next" in argv[2:]:
            print()
            print("\n".join(next_steps(f, try_cmd())))
        return 1 if gaps else 0
    if cmd == "config":
        for sec, kv in cfg.items():
            print(f"({sec} " + " ".join(f"{k} {v!r}" for k, v in kv.items()) + ")")
        return 0
    if cmd == "brain":
        chosen = cfg.get("brain", {}).get(":kind")
        for n, ok, c in brains(acp_lib.AGENTS, shutil.which):
            print(
                f"  {'*' if n == chosen else ' '} {n:<8} {'installed' if ok else 'NOT on PATH':<12} {c}"
            )
        ready = _free_ready()
        print(
            f"  {'*' if chosen == FREE else ' '} {FREE:<8} {'ready' if ready else 'no key yet':<12}"
            f" {'free models through OpenRouter, text only' if ready else 'run `thomas login --free`'}"
        )
        if chosen not in acp_lib.AGENTS and chosen != FREE:
            print(
                f"  chosen brain {chosen!r} is not one Thomas knows -- pick claude, codex, gemini or free"
            )
        return 0
    if cmd == "idioms":
        rest = argv[2:]
        project = os.getcwd()
        if "--project" in rest:
            i = rest.index("--project")
            if i + 1 >= len(rest):
                print("thomas: --project needs a folder", file=sys.stderr)
                return 2
            project = os.path.abspath(rest[i + 1])
        import json

        import claude_idioms

        cat = claude_idioms.resolve(project)
        if "--json" in rest:
            print(json.dumps(cat))
        else:
            for g in cat["groups"]:
                print(g["group"])
                for it in g["items"]:
                    print(f"  {it['name']:<14} {it['what']}")
        return 0
    if cmd == "persona":
        if len(argv) >= 5 and argv[2] == "set":
            persona_set(argv[3], " ".join(argv[4:]))
        for k in PERSONA_KEYS:
            print(f"  {k:<13} {thomas_config.load().get('persona', {}).get(k)!r}")
        return 0
    if cmd == "remember":
        note = " ".join(argv[2:]).strip()
        if not note:
            print(
                f'usage: thomas remember "something every turn should know"   (kept in {NOTES})',
                file=sys.stderr,
            )
            return 2
        if not memory_settings(cfg)[0]:
            print(
                "Memory is off ((memory :keep nil) in ~/.thomas/thomas.sexpr), so nothing was saved.",
                file=sys.stderr,
            )
            return 1
        try:
            _private_append(NOTES, "- " + clean_text(note) + "\n")
        except OSError as e:
            print(f"thomas: could not write {NOTES} ({e.strerror or e})", file=sys.stderr)
            return 1
        print(f"Noted. Every turn now carries it; edit or delete it in {NOTES}.")
        return 0
    if cmd == "forget":
        try:
            os.remove(CONVO)
            print(
                f"Deleted the conversation ({CONVO}). Your notes in {NOTES} stay; delete that file to drop them."
            )
        except FileNotFoundError:
            print("There was no kept conversation.")
        except OSError as e:
            print(f"thomas: could not delete {CONVO} ({e.strerror or e})", file=sys.stderr)
            return 1
        return 0
    if cmd == "check":
        kind, rest = take_brain(argv[2:], cfg.get("brain", {}).get(":kind") or "claude")
        if kind is None or rest:
            print("thomas: usage: thomas check [--brain claude|codex|gemini|free]", file=sys.stderr)
            return 2
        refusal = confinement_refusal(cfg.get("brain", {}), kind)
        if refusal:
            print("thomas: " + refusal, file=sys.stderr)
            return 1
        return check_turn(cfg, kind)
    if cmd == "telegram":
        return telegram_cmd(cfg, argv[2:])
    if cmd in ("ask", "chat"):
        rest = argv[2:]
        kind, rest = take_brain(rest, cfg.get("brain", {}).get(":kind") or "claude")
        if kind is None:
            print("thomas: --brain needs a name (claude, codex, gemini or free)", file=sys.stderr)
            return 2
        fresh = "--new" in rest
        rest = [a for a in rest if a != "--new"]
        refusal = confinement_refusal(cfg.get("brain", {}), kind)
        if refusal:
            print("thomas: " + refusal, file=sys.stderr)
            return 1
        gaps = [
            c
            for c in doctor_checks(
                _facts({**cfg, "brain": {**cfg.get("brain", {}), ":kind": kind}})
            )
            if not c[0]
        ]
        if gaps:
            print_doctor(gaps)
            return 1
        if cmd == "ask":
            if kind == FREE and sys.stderr.isatty():
                print(
                    "(thinking -- free models can take a minute or more)",
                    file=sys.stderr,
                    flush=True,
                )
            return ask_turn(cfg, kind, " ".join(rest), fresh)
        name = cfg.get("persona", {}).get(":name") or "Thomas"
        print(
            f"Talking with {name}. Type a message and press Return; an empty line or Ctrl-D ends the chat."
        )
        while True:
            try:
                line = input("\nyou> ").strip()
            except (EOFError, KeyboardInterrupt):
                print()
                return 0
            if not line or line in ("exit", "quit"):
                return 0
            print(f"({name} is thinking...)", file=sys.stderr)
            print()
            ask_turn(cfg, kind, line, fresh)
            fresh = False
    print(USAGE)
    return 2


def take_brain(rest, default):
    ""
    if "--brain" not in rest:
        return default, list(rest)
    i = rest.index("--brain")
    if i + 1 >= len(rest):
        return None, list(rest)
    return rest[i + 1], rest[:i] + rest[i + 2 :]


def memory_settings(cfg):
    ""
    mem = cfg.get("memory", {})
    keep = not (":keep" in mem and mem[":keep"] in (None, False))
    try:
        max_turns = int(mem.get(":turns") or 8)
    except (TypeError, ValueError):
        print(f"thomas: :turns is {mem.get(':turns')!r}, not a number; using 8", file=sys.stderr)
        max_turns = 8
    return keep, max_turns


def telegram_cmd(cfg, rest):
    ""
    import getpass
    import time

    import thomas_telegram as tg

    sub = rest[0] if rest else "status"
    conf = tg.load_conf()
    import platform

    running = tg.service_running(platform.system(), os.getuid())
    is_service = os.environ.get(tg.SERVICE_ENV) == "1"
    if (sub in ("setup", "unpair") or (sub == "run" and not is_service)) and running:
        print(
            "The background service is running; stop it first: thomas telegram uninstall",
            file=sys.stderr,
        )
        return 1
    if sub == "setup":
        print(
            "Create a bot: in Telegram, message @BotFather, send /newbot, and copy the token it gives you."
        )
        try:
            if sys.stdin.isatty():
                token = getpass.getpass("Paste the bot token (it is not shown): ").strip()
            else:  # piped in: getpass cannot hide anything there and warns, so read the line plainly
                token = sys.stdin.readline().strip()
        except (
            EOFError,
            KeyboardInterrupt,
        ):  # Ctrl-D / Ctrl-C at the prompt: no traceback
            print()
            token = ""
        if not token:
            print("No token given; nothing changed.", file=sys.stderr)
            return 2
        try:
            me = tg.http(token, "getMe", {}, timeout=20)
        except RuntimeError as e:
            print(f"Telegram did not accept that token ({e}); nothing saved.", file=sys.stderr)
            return 1
        name = (me.get("result") or {}).get("username", "?")
        tg.save_conf({"token": token, "offset": 0})  # a new token starts unpaired
        print(f"Saved for @{name} in {tg.CONF} (readable only by you). Next: thomas telegram run")
        return 0
    if sub == "unpair":
        conf.pop("owner", None)
        if conf:
            tg.save_conf(conf)
        print("Unpaired. The next `thomas telegram run` prints a new pairing code.")
        return 0
    if sub in ("install", "uninstall"):
        system = platform.system()
        me = os.path.realpath(__file__)
        ((svc_path, svc_text),) = tg.service_files(
            me, os.path.expanduser("~"), system, os.environ.get("PATH", "")
        )
        if sub == "install":
            if cfg.get("brain", {}).get(":autonomy") is True:
                print(
                    "thomas: Telegram runs only with autonomy off; remove (brain :autonomy t) first.",
                    file=sys.stderr,
                )
                return 1
            if not (conf.get("token") and conf.get("owner")):
                print(
                    "Pair first: thomas telegram setup, then thomas telegram run and send the /pair code. "
                    "The background service cannot show you a pairing code.",
                    file=sys.stderr,
                )
                return 1
            os.makedirs(os.path.dirname(svc_path), exist_ok=True)
            with open(svc_path, "w") as fh:
                fh.write(svc_text)
            os.close(os.open(tg.LOG, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600))
            os.chmod(tg.LOG, 0o600)
        for cmd, must in tg.service_commands(system, os.getuid(), svc_path, sub == "install"):
            try:
                p = subprocess.run(cmd, capture_output=True, text=True, check=False)
            except OSError:
                if not must:
                    continue
                print(
                    f"thomas: this system has no `{cmd[0]}`, so Thomas cannot install a background service here; "
                    "keep `thomas telegram run` open in a terminal instead.",
                    file=sys.stderr,
                )
                if sub == "install" and os.path.exists(svc_path):
                    os.remove(svc_path)  # no half-installed unit left behind
                return 1
            if p.returncode != 0 and must:
                print(
                    f"thomas: `{' '.join(cmd)}` failed: {(p.stderr or p.stdout).strip()[:200]}",
                    file=sys.stderr,
                )
                return 1
        if sub == "uninstall":
            try:
                os.remove(svc_path)
            except (
                FileNotFoundError
            ):  # the service file is already gone, which is the state `stop` wants
                pass
            if tg.service_running(system, os.getuid()):
                probe = " ".join(tg.service_probe(system, os.getuid()))
                print(
                    f"thomas: the service is STILL running after the stop; check `{probe}`",
                    file=sys.stderr,
                )
                return 1
            print(
                "Stopped and removed. Your assistant no longer answers Telegram until you run or install it again."
            )
        else:
            print(
                f"Running in the background, and again after every login. Log: {tg.LOG}. "
                "Stop it with: thomas telegram uninstall"
            )
        return 0
    if sub == "status":
        if not conf.get("token"):
            print("Not set up. Run: thomas telegram setup")
        else:
            print(
                "Set up; "
                + (
                    "paired with one chat."
                    if conf.get("owner")
                    else "not paired yet (thomas telegram run)."
                )
            )
        return 0
    if sub != "run":
        print(
            "usage: thomas telegram setup | run | install | uninstall | status | unpair",
            file=sys.stderr,
        )
        return 2
    if not conf.get("token"):
        print("Not set up. Run: thomas telegram setup", file=sys.stderr)
        return 1
    kind = cfg.get("brain", {}).get(":kind") or "claude"
    if cfg.get("brain", {}).get(":autonomy") is True:
        print(
            "thomas: Telegram runs only with autonomy off; remove (brain :autonomy t) to use it.",
            file=sys.stderr,
        )
        return 1
    refusal = confinement_refusal(cfg.get("brain", {}), kind)
    if refusal:
        print("thomas: " + refusal, file=sys.stderr)
        return 1
    gaps = [c for c in doctor_checks(_facts(cfg)) if not c[0]]
    if gaps:
        print_doctor(gaps)
        return 1
    reconf = getattr(
        sys.stdout, "reconfigure", None
    )  # absent on a replaced stdout; then printing is already its job
    if reconf:
        reconf(line_buffering=True)
    state = {"code": None if conf.get("owner") else tg.new_code(), "wrong": 0}
    if state["code"]:
        print(f"From your phone, send this to your bot:  /pair {state['code']}")
    print(
        "Listening for Telegram messages."
        + (
            " Leave this window open; Ctrl-C stops it (or: thomas telegram install)."
            if sys.stdout.isatty()
            else ""
        )
    )

    def turn(text):
        try:
            rc, reply, _out, _err = run_turn(cfg, kind, text)
        except Exception as e:  # noqa: BLE001
            print(f"thomas telegram: a turn failed ({type(e).__name__})", file=sys.stderr)
            rc, reply = 1, ""
        return (
            reply
            if rc == 0 and reply
            else "Your assistant could not answer that one; check the computer."
        )

    def call(method, params):
        return tg.http(conf["token"], method, params)

    try:
        while True:
            try:
                tg.poll_once(conf, state, call, turn, tg.save_conf)
            except RuntimeError as e:
                print(f"thomas telegram: {e}; retrying in 10 s", file=sys.stderr)
                time.sleep(10)
    except KeyboardInterrupt:
        print("\nStopped.")
        return 0


def ask_turn(cfg, kind, text, fresh=False, convo=CONVO, notes=NOTES):
    ""
    rc, _reply, out, err = run_turn(cfg, kind, text, fresh, convo, notes)
    print(out, end="" if out.endswith("\n") else "\n")
    sys.stderr.write(err)
    hint = auth_hint(kind, out + err) if rc != 0 else None
    if hint:
        print("\n  " + hint)
    return rc


def run_turn(cfg, kind, text, fresh=False, convo=CONVO, notes=NOTES):
    ""
    import json
    import time

    keep, max_turns = memory_settings(cfg)
    history = "" if (fresh or not keep) else history_block(load_turns(convo), max_turns)
    prompt = compose_prompt(
        persona_preamble(cfg.get("persona", {})), read_notes(notes) if keep else "", history, text
    )
    rc, reply, out, err = brain_call(cfg, kind, prompt)
    record_ask(rc == 0, len(prompt), len(reply) if rc == 0 else 0, kind)
    if rc == 0 and keep and reply:
        try:
            row = {"t": round(time.time()), "you": clean_text(text), "thomas": clean_text(reply)}
            _private_append(convo, json.dumps(row) + "\n")
        except OSError as e:
            print(f"thomas: could not keep this turn in {convo} ({e})", file=sys.stderr)
    return rc, reply, out, err


def brain_call(cfg, kind, prompt, rotate=False):
    ""
    import tempfile

    if kind == FREE:
        rc, reply, err = free_turn(prompt, rotate=rotate)
        return rc, reply, reply + ("\n" if reply else ""), err
    probe = os.path.join(os.path.dirname(os.path.realpath(__file__)), "acp-probe")
    scratch = (
        None
        if cfg.get("brain", {}).get(":autonomy") is True
        else tempfile.mkdtemp(prefix="thomas-ask-")
    )
    p = subprocess.run(
        [
            probe,
            "--agent",
            kind,
            "--timeout",
            "300",
            "--text",
            *ask_flags(cfg.get("brain", {}), kind, scratch),
            prompt,
        ],
        capture_output=True,
        text=True,
        check=False,
    )
    if scratch:
        shutil.rmtree(scratch, ignore_errors=True)
    return p.returncode, p.stdout.rstrip("\n"), p.stdout, p.stderr


CHECK_MARKS = ("OK", "DOUBT", "UNSURE")


def check_prompt(question, answer):
    ""
    return (
        "Another assistant wrote the ANSWER below to the QUESTION. You did not write it, and your job is to find what "
        "is wrong with it, not to defend it.\n"
        "List each checkable claim in the ANSWER (facts, numbers, names, dates, steps, code). For each, write ONE line:\n"
        "OK | <claim> | <why it holds>\n"
        "DOUBT | <claim> | <what is wrong or likely wrong>\n"
        "UNSURE | <claim> | <what you would need to check it>\n"
        "Do not rewrite the answer. If it has no checkable claims, write only: NONE\n\n"
        f"QUESTION:\n{question}\n\nANSWER:\n{answer}\n"
    )


def parse_check(text):
    ""
    rows = []
    for ln in (text or "").splitlines():
        parts = [p.strip() for p in ln.strip().lstrip("-*• ").split("|")]
        mark = parts[0].strip("*_` ").rstrip(":").strip("*_` ").upper() if parts else ""
        if mark in CHECK_MARKS and len(parts) >= 2 and parts[1]:
            rows.append((mark, parts[1], " | ".join(parts[2:]).strip()))
    return rows


def check_turn(cfg, kind, convo=CONVO, _call=None, out=print):
    ""
    if not memory_settings(cfg)[0]:
        out(
            "Nothing to check: (memory :keep nil) keeps no conversation, so there is no last answer to read."
        )
        return 1
    turns = load_turns(convo)
    if not turns:
        out("Nothing to check yet: ask something first (thomas ask or thomas chat).")
        return 1
    last = turns[-1]
    rc, reply, _o, err = (_call or brain_call)(
        cfg, kind, check_prompt(last["you"], last["thomas"]), rotate=True
    )
    if rc != 0:
        out(
            "The check could not run: "
            + (err.strip().rstrip(".") or "the brain did not answer")
            + "."
        )
        return 1
    rows = parse_check(reply)
    out(
        "Checked by a fresh session of the brain that did not write the answer -- a second opinion, not proof: it"
    )
    out("can share the first answer's blind spots, and it can be wrong itself.\n")
    if not rows:
        out(
            "  "
            + (
                "No checkable claims found."
                if reply.strip().strip("*_`. ").upper() == "NONE"
                else reply.strip()[:600]
            )
        )
        return 0
    for mark, claim, why in rows:
        out(f"  {mark:<6} {claim}" + (f"\n         {why}" if why else ""))
    n = {m: sum(1 for r in rows if r[0] == m) for m in CHECK_MARKS}
    out(f"\n{len(rows)} claim(s): {n['OK']} OK, {n['DOUBT']} doubted, {n['UNSURE']} unsure.")
    return 0


def selftest():
    import tempfile

    cases = []

    def ck(n, got, want):
        cases.append((n, got == want, got))

    ck("preamble-default", persona_preamble({}), "You are Thomas, the user's assistant.")
    _cp = check_prompt("When did WW2 end?", "1946, in May.")
    ck(
        "check-prompt-carries-question-and-answer-and-nothing-of-yours",
        (
            "When did WW2 end?" in _cp,
            "1946, in May." in _cp,
            "You are Thomas" in _cp,
            "did not write it" in _cp,
        ),
        (True, True, False, True),
    )
    ck(
        "parse-check-reads-marked-lines-only",
        parse_check(
            "Here you go:\nDOUBT | it ended in 1946 | it ended in 1945\nOK | in May | V-E Day was 8 May\nthanks"
        ),
        [("DOUBT", "it ended in 1946", "it ended in 1945"), ("OK", "in May", "V-E Day was 8 May")],
    )
    ck(
        "parse-check-ignores-unmarked-and-empty-claims",
        parse_check("NONE\nMAYBE | x | y\nOK |  | z"),
        [],
    )
    with tempfile.TemporaryDirectory() as _d:
        _seen: list = []
        _lines: list = []
        ck(
            "check-with-no-turns-says-so",
            check_turn({}, "free", os.path.join(_d, "none.jsonl"), out=_lines.append),
            1,
        )
        _c = os.path.join(_d, "c.jsonl")
        with open(_c, "w") as _fh:
            _fh.write('{"you": "When did WW2 end?", "thomas": "1946, in May."}\n')

        def _fake(cfg, kind, prompt, rotate=False):
            _seen.append((kind, rotate, "1946" in prompt))
            return 0, "DOUBT | it ended in 1946 | 1945", "", ""

        _lines.clear()
        ck(
            "check-asks-a-rotated-fresh-call-and-reports-the-doubt",
            (check_turn({}, "free", _c, _call=_fake, out=_lines.append), _seen, _lines[-1].strip()),
            (0, [("free", True, True)], "1 claim(s): 0 OK, 1 doubted, 0 unsure."),
        )
        _lines.clear()
        check_turn(
            {},
            "free",
            _c,
            _call=lambda *a, **k: (0, "Nonetheless, this is wrong.", "", ""),
            out=_lines.append,
        )
        ck(
            "check-shows-an-unparsed-objection-instead-of-no-claims",
            _lines[-1].strip(),
            "Nonetheless, this is wrong.",
        )
    ck(
        "parse-check-reads-a-mark-dressed-in-markdown",
        parse_check("**DOUBT** | it ended in 1946 | 1945\n- `OK`: | Berlin fell | May 1945"),
        [("DOUBT", "it ended in 1946", "1945"), ("OK", "Berlin fell", "May 1945")],
    )
    ck(
        "take-brain-reads-the-flag-and-leaves-the-rest",
        (
            take_brain(["x", "--brain", "free", "y"], "claude"),
            take_brain(["x"], "claude"),
            take_brain(["--brain"], "c"),
        ),
        (("free", ["x", "y"]), ("claude", ["x"]), (None, ["--brain"])),
    )
    with tempfile.TemporaryDirectory() as _d:
        _lines.clear()
        _n = check_turn(
            {"memory": {":keep": None}}, "free", os.path.join(_d, "none.jsonl"), out=_lines.append
        )
        ck("check-with-keep-off-says-why", (_n, "keep nil" in _lines[-1]), (1, True))
        _old = os.path.join(_d, "old.jsonl")
        with open(_old, "w") as _fh:
            _fh.write('{"you": "Q1", "thomas": "old"}\n')
        _lines.clear()
        _asked: list = []
        _n = check_turn(
            {"memory": {":keep": None}},
            "free",
            _old,
            _call=lambda *a, **k: _asked.append(1) or (0, "NONE", "", ""),
            out=_lines.append,
        )
        ck("check-with-keep-off-ignores-old-rows", (_n, _asked, "keep nil" in _lines[-1]), (1, [], True))
        _c2 = os.path.join(_d, "c.jsonl")
        with open(_c2, "w") as _fh:
            _fh.write('{"you": "q", "thomas": "a"}\n')
        _lines.clear()
        check_turn(
            {},
            "free",
            _c2,
            _call=lambda *a, **k: (1, "", "", "busy, try again."),
            out=_lines.append,
        )
        ck(
            "check-error-ends-with-one-period",
            _lines[-1],
            "The check could not run: busy, try again.",
        )
    ck(
        "free-rotate-starts-from-a-different-model",
        (rotated(["a", "b", "c"], True), rotated(["a", "b", "c"], False), rotated(["a"], True)),
        (["b", "c", "a"], ["a", "b", "c"], ["a"]),
    )
    with tempfile.TemporaryDirectory() as _d:
        import json as _json

        _p = os.path.join(_d, "sub", "asks.jsonl")
        record_ask(True, 12, 345, "claude", path=_p, now=1000)
        record_ask(False, 3, 0, "codex", path=_p, now=1001)
        with open(_p) as _fh:
            _rows = [_json.loads(x) for x in _fh]
        ck(
            "ask-history-one-line-per-ask-lengths-only-never-text",
            _rows,
            [
                {"t": 1000, "brain": "claude", "ok": True, "prompt_chars": 12, "reply_chars": 345},
                {"t": 1001, "brain": "codex", "ok": False, "prompt_chars": 3, "reply_chars": 0},
            ],
        )
        ck(
            "ask-history-unwritable-never-raises",
            record_ask(True, 1, 1, "claude", path="/dev/null/x/asks.jsonl")["ok"],
            True,
        )
    ck(
        "ask-flags-everything-off-unless-opted-in",
        (
            ask_flags({}, "gemini"),
            ask_flags({":autonomy": True, ":borrow-credentials": True, ":ledger": True}, "claude"),
        ),
        (["--mode", "default", "--no-borrow", "--no-ledger"], []),
    )
    ck(
        "ask-flags-asking-mode-runs-in-the-scratch-folder-autonomy-does-not",
        (ask_flags({}, "claude", "/tmp/s")[:4], ask_flags({":autonomy": True}, "claude", "/tmp/s")),
        (["--mode", "default", "--cwd", "/tmp/s"], ["--no-borrow", "--no-ledger"]),
    )
    ck(
        "help-names-the-scratch-folder-and-what-is-verified",
        (
            "scratch folder" in USAGE,
            "anywhere your user account can" in USAGE and "whoami" in USAGE,
        ),
        (True, True),
    )
    ck(
        "ask-flags-safe-mode-is-per-agent-and-nil-is-off",
        (ask_flags({":autonomy": None}, "codex")[:2], ask_flags({":ledger": True}, "claude")),
        (["--mode", "read-only"], ["--mode", "default", "--no-borrow"]),
    )
    ck(
        "confinement-refuses-unproven-brains-unless-autonomy",
        (
            confinement_refusal({}, "claude"),
            confinement_refusal({}, "codex") is not None,
            confinement_refusal({":autonomy": None}, "gemini") is not None,
            confinement_refusal({":autonomy": True}, "codex"),
        ),
        (None, True, True, None),
    )
    ready = {
        "py": (3, 12),
        "redis_mod": True,
        "config": "/kit/thomas.sexpr",
        "chosen": "claude",
        "agents": ("claude", "codex"),
        "adapter": "claude-agent-acp",
        "adapter_on_path": True,
        "node": 22,
        "probe_ok": True,
        "claude_login": True,
    }
    ck("doctor-ready-machine-has-no-gaps", [w for ok, w, _ in doctor_checks(ready) if not ok], [])
    ck(
        "doctor-claude-brain-without-a-login-is-a-gap",
        [
            w.split(" (")[0]
            for ok, w, _ in doctor_checks({**ready, "claude_login": False})
            if not ok
        ],
        ["logged in to Claude"],
    )
    signin = "Next: sign in"
    ck("next-signed-in-skips-login", any(signin in s for s in next_steps(ready, "T")), False)
    ck(
        "next-logged-out-asks-login",
        next_steps({**ready, "claude_login": False}, "T")[0].startswith(signin),
        True,
    )
    ck(
        "next-free-with-key-skips-login",
        any(
            signin in s
            for s in next_steps(
                {**ready, "chosen": FREE, "free_ready": True, "claude_login": False}, "T"
            )
        ),
        False,
    )
    ck(
        "next-free-without-key-asks-login",
        next_steps({**ready, "chosen": FREE, "free_ready": False}, "T")[0].startswith(signin),
        True,
    )
    ck(
        "next-shows-the-given-command",
        next_steps(ready, "/k/thomas")[-1].endswith('/k/thomas ask "hello"'),
        True,
    )
    ck(
        "next-signed-in-with-a-gap-says-fix-first",
        next_steps({**ready, "node": 18}, "T")[0].startswith("Next: fix"),
        True,
    )
    ck(
        "next-codex-names-its-own-login",
        "codex brain" in next_steps({**ready, "chosen": "codex"}, "T")[0],
        True,
    )
    newbie = {
        **ready,
        "redis_mod": False,
        "config": None,
        "chosen": None,
        "adapter": None,
        "node": 18,
        "probe_ok": False,
    }
    ck(
        "doctor-names-every-audit-gap",
        [w.split(" (")[0] for ok, w, _ in doctor_checks(newbie) if not ok],
        [
            "the redis package for Python",
            "the shipped defaults",
            "a chosen brain",
            "acp-probe can launch",
        ],
    )
    ck(
        "doctor-node-18-is-a-gap-for-the-claude-adapter-node-22-is-not",
        (
            [ok for ok, w, _ in doctor_checks({**ready, "node": 18}) if w.startswith("Node")],
            [
                ok
                for ok, w, _ in doctor_checks({**ready, "adapter_on_path": False})
                if w.startswith("the ") and w.endswith("adapter")
            ],
        ),
        ([False], [False]),
    )
    ck(
        "auth-hint-names-the-login-only-for-an-auth-failure",
        (
            auth_hint("claude", "RequestError: Authentication required"),
            auth_hint("claude", "hello there"),
        ),
        (
            "your claude brain needs YOUR login first: run `thomas login`, then ask again.",
            None,
        ),
    )
    ck(
        "sdk-claude-paths-cover-nested-and-hoisted",
        sdk_claude_paths(
            "/k/adapter/node_modules/@agentclientprotocol/claude-agent-acp/dist/index.js"
        ),
        [
            "/k/adapter/node_modules/@agentclientprotocol/claude-agent-acp/node_modules/@anthropic-ai",
            "/k/adapter/node_modules/@anthropic-ai",
        ],
    )
    ck(
        "logged-in-reads-the-field-and-fails-closed",
        (
            logged_in(
                "b",
                _run=lambda *a, **k: subprocess.CompletedProcess(a, 0, '{"loggedIn": true}', ""),
            ),
            logged_in(
                "b",
                _run=lambda *a, **k: subprocess.CompletedProcess(a, 1, '{"loggedIn": false}', ""),
            ),
            logged_in("b", _run=lambda *a, **k: subprocess.CompletedProcess(a, 1, "not json", "")),
            logged_in(None),
        ),
        (True, False, None, None),
    )
    ck(
        "logged-in-renamed-or-non-bool-field-is-unreadable-not-logged-out",
        tuple(
            logged_in("b", _run=lambda *a, o=o, **k: subprocess.CompletedProcess(a, 0, o, ""))
            for o in ('{"logged_in": true}', '{"loggedIn": "yes"}', "[true]")
        ),
        (None, None, None),
    )
    g = globals()
    real_li, real_cb = g["logged_in"], g["claude_binary"]
    saved = {k: os.environ.pop(k, None) for k in ("ANTHROPIC_API_KEY", "CLAUDE_CODE_OAUTH_TOKEN")}
    try:
        claude_cfg = {"brain": {":kind": "claude"}}
        verdicts = []
        for binary, state in (("b", False), ("b", None), ("b", True), (None, None)):
            g["claude_binary"] = lambda b=binary: b
            g["logged_in"] = lambda _b, s=state: s
            verdicts.append(_facts(claude_cfg)["claude_login"])
    finally:
        g["logged_in"], g["claude_binary"] = real_li, real_cb
        os.environ.update({k: v for k, v in saved.items() if v is not None})
    ck(
        "facts-login-gap-on-logged-out-or-no-binary-not-on-unreadable",
        verdicts,
        [False, True, True, False],
    )
    import tempfile as _tf

    fd = _tf.mkdtemp()
    fcfg, fkey, fmod = (os.path.join(fd, n) for n in ("thomas.sexpr", "g.key", "g.model"))
    rc_ok = free_login(
        fcfg,
        _getpass=lambda _p: "KEY",
        _check=lambda k: (["a/b:free", "c/d:free"], ""),
        key_path=fkey,
        model_path=fmod,
    )
    with open(fcfg, encoding="utf-8") as fh:
        cfg_text = fh.read()
    rc_bad = free_login(
        os.path.join(fd, "b.sexpr"),
        _getpass=lambda _p: "BAD",
        _check=lambda k: ([], "nope"),
        key_path=os.path.join(fd, "b.key"),
        model_path=os.path.join(fd, "b.model"),
    )
    ck(
        "free-login-saves-0600-and-chooses-free-brain-bad-key-saves-nothing",
        (
            rc_ok,
            thomas_config.parse(cfg_text).get("brain", {}).get(":kind") == FREE,
            oct(os.stat(fkey).st_mode & 0o777),
            oct(os.stat(fmod).st_mode & 0o777),
            rc_bad,
            os.path.exists(os.path.join(fd, "b.key")),
        ),
        (0, True, "0o600", "0o600", 1, False),
    )
    ck("free-brain-needs-no-confinement", confinement_refusal({}, FREE), None)
    ck(
        "try-hint-uses-the-full-path-until-a-new-terminal-finds-thomas",
        (try_cmd(lambda _n: "/x/thomas", "/k/thomas"), try_cmd(lambda _n: None, "/k/thomas")),
        ("thomas", "/k/thomas"),
    )
    ck(
        "claude-sign-in-refused-on-a-mac-when-HOME-is-not-the-account-home",
        (
            keychain_home_mismatch("darwin", "/tmp/x", "/acct/a")
            is not None,  # placeholder paths: only equality
            keychain_home_mismatch("darwin", "/acct/a", "/acct/a"),  # matters to this check
            keychain_home_mismatch("linux", "/tmp/x", "/home/a"),
        ),
        (True, None, None),
    )
    ck(
        "doctor-free-brain-without-key-names-login-free",
        [
            fix
            for ok, w, fix in doctor_checks(
                {
                    **ready,
                    "chosen": FREE,
                    "agents": (*acp_lib.AGENTS, FREE),
                    "adapter": None,
                    "free_ready": False,
                }
            )
            if not ok
        ],
        ["run `thomas login --free` -- it explains how to get a free key from openrouter.ai/keys"],
    )
    ck(
        "help-is-user-facing",
        ("src/" in USAGE, "thomas doctor" in USAGE),
        (False, True),
    )
    with tempfile.TemporaryDirectory() as _d:
        _link = os.path.join(_d, "thomas")
        os.symlink(os.path.realpath(__file__), _link)
        _out = subprocess.run(
            [sys.executable, _link, "doctor"],
            capture_output=True,
            text=True,
            timeout=60,
            check=False,
        ).stdout  # doctor exits 1 when a gap exists; the case reads the acp-probe line, not the code
    _beside = os.path.isfile(os.path.join(os.path.dirname(os.path.realpath(__file__)), "acp-probe"))
    ck(
        "doctor-through-a-symlink-looks-beside-the-real-file",
        [ln.split()[0] for ln in _out.splitlines() if "acp-probe can launch" in ln],
        ["ok" if _beside else "MISSING"],
    )
    ck(
        "preamble-designed",
        persona_preamble({":name": "Ada", ":pronouns": "she/her", ":voice": "dry"}),
        "You are Ada, the user's assistant.\nPronouns: she/her.\nVoice: dry",
    )
    ck("preamble-skips-unset", "None" in persona_preamble({":name": "T", ":voice": None}), False)
    ck(
        "brains-installed-flag",
        brains({"a": {"cmd": ["x"]}, "b": {"cmd": ["y", "--acp"]}}, lambda c: c == "x"),
        [("a", True, "x"), ("b", False, "y --acp")],
    )
    ck("set-form-creates", set_form([], "persona", ":name", "Ada"), [["persona", ":name", "Ada"]])
    ck(
        "set-form-replaces-one-key",
        set_form(
            [["persona", ":name", "A", ":voice", "v"], ["brain", ":kind", "codex"]],
            "persona",
            ":name",
            "B",
        ),
        [["persona", ":name", "B", ":voice", "v"], ["brain", ":kind", "codex"]],
    )
    with tempfile.TemporaryDirectory() as td:
        p = os.path.join(td, "thomas.sexpr")
        persona_set(":name", "Ada", p)
        persona_set(":personality", 'says "no" plainly', p)
        merged = thomas_config.load(env={}, user=p)
        ck(
            "persona-set-round-trips",
            (merged["persona"][":name"], merged["persona"][":personality"]),
            ("Ada", 'says "no" plainly'),
        )
        ck("persona-set-keeps-shipped-keys", merged["brain"].get(":kind"), "claude")
        try:
            persona_set(":image", "/nonexistent/x.png", p)
            img = "accepted"
        except SystemExit:
            img = "refused"
        ck("image-must-exist", img, "refused")
    _t = [{"you": f"q{i}", "thomas": f"a{i}"} for i in range(5)]
    ck("history-empty-is-empty-string", history_block([]), "")
    ck(
        "history-keeps-the-newest-max-turns",
        [x for x in ("q0", "q1", "q2", "q3", "q4") if f"User: {x}\n" in history_block(_t, 2)],
        ["q3", "q4"],
    )
    ck(
        "history-drops-oldest-whole-turn-to-fit",
        [
            x
            for x in ("q0", "q1", "q2", "q3", "q4")
            if f"User: {x}\n" in history_block(_t, 8, max_chars=20)
        ],
        ["q4"],
    )
    ck("history-skips-a-turn-with-no-reply", history_block([{"you": "q", "thomas": ""}]), "")
    ck("history-zero-turns-is-off", history_block(_t, 0), "")
    ck("prompt-without-memory-is-the-old-prompt", compose_prompt("P", "", "", "hi"), "P\n\nhi")
    _cp = compose_prompt("P", "- likes tea\n", "H", "hi")
    ck(
        "prompt-order-persona-notes-history-message",
        [_cp.index(x) for x in ("P", "likes tea", "H", "The user now says:\nhi")]
        == sorted(_cp.index(x) for x in ("P", "likes tea", "H", "The user now says:\nhi")),
        True,
    )
    ck("memory-on-by-default", memory_settings({}), (True, 8))
    ck(
        "memory-nil-turns-it-off",
        memory_settings({"memory": {":keep": None, ":turns": "3"}}),
        (False, 3),
    )
    ck("memory-bad-turns-is-8", memory_settings({"memory": {":turns": "lots"}}), (True, 8))
    with tempfile.TemporaryDirectory() as _d:
        _c = os.path.join(_d, "x", "conversation.jsonl")
        _private_append(_c, '{"you": "a", "thomas": "b"}\n')
        _private_append(_c, "torn{\n")
        ck("conversation-file-is-owner-only", oct(os.stat(_c).st_mode & 0o777), "0o600")
        ck("conversation-torn-line-skipped", load_turns(_c), [{"you": "a", "thomas": "b"}])
        ck("conversation-missing-is-empty", load_turns(os.path.join(_d, "none")), [])
        ck("notes-missing-is-empty", read_notes(os.path.join(_d, "none")), "")
        _bad = os.path.join(_d, "bad.jsonl")
        with open(_bad, "wb") as _bw:
            _bw.write(
                b'[1,2]\n5\nnull\n{"you": 3, "thomas": "x"}\n\xff\xfe junk\n{"you": "q", "thomas": "a"}\n'
            )
        ck(
            "conversation-non-objects-and-bad-bytes-skipped",
            load_turns(_bad),
            [{"you": "q", "thomas": "a"}],
        )
        _bn = os.path.join(_d, "bad.md")
        with open(_bn, "wb") as _bw:
            _bw.write(b"- likes \xe9t\xe9\n")
        ck("notes-in-another-encoding-read-not-crash", read_notes(_bn).startswith("- likes "), True)
    _p = compose_prompt("P\x00", "- note\x00\x00", "H\ud800", "hi\x00")
    ck(
        "prompt-has-no-nul-and-encodes",
        ("\x00" not in _p, len(_p.encode("utf-8")) > 0),
        (True, True),
    )
    ck(
        "long-notes-keep-the-newest",
        compose_prompt("P", "a" * 30000 + "END", "", "q").count("a") <= NOTES_CHARS,
        True,
    )
    ck("deep-nesting-skipped", _deep_ok(), True)
    bad = sum(not ok for _n, ok, _g in cases)
    for n, ok, got in cases:
        print(f"  {n:<32} {'PASS' if ok else 'FAIL'}" + ("" if ok else f"  got {got!r}"))
    print(
        "\nthomas --selftest RESULT: "
        + (f"FAIL -- {bad} case(s)" if bad else f"PASS -- {len(cases)} cases")
    )
    return 1 if bad else 0


if __name__ == "__main__":
    sys.exit(main(sys.argv))
