diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 529e49a..4791b98 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -8,8 +8,8 @@ { "name": "agsearch", "source": "./", - "description": "Search your past Claude Code and Codex sessions from inside Claude" + "description": "Search your past Claude Code, Codex, Cursor, opencode and Gemini CLI sessions from inside Claude" } ], - "description": "agsearch: search your past Claude Code and Codex sessions from inside Claude" + "description": "agsearch: search your past Claude Code, Codex, Cursor, opencode and Gemini CLI sessions from inside Claude" } diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 9e72abf..dc3253e 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "agsearch", - "description": "Search your past Claude Code and Codex sessions from inside Claude", + "description": "Search your past Claude Code, Codex, Cursor, opencode and Gemini CLI sessions from inside Claude", "version": "0.1.0", "author": { "name": "Dev Dalia" }, "homepage": "https://github.com/devcodes9/agsearch", diff --git a/CHANGELOG.md b/CHANGELOG.md index 377d8e4..6fda979 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,8 +11,39 @@ migration in the same line. ## [Unreleased] +### Added + +- **Cursor, opencode and Gemini CLI sessions are indexed, searched and resumed** alongside + Claude Code and Codex. Cursor keeps each chat as a SQLite store under + `~/.cursor/chats/`, opened read-only, reading message records and skipping the binary and + image blobs beside them; it resumes with `cursor-agent --resume `. Gemini keeps one JSON + object per session under `~/.gemini/tmp/`, and resumes with `gemini --session-file ` + because its `--resume` takes a project-scoped index number rather than a stable id. + opencode keeps every session in one database, so it also resumes by id + (`opencode --session `) but is read as a whole. + On a 852-session corpus, adding 101 Cursor sessions moved held-out ranking by +0.004, so + existing searches are unaffected. +- **A transcript file may now hold more than one session.** The indexer took the first row's + id as the id for the entire file, which is right for a file per session and wrong for a + harness that keeps them all in one database: every session but the first was unreachable. + It now registers each session a file contains, and reading one filters to it. No change for + Claude Code, Codex, Cursor or Gemini, which write one session per file. + ### Changed +- **The source column spells the tool out** (`claude`, `codex`, `cursor`, `opencode`, + `gemini`) instead of a two-letter code. `cc` and `cx` were guessable with two harnesses and + are not with five. The name is now stored once per harness and used for the column, the + assistant turn label and the preview, so those cannot drift apart, and the column width is + derived from the longest name so adding a harness cannot misalign the list. + +- **Harnesses are described by one source table instead of a ternary in five places.** Adding + an agent was supposed to be one line, but the file extension, the parser used for preview, + the row label, the preview label and the resume command each decided for themselves what a + source was, and two of them had already drifted (`codex` against `cx`). They now read one + record per harness, so a new agent is a parser plus one entry. Behaviour for Claude Code and + Codex is unchanged; the cache format bumps to 7 and reindexes once on first run. + - **Piped output is shaped for the program reading it.** `-n` and `read` are what a coding agent sees, and an agent pays per character for what a terminal gets free. Behind the same not-a-terminal test the colour seam already uses: session ids shorten to the shortest prefix diff --git a/README.md b/README.md index f98265a..d601957 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@

Ranked full-text search across the coding-agent sessions already on your machine.
- Claude Code and Codex CLI today, more next. + Claude Code, Codex, Cursor, opencode and Gemini CLI.

@@ -16,8 +16,8 @@

agsearch indexes the local transcripts your coding agents already write. Search them in one -ranked list, preview the matching lines, and resume the original Claude Code or Codex session. -Everything stays on your machine. +ranked list, preview the matching lines, and resume the original session in the tool it came +from. Everything stays on your machine.

Searching 52 sessions; the second query is misspelled and still lands on the right one

