#!/usr/bin/env python3
"""
agsearch — global full-text search across all your coding agent sessions.

Claude Code and Codex CLI each store every session as local JSONL (~/.claude/projects/
and ~/.codex/sessions/). Their native pickers search session *metadata* — the title, the
first prompt, the branch. This searches what was actually *said*, across both tools, and
drops you straight back into the session with `claude --resume` or `codex resume`.

Usage:
    agsearch                       # interactive fuzzy TUI (needs fzf)
    agsearch "stripe tax id"       # open the TUI pre-filtered to this query
    agsearch -n "stripe tax id"    # non-interactive: print ranked matches, no fzf
    agsearch --here "..."          # only sessions from the current directory's project
    agsearch --project myapp       # only sessions whose path matches 'myapp'
    agsearch --thinking            # also index assistant thinking blocks
    agsearch --reindex             # force a full rebuild of the cache
    agsearch --version             # print the installed version and exit
    agsearch _preview <sid> <seq> <thinking> <query...>   # (internal) fzf preview

In the TUI the right pane previews the matched session, auto-scrolled to your match
(marked ▶) with a "match N of M" header. Enter resumes the session (and copies your
query to the clipboard, so ⌘F → ⌘V → Enter jumps to it inside the replayed transcript).
Resume is id-based: a session whose project dir was deleted still resumes, from the nearest
surviving ancestor dir. Sessions that still look live are marked ● and confirm before resuming.

Warm runs are near-instant: parsed sessions are cached per-file and only re-parsed
when their .jsonl mtime changes.
"""

__version__ = "0.1.0"

import os
import re
import sys
import math
import json
import shlex
import time
import shutil
import hashlib
import subprocess

HOME = os.path.expanduser("~")
PROJECTS_DIR = os.path.join(HOME, ".claude", "projects")
CODEX_DIR = os.path.join(HOME, ".codex", "sessions")
CACHE_DIR = os.path.join(os.environ.get("XDG_CACHE_HOME", os.path.join(HOME, ".cache")), "agsearch")
FRAG_DIR = os.path.join(CACHE_DIR, "frag")
META_PATH = os.path.join(CACHE_DIR, "meta.json")
SESSIONS_PATH = os.path.join(CACHE_DIR, "sessions.tsv")   # one line per session, for _filter
SUBMAP_PATH = os.path.join(CACHE_DIR, "submap.json")      # parent-sid -> [subagent file paths]
INDEX_PATH = os.path.join(CACHE_DIR, "index.json")        # sid -> {source, path} for preview/resume

CACHE_FMT = 6   # bump when the TSV column layout / keying changes, to invalidate old fragments

# TSV columns (tab-separated, one row per message):
#   0 session_id  1 cwd  2 gitBranch  3 iso_date  4 role  5 seq  6 title  7 text
SEP = "\t"

# Preview layout: this many header lines print before the first message, so a message
# with sequence `seq` lands on preview line HEADER_LINES + 1 + seq. Keep in sync with
# render_preview() — the fzf scroll offset is computed from it.
HEADER_LINES = 5

# A session whose transcript was appended to this recently is almost certainly still running
# (an agent mid-turn, or a CLI you have open in another tab). Marked ● live in the list.
ACTIVE_WINDOW_SEC = 180

# sessions.tsv columns (written by build_sessions, read by cmd_filter).
C_SID, C_CWD, C_DATE, C_SOURCE, C_KIND, C_TITLE, C_FIRST, C_BLOB = range(8)
SESSION_COLS = 8

# Ranking knobs — see rank_sessions(). k1/b are textbook BM25; b<1 keeps length normalization
# firm but not brutal, since a long session that genuinely discusses a term should still win
# over a short one that name-drops it. Fields are weighted: what you opened the session ASKING
# for (first prompt) beats what got said somewhere in hour three.
BM25_K1 = 1.2
BM25_B = 0.65
W_TITLE = 2.0
W_FIRST = 2.5
W_BODY = 1.0
# How much of each message is searchable. _single_line defaults to 400, which is right for a
# preview line and wrong for an index: 41% of messages are longer than that, and capping there
# left only 19% of transcript text searchable at all. A table or a summary a few hundred
# characters into a reply was simply absent from the index, so no amount of ranking could
# surface it. 4,000 recovers that; 20,000 measured no better and costs more to scan.
MSG_INDEX_CHARS = 4_000

RECENCY_HALFLIFE_DAYS = 45.0
RECENCY_W = 0.35          # max multiplier bump for a session from today
USAGE_W = 0.30            # max multiplier bump for a session you resume often


# ------------------------------------------------------------------ parsing

def _flatten_content(content):
    """Pull human-readable text out of a message.content (str or block list)."""
    if isinstance(content, str):
        return content
    if isinstance(content, list):
        parts = []
        for b in content:
            if isinstance(b, str):
                parts.append(b)
            elif isinstance(b, dict):
                t = b.get("type")
                if t == "text" and b.get("text"):
                    parts.append(b["text"])
                elif t == "tool_result":
                    parts.append(_flatten_content(b.get("content", "")))
        return "\n".join(p for p in parts if p)
    return ""


def _single_line(s, limit=400):
    return " ".join(s.split())[:limit]


def parse_session(path, include_thinking=False, limit=MSG_INDEX_CHARS):
    """Parse one .jsonl file into ordered index rows (8-field lists, seq assigned).

    Rows are keyed by each entry's `sessionId` field, not the filename. For normal sessions
    those are identical; for `agent-*.jsonl` subagent transcripts the sessionId points to the
    PARENT conversation, so subagent content folds into (and resumes) its parent session.
    """
    file_id = os.path.splitext(os.path.basename(path))[0]
    session_id = file_id
    title = ""
    cwd = ""
    branch = ""
    entry = ""
    rows = []   # each: [sid, cwd, branch, ts, role, title, text]
    try:
        fh = open(path, "r", errors="replace")
    except OSError:
        return session_id, []
    with fh:
        for line in fh:
            line = line.strip()
            if not line:
                continue
            try:
                o = json.loads(line)
            except json.JSONDecodeError:
                continue
            t = o.get("type")
            if t == "ai-title" and o.get("aiTitle"):
                title = o["aiTitle"]
                continue
            if t not in ("user", "assistant"):
                continue
            session_id = o.get("sessionId") or session_id   # parent id for agent-* files
            cwd = o.get("cwd", cwd)
            branch = o.get("gitBranch", branch)
            entry = o.get("entrypoint") or entry            # cli vs sdk-py/sdk-ts
            msg = o.get("message", {}) or {}
            role = msg.get("role", t)
            ts = o.get("timestamp", "")

            if t == "assistant" and include_thinking:
                content = msg.get("content")
                if isinstance(content, list):
                    for b in content:
                        if isinstance(b, dict) and b.get("type") == "thinking" and b.get("thinking"):
                            rows.append([session_id, cwd, branch, ts, "thinking", title,
                                         _single_line(b["thinking"])])

            text = _single_line(_flatten_content(msg.get("content", "")), limit)
            if not text:
                continue
            rows.append([session_id, cwd, branch, ts, role, title, text])

    # Titles can appear after the messages they label; backfill, then stamp sequence.
    # `kind` marks who drove the session: your own CLI use vs a plugin/SDK-spawned run.
    kind = "auto" if entry.startswith("sdk") else "cli"
    final = []
    for i, r in enumerate(rows):
        final.append([r[0], r[1], r[2], r[3], r[4], str(i), title or r[5], r[6], kind])
    return session_id, final


