Skip to content
Open
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,14 @@ here. Native format compatibility is documented separately in

## Unreleased

- Add first-class support for Antigravity Desktop (macOS/Electron) 2.18.1
with bidirectional conversion, SQLite schema adaptation, Hub Summaries
Protobuf cache synchronization (`agyhub_summaries_proto.pb`), project-scoped
sidebar discovery, and verified native Go language server integration.
- Document the clean-room Antigravity Desktop storage architecture, Protobuf
wire formats, Connect-RPC interfaces, and collision invariants in
`docs/antigravity-desktop-format.md`.

## 0.11.0 - 2026-09-11

- Read root Codex rollouts written with `history_mode: "paginated"` by using
Expand Down
189 changes: 189 additions & 0 deletions docs/antigravity-desktop-format.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
# Antigravity Desktop session format

`session-migrate` supports Antigravity Desktop (macOS, Linux, and Windows)
through a clean-room adapter pinned to the 2.18.1 release:

| Property | Pinned value |
| --- | --- |
| Application | Antigravity Desktop `2.18.1` (Electron `44.3.0`) |
| macOS arm64 binary | `148,990,608` bytes, SHA-256 `300ee20f3108a511be1149602b91ba84cbbdcc42213067151e255584129be30f` |
| Linux x64 binary | `181,432,528` bytes, SHA-256 `ab4937445fa3817bc374a1db71062de7daecdb093cbfb6bd36b80f443198670e` |
| Windows x64 binary | `163,640,320` bytes, SHA-256 `1ed84e6a1d1e51064d80c9f382ab3a519eb2775cb91d552c63064a18cfdf3cf2` |
| Conversation store | `<home>/.gemini/antigravity/conversations/<uuid>.db` |
| Picker index | `<home>/.gemini/antigravity/conversation_summaries.db` (`PRAGMA user_version = 3`) |
| Hub summary cache | `<home>/.gemini/antigravity/agyhub_summaries_proto.pb` |
| UI project catalog | `<home>/.gemini/config/projects/<id>.json` + `app_storage.json` |

This is deliberately not a claim of compatibility with unreleased or arbitrary
Antigravity versions. Automatic installation checks executable digests and
schema invariants when native verification is requested.

No vendor code, binaries, descriptors, credentials, or user transcripts are
included in this repository. All schemas, wire mappings, and RPC endpoints were
established through clean-room differential probing and synthetic test fixtures.

## How the format was established

The adapter was derived from independent observations of synthetic sessions and
package analysis across macOS, Linux, and Windows distributions:

1. generate synthetic conversations with unique marker text in isolated environments;
2. inspect SQLite schema, columns, and Protobuf wire field numbers and types;
3. analyze Electron application resources (`app.asar`), verifying identical JavaScript
(`3c03ce352dc3c1b43f357c27ead1f73c74d198a8ded89b1b3d6a2539e7a6ac5c`) across all three OSes;
4. inspect native Go language server (`language_server`) symbols and Connect-RPC endpoints;
5. verify round-trip serialization and cold-reload step retrieval via Connect-RPC
(`GetCascadeTrajectorySteps`); and
6. test real-world conversation migration end-to-end with active multi-turn sessions.

## SQLite layout

Each conversation is stored as an independent SQLite 3 database with write-ahead
logging (`WAL`) containing these tables:

```text
trajectory_meta
steps
gen_metadata
executor_metadata
parent_references
trajectory_metadata_blob
battle_mode_infos
```

While the table names match Antigravity CLI, Desktop enforces distinct wire and
metadata invariants:

| Property | Antigravity CLI (`antigravity`) | Antigravity Desktop (`antigravity-desktop`) |
| --- | --- | --- |
| App data subdirectory | `~/.gemini/antigravity-cli` | `~/.gemini/antigravity` |
| `trajectory_meta.source` | `17` (`CLI`) | `1` (`IDE` / `Hub`) |
| `trajectory_metadata_blob` project ID | `"default-cli-project"` | `"outside-of-project"` (or workspace UUID) |
| `conversation_summaries.source` | `"antigravity-cli"` | `""` (empty string) |
| `conversation_summaries.app_data_dir` | `"antigravity-cli"` | `"antigravity"` |
| `conversation_summaries.status` | `""` (empty string) | `"CASCADE_RUN_STATUS_IDLE"` |
| `conversation_summaries.project_id` | `"default-cli-project"` | `"outside-of-project"` |
| Summary columns | 19 or 21 columns | 21 columns (includes `raw_summary`, `group_id`) |
| Subagent trajectory roots | root ID == conversation ID | root ID points to root cascade ID |