@@ -42,10 +42,9 @@ uvx agsearch -n "stripe tax id" - **Full-conversation search.** Search user prompts and assistant replies, not only titles and session metadata. -- **One list for both tools.** Claude Code and Codex sessions appear together, labelled `cc` - and `cx`. Adding another agent is a parser plus a source entry, with no change to search or - ranking — [Gemini CLI and opencode](https://github.com/devcodes9/agsearch/issues/40) are the - tracked candidates. +- **One list for every tool.** Sessions from all five agents appear together, each row named + after the tool it came from. Adding another agent is a parser plus one entry in the source + table, with no change to search or ranking. - **Ranked results.** BM25 ranking favors focused sessions and shows matching lines in context. - **Preview, read, or resume.** Inspect a match, open the transcript in a pager, or return to the original session. @@ -156,8 +155,8 @@ Either way it needs the `agsearch` binary, which the installation section above | Ctrl-Y | Copy the resume command | | Ctrl-/ | Toggle the preview pane | -Selecting a result starts `claude --resume` or `codex resume` from the session's project -directory. The current query is copied to the clipboard so you can find the same text after +Selecting a result resumes the session in the tool that created it, from that session's +project directory. The current query is copied to the clipboard so you can find the same text after resuming. For a global shortcut, see the @@ -189,8 +188,20 @@ words, and the first result is not guaranteed to be the session you intended. agsearch reads: -- `~/.claude/projects/**/*.jsonl` -- `~/.codex/sessions/**/*.jsonl` +| Agent | Read from | Resumed with | +| --- | --- | --- | +| Claude Code | `~/.claude/projects/**/*.jsonl` | `claude --resume ` | +| Codex | `~/.codex/sessions/**/*.jsonl` | `codex resume ` | +| cursor-cli | `~/.cursor/projects/**/agent-transcripts/` | `cursor-agent --resume ` | +| opencode | `~/.local/share/opencode/opencode.db` | `opencode --session ` | +| Gemini CLI | `~/.gemini/tmp/**/chats/*.json` | `gemini --session-file ` | + +opencode keeps sessions in SQLite; agsearch opens it read-only and reads message records +only. Gemini's `--resume` takes a project-scoped index number rather than a stable id, so +resume goes through the transcript file instead. + +Cursor and Gemini are read from their CLI's storage. Chats made in the Cursor IDE are kept +elsewhere and are not indexed, which is why the column names the CLI. Its cache lives under `~/.cache/agsearch/`. Transcript parsing and ranking happen locally, and only changed files are reparsed. diff --git a/agsearch b/agsearch index 711bb57..83f7ecf 100755 --- a/agsearch +++ b/agsearch @@ -2,10 +2,16 @@ """ 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`. +Every coding agent keeps its sessions on disk. Their native pickers search session +*metadata*: the title, the first prompt, the branch. This searches what was actually *said*, +across all of them at once, and drops you back into the session with that tool's own +resume command. + + claude ~/.claude/projects claude --resume + codex ~/.codex/sessions codex resume + cursor-cli ~/.cursor/projects cursor-agent --resume + opencode ~/.local/share/opencode opencode --session + gemini-cli ~/.gemini/tmp gemini --session-file Usage: agsearch # interactive fuzzy TUI (needs fzf) @@ -54,6 +60,10 @@ import subprocess HOME = os.path.expanduser("~") PROJECTS_DIR = os.path.join(HOME, ".claude", "projects") CODEX_DIR = os.path.join(HOME, ".codex", "sessions") +GEMINI_DIR = os.path.join(HOME, ".gemini", "tmp") +CURSOR_DIR = os.path.join(HOME, ".cursor", "projects") +CURSOR_CHATS_DIR = os.path.join(HOME, ".cursor", "chats") # titles only; see _cursor_titles +OPENCODE_DIR = os.path.join(HOME, ".local", "share", "opencode") 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") @@ -62,7 +72,7 @@ SUBMAP_PATH = os.path.join(CACHE_DIR, "submap.json") # parent-sid -> [subag INDEX_PATH = os.path.join(CACHE_DIR, "index.json") # sid -> {source, path} for preview/resume FORKS_PATH = os.path.join(CACHE_DIR, "forks.json") # forked sid -> {of, at} -CACHE_FMT = 6 # bump when the TSV column layout / keying changes, to invalidate old fragments +CACHE_FMT = 7 # 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 @@ -160,12 +170,27 @@ def parse_session(path, include_thinking=False, limit=MSG_INDEX_CHARS): continue if t not in ("user", "assistant"): continue + # Claude Code flags injected content as meta: a skill's whole SKILL.md pasted in + # as a user turn, hook notices, image-paste markers. It is filed under the user's + # role but nobody typed it, and a single skill injection runs to 15k characters, + # which buries the real prompt in the preview and the matched line. + if o.get("isMeta"): + 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) + # Claude Code files a tool's output under the user's role. Left as "user" the + # preview tells the reader you said "The file has been updated successfully", + # which is the one thing a transcript must not get wrong. Nine in ten user-role + # entries are this. Still indexed: an error string a search has to find lives in + # tool output, not in what anyone typed. + content = msg.get("content") + if role == "user" and isinstance(content, list) and any( + isinstance(b, dict) and b.get("type") == "tool_result" for b in content): + role = "tool" ts = o.get("timestamp", "") if t == "assistant" and include_thinking: @@ -265,6 +290,366 @@ def parse_codex_session(path, include_thinking=False, limit=MSG_INDEX_CHARS): return sid, final +# ------------------------------------------------------------------ gemini cli + +# Gemini writes a session as ONE json object, not jsonl, and tags every entry with a `type` +# rather than a role. Only the two conversational types are indexed: `info`, `error` and the +# rest are CLI chrome (auth prompts, update notices) that would match queries and mean nothing. +_GEMINI_ROLE = {"user": "user", "gemini": "assistant", "model": "assistant", + "assistant": "assistant"} + + +def _gemini_cwd(path): + """Gemini records a `projectHash`, never the directory it ran in. + + The transcript lives at ~/.gemini/tmp//chats/.json, and the sibling + `.project_root` file holds the real absolute path. Without it there is nothing to recover: + the hash is a sha256 and the directory name is a basename, not a path. + """ + proj = os.path.dirname(os.path.dirname(path)) # .../tmp/ + try: + with open(os.path.join(proj, ".project_root"), errors="replace") as fh: + return fh.read().strip() + except OSError: + return "" + + +def parse_gemini_session(path, include_thinking=False, limit=MSG_INDEX_CHARS): + """Parse one Gemini CLI chat json into the shared 9-field row schema. + + Keyed by the `sessionId` field. Resume is by file path (`gemini --session-file`), not by id, + so the id here is for display and dedupe only. + """ + sid = os.path.splitext(os.path.basename(path))[0] + try: + with open(path, errors="replace") as fh: + doc = json.load(fh) + except (OSError, json.JSONDecodeError, UnicodeDecodeError): + return sid, [] + if not isinstance(doc, dict): + return sid, [] + + sid = doc.get("sessionId") or sid + cwd = _gemini_cwd(path) + ts0 = doc.get("startTime") or doc.get("lastUpdated") or "" + + rows = [] + for m in doc.get("messages", []): + if not isinstance(m, dict): + continue + role = _GEMINI_ROLE.get(m.get("type")) + if not role: + continue + text = _single_line(_flatten_content(m.get("content", "")), limit) + if not text: + continue + rows.append([sid, cwd, "", m.get("timestamp") or ts0, role, "", "", text]) + + title = "" + for r in rows: + if r[4] == "user": + title = r[7][:90] + break + return sid, [[sid, r[1], r[2], r[3], r[4], str(i), title, r[7], "cli"] + for i, r in enumerate(rows)] + + +# ------------------------------------------------------------------ cursor + +# Cursor writes each session twice: a SQLite store under ~/.cursor/chats holding the raw API +# payloads, and a clean JSONL transcript under ~/.cursor/projects//agent-transcripts/. +# The JSONL is the better source in every way that matters here. It covers more sessions (all +# of the SQLite ones and more), it is ordered, and it carries only the conversation: the +# SQLite copy also contains injected hook and environment context, which lands in the index as +# session titles like ``. + +_CURSOR_QUERY = re.compile(r"(.*?)", re.DOTALL) +# Tags carry attributes (``), so a bare-tag pattern misses them. +_CURSOR_TAG_BLOCK = re.compile(r"<([a-z_]+)(?:\s[^>]*)?>.*?", re.DOTALL) + + +def _cursor_text(text, role): + """The words a human would recognise as the turn. + + Cursor wraps what the user typed in , and surrounds it with attachments, + timestamps and skill lists. Indexed whole, those swamp the actual prompt. + """ + if role == "user": + found = _CURSOR_QUERY.findall(text) + if found: + return " ".join(f.strip() for f in found) + prev = None + while prev != text: + prev = text + text = _CURSOR_TAG_BLOCK.sub(" ", text) + return text + + +_UNSLUG_CACHE = {} + + +def _unslug(slug): + """Turn a Cursor project directory name back into the path it was made from. + + The name is the path with every non-alphanumeric replaced by `-`, which is not reversible + on its own: a real dash is indistinguishable from a separator. Resolve it against the + filesystem instead, taking the longest prefix that exists at each step. Returns "" for a + directory that is gone, which is a display gap, not a reason to skip the session. + """ + if slug in _UNSLUG_CACHE: + return _UNSLUG_CACHE[slug] + parts = [p for p in slug.split("-") if p] + path, i = "", 0 + while i < len(parts): + for j in range(len(parts), i, -1): + cand = path + os.sep + "-".join(parts[i:j]) + if os.path.isdir(cand): + path, i = cand, j + break + else: + path = "" + break + _UNSLUG_CACHE[slug] = path + return path + + +_CURSOR_TITLES = None + + +def _cursor_titles(): + """Chat id -> title, from the SQLite side's meta.json files. + + The JSONL transcript has no title of its own, and Cursor's own titles read far better than + a truncated first prompt. Read once: there is one small file per chat. + """ + global _CURSOR_TITLES + if _CURSOR_TITLES is None: + _CURSOR_TITLES = {} + for root, _dirs, files in os.walk(CURSOR_CHATS_DIR): + if "meta.json" not in files: + continue + try: + with open(os.path.join(root, "meta.json"), errors="replace") as fh: + meta = json.load(fh) + except (OSError, json.JSONDecodeError, UnicodeDecodeError): + continue + if isinstance(meta, dict) and meta.get("title"): + _CURSOR_TITLES[os.path.basename(root)] = _single_line(meta["title"], 90) + return _CURSOR_TITLES + + +def parse_cursor_session(path, include_thinking=False, limit=MSG_INDEX_CHARS): + """Parse one Cursor transcript into the shared 9-field row schema. + + Keyed by the file stem, which is the chat id `cursor-agent --resume ` takes. The + transcript records no per-message time, so every row carries the file's mtime: that is when + the session was last written to, which is what the date column and the recency boost want. + """ + sid = os.path.splitext(os.path.basename(path))[0] + proj = os.path.basename(os.path.dirname(os.path.dirname(os.path.dirname(path)))) + cwd = _unslug(proj) + try: + ts = time.strftime("%Y-%m-%dT%H:%M:%S", time.localtime(os.path.getmtime(path))) + except (OSError, ValueError): + ts = "" + + rows = [] + try: + fh = open(path, 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 + role = o.get("role") + if role not in ("user", "assistant"): # status and error entries are not turns + continue + msg = o.get("message") + if not isinstance(msg, dict): + continue + text = _single_line(_cursor_text(_flatten_content(msg.get("content", "")), role), + limit) + if not text: + continue + rows.append([sid, cwd, "", ts, role, "", "", text]) + + title = _cursor_titles().get(sid, "") + if not title: + title = next((r[7][:90] for r in rows if r[4] == "user"), "") + return sid, [[sid, r[1], r[2], r[3], r[4], str(i), title, r[7], "cli"] + for i, r in enumerate(rows)] + + +# ------------------------------------------------------------------ opencode + +# opencode keeps every session in one SQLite database rather than a file per session, so this +# parser returns rows for all of them at once and the indexer registers each session it finds. +# Message text lives in `part`, one row per span, with the role on the parent `message`. +_OPENCODE_SQL = """ +SELECT p.session_id, m.data, p.data +FROM part p JOIN message m ON p.message_id = m.id +ORDER BY m.time_created, p.time_created, p.id +""" +_OPENCODE_SESSIONS = "SELECT id, directory, title, time_updated FROM session" + + +def _sqlite_ro(path): + """Open a harness database without ever taking a write lock on one it may still be using.""" + import sqlite3 + for uri in ("file:%s?mode=ro" % path, "file:%s?immutable=1" % path): + try: + return sqlite3.connect(uri, uri=True, timeout=1.0) + except sqlite3.Error: + continue + return None + + +def _opencode_iso(ms): + try: + return time.strftime("%Y-%m-%dT%H:%M:%S", time.localtime(ms / 1000.0)) if ms else "" + except (TypeError, ValueError, OSError): + return "" + + +def parse_opencode_session(path, include_thinking=False, limit=MSG_INDEX_CHARS): + """Parse the opencode database into the shared 9-field row schema, all sessions at once.""" + conn = _sqlite_ro(path) + if conn is None: + return "", [] + + wanted = {"text", "reasoning"} if include_thinking else {"text"} + per_session = {} + try: + meta = {} + for sid, directory, title, updated in conn.execute(_OPENCODE_SESSIONS): + meta[sid] = (directory or "", _single_line(title or "", 90), _opencode_iso(updated)) + for sid, mdata, pdata in conn.execute(_OPENCODE_SQL): + if sid not in meta: + continue + try: + part = json.loads(pdata) + msg = json.loads(mdata) + except (json.JSONDecodeError, TypeError, ValueError): + continue + if not isinstance(part, dict) or part.get("type") not in wanted: + continue + role = msg.get("role") if isinstance(msg, dict) else None + if role not in ("user", "assistant"): + continue + text = _single_line(part.get("text") or "", limit) + if not text: + continue + per_session.setdefault(sid, []).append((role, text)) + except Exception: # a database mid-write is not worth crashing the whole index on + pass + finally: + conn.close() + + rows = [] + for sid, turns in per_session.items(): + cwd, title, ts = meta.get(sid, ("", "", "")) + if not title: + title = next((t for r, t in turns if r == "user"), "")[:90] + for i, (role, text) in enumerate(turns): + rows.append([sid, cwd, "", ts, role, str(i), title, text, "cli"]) + return (rows[0][0] if rows else ""), rows + +# ------------------------------------------------------------------ sources + +def _is_jsonl(path): + return path.endswith(".jsonl") + + +def _is_gemini_chat(path): + name = os.path.basename(path) + return name.startswith("session-") and name.endswith(".json") + + +def _is_cursor_transcript(path): + """`/.jsonl` is a session; `/subagents/.jsonl` is one of its subagents. + + Only the filename used to be tested, and these two are indistinguishable by name alone. + """ + if not path.endswith(".jsonl"): + return False + return os.path.splitext(os.path.basename(path))[0] == os.path.basename(os.path.dirname(path)) + + +def _is_opencode_db(path): + return os.path.basename(path) == "opencode.db" + + +# One record per harness, keyed by the source tag stored in index.json. Everything that used to +# be a `source == "codex"` ternary reads this table instead, so adding a harness is one entry +# plus a parser rather than an edit in five places that can silently disagree. +# +# roots directories to walk for transcripts +# match path predicate; harnesses agree on neither the extension nor the layout +# parse (path, include_thinking, limit) -> (sid, rows) in the shared 9-field schema +# label the harness name, used for the source column, assistant turns and the preview. +# One whitespace-free token: the piped row is columns, and a name with a space in +# it shifts every field after it for anything parsing them. +# colour SGR code for the source column +# resume ("id", argv) substitutes {sid}; ("path", argv) substitutes {path} +# subagents harness writes separate subagent transcripts that fold into the parent +# launch_dir resume is scoped to the directory the session was started in +SOURCES = { + "cc": { + "roots": [PROJECTS_DIR], "match": _is_jsonl, "parse": None, + "label": "claude", "colour": "34", + "resume": ("id", ["claude", "--resume", "{sid}"]), + "subagents": True, "launch_dir": True, + }, + "codex": { + "roots": [CODEX_DIR], "match": _is_jsonl, "parse": None, + "label": "codex", "colour": "35", + "resume": ("id", ["codex", "resume", "{sid}"]), + "subagents": False, "launch_dir": False, + }, + "gemini": { + "roots": [GEMINI_DIR], "match": _is_gemini_chat, "parse": None, + "label": "gemini-cli", "colour": "36", + # --resume takes a project-scoped index number, which is not a stable handle for a + # session found by search. --session-file takes the transcript path, which is. + "resume": ("path", ["gemini", "--session-file", "{path}"]), + "subagents": False, "launch_dir": False, + }, + "cursor": { + "roots": [CURSOR_DIR], "match": _is_cursor_transcript, "parse": None, + "label": "cursor-cli", "colour": "32", + "resume": ("id", ["cursor-agent", "--resume", "{sid}"]), + "subagents": False, "launch_dir": False, + }, + "opencode": { + "roots": [OPENCODE_DIR], "match": _is_opencode_db, "parse": None, + "label": "opencode", "colour": "33", + # `opencode run` is the non-interactive form and demands a message; the bare command + # opens the TUI on that session, which is what resuming means here. + "resume": ("id", ["opencode", "--session", "{sid}"]), + "subagents": False, "launch_dir": False, + }, +} + +SOURCES["cc"]["parse"] = parse_session +SOURCES["codex"]["parse"] = parse_codex_session +SOURCES["gemini"]["parse"] = parse_gemini_session +SOURCES["cursor"]["parse"] = parse_cursor_session +SOURCES["opencode"]["parse"] = parse_opencode_session + +DEFAULT_SOURCE = "cc" + + +def _source(name): + """The record for a source tag, falling back to Claude for an index written by an older + version that did not know this harness.""" + return SOURCES.get(name) or SOURCES[DEFAULT_SOURCE] + # ------------------------------------------------------------------ forks # Claude Code forks a session by copying the transcript so far into a new file under a new @@ -476,21 +861,25 @@ def build_index(include_thinking=False, force=False): 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)] + # Harnesses disagree on where transcripts live and what they are named, so both the roots + # and the filename test come from the source record rather than being hardcoded here. files = [] # (path, source, parser) - for source, root, parser in sources: - if os.path.isdir(root): + for source, rec in SOURCES.items(): + match, parser = rec["match"], rec["parse"] + for root in rec["roots"]: + if not os.path.isdir(root): + continue 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)) + full = os.path.join(r, fn) + if match(full): + files.append((full, 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: + for path, _src, _parser in files: try: mtimes[path] = os.path.getmtime(path) except OSError: @@ -523,11 +912,15 @@ def build_index(include_thinking=False, force=False): 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-"): + if _source(source)["subagents"] and base.startswith("agent-"): + sid0 = frag_lines[0].split(SEP, 1)[0] sub_map.setdefault(sid0, []).append(path) # subagent folds into parent - else: + continue + # Most harnesses write one file per session, but some keep every session in a single + # database. Register whatever sessions the fragment actually contains rather than + # assuming the first row speaks for the file. + for sid0 in dict.fromkeys(l.split(SEP, 1)[0] for l in frag_lines): index[sid0] = {"source": source, "path": path} if source == "cc": root = (old_index.get(sid0) or {}).get("root") @@ -583,13 +976,15 @@ def _highlight(text, terms, code="\033[1;30;43m"): ROW_TEXT_WIDTH = 160 # the "why it matched" line under a result, 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). +def _agent_name(source): + """Name the agent side of a session after the tool it came from. - 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. + The session list marks the source with the same name, so a row or preview line that calls + every assistant turn `claude` contradicts the column two inches to its left. Spelled out + rather than abbreviated: two-letter codes were guessable with two harnesses and are not + with five. """ - return "cx" if source == "codex" else "cc" + return _source(source)["label"] AGENT_ID_MIN = 12 # git's short-hash rule; see _short_id_len for why 8 is not enough @@ -621,11 +1016,11 @@ def _match_entry(f, matched, total, texts, keys, idlen=36, pad=True): neither the padding nor the full id: alignment buys an agent nothing and every run of spaces costs it a token. """ - tag = "auto" if f[C_KIND] == "auto" else _agent_tag(f[C_SOURCE]) + tag = "auto" if f[C_KIND] == "auto" else _agent_name(f[C_SOURCE]) badge = f"{matched}/{total}" if total else "" proj = short_proj(f[C_CWD])[:20] if pad: - head = (f"{f[C_SID]} {f[C_DATE]} {tag:<4} {badge:<4} " + head = (f"{f[C_SID]} {f[C_DATE]} {tag:<{SOURCE_COL}} {badge:<4} " f"\033[36m{proj:<20}\033[0m {f[C_TITLE][:60]}") else: head = (f"{f[C_SID][:idlen]} {f[C_DATE]} {tag} {badge} " @@ -713,16 +1108,17 @@ def _session_path(sid): def _turn_header(role, source, is_sub): - """Chat-style role gutter for a preview turn: '▌ you', '▌ claude', '▌ ⤷ codex'. + """Chat-style role gutter for a preview turn: '▌ you', '▌ claude', '▌ tool'. 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 "?") + agent = _source(source)["label"] + name = {"user": "you", "assistant": agent, "thinking": "thinking", + "tool": "tool"}.get(role, role or "?") if is_sub: return f"\033[35m▌ ⤷ {name}\033[0m" - color = {"user": "36", "assistant": "32", "thinking": "90"}.get(role, "37") + color = {"user": "36", "assistant": "32", "thinking": "90", "tool": "90"}.get(role, "37") return f"\033[{color}m▌ {name}\033[0m" @@ -984,16 +1380,14 @@ def load_session_rows(sid, thinking=False, limit=MSG_INDEX_CHARS): source = info.get("source", "cc") path = info.get("path") or _session_path(sid) - # Codex sessions have no subagents; Claude folds them in. + # Only some harnesses write separate subagent transcripts; the rest are a single file. + rec = _source(source) 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] + if path: + _s, prows = rec["parse"](path, include_thinking=thinking, limit=limit) + # A shared database hands back every session in the file; keep the one asked for. + tagged += [(r, False) for r in prows if r[0] == sid] + if rec["subagents"]: try: submap = json.load(open(SUBMAP_PATH)) except (OSError, json.JSONDecodeError): @@ -1206,13 +1600,16 @@ def resume_plan(sid, cwd): 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]) + source = info.get("source", DEFAULT_SOURCE) + rec = _source(source) + kind, template = rec["resume"] + handle = info.get("path", "") if kind == "path" else sid + argv = [a.replace("{sid}", sid).replace("{path}", handle) for a in template] + bin_ = argv[0] # 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": + # from the dir it was launched in, not the `cwd` on the messages, which may be a subdir. + if rec["launch_dir"]: 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 @@ -1365,8 +1762,14 @@ def _fuzzy_span(hay, term): 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 +# Derived from SOURCES so the column and the assistant-turn label can never disagree about +# what a harness is called. They used to be written out separately, and had already drifted. +# Width of the source column, from the longest harness name, so adding one cannot misalign +# every row below it. +SOURCE_COL = max([len("auto")] + [len(r["label"]) for r in SOURCES.values()]) +_SRC_MARK = {name: "\033[%sm%-*s\033[0m" % (rec["colour"], SOURCE_COL, rec["label"]) + for name, rec in SOURCES.items()} +_AUTO_MARK = "\033[90m%-*s\033[0m" % (SOURCE_COL, "auto") # plugin/SDK-spawned, not your 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. @@ -1415,7 +1818,7 @@ def _missing_dirs(cwds): def _row(sid, cwd, date, source, kind, title, badge, active=False, dir_gone=False, forked=False): - mark = _AUTO_MARK if kind == "auto" else _SRC_MARK.get(source, " ") + mark = _AUTO_MARK if kind == "auto" else _SRC_MARK.get(source, " " * SOURCE_COL) live = _LIVE_MARK if active else " " forkm = _FORK_MARK if forked else "" tail = _GONE_MARK if dir_gone else "" diff --git a/packaging/agsearch.rb b/packaging/agsearch.rb index ce5c9d5..f8c6af4 100644 --- a/packaging/agsearch.rb +++ b/packaging/agsearch.rb @@ -9,7 +9,7 @@ class Agsearch < Formula include Language::Python::Shebang - desc "Search every Claude Code and Codex CLI session, then resume the right one" + desc "Search every Claude Code, Codex, Cursor, opencode and Gemini CLI session, then resume the right one" homepage "https://github.com/devcodes9/agsearch" url "https://github.com/devcodes9/agsearch/archive/refs/tags/v0.1.0.tar.gz" sha256 "0000000000000000000000000000000000000000000000000000000000000000" diff --git a/pyproject.toml b/pyproject.toml index ee0b4f1..a681e05 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,12 +4,12 @@ build-backend = "hatchling.build" [project] name = "agsearch" -description = "Search every Claude Code and Codex CLI session by what was said in it, then resume it" +description = "Search every Claude Code, Codex, Cursor, opencode and Gemini CLI session by what was said in it, then resume it" readme = "README.md" license = "MIT" license-files = ["LICENSE"] authors = [{ name = "Dev Dalia" }] -keywords = ["claude-code", "codex", "cli", "search", "tui", "session", "resume"] +keywords = ["claude-code", "codex", "cursor", "opencode", "gemini-cli", "cli", "search", "tui", "session", "resume"] requires-python = ">=3.9" # Deliberately empty, and it is a feature. agsearch is stdlib-only, which is diff --git a/skills/agsearch/SKILL.md b/skills/agsearch/SKILL.md index 40dd64d..a92b068 100644 --- a/skills/agsearch/SKILL.md +++ b/skills/agsearch/SKILL.md @@ -1,6 +1,6 @@ --- name: agsearch -description: Use when the user refers to earlier work ("what did we decide about X", "we hit this error before"), asks to continue or hand off work started in another session, or when you are about to say you have no record of a conversation that happened before this one. Use this instead of grepping or reading ~/.claude/projects, ~/.codex/sessions or any transcript directory yourself. Searches Claude Code, Codex, Cursor, opencode and Gemini CLI transcripts saved on this machine. +description: Use when the user refers to work from an earlier session ("what did we decide about X", "we hit this error before"), asks to continue or hand one off, or when you are about to say you have no record of a conversation that happened before this one. Not for earlier in this same conversation. Use this instead of grepping ~/.claude/projects, ~/.codex/sessions or any transcript directory yourself. Searches Claude Code, Codex, Cursor, opencode and Gemini CLI transcripts on this machine. --- # agsearch diff --git a/tests/test_adapters.py b/tests/test_adapters.py new file mode 100644 index 0000000..665dfaa --- /dev/null +++ b/tests/test_adapters.py @@ -0,0 +1,538 @@ +"""Every harness gets its own row schema, labels and resume command. + +The old code answered "which harness is this?" with a ternary in five places, so a source it +did not know about was silently parsed, labelled and resumed as Claude. These tests pin the +table that replaced them, and the two parsers added with it. + +Fixtures here are synthetic. Real transcripts are not committed. +""" + +import json +import os +import pathlib +import sqlite3 +import tempfile +import unittest + +from load_agsearch import load_agsearch + +ag = load_agsearch() + +# TSV columns, per the schema comment in agsearch. +C_SID, C_CWD, C_BRANCH, C_TS, C_ROLE, C_SEQ, C_TITLE, C_TEXT, C_KIND = range(9) + + +class RegistryTests(unittest.TestCase): + def test_every_source_is_complete(self): + keys = {"roots", "match", "parse", "label", "colour", "resume", + "subagents", "launch_dir"} + for name, rec in ag.SOURCES.items(): + self.assertEqual(keys, set(rec), name) + self.assertTrue(callable(rec["parse"]), name) + self.assertTrue(callable(rec["match"]), name) + + def test_names_have_no_whitespace(self): + """The piped row is whitespace-separated columns. A harness whose name contains a + space shifts every field after it for anything reading them.""" + for name, rec in ag.SOURCES.items(): + self.assertEqual(rec["label"], rec["label"].strip(), name) + self.assertNotIn(" ", rec["label"], name) + + def test_names_are_unique(self): + names = [r["label"] for r in ag.SOURCES.values()] + self.assertEqual(len(names), len(set(names))) + + def test_resume_templates_use_a_known_placeholder(self): + for name, rec in ag.SOURCES.items(): + kind, argv = rec["resume"] + self.assertIn(kind, ("id", "path"), name) + self.assertTrue(any("{sid}" in a or "{path}" in a for a in argv), name) + + def test_column_mark_matches_the_turn_name(self): + """The list column and the assistant-turn label used to be written out separately and + had drifted. They are now the same string by construction.""" + for name, rec in ag.SOURCES.items(): + self.assertIn(rec["label"], ag._SRC_MARK[name]) + self.assertEqual(rec["label"], ag._agent_name(name)) + + def test_source_column_fits_the_longest_name(self): + """Every row aligns on this column, so a harness whose name overflows it would ragged + the whole list.""" + for rec in ag.SOURCES.values(): + self.assertLessEqual(len(rec["label"]), ag.SOURCE_COL) + + def test_unknown_source_falls_back_to_claude(self): + self.assertIs(ag._source("harness-from-the-future"), ag.SOURCES["cc"]) + + +class ShippedDescriptionTests(unittest.TestCase): + """Every user-facing description has to name every harness agsearch indexes. + + They are written out in four files, and adding the fifth harness updated three of them. + A reader met the marketplace listing before anything else and it still said two. + """ + + # How each source is spelled in prose, which is not always its column label. + PROSE = {"cc": "Claude Code", "codex": "Codex", "cursor": "Cursor", + "opencode": "opencode", "gemini": "Gemini"} + + def described_files(self): + root = pathlib.Path(__file__).resolve().parents[1] + for rel in (".claude-plugin/plugin.json", ".claude-plugin/marketplace.json"): + blob = json.loads((root / rel).read_text()) + stack = [blob] + while stack: + node = stack.pop() + if isinstance(node, dict): + if isinstance(node.get("description"), str): + yield rel, node["description"] + stack.extend(node.values()) + elif isinstance(node, list): + stack.extend(node) + + def test_every_harness_is_named(self): + self.assertEqual(set(self.PROSE), set(ag.SOURCES), "a source has no prose spelling") + seen = 0 + for rel, text in self.described_files(): + seen += 1 + for source, prose in self.PROSE.items(): + self.assertIn(prose, text, "%s omits %s: %r" % (rel, source, text)) + self.assertGreaterEqual(seen, 3, "expected several descriptions to check") + + +class ResumeRecipeTests(unittest.TestCase): + def plan(self, source, sid, path): + d = tempfile.mkdtemp() + old = ag.INDEX_PATH + ag.INDEX_PATH = os.path.join(d, "index.json") + try: + with open(ag.INDEX_PATH, "w") as fh: + json.dump({sid: {"source": source, "path": path}}, fh) + return ag.resume_plan(sid, "") + finally: + ag.INDEX_PATH = old + + def test_id_recipe_substitutes_the_session_id(self): + _s, bin_, argv, _c, _t, _e = self.plan("cursor", "chat-123", "/tmp/x/store.db") + self.assertEqual("cursor-agent", bin_) + self.assertEqual(["cursor-agent", "--resume", "chat-123"], argv) + + def test_path_recipe_substitutes_the_transcript_path(self): + """Gemini's --resume takes a project-scoped index number, which is not a stable handle + for a session found by search. Resume must go through the file instead.""" + _s, bin_, argv, _c, _t, _e = self.plan("gemini", "sid-1", "/tmp/chats/s.json") + self.assertEqual(["gemini", "--session-file", "/tmp/chats/s.json"], argv) + self.assertNotIn("sid-1", argv) + + def test_every_resume_opens_an_interactive_session(self): + """Each harness has a one-shot form and an interactive form, and they are not the same + command. `opencode run --session ` exits with "You must provide a message"; the + bare command opens the session. Pin the exact argv so a wrong form is caught here and + not by a user pressing enter on a result.""" + expected = { + "cc": ["claude", "--resume", "ID"], + "codex": ["codex", "resume", "ID"], + "cursor": ["cursor-agent", "--resume", "ID"], + "opencode": ["opencode", "--session", "ID"], + "gemini": ["gemini", "--session-file", "PATH"], + } + self.assertEqual(set(ag.SOURCES), set(expected), "a source has no pinned resume command") + for source, want in expected.items(): + _s, _b, argv, _c, _t, _e = self.plan(source, "ID", "PATH") + self.assertEqual(want, argv, source) + + def test_existing_harnesses_are_unchanged(self): + _s, _b, argv, _c, _t, _e = self.plan("cc", "abc", "/tmp/p/abc.jsonl") + self.assertEqual(["claude", "--resume", "abc"], argv) + _s, _b, argv, _c, _t, _e = self.plan("codex", "abc", "/tmp/s/abc.jsonl") + self.assertEqual(["codex", "resume", "abc"], argv) + + +def write_gemini(dirpath, messages, project_root="/work/repo"): + chats = os.path.join(dirpath, "chats") + os.makedirs(chats, exist_ok=True) + with open(os.path.join(dirpath, ".project_root"), "w") as fh: + fh.write(project_root) + path = os.path.join(chats, "session-2026-01-01T00-00-abcd1234.json") + with open(path, "w") as fh: + json.dump({"sessionId": "11111111-2222-3333-4444-555555555555", + "projectHash": "deadbeef", "startTime": "2026-01-01T00:00:00.000Z", + "lastUpdated": "2026-01-01T00:05:00.000Z", "messages": messages}, fh) + return path + + +class GeminiParserTests(unittest.TestCase): + def parse(self, messages, **kw): + d = tempfile.mkdtemp() + return ag.parse_gemini_session(write_gemini(d, messages, **kw)) + + def test_rows_use_the_shared_schema(self): + sid, rows = self.parse([ + {"type": "user", "content": "why does the checksum retry twice"}, + {"type": "gemini", "content": "because the backoff resets"}, + ]) + self.assertEqual("11111111-2222-3333-4444-555555555555", sid) + self.assertEqual(2, len(rows)) + for i, r in enumerate(rows): + self.assertEqual(9, len(r)) + self.assertEqual(sid, r[C_SID]) + self.assertEqual("/work/repo", r[C_CWD]) + self.assertEqual(str(i), r[C_SEQ]) + self.assertEqual("cli", r[C_KIND]) + self.assertEqual(["user", "assistant"], [r[C_ROLE] for r in rows]) + + def test_cli_chrome_is_not_indexed(self): + """`info` entries are auth prompts and update notices. Indexing them makes every + Gemini session match the same words and mean nothing.""" + _sid, rows = self.parse([ + {"type": "info", "content": "Update successful! Waiting for authentication..."}, + {"type": "error", "content": "IneligibleTierError"}, + {"type": "user", "content": "real question"}, + ]) + self.assertEqual(["real question"], [r[C_TEXT] for r in rows]) + + def test_title_is_the_first_user_turn(self): + _sid, rows = self.parse([ + {"type": "gemini", "content": "assistant speaks first"}, + {"type": "user", "content": "the actual task"}, + ]) + self.assertTrue(all(r[C_TITLE] == "the actual task" for r in rows)) + + def test_missing_project_root_leaves_cwd_blank(self): + """Gemini stores a sha256 projectHash, never a path. With no .project_root there is + nothing to recover, and a guess would be worse than an empty column.""" + d = tempfile.mkdtemp() + path = write_gemini(d, [{"type": "user", "content": "hi"}]) + os.remove(os.path.join(d, ".project_root")) + _sid, rows = ag.parse_gemini_session(path) + self.assertEqual("", rows[0][C_CWD]) + + def test_unreadable_file_is_skipped_not_fatal(self): + d = tempfile.mkdtemp() + path = os.path.join(d, "session-broken.json") + with open(path, "w") as fh: + fh.write("{not json") + _sid, rows = ag.parse_gemini_session(path) + self.assertEqual([], rows) + + +def write_cursor(chat_id="chat-abc", turns=(), project="tmp", title=None): + """A Cursor transcript: /agent-transcripts//.jsonl, one json per line.""" + root = tempfile.mkdtemp() + chat = os.path.join(root, project, "agent-transcripts", chat_id) + os.makedirs(chat) + path = os.path.join(chat, chat_id + ".jsonl") + with open(path, "w") as fh: + for entry in turns: + fh.write(json.dumps(entry) + "\n") + if title is not None: + ag._CURSOR_TITLES = {chat_id: title} + else: + ag._CURSOR_TITLES = {} + return path + + +def cursor_turn(role, text): + return {"role": role, "message": {"content": [{"type": "text", "text": text}]}} + + +class CursorParserTests(unittest.TestCase): + def tearDown(self): + ag._CURSOR_TITLES = None + + def test_rows_use_the_shared_schema(self): + db = write_cursor(turns=[ + cursor_turn("user", "why is the badge count wrong"), + cursor_turn("assistant", "the filter runs before the join"), + ], title="Badge Discrepancy") + sid, rows = ag.parse_cursor_session(db) + self.assertEqual("chat-abc", sid) + self.assertEqual(2, len(rows)) + for i, r in enumerate(rows): + self.assertEqual(9, len(r)) + self.assertEqual("chat-abc", r[C_SID]) + self.assertEqual("Badge Discrepancy", r[C_TITLE]) + self.assertEqual(str(i), r[C_SEQ]) + self.assertTrue(r[C_TS].startswith("20")) + self.assertEqual(["user", "assistant"], [r[C_ROLE] for r in rows]) + + def test_session_id_is_the_resume_handle(self): + """`cursor-agent --resume ` takes the file stem, so that is the row key.""" + db = write_cursor(chat_id="7f3f46c7-7c48-43ba-9bd2-8ace1dd6b058", + turns=[cursor_turn("user", "hi")]) + sid, _rows = ag.parse_cursor_session(db) + self.assertEqual("7f3f46c7-7c48-43ba-9bd2-8ace1dd6b058", sid) + + def test_user_turn_is_the_query_not_its_wrapper(self): + """Cursor surrounds what was typed with attachments, timestamps and skill lists. + Indexed whole, those swamp the prompt and become the session title.""" + db = write_cursor(turns=[cursor_turn("user", + "[Image]\nMonday, Aug 3, 2026\n" + "\nfix the retry backoff\n")]) + _sid, rows = ag.parse_cursor_session(db) + self.assertEqual("fix the retry backoff", rows[0][C_TEXT]) + + def test_tags_with_attributes_are_stripped(self): + """The first pattern matched bare tags only, so `` + survived and ~50 sessions were titled with injected hook context.""" + db = write_cursor(turns=[cursor_turn("assistant", + '' + 'noise the real answer')]) + _sid, rows = ag.parse_cursor_session(db) + self.assertEqual("the real answer", rows[0][C_TEXT]) + self.assertNotIn("hooks_context", rows[0][C_TEXT]) + + def test_status_entries_are_not_turns(self): + db = write_cursor(turns=[{"type": "status", "status": "running"}, + {"error": "boom"}, + cursor_turn("user", "the only real turn")]) + _sid, rows = ag.parse_cursor_session(db) + self.assertEqual(["the only real turn"], [r[C_TEXT] for r in rows]) + + def test_title_falls_back_to_the_first_user_turn(self): + db = write_cursor(turns=[cursor_turn("user", "untitled chat topic")], title=None) + _sid, rows = ag.parse_cursor_session(db) + self.assertEqual("untitled chat topic", rows[0][C_TITLE]) + + def test_missing_file_is_skipped_not_fatal(self): + _sid, rows = ag.parse_cursor_session(os.path.join(tempfile.mkdtemp(), "gone.jsonl")) + self.assertEqual([], rows) + + +class UnslugTests(unittest.TestCase): + """Cursor names a project directory after its path with non-alphanumerics replaced by `-`, + which a real dash makes ambiguous. Resolve it against the filesystem, longest match first.""" + + def test_resolves_a_directory_containing_a_dash(self): + root = tempfile.mkdtemp() + target = os.path.join(root, "my-workspace", "sub") + os.makedirs(target) + slug = target.replace(os.sep, "-").strip("-") + ag._UNSLUG_CACHE.clear() + self.assertEqual(target, ag._unslug(slug)) + + def test_a_directory_that_is_gone_resolves_to_nothing(self): + ag._UNSLUG_CACHE.clear() + self.assertEqual("", ag._unslug("no-such-place-anywhere-12345")) + + +def write_opencode(sessions): + """sessions: {sid: (directory, title, [(role, [(part_type, text), ...]), ...])}""" + root = tempfile.mkdtemp() + db = os.path.join(root, "opencode.db") + conn = sqlite3.connect(db) + conn.execute("CREATE TABLE session (id text PRIMARY KEY, project_id text, directory text, " + "title text, time_created integer, time_updated integer)") + conn.execute("CREATE TABLE message (id text PRIMARY KEY, session_id text, " + "time_created integer, data text)") + conn.execute("CREATE TABLE part (id text PRIMARY KEY, message_id text, session_id text, " + "time_created integer, data text)") + t = 1767225600000 + mn = pn = 0 + for sid, (directory, title, turns) in sessions.items(): + conn.execute("INSERT INTO session VALUES (?,?,?,?,?,?)", + (sid, "proj", directory, title, t, t + 60000)) + for role, parts in turns: + mn += 1 + mid = "msg%d" % mn + conn.execute("INSERT INTO message VALUES (?,?,?,?)", + (mid, sid, t + mn, json.dumps({"role": role}))) + for ptype, text in parts: + pn += 1 + body = {"type": ptype} + if text is not None: + body["text"] = text + conn.execute("INSERT INTO part VALUES (?,?,?,?,?)", + ("prt%d" % pn, mid, sid, t + pn, json.dumps(body))) + conn.commit() + conn.close() + return db + + +class OpencodeParserTests(unittest.TestCase): + def test_one_database_yields_every_session(self): + """opencode keeps all sessions in a single database. The indexer used to take the + first row's id as the id for the whole file, which collapsed them into one.""" + db = write_opencode({ + "ses_a": ("/work/one", "First", [("user", [("text", "quasar buffer")])]), + "ses_b": ("/work/two", "Second", [("user", [("text", "checksum ladder")])]), + }) + _sid, rows = ag.parse_opencode_session(db) + self.assertEqual({"ses_a", "ses_b"}, {r[C_SID] for r in rows}) + by = {r[C_SID]: r for r in rows} + self.assertEqual("/work/one", by["ses_a"][C_CWD]) + self.assertEqual("Second", by["ses_b"][C_TITLE]) + + def test_rows_use_the_shared_schema(self): + db = write_opencode({"ses_a": ("/work", "T", [ + ("user", [("text", "why does it retry")]), + ("assistant", [("text", "the backoff resets")]), + ])}) + _sid, rows = ag.parse_opencode_session(db) + self.assertEqual(["user", "assistant"], [r[C_ROLE] for r in rows]) + for i, r in enumerate(rows): + self.assertEqual(9, len(r)) + self.assertEqual(str(i), r[C_SEQ]) + self.assertEqual("cli", r[C_KIND]) + self.assertTrue(r[C_TS].startswith("20")) + + def test_only_text_parts_are_indexed(self): + """A message is made of typed parts. Tool calls and step markers are not conversation, + and reasoning is only indexed when the user asked for thinking.""" + db = write_opencode({"ses_a": ("/w", "T", [("assistant", [ + ("step-start", None), ("tool", "grep -r foo"), + ("reasoning", "internal deliberation"), ("text", "the visible answer"), + ])])}) + _sid, rows = ag.parse_opencode_session(db) + self.assertEqual(["the visible answer"], [r[C_TEXT] for r in rows]) + _sid, rows = ag.parse_opencode_session(db, include_thinking=True) + self.assertEqual(["internal deliberation", "the visible answer"], + [r[C_TEXT] for r in rows]) + + def test_title_falls_back_to_the_first_user_turn(self): + db = write_opencode({"ses_a": ("/w", "", [("user", [("text", "the real task")])])}) + _sid, rows = ag.parse_opencode_session(db) + self.assertEqual("the real task", rows[0][C_TITLE]) + + def test_missing_database_is_skipped_not_fatal(self): + sid, rows = ag.parse_opencode_session(os.path.join(tempfile.mkdtemp(), "opencode.db")) + self.assertEqual("", sid) + self.assertEqual([], rows) + + +class SharedDatabaseTests(unittest.TestCase): + """A parser for a shared database hands back every session it holds. Reading one session + must show that session only.""" + + def test_preview_keeps_only_the_requested_session(self): + db = write_opencode({ + "ses_a": ("/w", "A", [("user", [("text", "alpha content")])]), + "ses_b": ("/w", "B", [("user", [("text", "beta content")])]), + }) + d = tempfile.mkdtemp() + old_index, old_sub = ag.INDEX_PATH, ag.SUBMAP_PATH + ag.INDEX_PATH = os.path.join(d, "index.json") + ag.SUBMAP_PATH = os.path.join(d, "submap.json") + try: + with open(ag.INDEX_PATH, "w") as fh: + json.dump({"ses_a": {"source": "opencode", "path": db}, + "ses_b": {"source": "opencode", "path": db}}, fh) + with open(ag.SUBMAP_PATH, "w") as fh: + json.dump({}, fh) + source, tagged = ag.load_session_rows("ses_b", False) + self.assertEqual("opencode", source) + self.assertEqual(["beta content"], [r[C_TEXT] for r, _sub in tagged]) + finally: + ag.INDEX_PATH, ag.SUBMAP_PATH = old_index, old_sub + + +class MetaEntryTests(unittest.TestCase): + """Claude Code files injected content under the user's role and flags it `isMeta`. + + A skill injection is the whole SKILL.md, up to 15k characters. Indexed, it outranks and + then hides the sentence the human actually typed. + """ + + def parse(self, entries): + d = tempfile.mkdtemp() + path = os.path.join(d, "11111111-2222-3333-4444-555555555555.jsonl") + with open(path, "w") as fh: + for e in entries: + fh.write(json.dumps(e) + "\n") + return ag.parse_session(path) + + def turn(self, role, text, meta=False): + e = {"type": role, "sessionId": "11111111-2222-3333-4444-555555555555", + "cwd": "/work", "timestamp": "2026-01-01T00:00:00Z", + "message": {"role": role, "content": [{"type": "text", "text": text}]}} + if meta: + e["isMeta"] = True + return e + + def test_injected_turns_are_not_indexed(self): + _sid, rows = self.parse([ + self.turn("user", "Base directory for this skill: /plugins/x " + "boilerplate " * 400, + meta=True), + self.turn("user", "why does the checksum retry twice"), + self.turn("assistant", "because the backoff resets"), + ]) + self.assertEqual(["why does the checksum retry twice", "because the backoff resets"], + [r[7] for r in rows]) + + def test_the_first_real_prompt_still_titles_the_session(self): + """The injection lands before the prompt, so indexing it takes the title too.""" + _sid, rows = self.parse([ + self.turn("user", "A session-scoped Stop hook is now active", meta=True), + self.turn("user", "fix the retry backoff"), + ]) + self.assertEqual(1, len(rows)) + self.assertEqual("fix the retry backoff", rows[0][7]) + + def test_tool_output_is_not_labelled_as_the_user(self): + """Claude Code files a tool's output under the user's role. Nine in ten user-role + entries are this, and a preview that calls them `you` tells the reader they said + "The file has been updated successfully".""" + d = tempfile.mkdtemp() + path = os.path.join(d, "11111111-2222-3333-4444-555555555555.jsonl") + entries = [ + self.turn("user", "fix the retry backoff"), + {"type": "user", "sessionId": "11111111-2222-3333-4444-555555555555", + "cwd": "/work", "timestamp": "2026-01-01T00:01:00Z", + "message": {"role": "user", "content": [ + {"type": "tool_result", "content": "The file has been updated successfully"}]}}, + self.turn("assistant", "done"), + ] + with open(path, "w") as fh: + for e in entries: + fh.write(json.dumps(e) + "\n") + _sid, rows = ag.parse_session(path) + self.assertEqual(["user", "tool", "assistant"], [r[4] for r in rows]) + + def test_tool_output_is_still_indexed(self): + """An error string a search has to find lives in tool output, not in what anyone + typed. Relabelling it must not drop it from the index.""" + d = tempfile.mkdtemp() + path = os.path.join(d, "11111111-2222-3333-4444-555555555555.jsonl") + with open(path, "w") as fh: + fh.write(json.dumps({ + "type": "user", "sessionId": "11111111-2222-3333-4444-555555555555", + "cwd": "/w", "timestamp": "2026-01-01T00:00:00Z", + "message": {"role": "user", "content": [ + {"type": "tool_result", "content": "ECONNRESET on webhook delivery"}]}}) + "\n") + _sid, rows = ag.parse_session(path) + self.assertEqual(1, len(rows)) + self.assertIn("ECONNRESET", rows[0][7]) + + def test_the_gutter_names_the_tool_not_the_user(self): + header = ag._turn_header("tool", "cc", False) + self.assertIn("tool", header) + self.assertNotIn("you", header) + + def test_ordinary_turns_are_untouched(self): + _sid, rows = self.parse([self.turn("user", "a"), self.turn("assistant", "b")]) + self.assertEqual(["a", "b"], [r[7] for r in rows]) + self.assertEqual(["0", "1"], [r[5] for r in rows]) + + +class DiscoveryTests(unittest.TestCase): + def test_each_harness_matches_only_its_own_files(self): + """The walk used to accept `.jsonl` globally, which made every non-jsonl transcript + invisible no matter what the source table said.""" + cases = [ + ("cc", "/p/abc.jsonl", True), ("cc", "/p/store.db", False), + ("codex", "/s/rollout.jsonl", True), + ("gemini", "/c/session-2026-01-01T00-00-ab.json", True), + ("gemini", "/c/logs.json", False), + # `/.jsonl` is the session; `/subagents/.jsonl` is not, and the + # two are indistinguishable by filename alone. + ("cursor", "/p/agent-transcripts/abc/abc.jsonl", True), + ("cursor", "/p/agent-transcripts/abc/subagents/def.jsonl", False), + ("cursor", "/p/agent-transcripts/abc/meta.json", False), + ("opencode", "/o/opencode.db", True), ("opencode", "/o/opencode.db-wal", False)] + for source, path, want in cases: + self.assertEqual(want, bool(ag.SOURCES[source]["match"](path)), + "%s / %s" % (source, path)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_agent_label.py b/tests/test_agent_label.py index 96a7c45..460c5f9 100644 --- a/tests/test_agent_label.py +++ b/tests/test_agent_label.py @@ -1,7 +1,7 @@ -"""A Codex session marked `cx` in the list must not call itself `cc` everywhere else. +"""A Codex session marked `codex` in the list must not call itself `claude` everywhere else. The list column is keyed on the session's source, but the row and preview role labels used to -hardcode `cc` for every assistant turn. On a Codex session the two disagreed on screen, which +hardcode `claude` for every assistant turn. On a Codex session the two disagreed on screen, which reads as "the preview is showing me a different session". """ @@ -30,21 +30,21 @@ def row(role, text, sid="s1", ts="2026-08-19T00:00:00", title="Session title"): class AgentTagTests(unittest.TestCase): def test_codex_is_cx_and_claude_is_cc(self): - self.assertEqual(ag._agent_tag("codex"), "cx") - self.assertEqual(ag._agent_tag("cc"), "cc") + self.assertEqual(ag._agent_name("codex"), "codex") + self.assertEqual(ag._agent_name("cc"), "claude") def test_unknown_source_falls_back_to_cc(self): - self.assertEqual(ag._agent_tag(""), "cc") - self.assertEqual(ag._agent_tag(None), "cc") + self.assertEqual(ag._agent_name(""), "claude") + self.assertEqual(ag._agent_name(None), "claude") class LabelTests(unittest.TestCase): """The preview names the agent in the turn gutter, so these assert the rendered gutter rather than a label helper. The list and `-n` paths keep the - two-character `cc`/`cx` form; only the preview spells the agent out. + harness name, the same one the preview uses. - `-n` lists sessions rather than messages, so it has no per-turn role to name; its `cc`/`cx` - comes from the session's source via _agent_tag, covered by AgentTagTests above.""" + `-n` lists sessions rather than messages, so it has no per-turn role to name; its source + comes from the session's source via _agent_name, covered by AgentTagTests above.""" def _gutter(self, source): # _preview_lines takes split rows, not the SEP-joined strings row() builds.