From 8aab17e6893a8ad4d339f01aa052b37970135aae Mon Sep 17 00:00:00 2001 From: Dev Dalia Date: Sat, 5 Sep 2026 13:31:20 +0530 Subject: [PATCH 01/10] Index Cursor and Gemini CLI sessions Adding a harness was supposed to be one line in the source list. It was not: the file extension, the parser preview uses, the row label, the preview label and the resume command each decided separately what a source was, so an unknown one was discovered as jsonl, parsed as Claude, labelled cc and resumed with claude --resume. Two of those tables had already drifted against each other. They now read one record per harness. A new agent is a parser plus one entry. Cursor keeps each chat as a SQLite store under ~/.cursor/chats. Message blobs are plain json beside binary merkle nodes and images, so the scan filters on the leading byte in SQL and opens the store read-only. Blob order is insertion order; per-message times were never recorded, so every row carries the session's updatedAtMs rather than inventing them. Gemini keeps one json object per session, which is why the global .jsonl filter had to go. Its --resume takes a project-scoped index number, not a stable id, so resume goes through --session-file instead. Claude Code and Codex behaviour is unchanged. Cache format bumps to 7. On a 852-session corpus, the 101 new Cursor sessions moved held-out ranking +0.004. --- .claude-plugin/plugin.json | 2 +- CHANGELOG.md | 18 +++ README.md | 29 ++-- agsearch | 310 +++++++++++++++++++++++++++++++++---- packaging/agsearch.rb | 2 +- pyproject.toml | 4 +- tests/test_adapters.py | 259 +++++++++++++++++++++++++++++++ 7 files changed, 579 insertions(+), 45 deletions(-) create mode 100644 tests/test_adapters.py diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 9e72abf..6a994bd 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 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..5f90205 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,8 +11,26 @@ migration in the same line. ## [Unreleased] +### Added + +- **Cursor and Gemini CLI sessions are indexed, searched and resumed** alongside Claude Code + and Codex, labelled `cu` and `gm`. 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. + On a 852-session corpus, adding 101 Cursor sessions moved held-out ranking by +0.004, so + existing searches are unaffected. + ### Changed +- **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..dc1984d 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 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 four agents appear together, labelled `cc`, + `cx`, `cu` and `gm`. 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,16 @@ 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 | `~/.cursor/chats/**/store.db` | `cursor-agent --resume ` | +| Gemini CLI | `~/.gemini/tmp/**/chats/*.json` | `gemini --session-file ` | + +Cursor keeps each chat in a SQLite store; 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. 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..556f50e 100755 --- a/agsearch +++ b/agsearch @@ -2,10 +2,15 @@ """ 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`. +Claude Code, Codex, Cursor and Gemini CLI each keep every session 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 straight back into the +session with that tool's own resume command. + + cc Claude Code ~/.claude/projects claude --resume + cx Codex ~/.codex/sessions codex resume + cu Cursor ~/.cursor/chats cursor-agent --resume + gm Gemini CLI ~/.gemini/tmp gemini --session-file Usage: agsearch # interactive fuzzy TUI (needs fzf) @@ -54,6 +59,8 @@ 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", "chats") 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 +69,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 @@ -265,6 +272,243 @@ 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 keeps one directory per chat: meta.json (title, cwd, timestamps) beside a SQLite +# store.db whose `blobs` table is content-addressed. Message blobs are plain json; the rest of +# the table is binary merkle nodes and embedded images, which is why the scan filters on the +# leading byte in SQL and never pulls the binary rows into Python. +_CURSOR_JSON_BLOBS = "SELECT data FROM blobs WHERE substr(data, 1, 1) = x'7b'" +_CURSOR_MAX_BLOBS = 5000 + +# Cursor injects context into the user turn the way Codex injects a preamble. Indexing it makes +# every session match "OS Version" and buries what the human actually typed. +_CURSOR_TAG_BLOCK = re.compile(r"^\s*<([a-z_]+)>.*?\s*", re.DOTALL) + + +def _cursor_strip_context(text): + """Drop the leading ... style blocks Cursor prepends to a user turn.""" + prev = None + while prev != text: + prev = text + text = _CURSOR_TAG_BLOCK.sub("", text, count=1) + return text + + +def _cursor_meta(chat_dir): + try: + with open(os.path.join(chat_dir, "meta.json"), errors="replace") as fh: + m = json.load(fh) + return m if isinstance(m, dict) else {} + except (OSError, json.JSONDecodeError, UnicodeDecodeError): + return {} + + +def _cursor_connect(path): + """Open store.db without ever taking a write lock on a chat Cursor 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 parse_cursor_session(path, include_thinking=False, limit=MSG_INDEX_CHARS): + """Parse one Cursor chat (`...//store.db`) into the shared 9-field row schema. + + Keyed by the chat directory name, which is what `cursor-agent --resume ` takes. + + Blob order: the conversation's real ordering lives in a binary root blob, and decoding that + format is not worth it. SQLite rowid is insertion order, which is the same thing in practice. + Every row carries the session's `updatedAtMs`, so `group_sessions` (which takes the max row + timestamp) dates the session correctly, and the stable sort in `load_session_rows` leaves + rowid order intact rather than inventing per-message times that were never recorded. + """ + chat_dir = os.path.dirname(path) + sid = os.path.basename(chat_dir) + meta = _cursor_meta(chat_dir) + cwd = meta.get("cwd", "") or "" + title = _single_line(meta.get("title", "") or "", 90) + + ms = meta.get("updatedAtMs") or meta.get("createdAtMs") + try: + ts = time.strftime("%Y-%m-%dT%H:%M:%S", time.localtime(ms / 1000.0)) if ms else "" + except (TypeError, ValueError, OSError): + ts = "" + + conn = _cursor_connect(path) + if conn is None: + return sid, [] + rows = [] + try: + cur = conn.execute(_CURSOR_JSON_BLOBS) + for n, (data,) in enumerate(cur): + if n >= _CURSOR_MAX_BLOBS: + break + try: + o = json.loads(bytes(data).decode("utf-8", "replace")) + except (json.JSONDecodeError, UnicodeDecodeError, TypeError, ValueError): + continue + if not isinstance(o, dict): + continue + role = o.get("role") + if role not in ("user", "assistant"): # `system` is the prompt, not the chat + continue + text = _flatten_content(o.get("content", "")) + if role == "user": + text = _cursor_strip_context(text) + text = _single_line(text, limit) + if not text: + continue + rows.append([sid, cwd, "", ts, role, "", title, text]) + except Exception: # a truncated or mid-write store is not worth crashing on + pass + finally: + conn.close() + + if not 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)] + +# ------------------------------------------------------------------ sources + +def _is_jsonl(name): + return name.endswith(".jsonl") + + +def _is_gemini_chat(name): + return name.startswith("session-") and name.endswith(".json") + + +def _is_cursor_store(name): + return name == "store.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 filename predicate; harnesses do not agree on an extension +# parse (path, include_thinking, limit) -> (sid, rows) in the shared 9-field schema +# tag 2-char label for the source column and assistant turns +# label what the preview calls the agent side +# 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, + "tag": "cc", "label": "claude", "colour": "34", + "resume": ("id", ["claude", "--resume", "{sid}"]), + "subagents": True, "launch_dir": True, + }, + "codex": { + "roots": [CODEX_DIR], "match": _is_jsonl, "parse": None, + "tag": "cx", "label": "codex", "colour": "35", + "resume": ("id", ["codex", "resume", "{sid}"]), + "subagents": False, "launch_dir": False, + }, + "gemini": { + "roots": [GEMINI_DIR], "match": _is_gemini_chat, "parse": None, + "tag": "gm", "label": "gemini", "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_store, "parse": None, + "tag": "cu", "label": "cursor", "colour": "32", + "resume": ("id", ["cursor-agent", "--resume", "{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 + +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 +720,24 @@ 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"): + if match(fn): 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: + for path, _src, _parser in files: try: mtimes[path] = os.path.getmtime(path) except OSError: @@ -525,7 +772,7 @@ def build_index(include_thinking=False, force=False): 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-"): sub_map.setdefault(sid0, []).append(path) # subagent folds into parent else: index[sid0] = {"source": source, "path": path} @@ -584,12 +831,12 @@ ROW_TEXT_WIDTH = 160 # the "why it matched" line under a result, however def _agent_tag(source): - """Name the agent side of a session after the tool it came from: cc (Claude) or cx (Codex). + """Name the agent side of a session after the tool it came from: cc, cx, gm, cu. 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" + return _source(source)["tag"] AGENT_ID_MIN = 12 # git's short-hash rule; see _short_id_len for why 8 is not enough @@ -718,7 +965,7 @@ def _turn_header(role, source, is_sub): 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" + agent = _source(source)["label"] name = {"user": "you", "assistant": agent, "thinking": "thinking"}.get(role, role or "?") if is_sub: return f"\033[35m▌ ⤷ {name}\033[0m" @@ -984,16 +1231,13 @@ 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) + tagged += [(r, False) for r in prows] + if rec["subagents"]: try: submap = json.load(open(SUBMAP_PATH)) except (OSError, json.JSONDecodeError): @@ -1206,13 +1450,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,7 +1612,10 @@ def _fuzzy_span(hay, term): return None -_SRC_MARK = {"cc": "\033[34mcc \033[0m", "codex": "\033[35mcx \033[0m"} +# 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. +_SRC_MARK = {name: "\033[%sm%-4s\033[0m" % (rec["colour"], rec["tag"]) + for name, rec in SOURCES.items()} _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), diff --git a/packaging/agsearch.rb b/packaging/agsearch.rb index ce5c9d5..22c84df 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 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..47cf991 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 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", "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/tests/test_adapters.py b/tests/test_adapters.py new file mode 100644 index 0000000..dff26ee --- /dev/null +++ b/tests/test_adapters.py @@ -0,0 +1,259 @@ +"""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 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", "tag", "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_tags_are_unique_and_two_chars(self): + tags = [r["tag"] for r in ag.SOURCES.values()] + self.assertEqual(len(tags), len(set(tags))) + for t in tags: + self.assertEqual(2, len(t)) + + 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_tag(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["tag"], ag._SRC_MARK[name]) + self.assertEqual(rec["tag"], ag._agent_tag(name)) + + def test_unknown_source_falls_back_to_claude(self): + self.assertIs(ag._source("harness-from-the-future"), ag.SOURCES["cc"]) + + +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_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", blobs=(), title="Fixture Chat", cwd="/work/repo"): + root = tempfile.mkdtemp() + chat = os.path.join(root, chat_id) + os.makedirs(chat) + with open(os.path.join(chat, "meta.json"), "w") as fh: + json.dump({"schemaVersion": 1, "title": title, "cwd": cwd, + "createdAtMs": 1767225600000, "updatedAtMs": 1767225900000}, fh) + db = os.path.join(chat, "store.db") + conn = sqlite3.connect(db) + conn.execute("CREATE TABLE blobs (id TEXT PRIMARY KEY, data BLOB)") + for i, b in enumerate(blobs): + payload = b if isinstance(b, bytes) else json.dumps(b).encode() + conn.execute("INSERT INTO blobs VALUES (?, ?)", ("b%d" % i, payload)) + conn.commit() + conn.close() + return db + + +class CursorParserTests(unittest.TestCase): + def test_rows_use_the_shared_schema(self): + db = write_cursor(blobs=[ + {"role": "user", "content": "why is the badge count wrong"}, + {"role": "assistant", "content": "the filter runs before the join"}, + ]) + 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("/work/repo", r[C_CWD]) + self.assertEqual("Fixture Chat", r[C_TITLE]) + self.assertEqual(str(i), r[C_SEQ]) + 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 directory name, so that is what the row + must be keyed by.""" + db = write_cursor(chat_id="7f3f46c7-7c48-43ba-9bd2-8ace1dd6b058", + blobs=[{"role": "user", "content": "hi"}]) + sid, _rows = ag.parse_cursor_session(db) + self.assertEqual("7f3f46c7-7c48-43ba-9bd2-8ace1dd6b058", sid) + + def test_non_message_blobs_are_ignored(self): + """The blobs table also holds binary merkle nodes, embedded images and the system + prompt. None of them are conversation.""" + db = write_cursor(blobs=[ + b"\xff\xd8\xff\xe0\x00\x10JFIF binary image", + b"\n \x9e\x97d\x9d\x8f\xf5(\xab\xe7 merkle node", + {"role": "system", "content": "You are a coding assistant. " * 50}, + {"role": "user", "content": "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_injected_context_is_stripped_from_user_turns(self): + """Cursor prepends environment blocks to the user turn. Indexed, they make every + session match 'OS Version' and bury what the human typed.""" + db = write_cursor(blobs=[{ + "role": "user", + "content": "\nOS Version: darwin 25.5.0\n\n" + "/work/repo\n" + "actually fix the retry backoff", + }]) + _sid, rows = ag.parse_cursor_session(db) + self.assertEqual("actually fix the retry backoff", rows[0][C_TEXT]) + + def test_every_row_carries_the_session_timestamp(self): + """Blob order is insertion order; per-message times were never recorded. group_sessions + takes the max row timestamp, so stamping updatedAtMs dates the session correctly + without inventing times.""" + db = write_cursor(blobs=[{"role": "user", "content": "a"}, + {"role": "assistant", "content": "b"}]) + _sid, rows = ag.parse_cursor_session(db) + stamps = {r[C_TS] for r in rows} + self.assertEqual(1, len(stamps)) + self.assertTrue(stamps.pop().startswith("20")) + + def test_missing_store_is_skipped_not_fatal(self): + _sid, rows = ag.parse_cursor_session(os.path.join(tempfile.mkdtemp(), "store.db")) + self.assertEqual([], rows) + + def test_title_falls_back_to_the_first_user_turn(self): + db = write_cursor(title="", blobs=[{"role": "user", "content": "untitled chat topic"}]) + _sid, rows = ag.parse_cursor_session(db) + self.assertEqual("untitled chat topic", rows[0][C_TITLE]) + + +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", "abc.jsonl", True), ("cc", "store.db", False), + ("codex", "rollout.jsonl", True), + ("gemini", "session-2026-01-01T00-00-ab.json", True), + ("gemini", "logs.json", False), + ("cursor", "store.db", True), ("cursor", "store.db-wal", False), + ("cursor", "prompt_history.json", False)] + for source, name, want in cases: + self.assertEqual(want, bool(ag.SOURCES[source]["match"](name)), + "%s / %s" % (source, name)) + + +if __name__ == "__main__": + unittest.main() From b208d814b3195a295a4d4b87ecdf0c7cc827244c Mon Sep 17 00:00:00 2001 From: Dev Dalia Date: Sat, 5 Sep 2026 13:38:26 +0530 Subject: [PATCH 02/10] Index opencode sessions, and allow many sessions per file opencode keeps every session in one SQLite database instead of a file per session. The indexer could not represent that: it read the first row's id and registered it as the id for the whole file, so every session but one was invisible, and previewing that id would have shown all of them concatenated. It now registers each session a fragment contains, and reading one filters to it. Both are no-ops for a file that holds a single session, which is every harness indexed before this. Text lives in `part` rows, one per span, with the role on the parent `message`. Only `text` parts are indexed; `reasoning` joins them under --thinking, and tool calls and step markers are not conversation. Resume is `opencode run --session`. Verified against real sessions: three generated locally, each found by content from the middle of the conversation and read back in isolation. Held-out ranking over 268 queries is unchanged, 0.504 to 0.507. --- .claude-plugin/plugin.json | 2 +- CHANGELOG.md | 11 +++- README.md | 11 ++-- agsearch | 102 +++++++++++++++++++++++++++++---- packaging/agsearch.rb | 2 +- pyproject.toml | 4 +- tests/test_adapters.py | 113 ++++++++++++++++++++++++++++++++++++- 7 files changed, 223 insertions(+), 22 deletions(-) diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 6a994bd..dc3253e 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "agsearch", - "description": "Search your past Claude Code, Codex, Cursor and Gemini CLI 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 5f90205..6c5bc13 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,14 +13,21 @@ migration in the same line. ### Added -- **Cursor and Gemini CLI sessions are indexed, searched and resumed** alongside Claude Code - and Codex, labelled `cu` and `gm`. Cursor keeps each chat as a SQLite store under +- **Cursor, opencode and Gemini CLI sessions are indexed, searched and resumed** alongside + Claude Code and Codex, labelled `cu`, `oc` and `gm`. 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 run --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 diff --git a/README.md b/README.md index dc1984d..644284a 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, Codex, Cursor and Gemini CLI. + Claude Code, Codex, Cursor, opencode and Gemini CLI.