The main `trajectory_meta` row stores a fresh trajectory UUID, conversation UUID,
trajectory type `4` (cascade), and source `1` (`IDE`/`Hub`). `steps.idx` is a
contiguous zero-based sequence. Every step duplicates its type and status in
both the SQLite row columns and the serialized `Step` blob.

### Sidebar discovery and project grouping

Antigravity Desktop filters sidebar conversations strictly by active projects:

1. **Active Projects List**: Tracked in `app_storage.json` under `projectsOrder`.
- macOS: `~/Library/Application Support/Antigravity/app_storage.json`
- Linux: `$XDG_CONFIG_HOME/Antigravity/app_storage.json` (or `~/.config/Antigravity`)
- Windows: `%APPDATA%\Antigravity\app_storage.json`
2. **Project Configurations**: Stored in `~/.gemini/config/projects/<id>.json`,
defining `project_id`, workspace paths, and display labels.
3. Unassociated conversations (`project_id = "outside-of-project"`) are not
grouped in the project tree; assigning a registered project ID ensures instant
sidebar visibility.

## Protobuf subset and Hub cache

The adapter contains a bounded wire codec without generated vendor classes. It
accepts canonical varints, length-delimited bytes, and fixed 32/64-bit wire types.

The `Step` envelope uses:

| Field | Meaning |
| --- | --- |
| `1` | step type enum |
| `4` | step status enum |
| `19` | user-input payload when type is `14` (`CORTEX_STEP_TYPE_USER_INPUT`) |
| `20` | planner-response payload when type is `15` (`CORTEX_STEP_TYPE_PLANNER_RESPONSE`) |
| `140` | generic-tool payload when type is `132` (`CORTEX_STEP_TYPE_GENERIC_TOOL`) |

Status `3` is done, status `5` is cleared, and status `7` is error. User-input
field `2` stores visible user text. Planner-response field `1` stores visible
assistant text, field `6` stores a fresh message ID, and repeated field `7`
stores tool calls.

### Hub summary cache (`agyhub_summaries_proto.pb`)

Antigravity Desktop caches summaries in `agyhub_summaries_proto.pb`. On startup,
the native Go process reconciles SQLite records with this protobuf cache. If a
conversation exists in SQLite but has no entry in the cache or `raw_summary`
column, metadata fields reset to zero defaults (`0001-01-01`). The adapter
synchronizes both stores during installation.

## Conversion policy

| Portable event | Antigravity Desktop representation |
| --- | --- |
| User text | done user-input step |
| Assistant text | done planner-response step |
| Tool call | planner-response tool call |
| Tool result | following generic-tool step |
| Thinking/reasoning | omitted and counted as `thinking:private` |
| Compaction | omitted and counted |
| System/developer message | omitted as privileged context |
| Images and other context | omitted by type and counted |
| Unknown/opaque source event | omitted by reason and counted |

Private chain-of-thought is not portable. Thinking bytes and model signatures are
omitted from generated outputs, and source reading emits only a content-free
`thinking` marker.

A tool result without a preceding call receives a fresh synthetic call and an
explicit orphan counter. Duplicate or out-of-order IDs are rewritten or counted
to prevent corrupted native trajectories.

## Reading and installation

Source databases may have active write-ahead logs (`.db-wal`). The reader uses
SQLite's backup API to obtain a transactionally consistent snapshot, then hashes
and parses that snapshot. It validates:

- exact table, column, and schema invariants;
- SQLite page bounds and `integrity_check`;
- valid canonical UUIDv4 IDs and source `1` / `17` discrimination;
- contiguous step indices and supported step types; and
- structurally valid, bounded protobuf values.

Native installation creates target directories privately, reserves the summary
ID with `BEGIN IMMEDIATE`, writes the database atomically, and commits the
summary transaction. If a transaction fails, only newly created files are
unlinked. Existing sessions are never overwritten.

## Native validation evidence

