From baac618368b1f68f00dd95871898a23495a43c5c Mon Sep 17 00:00:00 2001 From: Dev Dalia Date: Sat, 5 Sep 2026 13:31:20 +0530 Subject: [PATCH 1/2] 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 +- skills/agsearch/SKILL.md | 4 +- tests/test_adapters.py | 259 +++++++++++++++++++++++++++++++ 8 files changed, 581 insertions(+), 47 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/skills/agsearch/SKILL.md b/skills/agsearch/SKILL.md index 15ddea0..c41de94 100644 --- a/skills/agsearch/SKILL.md +++ b/skills/agsearch/SKILL.md @@ -1,11 +1,11 @@ --- name: agsearch -description: Search past Claude Code and Codex sessions saved on this machine. 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. +description: Search past Claude Code, Codex, Cursor and Gemini CLI sessions saved on this machine. 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. --- # agsearch -Every Claude Code and Codex session on this machine is a transcript on disk. `agsearch` +Every Claude Code, Codex, Cursor and Gemini CLI session on this machine is a transcript on disk. `agsearch` searches what was said inside them and prints ranked hits. You do not remember those sessions. The transcripts do. 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 06b868cfb58aea98fb25e34d159bba43d9b1f36b Mon Sep 17 00:00:00 2001 From: Dev Dalia Date: Sat, 5 Sep 2026 13:38:26 +0530 Subject: [PATCH 2/2] 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 +- skills/agsearch/SKILL.md | 4 +- tests/test_adapters.py | 113 ++++++++++++++++++++++++++++++++++++- 8 files changed, 225 insertions(+), 24 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/skills/agsearch/SKILL.md b/skills/agsearch/SKILL.md index c41de94..f6a2e4b 100644 --- a/skills/agsearch/SKILL.md +++ b/skills/agsearch/SKILL.md @@ -1,11 +1,11 @@ --- name: agsearch -description: Search past Claude Code, Codex, Cursor and Gemini CLI sessions saved on this machine. 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. +description: Search past Claude Code, Codex, Cursor, opencode and Gemini CLI sessions saved on this machine. 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. --- # agsearch -Every Claude Code, Codex, Cursor and Gemini CLI session on this machine is a transcript on disk. `agsearch` +Every Claude Code, Codex, Cursor, opencode and Gemini CLI session on this machine is a transcript on disk. `agsearch` searches what was said inside them and prints ranked hits. You do not remember those sessions. The transcripts do. 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))