_CODEX_NOISE = ("<environment_context>", "<permissions", "<multi_agent_mode>", "<user_instructions>")

# Codex user messages often lead with injected preambles instead of the real task. Pick the
# first message that's an actual request: strip a known security-preamble prefix, and skip
# messages that are pure wrappers (AGENTS.md dumps, XML blocks, leftover preamble).
_CODEX_PREAMBLE_ANCHOR = "repository code only."   # end of the injected security preamble
_CODEX_SKIP_PREFIXES = ("<", "# agents.md", "agents.md instructions", "important: do not")


def _codex_title(user_texts):
    for t in user_texts:
        t = t.strip()
        if _CODEX_PREAMBLE_ANCHOR in t:            # real task is appended after the preamble
            t = t.split(_CODEX_PREAMBLE_ANCHOR, 1)[1].strip()
        if not t or t.lower().startswith(_CODEX_SKIP_PREFIXES):
            continue                               # pure boilerplate → try the next message
        return t[:90]
    return ""


def parse_codex_session(path, include_thinking=False, limit=MSG_INDEX_CHARS):
    """Parse an OpenAI Codex CLI rollout file into the same 8-field row schema as Claude.

    Session id/cwd come from the `session_meta` entry; messages are `response_item` entries of
    payload type "message" (roles user/assistant; `developer`/`system` and injected context are
    dropped). Keyed by the meta `id` UUID, which is what `codex resume <id>` takes.
    """
    sid = os.path.splitext(os.path.basename(path))[0]
    cwd = branch = ts0 = ""
    rows = []
    try:
        fh = open(path, "r", errors="replace")
    except OSError:
        return sid, []
    with fh:
        for line in fh:
            line = line.strip()
            if not line:
                continue
            try:
                o = json.loads(line)
            except json.JSONDecodeError:
                continue
            t = o.get("type")
            if t == "session_meta":
                p = o.get("payload", {}) or {}
                sid = p.get("id") or p.get("session_id") or sid
                cwd = p.get("cwd", cwd)
                ts0 = p.get("timestamp", ts0)
                g = p.get("git")
                if isinstance(g, dict):
                    branch = g.get("branch") or branch
                continue
            if t != "response_item":
                continue
            p = o.get("payload", {}) or {}
            if p.get("type") != "message" or p.get("role") not in ("user", "assistant"):
                continue
            c = p.get("content")
            if isinstance(c, str):
                text = c
            elif isinstance(c, list):
                text = " ".join(b.get("text", "") for b in c if isinstance(b, dict) and b.get("text"))
            else:
                text = ""
            text = _single_line(text, limit)
            if not text or text.lstrip().startswith(_CODEX_NOISE):
                continue
            rows.append([sid, cwd, branch, o.get("timestamp") or ts0, p["role"], "", "", text])

    title = _codex_title([r[7] for r in rows if r[4] == "user"])
    final = [[sid, r[1], r[2], r[3], r[4], str(i), title, r[7], "cli"] for i, r in enumerate(rows)]
    return sid, final


# ------------------------------------------------------------------ cache

def _frag_path(jsonl_path):
    key = hashlib.sha1(jsonl_path.encode()).hexdigest()[:16]
    return os.path.join(FRAG_DIR, key + ".tsv")


# A cold build over ~1,200 sessions takes several seconds. Silence for that long
# reads as "hung" and is the cheapest bounce in the product, so we narrate it —
# but only when there is real work and someone watching. Warm runs re-parse a
# handful of files and must stay silent; piped/redirected stderr must stay clean
# so `agsearch -n` output can be consumed by scripts.
PROGRESS_MIN_FILES = 25     # below this a rebuild is fast enough to need no narration