1. **Synthetic Round-Trip Invariants**: Synthetic multi-turn sessions with tool
invocations, results, and thinking events serialize and re-parse with exact
fidelity, asserting that thinking text is removed.
2. **Native Language Server Verification**: The official Go binary
(`language_server`) was executed in an isolated sandbox, queried over Connect-RPC
(`GetCascadeTrajectorySteps`), and asserted to return all imported steps with
identical structure.
3. **Live User Session Migration**: A live ESPHome voice satellite conversation
with 256 steps was migrated from Antigravity CLI to Antigravity Desktop,
confirming complete rendering and sidebar searchability.

Run the test suite with:

```console
uv run pytest -q tests/test_antigravity_desktop_format.py
```

Run the native language server oracle (macOS with Antigravity Desktop installed):

```console
uv run pytest -q tests/test_antigravity_desktop_native.py
```

## Known boundaries

- Native oracle hash validation is pinned to official release builds. Unvalidated
binaries can be tested with `SESSION_MIGRATE_UNVALIDATED_DESKTOP_BIN=1`.
- Generic tool transport is visually render-proven; internal vendor-privileged tools
are preserved as generic tool steps to maintain auditability.
- Private reasoning tokens, system instructions, and ephemeral UI selections are
not transferred.
- Subagent conversations are mapped to independent portable sessions with their
parent reference preserved in metadata.
1 change: 1 addition & 0 deletions docs/format-compatibility.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ to separately installed host binaries:
| OpenCode source and target | `1.17.20` |
| GitHub Copilot CLI source and target | `1.0.70` |
| Antigravity CLI source and target | `1.1.16` |
| Antigravity Desktop source and target | `2.18.1` |
| Cursor Agent experimental text adapter | `2026.03.20-44cb435` |
| Mistral Vibe source and target | `2.24.3` |
| Muse Code source and target | `0.2.1 (0.2.1-R1215.1)` |
Expand Down
59 changes: 57 additions & 2 deletions src/session_migrate/catalog.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
from session_migrate.errors import JsonlError, SessionMigrateError
from session_migrate.formats import (
antigravity,
antigravity_desktop,
codex,
devin,
hermes,
Expand Down Expand Up @@ -237,6 +238,11 @@ def auto_roots(
user_home / ".gemini" / "antigravity-cli",
"default",
),
(
AgentFormat.ANTIGRAVITY_DESKTOP,
user_home / ".gemini" / "antigravity",
"default",
),
(
AgentFormat.CURSOR,
cursor_home,
Expand Down Expand Up @@ -336,6 +342,11 @@ def auto_roots(
antigravity_home = directory / ".gemini" / "antigravity-cli"
if (antigravity_home / "conversations").is_dir():
candidates.append((AgentFormat.ANTIGRAVITY, antigravity_home, "project"))
antigravity_desktop_home = directory / ".gemini" / "antigravity"
if (antigravity_desktop_home / "conversations").is_dir():
candidates.append(
(AgentFormat.ANTIGRAVITY_DESKTOP, antigravity_desktop_home, "project")
)
cursor_home = directory / ".cursor"
if (cursor_home / "chats").is_dir():
candidates.append((AgentFormat.CURSOR, cursor_home, "project"))
Expand Down Expand Up @@ -887,6 +898,7 @@ def add_root(
AgentFormat.OPENCODE,
AgentFormat.COPILOT,
AgentFormat.ANTIGRAVITY,
AgentFormat.ANTIGRAVITY_DESKTOP,
AgentFormat.CURSOR,
AgentFormat.VIBE,
AgentFormat.MUSE,
Expand Down Expand Up @@ -946,6 +958,7 @@ def refresh(
opencode_roots: Sequence[Path] = (),
copilot_roots: Sequence[Path] = (),
antigravity_roots: Sequence[Path] = (),
antigravity_desktop_roots: Sequence[Path] = (),
cursor_roots: Sequence[Path] = (),
vibe_roots: Sequence[Path] = (),
muse_roots: Sequence[Path] = (),
Expand Down Expand Up @@ -981,6 +994,8 @@ def refresh(
self.add_root(AgentFormat.COPILOT, path)
for path in antigravity_roots:
self.add_root(AgentFormat.ANTIGRAVITY, path)
for path in antigravity_desktop_roots:
self.add_root(AgentFormat.ANTIGRAVITY_DESKTOP, path)
for path in cursor_roots:
self.add_root(AgentFormat.CURSOR, path)
for path in vibe_roots:
Expand Down Expand Up @@ -1110,7 +1125,10 @@ def _refresh_root(self, root: CatalogRoot, *, validate: bool) -> dict[str, int]:
metadata = _codex_native_metadata(root_path)
elif root.format == AgentFormat.COPILOT.value:
metadata = _copilot_native_metadata(root_path)
elif root.format == AgentFormat.ANTIGRAVITY.value:
elif root.format in {
AgentFormat.ANTIGRAVITY.value,
AgentFormat.ANTIGRAVITY_DESKTOP.value,
}:
metadata = _antigravity_native_metadata(root_path)
else:
metadata = _NativeMetadata({}, {}, {}, False)
Expand Down Expand Up @@ -1156,6 +1174,7 @@ def _refresh_root(self, root: CatalogRoot, *, validate: bool) -> dict[str, int]:
continue
elif root.format in {
AgentFormat.ANTIGRAVITY.value,
AgentFormat.ANTIGRAVITY_DESKTOP.value,
AgentFormat.CURSOR.value,
}:
try:
Expand Down Expand Up @@ -1922,7 +1941,7 @@ def _candidate_files(agent_format: AgentFormat, root: Path) -> Iterable[Path]:
]
yield from sorted(candidates)
return
if agent_format == AgentFormat.ANTIGRAVITY:
if agent_format in {AgentFormat.ANTIGRAVITY, AgentFormat.ANTIGRAVITY_DESKTOP}:
conversations = root / "conversations"
if not conversations.is_dir():
return
Expand Down Expand Up @@ -2039,6 +2058,8 @@ def _raise_walk_error(error: OSError) -> None:
def _scan_file(path: Path, agent_format: AgentFormat, root: Path) -> _Scan:
if agent_format == AgentFormat.ANTIGRAVITY:
return _scan_antigravity_file(path, root)
if agent_format == AgentFormat.ANTIGRAVITY_DESKTOP:
return _scan_antigravity_desktop_file(path, root)
if agent_format == AgentFormat.CURSOR:
return _scan_cursor_file(path, root)
if agent_format == AgentFormat.VIBE:
Expand Down Expand Up @@ -2450,6 +2471,7 @@ def _base_scan(
lifecycle = "project"
elif agent_format in {
AgentFormat.ANTIGRAVITY,
AgentFormat.ANTIGRAVITY_DESKTOP,
AgentFormat.CURSOR,
AgentFormat.VIBE,
AgentFormat.MUSE,
Expand Down Expand Up @@ -2513,6 +2535,39 @@ def _scan_antigravity_file(path: Path, root: Path) -> _Scan:
)


def _scan_antigravity_desktop_file(path: Path, root: Path) -> _Scan:
base = _base_scan(path, AgentFormat.ANTIGRAVITY_DESKTOP, root, "candidate", None)
try:
parsed = antigravity_desktop.parse(path)
except SessionMigrateError:
return _replace_scan_status(base, "corrupt", "invalid_antigravity_database")
labels: list[_Label] = []
if parsed.title:
title = _bounded(parsed.title, LABEL_LIMIT)
if title:
labels.append(_Label("native_title", title, 0, 110))
has_conversation = any(
event.kind.value in {"message", "tool_call", "tool_result"} for event in parsed.events
)
status = "candidate" if has_conversation else "corrupt"
reason = None if has_conversation else "no_conversation_records"
return _Scan(
session_id=parsed.session_id,
filename_session_id=base.filename_session_id,
cwd=_bounded(str(parsed.cwd), PATH_VALUE_LIMIT) if parsed.cwd else None,
started_at=parsed.started_at,
cli_version=parsed.cli_version,
history_mode=None,
kind=base.kind,
lifecycle=base.lifecycle,
parent_session_id=None,
status=status,
reason=reason,
records=parsed.raw_record_count,
labels=tuple(labels),
)


def _scan_cursor_file(path: Path, root: Path) -> _Scan:
base = _base_scan(path, AgentFormat.CURSOR, root, "candidate", None)
try:
Expand Down
Loading