From 99b5897a170ab4bc10c1c62334d3ba7ddc861318 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 30 May 2026 15:16:51 -0500 Subject: [PATCH 1/7] agentgrep(feat[opencode]): Register opencode agent name across modules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: Adding OpenCode (anomalyco/opencode, issue #29) as a searchable backend starts by teaching every agent-name surface that "opencode" exists. This commit is inert on its own — opencode is recognized but has no stores yet, so discovery and parsing are never reached — which isolates the literal change from the catalog/discovery/parser layers that follow. what: - Add "opencode" to the AgentName literal in stores.py, __init__.py, and mcp/_library.py, plus the AgentSelector literal and AGENT_CHOICES. - Add "opencode" to the five MCP model agent literals and the query registry's agent enum_values (and its docstring values line). - Mention OpenCode in the MCP server-instruction header and trigger scope. - Add "opencode" to the package description and keywords. --- pyproject.toml | 4 ++-- src/agentgrep/__init__.py | 9 ++++++--- src/agentgrep/mcp/_library.py | 6 ++++-- src/agentgrep/mcp/instructions.py | 4 ++-- src/agentgrep/mcp/models.py | 20 +++++++++++++++----- src/agentgrep/query/registry.py | 14 ++++++++++++-- src/agentgrep/stores.py | 4 +++- 7 files changed, 44 insertions(+), 17 deletions(-) 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..f74b86c 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" @@ -223,8 +226,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. diff --git a/src/agentgrep/mcp/_library.py b/src/agentgrep/mcp/_library.py index 8c3f5cf..7f7d39b 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"] 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/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"] From 75a5288e524b15e542eacec7e783e478ae4a2fa4 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 30 May 2026 15:21:12 -0500 Subject: [PATCH 2/7] agentgrep(feat[opencode]): Add opencode store descriptors and adapter why: The catalogue is agentgrep's single source of truth for where agent data lives and what shape it takes. OpenCode gets one searchable store (its SQLite database) plus documentary descriptors for every other on-disk artifact, so the catalogue stays a complete inventory even for data agentgrep never searches. what: - Add _OPENCODE_OBSERVED_AT and the _OPENCODE_STORES tuple: opencode.db (PRIMARY_CHAT, searched, SQLite, adapter opencode.db_sqlite.v1) plus documentary rows for the legacy JSON layout, config, auth (PRIVATE credentials), snapshots, repos, logs, and tool output. - Splice _OPENCODE_STORES into CATALOG, bump catalog_version to 10, and advance captured_at to the OpenCode observation date. - Register opencode.db_sqlite.v1 in the MCP KNOWN_ADAPTERS tuple. - Add "opencode" to the test-side KNOWN_AGENTS so the catalogue invariants cover the new rows. --- src/agentgrep/mcp/_library.py | 1 + src/agentgrep/store_catalog.py | 157 ++++++++++++++++++++++++++++++++- tests/test_stores.py | 1 + 3 files changed, 157 insertions(+), 2 deletions(-) diff --git a/src/agentgrep/mcp/_library.py b/src/agentgrep/mcp/_library.py index 7f7d39b..1392654 100644 --- a/src/agentgrep/mcp/_library.py +++ b/src/agentgrep/mcp/_library.py @@ -80,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/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/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+[^}]+)?\}") From e8de36f6a30524b6a8529f8d79cab8e36fe5b8c6 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 30 May 2026 15:25:06 -0500 Subject: [PATCH 3/7] agentgrep(feat[opencode]): Discover opencode databases why: With OpenCode in the catalogue, discovery needs to resolve its XDG data directory and enumerate the SQLite databases there. OpenCode keeps its store at opencode.db (or opencode-.db) under ${XDG_DATA_HOME}/opencode, and OPENCODE_DB can relocate it. what: - Add discover_opencode_sources, resolving the data root via resolve_env_root("XDG_DATA_HOME", ...) + the opencode segment and discovering opencode*.db. When OPENCODE_DB points at an absolute file, its parent directory becomes the search root (matching how OpenCode replaces the default database location). - Wire the "opencode" branch into discover_sources. --- src/agentgrep/__init__.py | 68 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/src/agentgrep/__init__.py b/src/agentgrep/__init__.py index f74b86c..7c36efd 100644 --- a/src/agentgrep/__init__.py +++ b/src/agentgrep/__init__.py @@ -2345,6 +2345,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 @@ -3302,6 +3310,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, From 0cbfc8c9dd642ad988b201fb8f1a3cd97854d6bf Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 30 May 2026 15:30:19 -0500 Subject: [PATCH 4/7] agentgrep(feat[opencode]): Parse opencode session databases why: Discovered opencode.db files need an adapter to turn their relational rows into normalized search records. OpenCode keeps the prompt and reply text in part rows, so the parser joins each part up to its message (for role) and session (for title, working directory, and timestamp). what: - Add parse_opencode_db plus _opencode_json_object and _opencode_part_text helpers, reusing open_readonly_sqlite and sqlite_table_names. A single part -> message -> session join emits one record per text-bearing part: text/reasoning carry their text and subtask its prompt; kind is role-derived (user -> prompt, else history); the session title, model, and unix-ms timestamp are attached, with the working directory in metadata. Degrades gracefully when tables are missing or the database is unreadable. - Dispatch opencode.db_sqlite.v1 in iter_source_records and register it in ITER_SOURCE_RECORD_ADAPTERS. --- src/agentgrep/__init__.py | 98 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 98 insertions(+) diff --git a/src/agentgrep/__init__.py b/src/agentgrep/__init__.py index 7c36efd..bf596b0 100644 --- a/src/agentgrep/__init__.py +++ b/src/agentgrep/__init__.py @@ -177,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]] @@ -3901,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( @@ -5365,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]: From 9e6f49a32abbf8ee7121350dea09fd1a628ffb3f Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 30 May 2026 15:34:57 -0500 Subject: [PATCH 5/7] agentgrep(test[opencode]): Cover opencode discovery and parsing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: The opencode backend needs the same test depth as the SQLite-based Grok backend — discovery across its layouts and the per-part parse behaviour — so regressions surface before release. what: - Add a helper that builds a minimal opencode.db (session/message/part) inline, following the Grok session_search test. - Cover discovery at the default ~/.local/share/opencode location, the XDG_DATA_HOME override (with a decoy), and the absolute OPENCODE_DB override, plus an end-to-end search asserting user->prompt and assistant->history with the model and working directory attached. - Add a NamedTuple + test_id parametrized parse matrix over part types: text/reasoning/subtask are searchable; tool, file, step markers, and empty text are skipped. Register "opencode" in the test-local AgentName. --- tests/test_agentgrep.py | 299 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 298 insertions(+), 1 deletion(-) 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, From afa1291587f98750fe05cd26f46a13fe5b480e6c Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 30 May 2026 15:39:01 -0500 Subject: [PATCH 6/7] agentgrep(docs[opencode]): Add opencode backend page and catalogue entry why: Each backend gets a reference page documenting its layout and record schema, and the support matrix and agent lists must name OpenCode so readers can find it. what: - Add docs/backends/opencode.md: base path, env overrides, and the SQLite session/message/part schema with the searchable part types. - Add an OpenCode card and toctree entry to the backend index and an OpenCode section to the storage-catalogue dev page. - Name OpenCode in the README and docs landing agent lists. - Extend the backend-grid and coverage-grid doc tests to cover opencode. --- README.md | 2 +- docs/backends/index.md | 7 +++ docs/backends/opencode.md | 63 +++++++++++++++++++++++++++ docs/dev/index.md | 2 +- docs/dev/storage-catalog.md | 28 ++++++++++++ docs/getting-started/configuration.md | 2 +- docs/index.md | 2 +- docs/mcp/resources.md | 2 +- docs/tui/index.md | 2 +- tests/test_storage_docs.py | 12 ++++- tests/test_widgets.py | 11 ++++- 11 files changed, 125 insertions(+), 8 deletions(-) create mode 100644 docs/backends/opencode.md 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/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_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 From 78956f586cdf35d90676d22e886414ac01e3461c Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 30 May 2026 15:41:46 -0500 Subject: [PATCH 7/7] docs(CHANGES) Add opencode backend why: Record the new OpenCode backend for the unreleased version so readers know agentgrep now searches anomalyco/opencode. what: - Add an "OpenCode backend (#29)" deliverable under What's new for the unreleased 0.1.0a8 section, describing the single SQLite store, the part-join read path, the env overrides, and the catalogued documentary stores. --- CHANGES | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) 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