diff --git a/CHANGES b/CHANGES index 26b68f5..a08d247 100644 --- a/CHANGES +++ b/CHANGES @@ -42,6 +42,22 @@ $ uvx --from 'agentgrep' --prerelease allow python +### What's new + +#### OpenCode backend (#30) + +agentgrep now searches [OpenCode](https://github.com/anomalyco/opencode) +(formerly `sst/opencode`). OpenCode keeps conversations in a single +SQLite database (`opencode.db`) under `~/.local/share/opencode`, so it +joins the relational `session → message → part` tables and surfaces each +text-bearing part — user prompts, assistant replies, model reasoning, +and subtask prompts — with the session title, working directory, and +model attached. Discovery honours `XDG_DATA_HOME` and the `OPENCODE_DB` +override, and every other on-disk store (the legacy JSON layout, config, +snapshots, the repo cache, logs, and tool output) is catalogued for +completeness, with `auth.json` documented but never indexed. See +{doc}`/backends/opencode` for details. + ## agentgrep 0.1.0a12 (2026-05-31) agentgrep 0.1.0a12 adds Pi (the earendil-works "Pi Agent Harness") as diff --git a/README.md b/README.md index 31fb228..3bc08a0 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) Read-only search for local AI agent prompts and history across Codex, -Claude Code, Cursor, Gemini, Grok, and Pi. +Claude Code, Cursor, Gemini, Grok, Pi, and OpenCode. `agentgrep` provides a CLI and an MCP server over the same discovery + parsing layer: diff --git a/docs/backends/index.md b/docs/backends/index.md index 002c805..7b1ed10 100644 --- a/docs/backends/index.md +++ b/docs/backends/index.md @@ -53,6 +53,12 @@ Grok CLI prompt history, session transcripts, memory, logs, and config. Pi (earendil-works) session transcripts, settings, prompts, and managed extensions. ::: +:::{grid-item-card} OpenCode +:link: opencode +:link-type: doc +OpenCode (anomalyco) SQLite session store, config, snapshots, and caches. +::: + :::: ## Coverage levels @@ -91,4 +97,5 @@ cursor-ide gemini grok pi +opencode ``` diff --git a/docs/backends/opencode.md b/docs/backends/opencode.md new file mode 100644 index 0000000..27d6c45 --- /dev/null +++ b/docs/backends/opencode.md @@ -0,0 +1,63 @@ +(backend-opencode)= + +# OpenCode + +Base path: `~/.local/share/opencode` (env overrides: `XDG_DATA_HOME`, `OPENCODE_DB`). + +`observed_version`: `opencode v1.15.11` (observed 2026-05-30). + +OpenCode (anomalyco/opencode) stores conversations in a single SQLite +database, `opencode.db`, under its XDG data directory +(`${XDG_DATA_HOME:-~/.local/share}/opencode`). Non-stable install +channels use `opencode-.db`, and `OPENCODE_DB` can relocate the +database (an absolute path is used directly). This makes OpenCode a +SQLite backend, like Grok's `session_search` and Cursor's `state.vscdb`, +rather than a JSONL-transcript backend. + +## Stores + +```{storage:agent} opencode +``` + +## Record schema + +### opencode.db + +A relational `session → message → part` schema (Drizzle). A conversation +turn is reconstructed by joining a `part` row up to its `message` (for +the role) and `session` (for the title and working directory). + +`session` table — one row per session: + +| Column | Type | Description | +|--------|------|-------------| +| `id` | TEXT | Session id (primary key) | +| `project_id` | TEXT | Git remote/root hash, or `global` | +| `directory` | TEXT | Working directory, stored verbatim | +| `title` | TEXT | Session title | +| `time_created` / `time_updated` | INTEGER | Unix milliseconds | + +`message` table — `id`, `session_id` (FK), and a `data` JSON column: + +```json +{"role": "assistant", "modelID": "...", "providerID": "...", + "time": {"created": 1779999665000}, "path": {"cwd": "..."}} +``` + +`part` table — `id`, `message_id` (FK), `session_id`, and a `data` JSON +column holding one content part. The searchable text lives here: + +| Part `type` | Searchable field | +|-------------|------------------| +| `text` | `text` (user prompts and assistant replies) | +| `reasoning` | `text` (model thinking) | +| `subtask` | `prompt` | + +A part's `kind` is derived from the joined message `role` (`user` → +prompt, otherwise history). Tool, file, snapshot, patch, and step-marker +parts are metadata and stay outside default search. Message timestamps +are unix-milliseconds and are normalized to ISO-8601. + +The legacy pre-migration layout (one JSON file per session, message, and +part under `storage/`) is documented but no longer searched — current +installs migrate it into `opencode.db` on startup. diff --git a/docs/dev/index.md b/docs/dev/index.md index 4e0d3fa..9f1f0b3 100644 --- a/docs/dev/index.md +++ b/docs/dev/index.md @@ -16,7 +16,7 @@ Cross-commit `hyperfine` sweeps across HEAD, trunk, ranges, lookback, tags, or e :::{grid-item-card} Storage catalogue :link: storage-catalog :link-type: doc -On-disk store layouts for Codex, Claude Code, Cursor, Gemini CLI, Grok CLI, and Pi — useful for adapter authors and anyone tracing why a record was or wasn't found. +On-disk store layouts for Codex, Claude Code, Cursor, Gemini CLI, Grok CLI, Pi, and OpenCode — useful for adapter authors and anyone tracing why a record was or wasn't found. ::: :::{grid-item-card} Architecture decisions diff --git a/docs/dev/storage-catalog.md b/docs/dev/storage-catalog.md index 6a97ef5..ae59ec5 100644 --- a/docs/dev/storage-catalog.md +++ b/docs/dev/storage-catalog.md @@ -307,6 +307,34 @@ Documentary-only entries cover settings, auth (private credentials), models, themes, tools, managed binaries, prompt templates, the debug log, and the npm extension install root. +### OpenCode + +`observed_version`: ``opencode v1.15.11`` (observed 2026-05-30). + +OpenCode (anomalyco/opencode) stores conversations in a single SQLite +database under `${XDG_DATA_HOME or ${HOME}/.local/share}/opencode/`, +unlike the JSONL-transcript backends: + +- `opencode.db_sqlite.v1` parses the `opencode.db` SQLite database. It + joins the relational `part → message → session` tables: each + text-bearing `part` row becomes a record whose `kind` comes from the + joined message `role` (`user` → prompt, else history). Searchable text + is `part.data` of type `text`/`reasoning` (the `text` field) and + `subtask` (the `prompt`); the session `title`, `directory`, and the + message `model`/timestamp are attached. Message times are + unix-milliseconds, normalized to ISO-8601. + +Discovery resolves the data root via `XDG_DATA_HOME` (default +`~/.local/share`) plus the `opencode` segment and finds `opencode.db` by +filename — not a glob, so the binary SQLite file bypasses the text +prefilter. An absolute `OPENCODE_DB` value is discovered as that exact +file, so channel installs are reachable by pointing `OPENCODE_DB` at +their `opencode-.db`. + +Documentary-only entries cover the legacy per-file JSON layout, config, +auth (private credentials), snapshots, the repo cache, logs, and tool +output. + ## Adding or updating a store 1. Edit `src/agentgrep/store_catalog.py`. Stamp `observed_version` diff --git a/docs/getting-started/configuration.md b/docs/getting-started/configuration.md index d6b1e18..11ac70e 100644 --- a/docs/getting-started/configuration.md +++ b/docs/getting-started/configuration.md @@ -12,7 +12,7 @@ Use `--agent` one or more times to limit search or discovery: $ uv run agentgrep grep "cache" --agent codex ``` -Supported agents are `codex`, `claude`, `cursor-cli`, `cursor-ide`, `gemini`, `grok`, and `pi`. Omitting `--agent` searches all supported agents. +Supported agents are `codex`, `claude`, `cursor-cli`, `cursor-ide`, `gemini`, `grok`, `pi`, and `opencode`. Omitting `--agent` searches all supported agents. ## Search type diff --git a/docs/index.md b/docs/index.md index f5bb9d5..ef072cf 100644 --- a/docs/index.md +++ b/docs/index.md @@ -2,7 +2,7 @@ # agentgrep -Read-only search for local AI agent prompts and history across Codex, Claude Code, Cursor, Gemini, Grok, and Pi. +Read-only search for local AI agent prompts and history across Codex, Claude Code, Cursor, Gemini, Grok, Pi, and OpenCode. ```{warning} **Pre-alpha.** APIs may change. [Feedback welcome](https://github.com/tony/agentgrep/issues). diff --git a/docs/mcp/resources.md b/docs/mcp/resources.md index af9abb8..c27d54a 100644 --- a/docs/mcp/resources.md +++ b/docs/mcp/resources.md @@ -26,7 +26,7 @@ used to interpret that source. ```{fastmcp-resource-template} agentgrep_sources_by_agent ``` -Read `agentgrep://sources/codex`, `agentgrep://sources/claude`, `agentgrep://sources/cursor-cli`, `agentgrep://sources/cursor-ide`, `agentgrep://sources/gemini`, `agentgrep://sources/grok`, or `agentgrep://sources/pi` to filter discovery by agent. +Read `agentgrep://sources/codex`, `agentgrep://sources/claude`, `agentgrep://sources/cursor-cli`, `agentgrep://sources/cursor-ide`, `agentgrep://sources/gemini`, `agentgrep://sources/grok`, `agentgrep://sources/pi`, or `agentgrep://sources/opencode` to filter discovery by agent. ## Store catalog diff --git a/docs/tui/index.md b/docs/tui/index.md index f29955a..4c51b43 100644 --- a/docs/tui/index.md +++ b/docs/tui/index.md @@ -3,7 +3,7 @@ # TUI The `agentgrep ui` command launches the interactive Textual explorer -over the same Codex, Claude Code, Cursor, Gemini, Grok, and Pi stores the rest +over the same Codex, Claude Code, Cursor, Gemini, Grok, Pi, and OpenCode stores the rest of the CLI walks. It is read-only — agentgrep never mutates the source stores. Bare `agentgrep` prints the directory of choices, so the explorer always needs the explicit `ui` subcommand. diff --git a/pyproject.toml b/pyproject.toml index 1613880..977c0e1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "agentgrep" version = "0.1.0a12" -description = "Read-only search for local AI agent prompts and history (Codex, Claude Code, Cursor, Gemini, Grok, Pi)" +description = "Read-only search for local AI agent prompts and history (Codex, Claude Code, Cursor, Gemini, Grok, Pi, OpenCode)" requires-python = ">=3.14,<4.0" authors = [ {name = "Tony Narlock", email = "tony@git-pull.com"} @@ -21,7 +21,7 @@ classifiers = [ "Typing :: Typed", ] -keywords = ["ai", "codex", "claude", "cursor", "gemini", "grok", "pi", "mcp", "search", "agent-history"] +keywords = ["ai", "codex", "claude", "cursor", "gemini", "grok", "pi", "opencode", "mcp", "search", "agent-history"] readme = "README.md" packages = [ { include = "*", from = "src" }, diff --git a/src/agentgrep/__init__.py b/src/agentgrep/__init__.py index e244f81..bf596b0 100644 --- a/src/agentgrep/__init__.py +++ b/src/agentgrep/__init__.py @@ -83,7 +83,9 @@ else: PrivatePathBase = type(pathlib.Path()) -AgentName = t.Literal["codex", "claude", "cursor-cli", "cursor-ide", "gemini", "grok", "pi"] +AgentName = t.Literal[ + "codex", "claude", "cursor-cli", "cursor-ide", "gemini", "grok", "pi", "opencode" +] OutputMode = t.Literal["text", "json", "ndjson", "ui"] ProgressMode = t.Literal["auto", "always", "never"] SearchType = t.Literal["prompts", "history", "all"] @@ -103,6 +105,7 @@ "gemini", "grok", "pi", + "opencode", ) JSON_FILE_SUFFIXES: frozenset[str] = frozenset({".json", ".jsonl"}) SCHEMA_VERSION: str = "agentgrep.v1" @@ -174,6 +177,7 @@ "grok.session_search_sqlite.v1", "grok.sessions_jsonl.v1", "pi.sessions_jsonl.v1", + "opencode.db_sqlite.v1", }, ) EnvelopeFactory = t.Callable[[str, dict[str, object], list[dict[str, object]]], dict[str, object]] @@ -223,8 +227,8 @@ def build_description( CLI_DESCRIPTION = build_description( """ - Read-only search across Codex, Claude, Cursor, Gemini, Grok, and - Pi local stores. Pick a subcommand from the list below: + Read-only search across Codex, Claude, Cursor, Gemini, Grok, Pi, + and OpenCode local stores. Pick a subcommand from the list below: ``search`` for ranked results with dedup and session grouping, ``grep`` for rg-shaped content search, ``find`` for store enumeration, ``ui`` for the interactive Textual explorer. @@ -2342,6 +2346,14 @@ def discover_sources( include_non_default=include_non_default, ), ) + elif agent == "opencode": + discovered.extend( + discover_opencode_sources( + home, + backends, + include_non_default=include_non_default, + ), + ) discovered.sort(key=lambda item: (item.agent, item.store, str(item.path))) return discovered @@ -3299,6 +3311,66 @@ def discover_pi_sources( ) +def discover_opencode_sources( + home: pathlib.Path, + backends: BackendSelection, + *, + include_non_default: bool = False, +) -> list[SourceHandle]: + """Discover OpenCode (anomalyco/opencode) SQLite databases. + + OpenCode stores conversations in ``opencode.db`` under its XDG data + directory (``${XDG_DATA_HOME}/opencode``, falling back to + ``${HOME}/.local/share/opencode``). The store is discovered by + filename (not a glob) so the binary SQLite file bypasses the + text prefilter, the same way the Grok SQLite store is. + + ``OPENCODE_DB`` overrides the database location: when it points at an + absolute file, OpenCode uses that file (any filename) instead of the + default, so agentgrep discovers that exact file directly — which also + makes non-stable channel databases (``opencode-.db``) + reachable by pointing ``OPENCODE_DB`` at them. The default lookup and + adapter metadata come from the ``opencode.*`` rows of + :data:`agentgrep.store_catalog.CATALOG`. + """ + db_override = os.environ.get("OPENCODE_DB") + if db_override and db_override != ":memory:": + candidate = pathlib.Path(os.path.expandvars(db_override)).expanduser() + if candidate.is_absolute(): + if not candidate.is_file(): + return [] + from agentgrep.store_catalog import CATALOG + + descriptor = CATALOG.by_id("opencode.db") + handle = SourceHandle( + agent="opencode", + store="opencode.db", + adapter_id="opencode.db_sqlite.v1", + path=candidate, + path_kind="sqlite_db", + source_kind="sqlite", + search_root=None, + mtime_ns=file_mtime_ns(candidate), + ) + handle.version_detection = detect_source_version( + handle, + descriptor, + descriptor.discovery[0], + {}, + ) + return [handle] + base = resolve_env_root("XDG_DATA_HOME", home / ".local" / "share") / "opencode" + if not base.exists(): + return [] + return discover_from_catalog( + home, + "opencode", + base, + backends, + include_non_default=include_non_default, + ) + + def list_files_matching( root: pathlib.Path, glob_pattern: str, @@ -3830,6 +3902,9 @@ def iter_source_records( if source.adapter_id == "pi.sessions_jsonl.v1": yield from parse_pi_session_file(source) return + if source.adapter_id == "opencode.db_sqlite.v1": + yield from parse_opencode_db(source) + return def parse_codex_session_file( @@ -5294,6 +5369,100 @@ def parse_grok_session_search_db( connection.close() +def _opencode_json_object(raw: object) -> dict[str, object] | None: + """Parse a JSON object from an OpenCode SQLite ``data`` text column.""" + if not isinstance(raw, str): + return None + try: + value = json.loads(raw) + except ValueError, TypeError: + return None + return t.cast("dict[str, object]", value) if isinstance(value, dict) else None + + +def _opencode_part_text(part_type: str, part_data: dict[str, object]) -> str | None: + """Return the searchable text for an OpenCode message part. + + ``text``/``reasoning`` parts carry the prompt, reply, or model thinking + under ``text``; ``subtask`` parts carry a ``prompt``/``description``. + Other part types (tool, file, snapshot, patch, step markers, …) are + metadata or opt-in and contribute no default-search text. + """ + if part_type in {"text", "reasoning"}: + return as_optional_str(part_data.get("text")) + if part_type == "subtask": + return as_optional_str(part_data.get("prompt")) or as_optional_str( + part_data.get("description"), + ) + return None + + +def parse_opencode_db( + source: SourceHandle, +) -> cabc.Iterator[SearchRecord]: + """Parse an OpenCode ``opencode.db`` SQLite store. + + Joins ``part`` -> ``message`` -> ``session``: each text-bearing part + becomes one record whose ``kind`` is derived from the joined message + ``role`` (user -> prompt, else history), with the session title, + working directory, and the message model/timestamp attached. Degrades + gracefully when the expected tables or columns are absent. + """ + connection = open_readonly_sqlite(source.path) + try: + if not {"session", "message", "part"}.issubset(sqlite_table_names(connection)): + return + cursor = connection.execute( + "SELECT p.data, m.data, s.title, s.directory, s.id " + "FROM part p " + "JOIN message m ON p.message_id = m.id " + "JOIN session s ON p.session_id = s.id " + "ORDER BY s.id, m.id, p.id", + ) + for part_raw, message_raw, title_raw, directory_raw, session_id_raw in cursor: + part_data = _opencode_json_object(part_raw) + if part_data is None: + continue + part_type = as_optional_str(part_data.get("type")) + if not part_type: + continue + text = _opencode_part_text(part_type, part_data) + if not text: + continue + message_data = _opencode_json_object(message_raw) or {} + role = as_optional_str(message_data.get("role")) or "assistant" + kind: t.Literal["prompt", "history"] = ( + "prompt" if role.casefold() in USER_ROLES else "history" + ) + time_obj = message_data.get("time") + created = ( + t.cast("dict[str, object]", time_obj).get("created") + if isinstance(time_obj, dict) + else None + ) + session_id = as_optional_str(session_id_raw) + directory = as_optional_str(directory_raw) + yield SearchRecord( + kind=kind, + agent=source.agent, + store=source.store, + adapter_id=source.adapter_id, + path=source.path, + text=text, + title=as_optional_str(title_raw), + role=role, + timestamp=_unix_millis_to_isoformat(created), + model=as_optional_str(message_data.get("modelID")), + session_id=session_id, + conversation_id=session_id, + metadata={"directory": directory} if directory else {}, + ) + except sqlite3.DatabaseError: + return + finally: + connection.close() + + def parse_cursor_ai_tracking_db( source: SourceHandle, ) -> cabc.Iterator[SearchRecord]: diff --git a/src/agentgrep/mcp/_library.py b/src/agentgrep/mcp/_library.py index 8c3f5cf..1392654 100644 --- a/src/agentgrep/mcp/_library.py +++ b/src/agentgrep/mcp/_library.py @@ -13,9 +13,11 @@ import pathlib import typing as t -AgentName = t.Literal["codex", "claude", "cursor-cli", "cursor-ide", "gemini", "grok", "pi"] +AgentName = t.Literal[ + "codex", "claude", "cursor-cli", "cursor-ide", "gemini", "grok", "pi", "opencode" +] AgentSelector = t.Literal[ - "codex", "claude", "cursor-cli", "cursor-ide", "gemini", "grok", "pi", "all" + "codex", "claude", "cursor-cli", "cursor-ide", "gemini", "grok", "pi", "opencode", "all" ] SearchTypeName = t.Literal["prompts", "history", "all"] @@ -78,6 +80,7 @@ "grok.sessions_jsonl.v1", "grok.session_search_sqlite.v1", "pi.sessions_jsonl.v1", + "opencode.db_sqlite.v1", ) READONLY_TAGS = {"readonly", "agentgrep"} RESOURCE_ANNOTATIONS = {"readOnlyHint": True, "idempotentHint": True} diff --git a/src/agentgrep/mcp/instructions.py b/src/agentgrep/mcp/instructions.py index caba9b2..98c602c 100644 --- a/src/agentgrep/mcp/instructions.py +++ b/src/agentgrep/mcp/instructions.py @@ -10,7 +10,7 @@ _INSTR_HEADER = ( "agentgrep MCP server. Read-only search over local AI-agent prompts and " - "history across Codex, Claude Code, Cursor, Gemini, Grok, and Pi CLIs. All tools " + "history across Codex, Claude Code, Cursor, Gemini, Grok, Pi, and OpenCode CLIs. All tools " "are read-only and never spawn writes." ) @@ -18,7 +18,7 @@ "TRIGGERS: invoke for retrospective questions about what the user typed " "into or received from a coding-agent CLI (prompts, history, session " "transcripts, store discovery). Bare 'prompt', 'history', 'transcript', " - "'session', 'what did I ask Claude/Codex/Cursor/Gemini/Grok/Pi' default to " + "'session', 'what did I ask Claude/Codex/Cursor/Gemini/Grok/Pi/OpenCode' default to " "agentgrep.\n" "ANTI-TRIGGERS: do NOT invoke for IDE editor history (VS Code timeline), " "shell history (zsh/fish history), browser tabs, or live agent sessions " diff --git a/src/agentgrep/mcp/models.py b/src/agentgrep/mcp/models.py index 03d8cba..ecfb1d0 100644 --- a/src/agentgrep/mcp/models.py +++ b/src/agentgrep/mcp/models.py @@ -28,7 +28,9 @@ class SearchRecordModel(AgentGrepModel): schema_version: str = agentgrep.SCHEMA_VERSION kind: t.Literal["prompt", "history"] - agent: t.Literal["codex", "claude", "cursor-cli", "cursor-ide", "gemini", "grok", "pi"] + agent: t.Literal[ + "codex", "claude", "cursor-cli", "cursor-ide", "gemini", "grok", "pi", "opencode" + ] store: str adapter_id: str path: str @@ -52,7 +54,9 @@ class FindRecordModel(AgentGrepModel): schema_version: str = agentgrep.SCHEMA_VERSION kind: t.Literal["find"] - agent: t.Literal["codex", "claude", "cursor-cli", "cursor-ide", "gemini", "grok", "pi"] + agent: t.Literal[ + "codex", "claude", "cursor-cli", "cursor-ide", "gemini", "grok", "pi", "opencode" + ] store: str adapter_id: str path: str @@ -84,7 +88,9 @@ class SourceRecordModel(AgentGrepModel): """Discovered source summary payload.""" schema_version: str = agentgrep.SCHEMA_VERSION - agent: t.Literal["codex", "claude", "cursor-cli", "cursor-ide", "gemini", "grok", "pi"] + agent: t.Literal[ + "codex", "claude", "cursor-cli", "cursor-ide", "gemini", "grok", "pi", "opencode" + ] store: str adapter_id: str path: str @@ -150,7 +156,9 @@ class CapabilitiesModel(AgentGrepModel): name: str = "agentgrep" version: str = SERVER_VERSION read_only: bool = True - agents: list[t.Literal["codex", "claude", "cursor-cli", "cursor-ide", "gemini", "grok", "pi"]] + agents: list[ + t.Literal["codex", "claude", "cursor-cli", "cursor-ide", "gemini", "grok", "pi", "opencode"] + ] search_types: list[SearchTypeName] adapters: list[str] tools: list[str] @@ -185,7 +193,9 @@ class StoreDescriptorModel(AgentGrepModel): schema_version: str = agentgrep.SCHEMA_VERSION kind: t.Literal["store"] = "store" - agent: t.Literal["codex", "claude", "cursor-cli", "cursor-ide", "gemini", "grok", "pi"] + agent: t.Literal[ + "codex", "claude", "cursor-cli", "cursor-ide", "gemini", "grok", "pi", "opencode" + ] store_id: str role: str format: str diff --git a/src/agentgrep/query/registry.py b/src/agentgrep/query/registry.py index 4a458c2..3883460 100644 --- a/src/agentgrep/query/registry.py +++ b/src/agentgrep/query/registry.py @@ -100,7 +100,8 @@ def default_registry() -> FieldRegistry: ============= ====== ======= =========================================== Field Kind Layer Notes ============= ====== ======= =========================================== - ``agent`` enum source Values: codex, claude, cursor-cli, cursor-ide, gemini, grok, pi + ``agent`` enum source Values: codex, claude, cursor-cli, cursor-ide, + gemini, grok, pi, opencode ``store`` string source Substring against :attr:`SourceHandle.store` ``adapter`` string source Alias of ``adapter_id`` ``path`` path source Glob against the file basename by default @@ -117,7 +118,16 @@ def default_registry() -> FieldRegistry: name="agent", kind="enum", layer="source", - enum_values=("codex", "claude", "cursor-cli", "cursor-ide", "gemini", "grok", "pi"), + enum_values=( + "codex", + "claude", + "cursor-cli", + "cursor-ide", + "gemini", + "grok", + "pi", + "opencode", + ), ), FieldSpec(name="store", kind="string", layer="source"), FieldSpec( diff --git a/src/agentgrep/store_catalog.py b/src/agentgrep/store_catalog.py index b28be22..56840b4 100644 --- a/src/agentgrep/store_catalog.py +++ b/src/agentgrep/store_catalog.py @@ -34,6 +34,7 @@ _CLAUDE_HISTORY_OBSERVED_AT = datetime.date(2026, 5, 29) _CURSOR_CONFIG_OBSERVED_AT = datetime.date(2026, 5, 30) _PI_OBSERVED_AT = datetime.date(2026, 5, 30) +_OPENCODE_OBSERVED_AT = datetime.date(2026, 5, 30) def gemini_project_hash(project_root: pathlib.Path) -> str: @@ -2945,9 +2946,160 @@ def gemini_project_hash(project_root: pathlib.Path) -> str: ) +_OPENCODE_STORES: tuple[StoreDescriptor, ...] = ( + StoreDescriptor( + agent="opencode", + store_id="opencode.db", + role=StoreRole.PRIMARY_CHAT, + format=StoreFormat.SQLITE, + path_pattern="${XDG_DATA_HOME or ${HOME}/.local/share}/opencode/opencode.db", + env_overrides=("XDG_DATA_HOME", "OPENCODE_DB"), + observed_version="opencode v1.15.11 (observed 2026-05-30)", + observed_at=_OPENCODE_OBSERVED_AT, + upstream_ref=( + "github.com/anomalyco/opencode/blob/v1.15.11/packages/opencode/" + "src/session/session.sql.ts#L16-L91" + ), + schema_notes=( + "SQLite store (Drizzle). Tables `session` (id, project_id, " + "`directory` = working dir, title, version, time_created/updated, " + "model, cost, tokens_*), `message` (id, session_id FK, `data` JSON " + "with role user/assistant, modelID/providerID, time, path.cwd), and " + "`part` (id, message_id FK, session_id, `data` JSON with type + " + "payload). Searchable text lives in `part.data`: type `text`/" + "`reasoning` -> `text`, `subtask` -> `prompt`. A conversation turn " + "is reconstructed by joining part -> message -> session. Channel " + "installs use `opencode-.db`; `OPENCODE_DB` overrides the " + "path (also `:memory:`/absolute)." + ), + sample_record=( + 'part.data: {"type":"text","text":"",' + '"time":{"start":1779999665000,"end":1779999666000}}' + ), + search_by_default=True, + search_notes=( + "The sole searchable OpenCode store. kind is derived from the " + "joined message role (user -> prompt, else history)." + ), + discovery=( + DiscoverySpec( + store="opencode.db", + adapter_id="opencode.db_sqlite.v1", + path_kind="sqlite_db", + source_kind="sqlite", + root_key="default", + files=("opencode.db",), + ), + ), + ), + StoreDescriptor( + agent="opencode", + store_id="opencode.storage_legacy", + role=StoreRole.PRIMARY_CHAT, + format=StoreFormat.JSON_OBJECT, + path_pattern=( + "${XDG_DATA_HOME or ${HOME}/.local/share}/opencode/storage/" + "{session,message,part}/**/*.json" + ), + env_overrides=("XDG_DATA_HOME",), + observed_version="opencode v1.15.11 (observed 2026-05-30)", + observed_at=_OPENCODE_OBSERVED_AT, + upstream_ref=( + "github.com/anomalyco/opencode/blob/v1.15.11/packages/opencode/" + "src/storage/storage.ts#L189-L230" + ), + schema_notes=( + "Pre-migration on-disk layout: one JSON file per session/message/" + "part. A startup migration folds these into opencode.db; migrated " + "installs keep only an empty `storage/session_diff/` and a " + "`storage/migration` marker. Documentary — relevant only to older, " + "un-migrated installs." + ), + distinguishes_from=("opencode.db",), + search_by_default=False, + ), + StoreDescriptor( + agent="opencode", + store_id="opencode.config", + role=StoreRole.APP_STATE, + format=StoreFormat.JSON_OBJECT, + path_pattern="${XDG_CONFIG_HOME or ${HOME}/.config}/opencode/opencode.json", + env_overrides=("XDG_CONFIG_HOME", "OPENCODE_CONFIG_DIR"), + observed_version="opencode v1.15.11 (observed 2026-05-30)", + observed_at=_OPENCODE_OBSERVED_AT, + schema_notes=( + "Application config (`opencode.json`/`opencode.jsonc`): providers, " + "agents, plugins, commands, UI settings. Configuration, not chat." + ), + search_by_default=False, + ), + StoreDescriptor( + agent="opencode", + store_id="opencode.auth", + role=StoreRole.APP_STATE, + format=StoreFormat.JSON_OBJECT, + path_pattern="${XDG_DATA_HOME or ${HOME}/.local/share}/opencode/auth.json", + env_overrides=("XDG_DATA_HOME",), + observed_version="opencode v1.15.11 (observed 2026-05-30)", + observed_at=_OPENCODE_OBSERVED_AT, + schema_notes="Provider API keys and OAuth tokens. Documented but never enumerated.", + coverage=StoreCoverage.PRIVATE, + search_by_default=False, + ), + StoreDescriptor( + agent="opencode", + store_id="opencode.snapshots", + role=StoreRole.SOURCE_TREE, + format=StoreFormat.OPAQUE, + path_pattern="${XDG_DATA_HOME or ${HOME}/.local/share}/opencode/snapshot/", + env_overrides=("XDG_DATA_HOME",), + observed_version="opencode v1.15.11 (observed 2026-05-30)", + observed_at=_OPENCODE_OBSERVED_AT, + schema_notes="Per-project git repositories holding session file snapshots.", + search_by_default=False, + ), + StoreDescriptor( + agent="opencode", + store_id="opencode.repos", + role=StoreRole.CACHE, + format=StoreFormat.OPAQUE, + path_pattern="${XDG_DATA_HOME or ${HOME}/.local/share}/opencode/repos/", + env_overrides=("XDG_DATA_HOME",), + observed_version="opencode v1.15.11 (observed 2026-05-30)", + observed_at=_OPENCODE_OBSERVED_AT, + schema_notes="Cache of cloned git repositories referenced during sessions.", + search_by_default=False, + ), + StoreDescriptor( + agent="opencode", + store_id="opencode.logs", + role=StoreRole.APP_STATE, + format=StoreFormat.TEXT, + path_pattern="${XDG_DATA_HOME or ${HOME}/.local/share}/opencode/log/", + env_overrides=("XDG_DATA_HOME",), + observed_version="opencode v1.15.11 (observed 2026-05-30)", + observed_at=_OPENCODE_OBSERVED_AT, + schema_notes="Timestamped application logs. Diagnostics, not chat content.", + search_by_default=False, + ), + StoreDescriptor( + agent="opencode", + store_id="opencode.tool_output", + role=StoreRole.CACHE, + format=StoreFormat.TEXT, + path_pattern="${XDG_DATA_HOME or ${HOME}/.local/share}/opencode/tool-output/", + env_overrides=("XDG_DATA_HOME",), + observed_version="opencode v1.15.11 (observed 2026-05-30)", + observed_at=_OPENCODE_OBSERVED_AT, + schema_notes="Overflow storage for large tool output that exceeds inline limits.", + search_by_default=False, + ), +) + + CATALOG = StoreCatalog( - catalog_version=12, - captured_at=_PI_OBSERVED_AT, + catalog_version=13, + captured_at=_OPENCODE_OBSERVED_AT, stores=( *_CLAUDE_STORES, *_CURSOR_CLI_STORES, @@ -2956,6 +3108,7 @@ def gemini_project_hash(project_root: pathlib.Path) -> str: *_GEMINI_STORES, *_GROK_STORES, *_PI_STORES, + *_OPENCODE_STORES, ), ) """The canonical agentgrep store catalogue. diff --git a/src/agentgrep/stores.py b/src/agentgrep/stores.py index f7ed528..25129be 100644 --- a/src/agentgrep/stores.py +++ b/src/agentgrep/stores.py @@ -88,7 +88,9 @@ class VersionDetectionConfidence(enum.StrEnum): LOW = "low" -AgentName = t.Literal["claude", "cursor-cli", "cursor-ide", "codex", "gemini", "grok", "pi"] +AgentName = t.Literal[ + "claude", "cursor-cli", "cursor-ide", "codex", "gemini", "grok", "pi", "opencode" +] PathKind = t.Literal["history_file", "session_file", "sqlite_db", "store_file"] SourceKind = t.Literal["json", "jsonl", "sqlite", "text", "opaque"] diff --git a/tests/test_agentgrep.py b/tests/test_agentgrep.py index c5fbc8c..1cccfbc 100644 --- a/tests/test_agentgrep.py +++ b/tests/test_agentgrep.py @@ -27,7 +27,9 @@ if t.TYPE_CHECKING: import collections.abc as cabc -AgentName = t.Literal["codex", "claude", "cursor-cli", "cursor-ide", "gemini", "grok", "pi"] +AgentName = t.Literal[ + "codex", "claude", "cursor-cli", "cursor-ide", "gemini", "grok", "pi", "opencode" +] ANSI_RE = re.compile(r"\x1b\[[0-?]*[ -/]*[@-~]") @@ -6791,6 +6793,301 @@ class UnixToIsoCase(t.NamedTuple): ) +def _build_opencode_db( + db_path: pathlib.Path, + *, + messages: list[tuple[str, list[dict[str, object]]]], + session_title: str = "Test session", + directory: str = "/work/proj", + model: str = "example/model", + created: int = 1780000000000, +) -> None: + """Build a minimal OpenCode ``opencode.db`` with session/message/part rows.""" + db_path.parent.mkdir(parents=True, exist_ok=True) + conn = sqlite3.connect(str(db_path)) + try: + conn.execute("CREATE TABLE session (id TEXT PRIMARY KEY, title TEXT, directory TEXT)") + conn.execute("CREATE TABLE message (id TEXT PRIMARY KEY, session_id TEXT, data TEXT)") + conn.execute( + "CREATE TABLE part (id TEXT PRIMARY KEY, message_id TEXT, session_id TEXT, data TEXT)", + ) + conn.execute("INSERT INTO session VALUES (?, ?, ?)", ("ses_1", session_title, directory)) + part_index = 0 + for message_index, (role, parts) in enumerate(messages): + message_id = f"msg_{message_index}" + conn.execute( + "INSERT INTO message VALUES (?, ?, ?)", + ( + message_id, + "ses_1", + json.dumps({"role": role, "time": {"created": created}, "modelID": model}), + ), + ) + for part in parts: + conn.execute( + "INSERT INTO part VALUES (?, ?, ?, ?)", + (f"prt_{part_index}", message_id, "ses_1", json.dumps(part)), + ) + part_index += 1 + conn.commit() + finally: + conn.close() + + +def _parse_opencode_records( + agentgrep: AgentGrepModule, + home: pathlib.Path, +) -> list[t.Any]: + """Discover and parse every ``opencode.db`` record under ``home``.""" + backends = t.cast("t.Any", agentgrep).BackendSelection(None, None, None) + sources = t.cast("t.Any", agentgrep).discover_sources(home, ("opencode",), backends) + records: list[t.Any] = [] + for source in sources: + if source.store == "opencode.db": + records.extend(t.cast("t.Any", agentgrep).iter_source_records(source)) + return records + + +def test_discover_opencode_sources_default_xdg_location( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """opencode.db under the default ``~/.local/share/opencode`` is discovered.""" + agentgrep = load_agentgrep_module() + home = tmp_path / "home" + monkeypatch.setenv("HOME", str(home)) + monkeypatch.delenv("XDG_DATA_HOME", raising=False) + monkeypatch.delenv("OPENCODE_DB", raising=False) + db_path = home / ".local" / "share" / "opencode" / "opencode.db" + _build_opencode_db(db_path, messages=[("user", [{"type": "text", "text": "hi"}])]) + + backends = t.cast("t.Any", agentgrep).BackendSelection(None, None, None) + sources = t.cast("t.Any", agentgrep).discover_opencode_sources(home, backends) + + assert db_path in {s.path for s in sources} + + +def test_discover_opencode_sources_honours_xdg_data_home( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """``XDG_DATA_HOME`` relocates the opencode data directory.""" + agentgrep = load_agentgrep_module() + home = tmp_path / "home" + alt = tmp_path / "xdg" + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("XDG_DATA_HOME", str(alt)) + monkeypatch.delenv("OPENCODE_DB", raising=False) + decoy = home / ".local" / "share" / "opencode" / "opencode.db" + _build_opencode_db(decoy, messages=[("user", [{"type": "text", "text": "decoy"}])]) + real = alt / "opencode" / "opencode.db" + _build_opencode_db(real, messages=[("user", [{"type": "text", "text": "real"}])]) + + backends = t.cast("t.Any", agentgrep).BackendSelection(None, None, None) + paths = {s.path for s in t.cast("t.Any", agentgrep).discover_opencode_sources(home, backends)} + + assert real in paths + assert decoy not in paths + + +class OpencodeOverrideCase(t.NamedTuple): + """Parametrized case for the ``OPENCODE_DB`` absolute-path override.""" + + test_id: str + db_filename: str + + +OPENCODE_OVERRIDE_CASES: tuple[OpencodeOverrideCase, ...] = ( + OpencodeOverrideCase("default-name", "opencode.db"), + OpencodeOverrideCase("custom-name", "my-sessions.db"), + OpencodeOverrideCase("channel-name", "opencode-canary.db"), +) + + +@pytest.mark.parametrize( + OpencodeOverrideCase._fields, + OPENCODE_OVERRIDE_CASES, + ids=[case.test_id for case in OPENCODE_OVERRIDE_CASES], +) +def test_discover_opencode_sources_honours_opencode_db_override( + test_id: str, + db_filename: str, + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """An absolute ``OPENCODE_DB`` is discovered as that exact file, any filename.""" + _ = test_id + agentgrep = load_agentgrep_module() + home = tmp_path / "home" + custom = tmp_path / "custom" / db_filename + monkeypatch.setenv("HOME", str(home)) + monkeypatch.delenv("XDG_DATA_HOME", raising=False) + monkeypatch.setenv("OPENCODE_DB", str(custom)) + _build_opencode_db(custom, messages=[("user", [{"type": "text", "text": "custom"}])]) + + backends = t.cast("t.Any", agentgrep).BackendSelection(None, None, None) + sources = t.cast("t.Any", agentgrep).discover_opencode_sources(home, backends) + + assert {s.path for s in sources} == {custom} + assert sources[0].store == "opencode.db" + + +def test_search_opencode_sessions( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """opencode.db yields user prompts and assistant history carrying the model.""" + agentgrep = load_agentgrep_module() + home = tmp_path / "home" + monkeypatch.setenv("HOME", str(home)) + monkeypatch.delenv("XDG_DATA_HOME", raising=False) + monkeypatch.delenv("OPENCODE_DB", raising=False) + db_path = home / ".local" / "share" / "opencode" / "opencode.db" + _build_opencode_db( + db_path, + messages=[ + ("user", [{"type": "text", "text": "explain the streaming design"}]), + ("assistant", [{"type": "text", "text": "the streaming design is event-driven"}]), + ], + ) + + backends = t.cast("t.Any", agentgrep).BackendSelection(None, None, None) + query = t.cast("t.Any", agentgrep).SearchQuery( + terms=("streaming",), + search_type="all", + any_term=False, + regex=False, + case_sensitive=False, + agents=("opencode",), + limit=None, + ) + sources = t.cast("t.Any", agentgrep).discover_sources(home, ("opencode",), backends) + records = t.cast("t.Any", agentgrep).search_sources(query, sources, backends) + + assert len(records) >= 2, "expected user + assistant records" + by_role = {r.role: r for r in records} + assert by_role["user"].kind == "prompt" + assert by_role["user"].agent == "opencode" + assert by_role["user"].metadata.get("directory") == "/work/proj" + assert by_role["assistant"].kind == "history" + assert by_role["assistant"].model == "example/model" + + +class OpencodePartCase(t.NamedTuple): + """Parametrized case for one OpenCode message part through the parser.""" + + test_id: str + message_role: str + part: dict[str, object] + expected_count: int + expected_kind: str | None + expected_text_contains: str | None + + +OPENCODE_PART_CASES: tuple[OpencodePartCase, ...] = ( + OpencodePartCase( + "user-text-is-prompt", + "user", + {"type": "text", "text": "a design question"}, + 1, + "prompt", + "a design question", + ), + OpencodePartCase( + "assistant-text-is-history", + "assistant", + {"type": "text", "text": "an answer"}, + 1, + "history", + "an answer", + ), + OpencodePartCase( + "reasoning-is-history", + "assistant", + {"type": "reasoning", "text": "internal thinking"}, + 1, + "history", + "internal thinking", + ), + OpencodePartCase( + "subtask-prompt-is-searchable", + "assistant", + {"type": "subtask", "prompt": "spawn a search subtask", "description": "desc"}, + 1, + "history", + "spawn a search subtask", + ), + OpencodePartCase( + "tool-part-is-skipped", + "assistant", + {"type": "tool", "tool": "read", "state": {"status": "completed", "output": "x"}}, + 0, + None, + None, + ), + OpencodePartCase( + "file-part-is-skipped", + "assistant", + {"type": "file", "mime": "text/plain", "url": "file://x"}, + 0, + None, + None, + ), + OpencodePartCase( + "step-start-is-skipped", + "assistant", + {"type": "step-start"}, + 0, + None, + None, + ), + OpencodePartCase( + "empty-text-is-skipped", + "user", + {"type": "text", "text": ""}, + 0, + None, + None, + ), +) + + +@pytest.mark.parametrize( + OpencodePartCase._fields, + OPENCODE_PART_CASES, + ids=[case.test_id for case in OPENCODE_PART_CASES], +) +def test_parse_opencode_part( + test_id: str, + message_role: str, + part: dict[str, object], + expected_count: int, + expected_kind: str | None, + expected_text_contains: str | None, + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Each OpenCode part type maps to the expected record (or is skipped).""" + _ = test_id + agentgrep = load_agentgrep_module() + home = tmp_path / "home" + monkeypatch.setenv("HOME", str(home)) + monkeypatch.delenv("XDG_DATA_HOME", raising=False) + monkeypatch.delenv("OPENCODE_DB", raising=False) + db_path = home / ".local" / "share" / "opencode" / "opencode.db" + _build_opencode_db(db_path, messages=[(message_role, [part])]) + + records = _parse_opencode_records(agentgrep, home) + + assert len(records) == expected_count + if expected_count: + record = records[0] + assert record.agent == "opencode" + assert record.kind == expected_kind + if expected_text_contains is not None: + assert expected_text_contains in record.text + + @pytest.mark.parametrize( UnixToIsoCase._fields, UNIX_TO_ISO_CASES, diff --git a/tests/test_storage_docs.py b/tests/test_storage_docs.py index 19af15d..02faec5 100644 --- a/tests/test_storage_docs.py +++ b/tests/test_storage_docs.py @@ -145,6 +145,7 @@ def test_storage_coverage_grid_summarizes_catalog(tmp_path: pathlib.Path) -> Non gemini grok pi + opencode ``` ```{storage:coverage-grid} @@ -153,7 +154,16 @@ def test_storage_coverage_grid_summarizes_catalog(tmp_path: pathlib.Path) -> Non ), encoding="utf-8", ) - for agent in ("claude", "codex", "cursor-cli", "cursor-ide", "gemini", "grok", "pi"): + for agent in ( + "claude", + "codex", + "cursor-cli", + "cursor-ide", + "gemini", + "grok", + "pi", + "opencode", + ): (srcdir / f"{agent}.md").write_text( textwrap.dedent( f"""\ diff --git a/tests/test_stores.py b/tests/test_stores.py index e7c8ced..48bcfce 100644 --- a/tests/test_stores.py +++ b/tests/test_stores.py @@ -32,6 +32,7 @@ "gemini", "grok", "pi", + "opencode", ) PATH_TOKEN_RE = re.compile(r"\$\{(?:HOME|[A-Z][A-Z0-9_]*)(?:\s+or\s+[^}]+)?\}") diff --git a/tests/test_widgets.py b/tests/test_widgets.py index eef31e8..7110806 100644 --- a/tests/test_widgets.py +++ b/tests/test_widgets.py @@ -149,5 +149,14 @@ def test_backend_index_renders_backend_shortcut_grid(tmp_path: pathlib.Path) -> backend_index = (tmp_path / "backends" / "index.html").read_text(encoding="utf-8") assert "Backend pages" in backend_index assert backend_index.index("Backend pages") < backend_index.index("Coverage levels") - for backend in ("codex", "claude", "cursor-cli", "cursor-ide", "gemini", "grok", "pi"): + for backend in ( + "codex", + "claude", + "cursor-cli", + "cursor-ide", + "gemini", + "grok", + "pi", + "opencode", + ): assert f'href="{backend}/"' in backend_index