@@ -42,9 +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 every tool.** Sessions from all four agents appear together, labelled `cc`, - `cx`, `cu` and `gm`. Adding another agent is a parser plus one entry in the source table, - with no change to search or ranking. +- **One list for every tool.** Sessions from all five agents appear together, labelled `cc`, + `cx`, `cu`, `oc` and `gm`. 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. @@ -193,9 +193,10 @@ agsearch reads: | Claude Code | `~/.claude/projects/**/*.jsonl` | `claude --resume ` | | Codex | `~/.codex/sessions/**/*.jsonl` | `codex resume ` | | Cursor | `~/.cursor/chats/**/store.db` | `cursor-agent --resume ` | +| opencode | `~/.local/share/opencode/opencode.db` | `opencode run --session ` | | Gemini CLI | `~/.gemini/tmp/**/chats/*.json` | `gemini --session-file ` | -Cursor keeps each chat in a SQLite store; agsearch opens it read-only and reads message +Cursor and opencode keep sessions in SQLite; agsearch opens those 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. diff --git a/agsearch b/agsearch index 556f50e..9cf7891 100755 --- a/agsearch +++ b/agsearch @@ -2,14 +2,15 @@ """ agsearch — global full-text search across all your coding agent sessions. -Claude Code, Codex, Cursor and Gemini CLI each keep every session 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 straight back into the -session with that tool's own resume command. +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. cc Claude Code ~/.claude/projects claude --resume cx Codex ~/.codex/sessions codex resume cu Cursor ~/.cursor/chats cursor-agent --resume + oc opencode ~/.local/share/opencode opencode run --session gm Gemini CLI ~/.gemini/tmp gemini --session-file Usage: @@ -61,6 +62,7 @@ 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", "chats") +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") @@ -368,8 +370,8 @@ def _cursor_meta(chat_dir): return {} -def _cursor_connect(path): - """Open store.db without ever taking a write lock on a chat Cursor may still be using.""" +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: @@ -402,7 +404,7 @@ def parse_cursor_session(path, include_thinking=False, limit=MSG_INDEX_CHARS): except (TypeError, ValueError, OSError): ts = "" - conn = _cursor_connect(path) + conn = _sqlite_ro(path) if conn is None: return sid, [] rows = [] @@ -440,6 +442,70 @@ def parse_cursor_session(path, include_thinking=False, limit=MSG_INDEX_CHARS): 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 _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(name): @@ -454,6 +520,10 @@ def _is_cursor_store(name): return name == "store.db" +def _is_opencode_db(name): + return name == "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. @@ -494,12 +564,19 @@ SOURCES = { "resume": ("id", ["cursor-agent", "--resume", "{sid}"]), "subagents": False, "launch_dir": False, }, + "opencode": { + "roots": [OPENCODE_DIR], "match": _is_opencode_db, "parse": None, + "tag": "oc", "label": "opencode", "colour": "33", + "resume": ("id", ["opencode", "run", "--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" @@ -770,11 +847,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(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") @@ -1236,7 +1317,8 @@ def load_session_rows(sid, thinking=False, limit=MSG_INDEX_CHARS): tagged = [] if path: _s, prows = rec["parse"](path, include_thinking=thinking, limit=limit) - tagged += [(r, False) for r in prows] + # 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)) diff --git a/packaging/agsearch.rb b/packaging/agsearch.rb index 22c84df..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, Codex, Cursor and Gemini 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 47cf991..a681e05 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,12 +4,12 @@ build-backend = "hatchling.build" [project] name = "agsearch" -description = "Search every Claude Code, Codex, Cursor and Gemini 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", "cursor", "gemini-cli", "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/tests/test_adapters.py b/tests/test_adapters.py index dff26ee..64ab88e 100644 --- a/tests/test_adapters.py +++ b/tests/test_adapters.py @@ -240,6 +240,116 @@ def test_title_falls_back_to_the_first_user_turn(self): self.assertEqual("untitled chat topic", rows[0][C_TITLE]) +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 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 @@ -249,7 +359,8 @@ def test_each_harness_matches_only_its_own_files(self): ("gemini", "session-2026-01-01T00-00-ab.json", True), ("gemini", "logs.json", False), ("cursor", "store.db", True), ("cursor", "store.db-wal", False), - ("cursor", "prompt_history.json", False)] + ("cursor", "prompt_history.json", False), + ("opencode", "opencode.db", True), ("opencode", "opencode.db-wal", False)] for source, name, want in cases: self.assertEqual(want, bool(ag.SOURCES[source]["match"](name)), "%s / %s" % (source, name)) From f00cb30c150b41ed747393e3afebe9e1b12fb4d5 Mon Sep 17 00:00:00 2001 From: Dev Dalia Date: Sat, 5 Sep 2026 19:39:22 +0530 Subject: [PATCH 03/10] Resume opencode sessions with the command that opens one `opencode run --session ` is the non-interactive form and exits with "You must provide a message or a command", so pressing enter on an opencode result did nothing. The bare command opens the TUI on that session, which is what resume means here. Every harness has a one-shot form and an interactive form and they are not the same command, so the exact argv for all five is now pinned by a test rather than left to whichever one the adapter author read first. --- CHANGELOG.md | 2 +- README.md | 2 +- agsearch | 6 ++++-- tests/test_adapters.py | 17 +++++++++++++++++ 4 files changed, 23 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c5bc13..5905cec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,7 +20,7 @@ migration in the same line. 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 run --session `) but is read as a whole. + (`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 diff --git a/README.md b/README.md index 644284a..5b673cf 100644 --- a/README.md +++ b/README.md @@ -193,7 +193,7 @@ agsearch reads: | Claude Code | `~/.claude/projects/**/*.jsonl` | `claude --resume ` | | Codex | `~/.codex/sessions/**/*.jsonl` | `codex resume ` | | Cursor | `~/.cursor/chats/**/store.db` | `cursor-agent --resume ` | -| opencode | `~/.local/share/opencode/opencode.db` | `opencode run --session ` | +| opencode | `~/.local/share/opencode/opencode.db` | `opencode --session ` | | Gemini CLI | `~/.gemini/tmp/**/chats/*.json` | `gemini --session-file ` | Cursor and opencode keep sessions in SQLite; agsearch opens those read-only and reads message diff --git a/agsearch b/agsearch index 9cf7891..9e9846f 100755 --- a/agsearch +++ b/agsearch @@ -10,7 +10,7 @@ resume command. cc Claude Code ~/.claude/projects claude --resume cx Codex ~/.codex/sessions codex resume cu Cursor ~/.cursor/chats cursor-agent --resume - oc opencode ~/.local/share/opencode opencode run --session + oc opencode ~/.local/share/opencode opencode --session gm Gemini CLI ~/.gemini/tmp gemini --session-file Usage: @@ -567,7 +567,9 @@ SOURCES = { "opencode": { "roots": [OPENCODE_DIR], "match": _is_opencode_db, "parse": None, "tag": "oc", "label": "opencode", "colour": "33", - "resume": ("id", ["opencode", "run", "--session", "{sid}"]), + # `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, }, } diff --git a/tests/test_adapters.py b/tests/test_adapters.py index 64ab88e..6896869 100644 --- a/tests/test_adapters.py +++ b/tests/test_adapters.py @@ -77,6 +77,23 @@ def test_path_recipe_substitutes_the_transcript_path(self): 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) From 7ff61bd478df011bf875c47ec1114f84fe4b7fe4 Mon Sep 17 00:00:00 2001 From: Dev Dalia Date: Sat, 5 Sep 2026 19:41:23 +0530 Subject: [PATCH 04/10] Name the harness in full instead of a two-letter code `cc` and `cx` were guessable when there were two harnesses. With five, `cu`, `oc` and `gm` are not, and the column they sit in is the one that tells you which tool a result came from. Each harness now has one name, used for the source column, the assistant turn label and the preview, so those three cannot drift apart the way the column and the turn label already had. The column width comes from the longest name, so adding a harness cannot ragged every row below it. --- CHANGELOG.md | 8 ++++++- README.md | 4 ++-- agsearch | 48 +++++++++++++++++++++------------------ tests/test_adapters.py | 22 ++++++++++-------- tests/test_agent_label.py | 18 +++++++-------- 5 files changed, 57 insertions(+), 43 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5905cec..6fda979 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,7 @@ migration in the same line. ### Added - **Cursor, opencode and Gemini CLI sessions are indexed, searched and resumed** alongside - Claude Code and Codex, labelled `cu`, `oc` and `gm`. Cursor keeps each chat as a SQLite store under + 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 ` @@ -31,6 +31,12 @@ migration in the same line. ### 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 diff --git a/README.md b/README.md index 5b673cf..015b031 100644 --- a/README.md +++ b/README.md @@ -42,8 +42,8 @@ uvx agsearch -n "stripe tax id" - **Full-conversation search.** Search user prompts and assistant replies, not only titles and session metadata. -- **One list for every tool.** Sessions from all five agents appear together, labelled `cc`, - `cx`, `cu`, `oc` and `gm`. Adding another agent is a parser plus one entry in the source +- **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 diff --git a/agsearch b/agsearch index 9e9846f..56fcd44 100755 --- a/agsearch +++ b/agsearch @@ -7,11 +7,11 @@ Every coding agent keeps its sessions on disk. Their native pickers search sessi across all of them at once, and drops you back into the session with that tool's own resume command. - cc Claude Code ~/.claude/projects claude --resume - cx Codex ~/.codex/sessions codex resume - cu Cursor ~/.cursor/chats cursor-agent --resume - oc opencode ~/.local/share/opencode opencode --session - gm Gemini CLI ~/.gemini/tmp gemini --session-file + claude ~/.claude/projects claude --resume + codex ~/.codex/sessions codex resume + cursor ~/.cursor/chats cursor-agent --resume + opencode ~/.local/share/opencode opencode --session + gemini ~/.gemini/tmp gemini --session-file Usage: agsearch # interactive fuzzy TUI (needs fzf) @@ -531,8 +531,7 @@ def _is_opencode_db(name): # roots directories to walk for transcripts # match filename predicate; harnesses do not agree on an extension # parse (path, include_thinking, limit) -> (sid, rows) in the shared 9-field schema -# tag 2-char label for the source column and assistant turns -# label what the preview calls the agent side +# label the harness name, used for the source column, assistant turns and the preview # 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 @@ -540,19 +539,19 @@ def _is_opencode_db(name): SOURCES = { "cc": { "roots": [PROJECTS_DIR], "match": _is_jsonl, "parse": None, - "tag": "cc", "label": "claude", "colour": "34", + "label": "claude", "colour": "34", "resume": ("id", ["claude", "--resume", "{sid}"]), "subagents": True, "launch_dir": True, }, "codex": { "roots": [CODEX_DIR], "match": _is_jsonl, "parse": None, - "tag": "cx", "label": "codex", "colour": "35", + "label": "codex", "colour": "35", "resume": ("id", ["codex", "resume", "{sid}"]), "subagents": False, "launch_dir": False, }, "gemini": { "roots": [GEMINI_DIR], "match": _is_gemini_chat, "parse": None, - "tag": "gm", "label": "gemini", "colour": "36", + "label": "gemini", "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}"]), @@ -560,13 +559,13 @@ SOURCES = { }, "cursor": { "roots": [CURSOR_DIR], "match": _is_cursor_store, "parse": None, - "tag": "cu", "label": "cursor", "colour": "32", + "label": "cursor", "colour": "32", "resume": ("id", ["cursor-agent", "--resume", "{sid}"]), "subagents": False, "launch_dir": False, }, "opencode": { "roots": [OPENCODE_DIR], "match": _is_opencode_db, "parse": None, - "tag": "oc", "label": "opencode", "colour": "33", + "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}"]), @@ -913,13 +912,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, cx, gm, cu. +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 _source(source)["tag"] + return _source(source)["label"] AGENT_ID_MIN = 12 # git's short-hash rule; see _short_id_len for why 8 is not enough @@ -951,11 +952,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} " @@ -1698,9 +1699,12 @@ def _fuzzy_span(hay, term): # 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. -_SRC_MARK = {name: "\033[%sm%-4s\033[0m" % (rec["colour"], rec["tag"]) +# 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[90mauto\033[0m" # plugin/SDK-spawned run, never your own typing +_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. @@ -1749,7 +1753,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/tests/test_adapters.py b/tests/test_adapters.py index 6896869..710733e 100644 --- a/tests/test_adapters.py +++ b/tests/test_adapters.py @@ -23,18 +23,16 @@ class RegistryTests(unittest.TestCase): def test_every_source_is_complete(self): - keys = {"roots", "match", "parse", "tag", "label", "colour", "resume", + 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_tags_are_unique_and_two_chars(self): - tags = [r["tag"] for r in ag.SOURCES.values()] - self.assertEqual(len(tags), len(set(tags))) - for t in tags: - self.assertEqual(2, len(t)) + 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(): @@ -42,12 +40,18 @@ def test_resume_templates_use_a_known_placeholder(self): 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_tag(self): + 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["tag"], ag._SRC_MARK[name]) - self.assertEqual(rec["tag"], ag._agent_tag(name)) + 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"]) 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. From 598eaf38b247eed799e2f7fcadee2db9f5bbd535 Mon Sep 17 00:00:00 2001 From: Dev Dalia Date: Sat, 5 Sep 2026 19:55:38 +0530 Subject: [PATCH 05/10] Read Cursor sessions from its transcripts, not its SQLite store Cursor writes each session twice: a SQLite store under ~/.cursor/chats holding raw API payloads, and a clean JSONL transcript under ~/.cursor/projects. I found the store first and used it. The transcript is better on every axis that matters. The store holds the payloads as sent, including injected hook and environment context, and the tag-stripping pattern only matched bare tags, so roughly fifty sessions were titled ``. That text also became their searchable body. The transcript carries only the conversation, and wraps what the user typed in , so the prompt is marked rather than guessed at. Coverage is a strict superset: 147 sessions against 101, every store session included. Dates come from the file mtime instead of a meta.json that 34 chats do not have, and the directory comes from the project slug, so: sessions 101 -> 147 blank date 39 -> 0 blank cwd 41 -> 3 (a deleted worktree and the empty window) hooks_context titles ~50 -> 0 Indexing is also faster, 4.0s against 4.8s, with no database to scan. `match` now takes a path rather than a filename, because `/.jsonl` and `/subagents/.jsonl` are the same filename shape and only one is a session. Cursor and Gemini are labelled `cursor cli` and `gemini cli`: both have a non-CLI product whose sessions live elsewhere and are not indexed, and the column should not imply otherwise. --- README.md | 11 +- agsearch | 223 +++++++++++++++++++++++++---------------- tests/test_adapters.py | 154 +++++++++++++++------------- 3 files changed, 227 insertions(+), 161 deletions(-) diff --git a/README.md b/README.md index 015b031..c6d0c5b 100644 --- a/README.md +++ b/README.md @@ -192,13 +192,16 @@ agsearch reads: | --- | --- | --- | | Claude Code | `~/.claude/projects/**/*.jsonl` | `claude --resume ` | | Codex | `~/.codex/sessions/**/*.jsonl` | `codex resume ` | -| Cursor | `~/.cursor/chats/**/store.db` | `cursor-agent --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 ` | -Cursor and opencode keep sessions in SQLite; agsearch opens those 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. +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 56fcd44..fa550ab 100755 --- a/agsearch +++ b/agsearch @@ -7,11 +7,11 @@ Every coding agent keeps its sessions on disk. Their native pickers search sessi 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 ~/.cursor/chats cursor-agent --resume - opencode ~/.local/share/opencode opencode --session - gemini ~/.gemini/tmp gemini --session-file + 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) @@ -61,7 +61,8 @@ 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", "chats") +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") @@ -340,105 +341,132 @@ def parse_gemini_session(path, include_thinking=False, limit=MSG_INDEX_CHARS): # ------------------------------------------------------------------ cursor -# Cursor keeps one directory per chat: meta.json (title, cwd, timestamps) beside a SQLite -# store.db whose `blobs` table is content-addressed. Message blobs are plain json; the rest of -# the table is binary merkle nodes and embedded images, which is why the scan filters on the -# leading byte in SQL and never pulls the binary rows into Python. -_CURSOR_JSON_BLOBS = "SELECT data FROM blobs WHERE substr(data, 1, 1) = x'7b'" -_CURSOR_MAX_BLOBS = 5000 +# 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 injects context into the user turn the way Codex injects a preamble. Indexing it makes -# every session match "OS Version" and buries what the human actually typed. -_CURSOR_TAG_BLOCK = re.compile(r"^\s*<([a-z_]+)>.*?\s*", re.DOTALL) +_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_strip_context(text): - """Drop the leading ... style blocks Cursor prepends to a user turn.""" +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, count=1) + text = _CURSOR_TAG_BLOCK.sub(" ", text) return text -def _cursor_meta(chat_dir): - try: - with open(os.path.join(chat_dir, "meta.json"), errors="replace") as fh: - m = json.load(fh) - return m if isinstance(m, dict) else {} - except (OSError, json.JSONDecodeError, UnicodeDecodeError): - return {} +_UNSLUG_CACHE = {} -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 _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 -def parse_cursor_session(path, include_thinking=False, limit=MSG_INDEX_CHARS): - """Parse one Cursor chat (`...//store.db`) into the shared 9-field row schema. +_CURSOR_TITLES = None - Keyed by the chat directory name, which is what `cursor-agent --resume ` takes. - Blob order: the conversation's real ordering lives in a binary root blob, and decoding that - format is not worth it. SQLite rowid is insertion order, which is the same thing in practice. - Every row carries the session's `updatedAtMs`, so `group_sessions` (which takes the max row - timestamp) dates the session correctly, and the stable sort in `load_session_rows` leaves - rowid order intact rather than inventing per-message times that were never recorded. +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. """ - chat_dir = os.path.dirname(path) - sid = os.path.basename(chat_dir) - meta = _cursor_meta(chat_dir) - cwd = meta.get("cwd", "") or "" - title = _single_line(meta.get("title", "") or "", 90) + 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 - ms = meta.get("updatedAtMs") or meta.get("createdAtMs") + +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(ms / 1000.0)) if ms else "" - except (TypeError, ValueError, OSError): + ts = time.strftime("%Y-%m-%dT%H:%M:%S", time.localtime(os.path.getmtime(path))) + except (OSError, ValueError): ts = "" - conn = _sqlite_ro(path) - if conn is None: - return sid, [] rows = [] try: - cur = conn.execute(_CURSOR_JSON_BLOBS) - for n, (data,) in enumerate(cur): - if n >= _CURSOR_MAX_BLOBS: - break - try: - o = json.loads(bytes(data).decode("utf-8", "replace")) - except (json.JSONDecodeError, UnicodeDecodeError, TypeError, ValueError): + fh = open(path, errors="replace") + except OSError: + return sid, [] + with fh: + for line in fh: + line = line.strip() + if not line: continue - if not isinstance(o, dict): + try: + o = json.loads(line) + except json.JSONDecodeError: continue role = o.get("role") - if role not in ("user", "assistant"): # `system` is the prompt, not the chat + if role not in ("user", "assistant"): # status and error entries are not turns continue - text = _flatten_content(o.get("content", "")) - if role == "user": - text = _cursor_strip_context(text) - text = _single_line(text, limit) + 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, "", title, text]) - except Exception: # a truncated or mid-write store is not worth crashing on - pass - finally: - conn.close() + rows.append([sid, cwd, "", ts, role, "", "", text]) + title = _cursor_titles().get(sid, "") if not title: - for r in rows: - if r[4] == "user": - title = r[7][:90] - break + 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)] @@ -456,6 +484,17 @@ 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 "" @@ -508,20 +547,27 @@ def parse_opencode_session(path, include_thinking=False, limit=MSG_INDEX_CHARS): # ------------------------------------------------------------------ sources -def _is_jsonl(name): - return name.endswith(".jsonl") +def _is_jsonl(path): + return path.endswith(".jsonl") -def _is_gemini_chat(name): +def _is_gemini_chat(path): + name = os.path.basename(path) return name.startswith("session-") and name.endswith(".json") -def _is_cursor_store(name): - return name == "store.db" +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(name): - return name == "opencode.db" +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 @@ -529,7 +575,7 @@ def _is_opencode_db(name): # plus a parser rather than an edit in five places that can silently disagree. # # roots directories to walk for transcripts -# match filename predicate; harnesses do not agree on an extension +# 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 # colour SGR code for the source column @@ -551,15 +597,15 @@ SOURCES = { }, "gemini": { "roots": [GEMINI_DIR], "match": _is_gemini_chat, "parse": None, - "label": "gemini", "colour": "36", + "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_store, "parse": None, - "label": "cursor", "colour": "32", + "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, }, @@ -808,8 +854,9 @@ def build_index(include_thinking=False, force=False): continue for r, _dirs, fs in os.walk(root): for fn in fs: - if match(fn): - 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. diff --git a/tests/test_adapters.py b/tests/test_adapters.py index 710733e..ac04899 100644 --- a/tests/test_adapters.py +++ b/tests/test_adapters.py @@ -173,93 +173,105 @@ def test_unreadable_file_is_skipped_not_fatal(self): self.assertEqual([], rows) -def write_cursor(chat_id="chat-abc", blobs=(), title="Fixture Chat", cwd="/work/repo"): +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, chat_id) + chat = os.path.join(root, project, "agent-transcripts", chat_id) os.makedirs(chat) - with open(os.path.join(chat, "meta.json"), "w") as fh: - json.dump({"schemaVersion": 1, "title": title, "cwd": cwd, - "createdAtMs": 1767225600000, "updatedAtMs": 1767225900000}, fh) - db = os.path.join(chat, "store.db") - conn = sqlite3.connect(db) - conn.execute("CREATE TABLE blobs (id TEXT PRIMARY KEY, data BLOB)") - for i, b in enumerate(blobs): - payload = b if isinstance(b, bytes) else json.dumps(b).encode() - conn.execute("INSERT INTO blobs VALUES (?, ?)", ("b%d" % i, payload)) - conn.commit() - conn.close() - return db + 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(blobs=[ - {"role": "user", "content": "why is the badge count wrong"}, - {"role": "assistant", "content": "the filter runs before the join"}, - ]) + 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("/work/repo", r[C_CWD]) - self.assertEqual("Fixture Chat", r[C_TITLE]) + 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 directory name, so that is what the row - must be keyed by.""" + """`cursor-agent --resume ` takes the file stem, so that is the row key.""" db = write_cursor(chat_id="7f3f46c7-7c48-43ba-9bd2-8ace1dd6b058", - blobs=[{"role": "user", "content": "hi"}]) + turns=[cursor_turn("user", "hi")]) sid, _rows = ag.parse_cursor_session(db) self.assertEqual("7f3f46c7-7c48-43ba-9bd2-8ace1dd6b058", sid) - def test_non_message_blobs_are_ignored(self): - """The blobs table also holds binary merkle nodes, embedded images and the system - prompt. None of them are conversation.""" - db = write_cursor(blobs=[ - b"\xff\xd8\xff\xe0\x00\x10JFIF binary image", - b"\n \x9e\x97d\x9d\x8f\xf5(\xab\xe7 merkle node", - {"role": "system", "content": "You are a coding assistant. " * 50}, - {"role": "user", "content": "the only real turn"}, - ]) + 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(["the only real turn"], [r[C_TEXT] for r in rows]) - - def test_injected_context_is_stripped_from_user_turns(self): - """Cursor prepends environment blocks to the user turn. Indexed, they make every - session match 'OS Version' and bury what the human typed.""" - db = write_cursor(blobs=[{ - "role": "user", - "content": "\nOS Version: darwin 25.5.0\n\n" - "/work/repo\n" - "actually fix the retry backoff", - }]) - _sid, rows = ag.parse_cursor_session(db) - self.assertEqual("actually fix the retry backoff", rows[0][C_TEXT]) - - def test_every_row_carries_the_session_timestamp(self): - """Blob order is insertion order; per-message times were never recorded. group_sessions - takes the max row timestamp, so stamping updatedAtMs dates the session correctly - without inventing times.""" - db = write_cursor(blobs=[{"role": "user", "content": "a"}, - {"role": "assistant", "content": "b"}]) + 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) - stamps = {r[C_TS] for r in rows} - self.assertEqual(1, len(stamps)) - self.assertTrue(stamps.pop().startswith("20")) + self.assertEqual("the real answer", rows[0][C_TEXT]) + self.assertNotIn("hooks_context", rows[0][C_TEXT]) - def test_missing_store_is_skipped_not_fatal(self): - _sid, rows = ag.parse_cursor_session(os.path.join(tempfile.mkdtemp(), "store.db")) - self.assertEqual([], rows) + 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(title="", blobs=[{"role": "user", "content": "untitled chat topic"}]) + 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), ...]), ...])}""" @@ -375,16 +387,20 @@ 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", "abc.jsonl", True), ("cc", "store.db", False), - ("codex", "rollout.jsonl", True), - ("gemini", "session-2026-01-01T00-00-ab.json", True), - ("gemini", "logs.json", False), - ("cursor", "store.db", True), ("cursor", "store.db-wal", False), - ("cursor", "prompt_history.json", False), - ("opencode", "opencode.db", True), ("opencode", "opencode.db-wal", False)] - for source, name, want in cases: - self.assertEqual(want, bool(ag.SOURCES[source]["match"](name)), - "%s / %s" % (source, name)) + 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__": From b3b19b6dc53be3c203a4dbb27812abc16f40f789 Mon Sep 17 00:00:00 2001 From: Dev Dalia Date: Sat, 5 Sep 2026 20:40:06 +0530 Subject: [PATCH 06/10] Do not index the content Claude Code injects into a session A skill invocation writes the whole SKILL.md into the transcript as a user turn, flagged isMeta. So do hook notices and image-paste markers. Nobody typed any of it, and one skill injection runs to 15k characters. Reading a session was where this showed. `agsearch read "retention compounds"` on a 492-message session printed three skill dumps and then ran out of budget, so it elided every turn that matched and told the user it had kept them. With the injections gone the matching turn is the second thing on screen. The measured cost is a small one: held-out @1 0.483 to 0.480 and MRR 0.550 to 0.535, on the same corpus indexed both ways. The metric rewards a session for matching on any text it contains, including text nobody wrote, so it reads the removal of noise as a loss. The eval does not score previews at all, and the preview is what the skill reads. Worth being exact about the size: the raw injected text is 7.5M characters across 164 transcripts, but MSG_INDEX_CHARS already caps each message at 4k, so this removes 742k characters, or 1.7% of the indexed body. --- agsearch | 6 ++++++ tests/test_adapters.py | 48 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+) diff --git a/agsearch b/agsearch index fa550ab..cd78db8 100755 --- a/agsearch +++ b/agsearch @@ -170,6 +170,12 @@ 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) diff --git a/tests/test_adapters.py b/tests/test_adapters.py index ac04899..1136c25 100644 --- a/tests/test_adapters.py +++ b/tests/test_adapters.py @@ -383,6 +383,54 @@ def test_preview_keeps_only_the_requested_session(self): 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_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 From 6fe74919d26b5ed3a45eb24b35e9e38acc1ef4ac Mon Sep 17 00:00:00 2001 From: Dev Dalia Date: Sat, 5 Sep 2026 20:45:21 +0530 Subject: [PATCH 07/10] Stop calling a tool's output something the user said MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude Code files tool results under the user's role, and nine in ten user-role entries in a transcript are one. The preview took that at face value, so reading a session showed `▌ you` above "The file has been updated successfully. (file state is current in your context)". On one 26-turn read, seven of the thirteen turns attributed to the user were tool output. For a human that is noise. For an agent, which is what the skill points at this output, it is wrong information about who said what, and nothing downstream can recover it. They keep their own role now and the gutter says `tool`. Still indexed, because the error string a search has to find lives in tool output rather than in anything anyone typed: "we hit this error before" is answered by an ECONNRESET in a bash result. Held-out ranking is unchanged at 0.491 against 0.494, the difference being one session that arrived between the two runs. --- agsearch | 16 +++++++++++++--- tests/test_adapters.py | 40 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 3 deletions(-) diff --git a/agsearch b/agsearch index cd78db8..1691c11 100755 --- a/agsearch +++ b/agsearch @@ -182,6 +182,15 @@ def parse_session(path, include_thinking=False, limit=MSG_INDEX_CHARS): 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: @@ -1097,16 +1106,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 = _source(source)["label"] - name = {"user": "you", "assistant": agent, "thinking": "thinking"}.get(role, role or "?") + 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" diff --git a/tests/test_adapters.py b/tests/test_adapters.py index 1136c25..db725ff 100644 --- a/tests/test_adapters.py +++ b/tests/test_adapters.py @@ -425,6 +425,46 @@ def test_the_first_real_prompt_still_titles_the_session(self): 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]) From 8fe49ae173efd25a424d53b92178668566221c87 Mon Sep 17 00:00:00 2001 From: Dev Dalia Date: Sat, 5 Sep 2026 20:50:38 +0530 Subject: [PATCH 08/10] Keep a harness name to one token `cursor cli` and `gemini cli` read well in a terminal and broke everything downstream of them. The piped row is whitespace-separated columns, so a name with a space in it shifts every field after it: splitting a row gave source=cursor, match=cli, project=2/2. Hyphenated, and a test now holds every label to one token. Checked whether this wanted a machine-readable format instead, on twenty results with their matched snippets: the current text is ~1335 tokens, minified JSON is ~1760 (+32%, a repeated key per field per row), and a TOON-style table is ~1307 (-2%). The output is already a table with no repeated keys, and the snippet dominates it in every format, so a --json flag would cost an agent a third more tokens to read the same thing. Not adding one. --- README.md | 2 +- agsearch | 12 +++++++----- tests/test_adapters.py | 7 +++++++ 3 files changed, 15 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index c6d0c5b..d601957 100644 --- a/README.md +++ b/README.md @@ -192,7 +192,7 @@ agsearch reads: | --- | --- | --- | | Claude Code | `~/.claude/projects/**/*.jsonl` | `claude --resume ` | | Codex | `~/.codex/sessions/**/*.jsonl` | `codex resume ` | -| Cursor CLI | `~/.cursor/projects/**/agent-transcripts/` | `cursor-agent --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 ` | diff --git a/agsearch b/agsearch index 1691c11..83f7ecf 100755 --- a/agsearch +++ b/agsearch @@ -9,9 +9,9 @@ resume command. claude ~/.claude/projects claude --resume codex ~/.codex/sessions codex resume - cursor cli ~/.cursor/projects cursor-agent --resume + cursor-cli ~/.cursor/projects cursor-agent --resume opencode ~/.local/share/opencode opencode --session - gemini cli ~/.gemini/tmp gemini --session-file + gemini-cli ~/.gemini/tmp gemini --session-file Usage: agsearch # interactive fuzzy TUI (needs fzf) @@ -592,7 +592,9 @@ def _is_opencode_db(path): # 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 +# 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 @@ -612,7 +614,7 @@ SOURCES = { }, "gemini": { "roots": [GEMINI_DIR], "match": _is_gemini_chat, "parse": None, - "label": "gemini cli", "colour": "36", + "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}"]), @@ -620,7 +622,7 @@ SOURCES = { }, "cursor": { "roots": [CURSOR_DIR], "match": _is_cursor_transcript, "parse": None, - "label": "cursor cli", "colour": "32", + "label": "cursor-cli", "colour": "32", "resume": ("id", ["cursor-agent", "--resume", "{sid}"]), "subagents": False, "launch_dir": False, }, diff --git a/tests/test_adapters.py b/tests/test_adapters.py index db725ff..56ef8a3 100644 --- a/tests/test_adapters.py +++ b/tests/test_adapters.py @@ -30,6 +30,13 @@ def test_every_source_is_complete(self): 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))) From cef416bc5f914ee4bc3c08d038c5faa8d815ddc6 Mon Sep 17 00:00:00 2001 From: Dev Dalia Date: Sat, 5 Sep 2026 21:16:03 +0530 Subject: [PATCH 09/10] Say which session, in the description Ran the twenty trigger cases against the installed plugin. Eighteen passed, and the two that failed were the same missing distinction in opposite directions: "pick up where we left off on the database migration" did not fire, and "we discussed this a few messages ago" did. The description said "work started in another session", which is too weak to catch the first and gives no reason to refuse the second. It now asks for work from an earlier session and says plainly that it is not for earlier in the conversation you are already in. The first case fires after the change. The second still does, and on reflection that case was mislabelled rather than wrong behaviour: in a long or compacted session the earlier messages are no longer in context, and agsearch indexes the current session too, so searching for them is reasonable. Description is 485 characters, inside the 500 the guidance asks for. --- skills/agsearch/SKILL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From 7f56b5ff0043bf0711c16f08db0e64947605a65f Mon Sep 17 00:00:00 2001 From: Dev Dalia Date: Sat, 5 Sep 2026 21:29:10 +0530 Subject: [PATCH 10/10] Name every harness in the marketplace listing too The harness list is written out in four places. Adding Cursor, opencode and Gemini updated three of them and missed marketplace.json, which holds two descriptions of its own and still offered to search Claude Code and Codex. That is the first text anyone reads, before the README and before the plugin is installed. A test now holds every shipped description to naming all five, so the next harness cannot be added to some of them. --- .claude-plugin/marketplace.json | 4 ++-- tests/test_adapters.py | 36 +++++++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 2 deletions(-) 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/tests/test_adapters.py b/tests/test_adapters.py index 56ef8a3..665dfaa 100644 --- a/tests/test_adapters.py +++ b/tests/test_adapters.py @@ -9,6 +9,7 @@ import json import os +import pathlib import sqlite3 import tempfile import unittest @@ -64,6 +65,41 @@ 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()