class _IndexProgress:
    """Single overwriting stderr line: 'indexing 412/1238 sessions...'."""

    def __init__(self, total, stream=None):
        self.total = total
        self.done = 0
        self.stream = stream if stream is not None else sys.stderr
        self.active = total >= PROGRESS_MIN_FILES and _isatty(self.stream)
        self._width = 0

    def tick(self):
        self.done += 1
        if not self.active:
            return
        # Repaint on the first file, then ~every 1%, so a big corpus does not
        # spend its time writing escape codes.
        step = max(1, self.total // 100)
        if self.done != 1 and self.done % step and self.done != self.total:
            return
        msg = f"indexing {self.done}/{self.total} sessions..."
        self._width = max(self._width, len(msg))
        self.stream.write("\r" + msg.ljust(self._width))
        self.stream.flush()

    def done_(self):
        """Erase the line so it leaves no residue above the TUI or the results."""
        if not self.active or not self._width:
            return
        self.stream.write("\r" + " " * self._width + "\r")
        self.stream.flush()
        self._width = 0


def _isatty(stream):
    try:
        return bool(stream.isatty())
    except (AttributeError, ValueError):
        return False


def build_index(include_thinking=False, force=False):
    """Return the full index as a list of TSV strings, refreshing per-file caches."""
    os.makedirs(FRAG_DIR, exist_ok=True)
    meta = {}
    if os.path.exists(META_PATH) and not force:
        try:
            meta = json.load(open(META_PATH))
        except (OSError, json.JSONDecodeError):
            meta = {}
    if meta.get("_thinking") != include_thinking or meta.get("_fmt") != CACHE_FMT:
        force = True          # thinking toggle or format change invalidates fragments
        meta = {}

    # Each source: (tag, root dir, parser). Add more agents here later (Cursor, Gemini…).
    sources = [("cc", PROJECTS_DIR, parse_session), ("codex", CODEX_DIR, parse_codex_session)]
    files = []                          # (path, source, parser)
    for source, root, parser in sources:
        if os.path.isdir(root):
            for r, _dirs, fs in os.walk(root):
                for fn in fs:
                    if fn.endswith(".jsonl"):
                        files.append((os.path.join(r, fn), source, parser))

    # Stat every file once up front: the same mtimes decide cache hits below and
    # tell us how many sessions actually need parsing, which is what we report.
    mtimes = {}
    stale = 0
    for path, _source, _parser in files:
        try:
            mtimes[path] = os.path.getmtime(path)
        except OSError:
            continue
        if force or meta.get(path) != mtimes[path] or not os.path.exists(_frag_path(path)):
            stale += 1
    progress = _IndexProgress(stale)

    new_meta = {"_thinking": include_thinking, "_fmt": CACHE_FMT}
    live_frags = set()
    lines = []
    sub_map = {}                        # parent sid -> [subagent file paths] (Claude only)
    index = {}                          # sid -> {source, path} for preview + resume routing
    for path, source, parser in files:
        if path not in mtimes:              # vanished between the stat pass and now
            continue
        mtime = mtimes[path]
        frag = _frag_path(path)
        live_frags.add(os.path.basename(frag))
        if not force and meta.get(path) == mtime and os.path.exists(frag):
            with open(frag, errors="replace") as fh:
                frag_lines = fh.read().splitlines()
        else:
            _sid, rows = parser(path, include_thinking)
            frag_lines = [SEP.join(r) for r in rows]
            with open(frag, "w") as fh:
                fh.write("\n".join(frag_lines))
            progress.tick()
        new_meta[path] = mtime
        lines.extend(frag_lines)
        if not frag_lines:
            continue
        sid0 = frag_lines[0].split(SEP, 1)[0]
        base = os.path.basename(path)
        if source == "cc" and base.startswith("agent-"):
            sub_map.setdefault(sid0, []).append(path)     # subagent folds into parent
        else:
            index[sid0] = {"source": source, "path": path}

    for fn in os.listdir(FRAG_DIR):     # drop fragments for deleted sessions
        if fn not in live_frags:
            try:
                os.remove(os.path.join(FRAG_DIR, fn))
            except OSError:
                pass

    progress.done_()

    json.dump(new_meta, open(META_PATH, "w"))
    json.dump(sub_map, open(SUBMAP_PATH, "w"))
    json.dump(index, open(INDEX_PATH, "w"))
    return lines


# ------------------------------------------------------------------ filtering

def apply_scope(lines, here=False, project=None):
    if here:
        cwd = os.getcwd()
        lines = [l for l in lines
                 if l.split(SEP, 2)[1] == cwd or l.split(SEP, 2)[1].startswith(cwd + os.sep)]
    if project:
        p = project.lower()
        lines = [l for l in lines if p in l.split(SEP, 2)[1].lower()]
    return lines


def rank_matches(lines, query):
    """AND-of-terms substring filter, ranked by recency (iso date desc)."""
    terms = [t.lower() for t in query.split()]
    hits = [l for l in lines if all(t in l.lower() for t in terms)]
    hits.sort(key=lambda l: l.split(SEP)[3], reverse=True)
    return hits


# ------------------------------------------------------------------ rendering

def short_proj(cwd):
    return os.path.basename(cwd.rstrip("/")) or cwd


def _highlight(text, terms, code="\033[1;30;43m"):
    """Bold-highlight each query term (case-insensitive, first occurrence per term)."""
    for t in terms:
        if not t:
            continue
        low = text.lower()
        idx = low.find(t)
        if idx >= 0:
            text = text[:idx] + code + text[idx:idx + len(t)] + "\033[0m" + text[idx + len(t):]
    return text


ROW_TEXT_WIDTH = 160        # keeps -n rows one terminal line each, however big the message was


def _agent_tag(source):
    """Name the agent side of a session after the tool it came from: cc (Claude) or cx (Codex).

    The session list already marks the source that way, so a row or preview line that calls
    every assistant turn `cc` contradicts the column two inches to its left.
    """
    return "cx" if source == "codex" else "cc"


def _source_map():
    """sid -> source, from the index build_index() writes. Missing/unreadable reads as Claude,
    which is what the rest of the tool defaults to."""
    try:
        index = json.load(open(INDEX_PATH))
    except (OSError, json.JSONDecodeError):
        return {}
    return {sid: info.get("source", "cc") for sid, info in index.items()}


def fmt_row(l, terms=(), source="cc"):
    f = (l.split(SEP) + [""] * 8)[:8]
    sid, cwd, branch, ts, role, seq, title, text = f
    tag = {"user": "you", "assistant": _agent_tag(source), "thinking": "th"}.get(role, role)
    body = _snippet(text, [t for t in terms if t], ROW_TEXT_WIDTH)
    return f"{ts[:10]}  {short_proj(cwd)[:18]:<18}  {tag:<3} {title[:32]:<32} │ {body}"


def print_matches(lines, query, limit=40):
    hits = rank_matches(lines, query) if query else lines
    if not hits:
        print("No matches.", file=sys.stderr)
        return 1
    terms = [t.lower() for t in query.split()]
    sources = _source_map()
    for l in hits[:limit]:
        print(fmt_row(l, terms, sources.get(l.split(SEP, 1)[0], "cc")))
    extra = len(hits) - limit
    if extra > 0:
        print(f"... and {extra} more (narrow the query or use the fzf TUI).", file=sys.stderr)
    return 0


def _session_path(sid):
    if os.path.isdir(PROJECTS_DIR):
        for root, _dirs, files in os.walk(PROJECTS_DIR):
            if sid + ".jsonl" in files:
                return os.path.join(root, sid + ".jsonl")
    return None


def _turn_header(role, source, is_sub):
    """Chat-style role gutter for a preview turn: '▌ you', '▌ claude', '▌ ⤷ codex'.

    The agent side is named after the source so the preview mirrors the tool the session came
    from, the way you saw it in Claude Code or Codex.
    """
    agent = "codex" if source == "codex" else "claude"
    name = {"user": "you", "assistant": agent, "thinking": "thinking"}.get(role, role or "?")
    if is_sub:
        return f"\033[35m▌ ⤷ {name}\033[0m"
    color = {"user": "36", "assistant": "32", "thinking": "90"}.get(role, "37")
    return f"\033[{color}m▌ {name}\033[0m"


def _turn(row, is_sub, source, keys, width=200):
    """One conversation turn: role gutter line, then the message."""
    return [_turn_header(row[4], source, is_sub), _snippet(row[7], keys, width)]


# --- snippet cleanup: transcripts are full of payloads nobody wants to read in a result row.
_IMAGE_RE = re.compile(r"\[Image:[^\]]*\]")
_FENCE_RE = re.compile(r"```[a-zA-Z0-9_+.-]*.*?```", re.S)      # closed fence
_OPEN_FENCE_RE = re.compile(r"```[a-zA-Z0-9_+.-]*.*", re.S)     # fence cut off by the 400-char cap
_MD_LINK_RE = re.compile(r"!?\[([^\]\n]{1,80})\]\([^)\s]*\)")   # [text](url) -> text
_MD_CODESPAN_RE = re.compile(r"`+([^`]+)`+")
# Emphasis markers only count when they stand alone — otherwise a C-style /** comment */ or a
# glob pattern gets shredded into gibberish.
_MD_BOLD_RE = re.compile(r"(?<![\w/*])(\*\*|__)(?!\s)(.+?)(?<!\s)\1(?![\w/*])", re.S)
_MD_ITALIC_RE = re.compile(r"(?<![\w/*])\*(?!\s)([^*/\n]{1,160}?)(?<!\s)\*(?![\w/*])")
_MD_HEADING_RE = re.compile(r"(?:^|(?<= ))#{1,6}\s+")
# Newlines are already collapsed by index time, so a mid-string "- " is ambiguous with a dash in
# prose. Only strip one when it introduces a bold/heading run — the shape list items actually have.
_MD_BULLET_RE = re.compile(r"(?:^|(?<= ))[-*+•]\s+(?=\*\*|#{1,6}\s)")
_MD_LEAD_LIST_RE = re.compile(r"^\s*(?:[-*+•]|\d+\.|>)\s+")
_LONG_TOKEN_RE = re.compile(r"\S{45,}")


def _condense_json(s, minlen=40):
    """Replace embedded JSON objects/arrays with a [json] placeholder. Hand-scanned rather than
    regexed because these dumps nest, and they're routinely truncated mid-structure."""
    out = []
    i, n = 0, len(s)
    while i < n:
        c = s[i]
        nxt = s[i + 1:i + 3].lstrip()[:1]
        if c in "[{" and nxt in ('"', "{", "["):
            j, depth, in_str, esc = i, 0, False, False
            while j < n:
                ch = s[j]
                if in_str:
                    if esc:
                        esc = False
                    elif ch == "\\":
                        esc = True
                    elif ch == '"':
                        in_str = False
                elif ch == '"':
                    in_str = True
                elif ch in "[{":
                    depth += 1
                elif ch in "]}":
                    depth -= 1
                    if depth == 0:
                        j += 1
                        break
                j += 1
            if j - i >= minlen:                  # short inline objects read fine, leave them
                out.append("[json]")
                i = j
                continue
        out.append(c)
        i += 1
    return "".join(out)


def _condense_line_numbers(s, minrun=4):
    """`cat -n`-style file reads — "1 import x 2 import y 3 …" — are pure noise once newlines
    are gone. Collapse a run of ascending bare line numbers into a [file] placeholder. Bare
    digits only, so a "1. do this 2. do that" prose list is left alone."""
    toks = s.split(" ")
    out, i, n = [], 0, len(toks)
    while i < n:
        if toks[i].isdigit():
            last, chain = int(toks[i]), [i]
            for k in range(i + 1, n):
                if toks[k].isdigit():
                    v = int(toks[k])
                    if 0 < v - last <= 3:      # small gaps = blank lines in the file
                        last, _ = v, chain.append(k)
                    else:
                        break
            if len(chain) >= minrun:
                out.append("[file]")
                i = chain[-1] + 1
                continue
        out.append(toks[i])
        i += 1
    return " ".join(out)


def clean_snippet(text):
    """Turn one raw transcript message into readable prose for a result row.

    Strips markdown noise (fences, headings, bullets, emphasis, link syntax) and condenses
    payloads — JSON dumps, pasted images, long opaque tokens — into short placeholders, so a
    snippet shows what was said rather than what was dumped. Search still runs over the raw
    text; this is display only.
    """
    if not text:
        return ""
    t = _FENCE_RE.sub(" [code] ", text)
    t = _OPEN_FENCE_RE.sub(" [code] ", t)
    t = _IMAGE_RE.sub(" [image] ", t)
    t = _condense_json(t)
    t = _condense_line_numbers(t)
    t = _MD_LINK_RE.sub(r"\1", t)
    t = _MD_CODESPAN_RE.sub(r"\1", t)
    t = _MD_LEAD_LIST_RE.sub("", t)
    t = _MD_BULLET_RE.sub("", t)          # before emphasis: the bullet is spotted by the ** it leads
    t = _MD_BOLD_RE.sub(r"\2", t)
    t = _MD_ITALIC_RE.sub(r"\1", t)
    t = _MD_HEADING_RE.sub("", t)
    t = _LONG_TOKEN_RE.sub(lambda mo: mo.group(0)[:20] + "…", t)
    return " ".join(t.split())


def _snippet(text, terms, width=200):
    """A readable window of text centered on the first matching term, highlighted.

    Cleaned first, so the window shows prose instead of raw payload. If the match only exists
    in the part that got condensed, fall back to the raw text — a snippet without the term you
    searched for is worse than a noisy one.
    """
    live = [t for t in terms if t]
    clean = clean_snippet(text)
    if live and not any(t in clean.lower() for t in live):
        clean = _single_line(text, len(text))
    lo = clean.lower()
    pos = min([lo.find(t) for t in live if t in lo] or [0])
    start = max(0, pos - 40)
    frag = clean[start:start + width]
    if start:
        frag = "…" + frag
    if start + width < len(clean):
        frag += "…"
    return _highlight(frag, live)


def best_matching(texts, keys):
    """Indices of the messages containing the most query words, and how many that was.

    An exact AND when some message has them all, otherwise the closest ones. A blank card on
    the result you were just told is the best match reads as a broken search, and the session
    may well have been ranked there on its title or first prompt.
    """
    counts = [sum(1 for k in keys if _at_word_start(t.lower(), k)) for t in texts]
    best = max(counts, default=0)
    return [i for i, n in enumerate(counts) if n and n == best], best


def _arc_segments(tagged, source):
    """How the chat opened and where it ended, for when there is nothing matched to show."""
    segs = []
    idxs = []
    first_user = next((i for i, (r, _s) in enumerate(tagged) if r[4] == "user"), None)
    if first_user is not None:
        idxs.append(first_user)
        reply = next((i for i in range(first_user + 1, len(tagged))
                      if tagged[i][0][4] == "assistant"), None)
        if reply is not None:
            idxs.append(reply)
    for i in idxs:
        segs.append(_turn(*tagged[i][:1], tagged[i][1], source, []))
    last = len(tagged) - 1
    if last >= 0 and last not in idxs:
        if idxs and last > idxs[-1] + 1:
            segs.append(["\033[2m⋯\033[0m"])
        segs.append(_turn(tagged[last][0], tagged[last][1], source, []))
    return segs


# Six turns with a blank line between each ran 1.76x the old card's height, so a
# preview that used to fit began to scroll. Four turns and no blank lines lands at
# 1.05x: the gutter (`> you`) already separates turns, so the blank line was paying
# ~30% of the height for separation it was not providing.
PREVIEW_TURNS = 4


def _preview_lines(tagged, keys, source):
    """The preview body, rendered as a chat excerpt. Returns lines; kept pure so it is testable.

    Matching rules are the ranker's, not a second set: word-start keys, exact AND when some
    turn has every word, otherwise the closest turns. The chat shape is what changed — turns
    with a role gutter instead of isolated snippet lines, anchored by the opening prompt so
    what the chat was about stays visible even when the match is buried deep in it.
    """
    segs = []
    if keys:
        picked, best = best_matching([r[7] for r, _sub in tagged], keys)
        matches = [tagged[i] for i in picked]
        if best == len(keys):
            head = f"● {len(matches)} match(es)"
        elif best:
            head = f"● {len(matches)} partial · best turn has {best} of {len(keys)} words"
        else:
            head = "● matched on title or first prompt, not in any turn"
        segs.append([f"\033[1;33m{head}\033[0m"])

        shown = matches[:PREVIEW_TURNS]
        if shown:
            shown_rows = [r for r, _sub in shown]
            opening = next(((r, sub) for r, sub in tagged if r[4] == "user"), None)
            if opening and opening[0] not in shown_rows:
                segs.append(_turn(opening[0], opening[1], source, []))
                segs.append(["\033[2m⋯\033[0m"])
            for r, sub in shown:
                segs.append(_turn(r, sub, source, keys))
            if len(matches) > PREVIEW_TURNS:
                segs.append([f"\033[2m… {len(matches) - PREVIEW_TURNS} more (resume to read)\033[0m"])
        else:
            segs += _arc_segments(tagged, source)
    else:
        segs += _arc_segments(tagged, source)

    out = []
    for seg in segs:
        out += seg
    return out


def _flag(v):
    """A flag that arrived either as a Python bool or as an argv string from fzf."""
    return str(v).lower() in ("1", "true")


def load_session_rows(sid, thinking=False, limit=MSG_INDEX_CHARS):
    """(source, [(row, is_subagent)]) for one session, chronological.

    Shared by the preview card and the transcript reader so both see exactly the
    same conversation, subagent turns folded in and all. `limit` is what separates
    them: the card reuses the index cap, the reader asks for the full text.
    """
    try:
        index = json.load(open(INDEX_PATH))
    except (OSError, json.JSONDecodeError):
        index = {}
    info = index.get(sid, {})
    source = info.get("source", "cc")
    path = info.get("path") or _session_path(sid)

    # Codex sessions have no subagents; Claude folds them in.
    tagged = []
    if source == "codex":
        if path:
            _s, rows0 = parse_codex_session(path, include_thinking=thinking, limit=limit)
            tagged = [(r, False) for r in rows0]
    else:
        if path:
            _s, prows = parse_session(path, include_thinking=thinking, limit=limit)
            tagged += [(r, False) for r in prows]
        try:
            submap = json.load(open(SUBMAP_PATH))
        except (OSError, json.JSONDecodeError):
            submap = {}
        for sp in submap.get(sid, [])[:40]:
            _s, srows = parse_session(sp, include_thinking=thinking, limit=limit)
            tagged += [(r, True) for r in srows]
    tagged.sort(key=lambda x: x[0][3])          # chronological by timestamp
    return source, tagged


def resume_line(sid, cwd):
    """The paste-ready reattach line for one session id.

    Built on resume_plan so this lands in the directory the session was actually
    filed under. Reattaching from the cwd recorded on a message fails whenever
    that cwd is a subdirectory of the launch dir, which is common.
    """
    _source, _bin, argv, cwd, target, _exists = resume_plan(sid, cwd)
    return resume_command(target or cwd, argv)


def render_transcript(sid, thinking="0", query=""):
    """The whole conversation, readable, without resuming it.

    Resuming to read costs a CLI start, a context load, and a session you then
    have to leave. Usually you only wanted to check this is the right session or
    lift one answer out of it. Matched turns are marked with a bar so the pager
    can jump between them.
    """
    # Full text, not the index cap: this view exists to be read.
    source, tagged = load_session_rows(sid, _flag(thinking), limit=100_000)
    if not tagged:
        print("(session not found)")
        return
    rows = [r for r, _ in tagged]
    keys = query_keys(parse_query(query)) if query.strip() else []

    r0 = rows[0]
    title = r0[6] or next((r[7] for r, _ in tagged if r[4] == "user"), "") or "(untitled)"
    n_sub = sum(1 for _, sub in tagged if sub)
    print(f"\033[1m{title[:80]}\033[0m")
    print(f"\033[2m{short_proj(r0[1])} · {r0[3][:10]} · {len(rows)} msgs"
          + (f" · {n_sub} subagent" if n_sub else "") + "\033[0m")
    print(f"\033[2m{resume_line(sid, r0[1])}\033[0m\n")

    for r, sub in tagged:
        hit = bool(keys) and all(k in r[7].lower() for k in keys)
        mark = "\033[1;33m▶\033[0m " if hit else "  "
        print(mark + _turn_header(r[4], source, sub))
        print(f"\033[2m{r[3][11:16]}\033[0m  " + _snippet(r[7], keys, 100_000))
        print()


def render_preview(sid, thinking, query):
    """Compact preview card for one session (no full transcript — that's what resume is for).

    With a query: the matched lines only, highlighted. Without one: the bookends (first prompt
    + last message) so you know what it was about. Merges subagent transcripts (tagged ⤷) so
    their content is previewable too. Re-runs per keystroke via fzf's {q}.
    """
    thinking = str(thinking) == "1"
    source, tagged = load_session_rows(sid, thinking)
    if not tagged:
        print("(session not found)")
        return
    tagged.sort(key=lambda x: x[0][3])   # chronological by timestamp
    rows = [r for r, _ in tagged]

    keys = query_keys(parse_query(query)) if query.strip() else []
    r0 = rows[0]
    n_sub = sum(1 for _, sub in tagged if sub)
    disp_title = r0[6] or next((r[7] for r, _ in tagged if r[4] == "user"), "") or "(untitled)"
    print(f"\033[1m{disp_title[:80]}\033[0m")
    gone = " · orig dir gone" if r0[1] and not os.path.isdir(r0[1]) else ""
    print(f"\033[2m{short_proj(r0[1])} · {r0[3][:10]} · {len(rows)} msgs"
          + (f" · {n_sub} subagent" if n_sub else "") + gone + "\033[0m")

    body = _preview_lines(tagged, keys, source)
    if body:
        print()
        print("\n".join(body))


# ------------------------------------------------------------------ resume

def _die(msg):
    """Show an error and hold the window open — otherwise the popup just vanishes."""
    print(f"\n\033[31m{msg}\033[0m\n", file=sys.stderr)
    try:
        input("press enter to close ")
    except (EOFError, KeyboardInterrupt):
        pass
    sys.exit(1)


def _launch_dir(session_path, cwd):
    """The directory Claude actually filed this session under, or "" if undetermined.

    Claude scopes `--resume <id>` by the directory it was launched from, storing the transcript
    in ~/.claude/projects/<slug>/ where slug = the launch dir with non-alphanumerics replaced by
    `-`. The `cwd` recorded on messages can be a SUBDIRECTORY of that launch dir, and resuming
    from the subdirectory makes Claude look in the wrong project ("No conversation found").
    Slugs can't be decoded back to a path unambiguously (real dashes are indistinguishable from
    separators), so walk cwd's ancestors and take the one whose slug matches.
    """
    if not session_path or not cwd:
        return ""
    slug = os.path.basename(os.path.dirname(session_path))
    d = os.path.abspath(os.path.expanduser(cwd))
    while d and d != os.sep:
        if re.sub(r"[^A-Za-z0-9]", "-", d) == slug:
            return d
        parent = os.path.dirname(d)
        if parent == d:
            break
        d = parent
    return ""


def _nearest_existing_dir(path):
    """Closest existing ancestor of `path` (the path itself if it exists), else $HOME.

    Worktrees get deleted, but `claude --resume <id>` / `codex resume <id>` are id-based and
    don't need the original directory — so a dead cwd is a reason to relocate, not to abort.
    """
    p = os.path.abspath(os.path.expanduser(path)) if path else ""
    while p and p != os.sep:
        if os.path.isdir(p):
            return p
        parent = os.path.dirname(p)
        if parent == p:
            break
        p = parent
    if os.path.isdir(os.sep) and not os.path.isdir(HOME):
        return os.sep
    return HOME


def _confirm_active(sid, bin_):
    """Warn that a session still looks live, and ask before attaching. Never hard-blocks:
    with no tty to prompt on (fzf popup, piped run) it warns and proceeds."""
    warn = (f"\033[33m⚠ this session looks active (written to in the last "
            f"{ACTIVE_WINDOW_SEC}s) — resuming may collide with the running {bin_}.\033[0m")
    print(warn, file=sys.stderr)
    if not sys.stdin.isatty():
        return True
    try:
        ok = input("resume anyway? [Y/n] ").strip().lower() in ("", "y", "yes")
    except (EOFError, KeyboardInterrupt):
        return True
    if not ok:
        print("aborted.", file=sys.stderr)
    return ok


def resume_plan(sid, cwd):
    """Where to resume from and what to run: (source, bin, argv, cwd, target, cwd_exists).

    Shared by the launcher and by `--no-resume`, so the command printed for you to run by hand
    can never drift from the one agsearch would have run itself.
    """
    try:
        with open(INDEX_PATH) as fh:
            info = json.load(fh).get(sid, {})
    except (OSError, json.JSONDecodeError):
        info = {}
    source = info.get("source", "cc")
    bin_, argv = ("codex", ["codex", "resume", sid]) if source == "codex" \
        else ("claude", ["claude", "--resume", sid])

    # Claude looks for the session in the project of whatever directory it starts in, so resume
    # from the dir it was launched in — not the `cwd` on the messages, which may be a subdir.
    if source != "codex":
        cwd = _launch_dir(info.get("path", ""), cwd) or cwd

    # The recorded worktree may be long gone. Resume is id-based, so relocate to the nearest
    # surviving ancestor (or $HOME) instead of refusing to launch.
    cwd_exists = bool(cwd) and os.path.isdir(cwd)
    target = cwd if cwd_exists else (_nearest_existing_dir(cwd) if cwd else "")
    return source, bin_, argv, cwd, target, cwd_exists


def resume_command(target, argv):
    """A shell line you can paste. Worktree paths contain spaces often enough to quote."""
    cmd = " ".join(argv)
    return f"cd {shlex.quote(target)} && {cmd}" if target else cmd


# Clipboard tools in preference order: macOS, then Wayland, then the two common X11 ones.
CLIPBOARD_CMDS = (["pbcopy"], ["wl-copy"], ["xclip", "-selection", "clipboard"],
                  ["xsel", "--clipboard", "--input"])


def copy_to_clipboard(text, which=None, run=None):
    """Put `text` on the clipboard with whatever tool exists. Returns the tool used, or "".

    Best effort on purpose: the copy is a convenience (⌘F/^F straight to your query inside the
    resumed session), so a machine with no clipboard tool should resume normally rather than
    fail. Injectable which/run so the fallback order is testable without installing anything.
    """
    which = which or shutil.which
    run = run or subprocess.run
    for cmd in CLIPBOARD_CMDS:
        if not which(cmd[0]):
            continue
        try:
            run(cmd, input=text, text=True, check=False)
            return cmd[0]
        except OSError:
            continue
    return ""


def resume(sid, cwd, query="", active=False):
    if query:
        copy_to_clipboard(query)                    # so ⌘F finds it inside the resumed session
    source, bin_, argv, cwd, target, cwd_exists = resume_plan(sid, cwd)

    # Leave a trace: if the launched CLI dies instantly the popup window vanishes with it,
    # so this log is the only way to see what was attempted.
    note = resume_command(target or cwd, argv)
    try:
        with open(os.path.join(CACHE_DIR, "last-resume.log"), "a") as fh:
            fh.write(f"{note}   [source={source} cwd_exists={cwd_exists}"
                     f" orig_cwd={cwd or '-'} fallback={'-' if cwd_exists else (target or '-')}"
                     f" active={bool(active)}]\n")
    except OSError:
        pass

    if not shutil.which(bin_):
        _die(f"{bin_} CLI not found on PATH.\nRun manually:\n  {note}")
    if cwd and not cwd_exists:
        print(f"\033[33moriginal dir gone ({cwd}), resuming from {target}\033[0m", file=sys.stderr)
    if active and not _confirm_active(sid, bin_):
        return
    if target:
        try:
            os.chdir(target)
        except OSError:                              # raced away between check and chdir
            os.chdir(HOME)
    os.execvp(bin_, argv)                            # replace this process


# ------------------------------------------------------------------ fzf TUI

def group_sessions(lines):
    """Collapse per-message index rows into one entry per session, newest first."""
    by = {}
    for l in lines:
        f = (l.split(SEP) + [""] * 9)[:9]
        sid, cwd, _branch, ts, _role, _seq, title, text, kind = f
        g = by.get(sid)
        if g is None:
            g = by[sid] = {"sid": sid, "cwd": cwd, "date": ts, "title": title,
                           "first_user": "", "kind": kind or "cli", "texts": []}
        if ts > g["date"]:
            g["date"] = ts
        if cwd:
            g["cwd"] = cwd
        if kind == "cli":
            g["kind"] = "cli"                # a real CLI turn outranks folded-in agent rows
        if title and not g["title"]:
            g["title"] = title
        if _role == "user" and not g["first_user"]:
            g["first_user"] = text
        g["texts"].append(text)
    sessions = list(by.values())
    for s in sessions:                       # fall back to the first prompt when untitled
        if not s["title"]:
            s["title"] = s["first_user"] or "(untitled)"
    sessions.sort(key=lambda s: s["date"], reverse=True)
    return sessions


def build_sessions(lines):
    """Write one line per session to SESSIONS_PATH: sid, cwd, date, source, kind, title, first
    prompt, blob. The first prompt is stored separately from the blob so ranking can weight it
    as its own field — it's the strongest single signal of what a session is about."""
    try:
        index = json.load(open(INDEX_PATH))
    except (OSError, json.JSONDecodeError):
        index = {}
    out = []
    for s in group_sessions(lines):
        source = index.get(s["sid"], {}).get("source", "cc")
        title = _single_line(s["title"] or "(untitled)", 90)
        first = _single_line(s["first_user"], 400)
        # High cap so full sessions (incl. folded-in subagent content) stay searchable.
        # Stored lowercased: ranking is the only reader, and it would otherwise re-lower the
        # whole corpus on every keystroke. Nothing displays this column.
        blob = _single_line(" · ".join(s["texts"]), 2_000_000).lower()
        out.append(SEP.join([s["sid"], s["cwd"], s["date"][:10], source, s["kind"],
                             title, first, blob]))
    with open(SESSIONS_PATH, "w") as fh:
        fh.write("\n".join(out))


# Common words that add noise, not signal, to a search ("migration OF the database").
_STOP = {"of", "the", "a", "an", "to", "for", "in", "on", "and", "or", "is", "it", "this",
         "that", "with", "from", "by", "at", "as", "be", "are", "was", "were", "how", "do",
         "i", "my", "me", "we", "you", "about", "into", "using", "use", "some", "any"}

def _stem(w):
    """Conservative inflectional stem: strip one reliable suffix only if a solid (>=5 char)
    root remains, so migration/migrate/migrating -> 'migrat' while running stays 'running'
    (better to under-stem than to produce junk roots like 'oper' or 'runn')."""
    for suf in ("ing", "ion", "ed", "es", "e", "s"):
        if w.endswith(suf) and len(w) - len(suf) >= 5:
            return w[:-len(suf)]
    return w


def _fuzzy_span(hay, term):
    """Greedy first subsequence match; returns its character span, or None (last-resort typo tier)."""
    i = start = 0
    for ci, c in enumerate(hay):
        if c == term[i]:
            if i == 0:
                start = ci
            i += 1
            if i == len(term):
                return ci - start + 1
    return None


_SRC_MARK = {"cc": "\033[34mcc  \033[0m", "codex": "\033[35mcx  \033[0m"}
_AUTO_MARK = "\033[90mauto\033[0m"          # plugin/SDK-spawned run, never your own typing
_LIVE_MARK = "\033[1;31m●\033[0m "           # session still being written to → probably running
# Informational only: the session still resumes (from the nearest surviving ancestor dir),
# so this is muted enough to read as a footnote rather than a warning.
_GONE_MARK = "  \033[2morig dir gone\033[0m"


def _active_sids(sids):
    """Subset of `sids` whose transcript — or one of its subagent transcripts — was appended to
    within ACTIVE_WINDOW_SEC, i.e. the session still looks live."""
    try:
        index = json.load(open(INDEX_PATH))
    except (OSError, json.JSONDecodeError):
        index = {}
    try:
        submap = json.load(open(SUBMAP_PATH))
    except (OSError, json.JSONDecodeError):
        submap = {}
    now = time.time()
    live = set()
    for sid in sids:
        for p in [index.get(sid, {}).get("path")] + submap.get(sid, [])[:40]:
            try:
                if p and now - os.path.getmtime(p) <= ACTIVE_WINDOW_SEC:
                    live.add(sid)
                    break
            except OSError:
                continue
    return live


def _missing_dirs(cwds):
    """Which of these recorded working directories no longer exist. Deduped before stat'ing,
    since a project's sessions all share one dir."""
    gone = set()
    for cwd in set(cwds):
        if cwd and not os.path.isdir(cwd):
            gone.add(cwd)
    return gone


def _row(sid, cwd, date, source, kind, title, badge, active=False, dir_gone=False):
    mark = _AUTO_MARK if kind == "auto" else _SRC_MARK.get(source, "    ")
    live = _LIVE_MARK if active else "  "
    tail = _GONE_MARK if dir_gone else ""
    body = (f"{date}  {mark}  \033[36m{short_proj(cwd)[:15]:<15}\033[0m  "
            f"{badge} {live}{title[:64]}{tail}")
    if kind == "auto":
        body = (f"\033[2m{date}  \033[0m{_AUTO_MARK}\033[2m  {short_proj(cwd)[:15]:<15}  "
                f"{badge} \033[0m{live}\033[2m{title[:64]}\033[0m{tail}")
    return SEP.join([sid, cwd, body, "1" if active else "0"])


def _bm25_tf(tf, dl, avgdl):
    """BM25 saturated term frequency with length normalization: a term mentioned twice counts
    for much less than twice once, and a hit in a huge transcript counts for less than the same
    hit in a short, on-point one."""
    if not tf:
        return 0.0
    return tf * (BM25_K1 + 1) / (tf + BM25_K1 * (1 - BM25_B + BM25_B * dl / (avgdl or 1)))


def _age_days(date_str, now=None):
    try:
        t = time.mktime(time.strptime(date_str[:10], "%Y-%m-%d"))
    except (ValueError, OverflowError, TypeError):
        return 3650.0                                # undated → treat as ancient, never boosted
    return max(0.0, ((now if now is not None else time.time()) - t) / 86400.0)


_RESUME_SID_RE = re.compile(r"(?:--resume|resume)\s+([0-9a-fA-F][0-9a-fA-F-]{7,})")


def _usage_counts(path=None):
    """How often each session was resumed, read back off the last-resume.log breadcrumb.
    That log is the only record of which sessions you actually return to — the sessions you
    keep reopening are the ones you most likely mean next time."""
    counts = {}
    try:
        with open(path or os.path.join(CACHE_DIR, "last-resume.log"), errors="replace") as fh:
            recent = fh.readlines()[-2000:]
    except OSError:
        return counts
    for line in recent:
        m = _RESUME_SID_RE.search(line)
        if m:
            counts[m.group(1)] = counts.get(m.group(1), 0) + 1
    return counts


def _boost(f, usage, now):
    """Multiplier for how likely this session is the one you want, independent of the query:
    recent sessions and ones you resume often. Bounded, so it re-orders near-ties without ever
    floating an irrelevant session above a real match."""
    recency = 0.5 ** (_age_days(f[C_DATE], now) / RECENCY_HALFLIFE_DAYS)
    used = usage.get(f[C_SID], 0)
    return 1.0 + RECENCY_W * recency + USAGE_W * min(1.0, math.log1p(used) / math.log(6))


def _is_word_char(c):
    return c.isalnum() or c == "_"


def _at_word_start(hay, key, whole=False):
    """Does `key` occur at the start of a word in `hay`? With `whole`, as a complete word.

    str.find is C-fast and this returns on the first real hit, so it stays cheap on a huge
    transcript. Prefix matching exists to undo stemming (migrat -> migration/migrate), so it
    is applied only to keys that were actually stemmed. An unstemmed key is the whole word you
    typed: `pr` should not match `print`, `previously` or `prisma`.
    """
    n = len(key)
    i = hay.find(key)
    while i != -1:
        if i == 0 or not _is_word_char(hay[i - 1]):
            end = i + n
            if not whole or end >= len(hay) or not _is_word_char(hay[end]):
                return True
        i = hay.find(key, i + 1)
    return False


def _key_probe(key, whole=False):
    """Term frequency of `key`, but zero unless it appears at a word start somewhere.

    Plain `str.count` counts substrings, and that is what poisons ranking: `pr` is a substring
    of 700/718 sessions but a word in 182, so its idf collapses to nothing and the coverage
    tiebreaker stops discriminating. Presence is the part that has to be exact; the count
    itself can stay a substring count because BM25 saturates tf anyway.
    """
    def probe(hay):
        if not _at_word_start(hay, key, whole):
            return 0
        return hay.count(key)
    return probe


def query_keys(qterms):
    """Ranking/preview keys: stem when it is long enough, else the raw term."""
    return [stem if len(stem) >= 3 else term for term, stem in qterms]


def rank_sessions(rows, qterms, usage=None, now=None):
    """Rank sessions for a query. Returns [(score, matched, row)] best-first.

    BM25 over three weighted fields — title+project, first prompt, full conversation — with
    length normalization, so a sprawling session no longer outranks a short exact match, and
    what a session was *opened to do* outweighs a passing mention buried in it. The result is
    then nudged by recency and how often you've resumed that session. A term that exists almost
    nowhere as text (a typo like 'conection') falls back to subsequence matching.
    """
    usage = usage or {}
    n = len(rows) or 1
    keys = query_keys(qterms)

    # Pass 1: per-field term frequencies, plus document frequency per term for idf.
    # A key equal to the word typed was never stemmed, so match it whole.
    probes = [_key_probe(k, whole=(k == t)) for (t, _st), k in zip(qterms, keys)]
    data = []
    df = dict.fromkeys(keys, 0)
    for f in rows:
        title_hay = (f[C_TITLE] + " " + short_proj(f[C_CWD])).lower()
        first_hay = f[C_FIRST].lower()
        body_hay = f[C_BLOB]                     # already lowercased at index time
        rec = []
        for key, probe in zip(keys, probes):
            tf = probe(body_hay)
            tf_first = probe(first_hay)
            it = probe(title_hay) > 0
            if tf or tf_first or it:
                df[key] += 1
            rec.append((tf, tf_first, it))
        data.append((f, title_hay, first_hay, body_hay, rec))

    avg_body = sum(len(d[3]) for d in data) / len(data) if data else 1
    avg_first = sum(len(d[2]) for d in data) / len(data) if data else 1
    # Standard BM25 idf, shifted to stay positive even for terms in most documents.
    idf = {k: math.log(1 + (n - df[k] + 0.5) / (df[k] + 0.5)) for k in df}
    # Barely-there word → probably a typo, so fall back to subsequence matching. Only for words
    # long enough that an in-order subsequence is evidence of anything: `p...r` within six
    # characters is satisfied by almost any English text, so short terms would fuzzy-match
    # every session they are genuinely absent from.
    typo = {k for k in df if df[k] <= 2 and len(k) >= 5}

    scored = []
    for f, title_hay, first_hay, body_hay, rec in data:
        score = 0.0
        matched = 0        # concepts covered, including typo-resolved ones — this is the badge
        strong = 0         # concepts the session actually contains — this is what ranks
        for (term, _stem_unused), key, (tf, tf_first, it) in zip(qterms, keys, rec):
            if tf or tf_first or it:
                matched += 1
                strong += 1
                score += idf[key] * (W_TITLE * it
                                     + W_FIRST * _bm25_tf(tf_first, len(first_hay), avg_first)
                                     + W_BODY * _bm25_tf(tf, len(body_hay), avg_body))
            elif key in typo:
                if _fuzzy_span(title_hay, term) is not None:
                    matched += 1
                    score += 1.0
                else:
                    sp = _fuzzy_span(body_hay, term)
                    if sp is not None and sp <= len(term) * 3:
                        matched += 1
                        score += 0.3
        if matched:
            scored.append((score * _boost(f, usage, now), matched, strong, f))
    # Categorise first: your own sessions always outrank plugin/SDK-spawned runs. Then coverage,
    # counting only words the session really contains, then relevance. Automation is demoted,
    # never hidden.
    #
    # Coverage deliberately ignores typo-resolved terms. A subsequence hit is a guess, and
    # letting a guess count toward coverage let a session that merely contains `b i l l i n g`
    # spread across a sentence outrank one genuinely about billing at three times the score.
    # The guess still raises `score`, so a good fuzzy match can win on relevance; it just
    # cannot win on breadth.
    scored.sort(key=lambda x: (x[3][C_KIND] == "auto", -x[2], -x[0]))
    return [(sc, m, f) for sc, m, _st, f in scored]


def _smart_rows(rows, qterms, live=frozenset(), usage=None, gone=frozenset()):
    """Render the ranked sessions as fzf rows. Badge = matched/total query terms."""
    total = len(qterms)
    return [_row(f[C_SID], f[C_CWD], f[C_DATE], f[C_SOURCE], f[C_KIND], f[C_TITLE],
                 f"\033[33m{m}/{total}\033[0m", f[C_SID] in live, f[C_CWD] in gone)
            for _score, m, f in rank_sessions(rows, qterms, usage)[:200]]


def parse_query(query):
    """Query string → [(word, stem)]: stopwords dropped, words conservatively stemmed."""
    words = re.findall(r"[a-z0-9]+", query.lower())
    qwords = [w for w in words if w not in _STOP] or words   # keep stopwords if that's all
    return [(w, _stem(w)) for w in qwords]


def cmd_filter(argv):
    """fzf reload target: print CLEAN session rows ranked for the live query.

    Search happens here (fzf runs --disabled), so the list shows only `date · project · N/T ·
    title` while full conversation text is searched. Smart ranking: stopwords dropped, words
    stemmed (migration≈migrate), matched per-concept (exact→stem→typo), scored by weighted
    BM25 over title / first prompt / transcript, then nudged by recency and resume count.
    """
    qterms = parse_query(" ".join(argv[1:]) if argv else "")
    try:
        raw = open(SESSIONS_PATH, errors="replace").read().splitlines()
    except OSError:
        return
    rows = [line.split(SEP) for line in raw if line.count(SEP) >= SESSION_COLS - 1]

    live = _active_sids([f[C_SID] for f in rows])
    gone = _missing_dirs([f[C_CWD] for f in rows])
    if not qterms:                                  # initial list: yours first, then automation
        rows = sorted(rows, key=lambda f: f[C_KIND] == "auto")
        out = [_row(f[C_SID], f[C_CWD], f[C_DATE], f[C_SOURCE], f[C_KIND], f[C_TITLE], "    ",
                    f[C_SID] in live, f[C_CWD] in gone) for f in rows]
    else:
        out = _smart_rows(rows, qterms, live, _usage_counts(), gone)
    sys.stdout.write("\n".join(out))


def run_fzf(lines, query, thinking=False, no_resume=False, fuzzy=False):
    if not shutil.which("fzf"):
        print("fzf not installed. Falling back to non-interactive output.\n"
              "Install with: brew install fzf\n", file=sys.stderr)
        return print_matches(lines, query)

    self = os.path.abspath(__file__)
    build_sessions(lines)                # refresh the per-session cache _filter reads
    thi = "1" if thinking else "0"
    fz = "1" if fuzzy else "0"
    filter_cmd = "python3 {} _filter {} {{q}}".format(shlex.quote(self), fz)
    preview = "python3 {} _preview {{1}} {} {{q}}".format(shlex.quote(self), thi)
    pager = os.environ.get("AGSEARCH_PAGER") or os.environ.get("PAGER") or "less -R"
    transcript = "python3 {} _transcript {{1}} {} {{q}} | {}".format(
        shlex.quote(self), thi, pager)
    copy_cmd = "python3 {} _copy {{1}} {{2}}".format(shlex.quote(self))
    args = [
        "fzf", "--ansi", "--delimiter", SEP, "--with-nth", "3", "--disabled",
        "--print-query",                 # so the clipboard gets what you actually typed
        "--bind", "start:reload:" + filter_cmd,
        "--bind", "change:reload:" + filter_cmd,
        "--preview", preview,
        "--preview-window", "right,58%,wrap",
        "--header", "type to search all sessions · enter: resume (copies query for ⌘F) · "
                    "ctrl-o: read · ctrl-y: copy cmd · ctrl-/: preview · ● = live session",
        "--bind", "ctrl-/:toggle-preview",
        # ctrl-o reads the whole conversation in a pager: no resume, no CLI start,
        # no tokens. ctrl-y puts the reattach line on the clipboard.
        #
        # Only these two, and only on keys fzf leaves free. ctrl-u and ctrl-d are
        # fzf defaults (unix-line-discard and delete-char/eof); rebinding them
        # would take away "clear the query", which in a search box is the edit
        # people reach for most.
        "--bind", "ctrl-o:execute(" + transcript + ")",
        "--bind", "ctrl-y:execute-silent(" + copy_cmd + ")+bell",
    ]
    if query:
        args += ["--query", query]
    proc = subprocess.run(args, input="", text=True, capture_output=True)
    out = proc.stdout.split("\n")
    typed = out[0] if out else query     # --print-query puts the final query on line 1
    sel = next((l for l in out[1:] if l.strip()), "")
    if not sel:
        return 0
    f = (sel.split(SEP) + [""] * 4)[:4]
    sid, cwd, active = f[0], f[1], f[3] == "1"
    if no_resume:
        _source, _bin, argv, _cwd, target, _exists = resume_plan(sid, cwd)
        print(resume_command(target or cwd, argv))
        return 0
    resume(sid, cwd, typed, active=active)
    return 0


# ------------------------------------------------------------------ main

def main(argv):
    if argv and argv[0] == "_preview":
        sid = argv[1] if len(argv) > 1 else ""
        thinking = argv[2] if len(argv) > 2 else "0"
        query = " ".join(argv[3:])
        render_preview(sid, thinking, query)
        return 0
    if argv and argv[0] == "_filter":
        cmd_filter(argv[1:])
        return 0
    if argv and argv[0] == "_transcript":
        render_transcript(argv[1] if len(argv) > 1 else "",
                          argv[2] if len(argv) > 2 else "0", " ".join(argv[3:]))
        return 0
    if argv and argv[0] == "_copy":
        sid = argv[1] if len(argv) > 1 else ""
        cwd = argv[2] if len(argv) > 2 else ""
        if sid:
            copy_to_clipboard(resume_line(sid, cwd))
        return 0

    no_fzf = no_resume = here = thinking = reindex = fuzzy = False
    project = None
    query_parts = []
    i = 0
    while i < len(argv):
        a = argv[i]
        if a in ("-n", "--no-fzf"):
            no_fzf = True
        elif a == "--no-resume":
            no_resume = True
        elif a == "--here":
            here = True
        elif a == "--thinking":
            thinking = True
        elif a == "--fuzzy":
            fuzzy = True
        elif a == "--reindex":
            reindex = True
        elif a in ("-p", "--project"):
            i += 1
            project = argv[i] if i < len(argv) else None
        elif a in ("-h", "--help"):
            print(__doc__)
            return 0
        elif a in ("-V", "--version"):
            print("agsearch " + __version__)
            return 0
        else:
            query_parts.append(a)
        i += 1

    query = " ".join(query_parts)
    lines = build_index(include_thinking=thinking, force=reindex)
    lines = apply_scope(lines, here=here, project=project)
    if not lines:
        print("No indexed sessions found.", file=sys.stderr)
        return 1

    if no_fzf:
        return print_matches(lines, query)
    return run_fzf(lines, query, thinking=thinking, no_resume=no_resume, fuzzy=fuzzy)


def _entry():
    """Console-script entry point.

    A [project.scripts] entry point is called with no arguments, while main()
    takes argv — so the two cannot be wired directly. This wrapper is what
    `pipx`/`uvx`/`pip install agsearch` invoke, and running the file directly
    goes through it too, so both paths share one error-handling path.
    """
    try:
        sys.exit(main(sys.argv[1:]))
    except KeyboardInterrupt:
        sys.exit(130)
    except SystemExit:
        raise
    except Exception:                       # never let the popup vanish without a reason
        import traceback
        _die("agsearch crashed:\n\n" + traceback.format_exc())


if __name__ == "__main__":
    _entry()
