diff --git a/CHANGELOG.md b/CHANGELOG.md index 19cae84..66a0f66 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,16 @@ here. Native format compatibility is documented separately in ## Unreleased +## Unreleased + +- Add first-class support for Antigravity Desktop (macOS, Linux, Windows) 2.18.1 + with bidirectional conversion, SQLite schema adaptation, summary index + registration (`conversation_summaries.db`), 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.12.0 - 2026-10-19 - Fix atomic writes on Windows in `write_private_atomic`: skip the Unix-only diff --git a/docs/antigravity-desktop-format.md b/docs/antigravity-desktop-format.md new file mode 100644 index 0000000..7def7a8 --- /dev/null +++ b/docs/antigravity-desktop-format.md @@ -0,0 +1,181 @@ +# 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 | `/.gemini/antigravity/conversations/.db` | +| Picker index | `/.gemini/antigravity/conversation_summaries.db` (`PRAGMA user_version = 3`) | + +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 organizes sidebar conversations by active project. In the +current implementation, `install_database` writes the authoritative SQLite +conversation database and summary entry with `project_id = "outside-of-project"`. +Project association and UI tree indexing are managed by the Desktop application +upon launch. + +## 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 + +Antigravity Desktop maintains an internal cache in `agyhub_summaries_proto.pb`. +When started, the native Go process (`language_server`) reconciles SQLite +conversation and summary records. `session-migrate` writes the authoritative +SQLite conversation and `conversation_summaries.db` records; protobuf cache +reconciliation occurs when the Desktop application starts. + +## 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. diff --git a/docs/format-compatibility.md b/docs/format-compatibility.md index 2380d32..30a5e00 100644 --- a/docs/format-compatibility.md +++ b/docs/format-compatibility.md @@ -24,6 +24,7 @@ to separately installed host binaries: | OpenCode v2 transfer adapter | Stock Linux `2.0.22` local gate; `2.0.23` CI pin | | 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)` | diff --git a/src/session_migrate/catalog.py b/src/session_migrate/catalog.py index 95269ec..2c41da0 100644 --- a/src/session_migrate/catalog.py +++ b/src/session_migrate/catalog.py @@ -25,6 +25,7 @@ from session_migrate.errors import JsonlError, SessionMigrateError from session_migrate.formats import ( antigravity, + antigravity_desktop, codex, devin, hermes, @@ -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, @@ -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")) @@ -887,6 +898,7 @@ def add_root( AgentFormat.OPENCODE, AgentFormat.COPILOT, AgentFormat.ANTIGRAVITY, + AgentFormat.ANTIGRAVITY_DESKTOP, AgentFormat.CURSOR, AgentFormat.VIBE, AgentFormat.MUSE, @@ -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] = (), @@ -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: @@ -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) @@ -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: @@ -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 @@ -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: @@ -2450,6 +2471,7 @@ def _base_scan( lifecycle = "project" elif agent_format in { AgentFormat.ANTIGRAVITY, + AgentFormat.ANTIGRAVITY_DESKTOP, AgentFormat.CURSOR, AgentFormat.VIBE, AgentFormat.MUSE, @@ -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: diff --git a/src/session_migrate/cli.py b/src/session_migrate/cli.py index 28e3f23..8f4fb36 100644 --- a/src/session_migrate/cli.py +++ b/src/session_migrate/cli.py @@ -21,6 +21,7 @@ detect_opencode_version, ensure_target_paths_available, install_antigravity_artifact, + install_antigravity_desktop_artifact, install_copilot_artifact, install_cursor_artifact, install_devin_artifact, @@ -240,6 +241,13 @@ def build_parser() -> argparse.ArgumentParser: default=[], help="register and scan an additional Antigravity CLI data home (repeatable)", ) + refresh_parser.add_argument( + "--antigravity-desktop-root", + type=_expanded_path, + action="append", + default=[], + help="register and scan an additional Antigravity Desktop data home (repeatable)", + ) refresh_parser.add_argument( "--cursor-root", type=_expanded_path, @@ -559,6 +567,7 @@ def main(argv: Sequence[str] | None = None) -> int: TargetFormat.OPENCODE, TargetFormat.KILO, TargetFormat.ANTIGRAVITY, + TargetFormat.ANTIGRAVITY_DESKTOP, TargetFormat.CURSOR, TargetFormat.HERMES, } @@ -639,6 +648,13 @@ def main(argv: Sequence[str] | None = None) -> int: target_cli=args.target_cli, dry_run=dry_run, ) + elif target_format == TargetFormat.ANTIGRAVITY_DESKTOP and args.command != "convert": + install_antigravity_desktop_artifact( + artifact, + target_home=home, + target_cli=args.target_cli, + dry_run=dry_run, + ) elif target_format == TargetFormat.CURSOR and args.command != "convert": install_cursor_artifact( artifact, @@ -821,6 +837,7 @@ def _run_catalog(args: argparse.Namespace) -> int: opencode_roots=args.opencode_root, copilot_roots=args.copilot_root, antigravity_roots=args.antigravity_root, + antigravity_desktop_roots=args.antigravity_desktop_root, cursor_roots=args.cursor_root, vibe_roots=args.vibe_root, muse_roots=args.muse_root, diff --git a/src/session_migrate/conversion.py b/src/session_migrate/conversion.py index 4af8f9b..ab344af 100644 --- a/src/session_migrate/conversion.py +++ b/src/session_migrate/conversion.py @@ -21,6 +21,7 @@ from session_migrate.errors import FormatDetectionError, JsonlError, SessionMigrateError from session_migrate.formats import ( antigravity, + antigravity_desktop, claude, codex, copilot, @@ -133,6 +134,8 @@ def load_session(path: Path, source_format: AgentFormat | None = None) -> Sessio # The adapter makes a transactionally consistent SQLite backup that # includes live WAL state; a plain file stat/hash check would not. return antigravity.parse_session(path) + if source_format == AgentFormat.ANTIGRAVITY_DESKTOP: + return antigravity_desktop.parse_session(path) if source_format == AgentFormat.CURSOR: # Cursor is also a live SQLite store. Its adapter takes a consistent # backup including committed WAL state before projecting the graph. @@ -393,6 +396,18 @@ def convert_session(session: Session, options: ConversionOptions) -> ConversionA model=options.model, timestamp=timestamp, ) + elif target_format == TargetFormat.ANTIGRAVITY_DESKTOP: + target_version = ( + options.target_cli_version or antigravity_desktop.PINNED_ANTIGRAVITY_DESKTOP_VERSION + ) + native_bytes, dropped = antigravity_desktop.serialize( + session, + session_id=target_id, + cwd=target_cwd, + cli_version=target_version, + model=options.model, + timestamp=timestamp, + ) elif target_format == TargetFormat.CURSOR: target_version = options.target_cli_version or cursor.PINNED_CURSOR_VERSION native_bytes, dropped = cursor.serialize( @@ -593,6 +608,9 @@ def convert_session(session: Session, options: ConversionOptions) -> ConversionA AgentFormat.OPENCODE: opencode.PINNED_OPENCODE_VERSION, AgentFormat.COPILOT: copilot.PINNED_COPILOT_VERSION, AgentFormat.ANTIGRAVITY: antigravity.PINNED_ANTIGRAVITY_VERSION, + AgentFormat.ANTIGRAVITY_DESKTOP: ( + antigravity_desktop.PINNED_ANTIGRAVITY_DESKTOP_VERSION + ), AgentFormat.CURSOR: cursor.PINNED_CURSOR_VERSION, AgentFormat.VIBE: vibe.PINNED_VIBE_VERSION, AgentFormat.MUSE: muse.PINNED_MUSE_VERSION, @@ -659,6 +677,8 @@ def target_import_paths(artifact: ConversionArtifact, target_home: Path) -> tupl native_path = target_home / copilot.session_relative_path(artifact.session_id) elif artifact.target_format == TargetFormat.ANTIGRAVITY: native_path = target_home / antigravity.session_relative_path(artifact.session_id) + elif artifact.target_format == TargetFormat.ANTIGRAVITY_DESKTOP: + native_path = target_home / antigravity_desktop.session_relative_path(artifact.session_id) elif artifact.target_format == TargetFormat.CURSOR: native_path = target_home / cursor.session_relative_path(artifact.session_id, artifact.cwd) elif artifact.target_format == TargetFormat.VIBE: @@ -703,6 +723,8 @@ def default_target_home(target_format: TargetFormat | AgentFormat) -> Path: return Path(configured).expanduser() if configured else Path.home() / ".copilot" if target_format.value == TargetFormat.ANTIGRAVITY.value: return antigravity.app_data_home() + if target_format.value == TargetFormat.ANTIGRAVITY_DESKTOP.value: + return antigravity_desktop.app_data_home() if target_format.value == TargetFormat.CURSOR.value: return cursor.config_home() if target_format.value == TargetFormat.VIBE.value: @@ -960,6 +982,91 @@ def install_antigravity_artifact( return native_path, manifest_path +def install_antigravity_desktop_artifact( + artifact: ConversionArtifact, + *, + target_home: Path, + target_cli: Path | None = None, + dry_run: bool = False, + environ: Mapping[str, str] | None = None, +) -> tuple[Path, Path]: + """Install a pinned Antigravity Desktop DB and finalize a content-free manifest.""" + + if artifact.target_format != TargetFormat.ANTIGRAVITY_DESKTOP: + raise SessionMigrateError( + "Antigravity Desktop installation requires an Antigravity Desktop artifact" + ) + if artifact.target_cli_version != antigravity_desktop.PINNED_ANTIGRAVITY_DESKTOP_VERSION: + raise SessionMigrateError( + "automatic Antigravity Desktop import requires target metadata version " + f"{antigravity_desktop.PINNED_ANTIGRAVITY_DESKTOP_VERSION}; " + "convert-only artifacts may opt into unvalidated metadata versions" + ) + native_path, manifest_path = target_import_paths(artifact, target_home) + ensure_target_paths_available(manifest_path) + if dry_run: + installed = antigravity_desktop.install_database( + artifact.native_bytes, + session_id=artifact.session_id, + cwd=artifact.cwd, + timestamp=artifact.timestamp, + title=artifact.source.title, + target_home=target_home, + target_cli=target_cli, + dry_run=True, + environ=environ, + ) + if installed.conversation_path != native_path: + raise SessionMigrateError( + "Antigravity Desktop installer resolved an unexpected target path" + ) + return native_path, manifest_path + + manifest_bytes = ( + json.dumps(artifact.manifest(output_path=native_path), indent=2, sort_keys=True) + "\n" + ).encode() + reservation_identity: tuple[int, int] | None = None + reservation_guard: int | None = None + install_succeeded = False + try: + reservation_identity = write_private_atomic(manifest_path, b"") + reservation_guard = _open_identity_guard(manifest_path, reservation_identity, writable=True) + installed = antigravity_desktop.install_database( + artifact.native_bytes, + session_id=artifact.session_id, + cwd=artifact.cwd, + timestamp=artifact.timestamp, + title=artifact.source.title, + target_home=target_home, + target_cli=target_cli, + environ=environ, + ) + install_succeeded = True + if installed.conversation_path != native_path: + raise SessionMigrateError( + "Antigravity Desktop installer resolved an unexpected target path" + ) + _write_reserved_file( + reservation_guard, + manifest_path, + reservation_identity, + manifest_bytes, + ) + except BaseException as exc: + if reservation_identity is not None: + _unlink_if_identity_matches(manifest_path, reservation_identity) + if install_succeeded: + raise SessionMigrateError( + "Antigravity Desktop import succeeded but migrator manifest finalization failed; " + f"the native session may already exist as {artifact.session_id}" + ) from exc + raise + finally: + if reservation_guard is not None: + os.close(reservation_guard) + return native_path, manifest_path + + def install_cursor_artifact( artifact: ConversionArtifact, *, @@ -1684,6 +1791,9 @@ def _validate_native_bytes(data: bytes, target_format: TargetFormat, session_id: if target_format == TargetFormat.ANTIGRAVITY: antigravity.validate_native_bytes(data, session_id) return + if target_format == TargetFormat.ANTIGRAVITY_DESKTOP: + antigravity_desktop.validate_native_bytes(data, session_id) + return if target_format == TargetFormat.CURSOR: cursor.validate_native_bytes(data, session_id) return @@ -1738,6 +1848,7 @@ def _pinned_target_version(target_format: TargetFormat) -> str: TargetFormat.OPENCODE: opencode.PINNED_OPENCODE_VERSION, TargetFormat.COPILOT: copilot.PINNED_COPILOT_VERSION, TargetFormat.ANTIGRAVITY: antigravity.PINNED_ANTIGRAVITY_VERSION, + TargetFormat.ANTIGRAVITY_DESKTOP: (antigravity_desktop.PINNED_ANTIGRAVITY_DESKTOP_VERSION), TargetFormat.CURSOR: cursor.PINNED_CURSOR_VERSION, TargetFormat.VIBE: vibe.PINNED_VIBE_VERSION, TargetFormat.MUSE: muse.PINNED_MUSE_VERSION, @@ -1771,6 +1882,8 @@ def _native_record_count(data: bytes, target_format: TargetFormat) -> int: return kimi.native_record_count(data) if target_format == TargetFormat.ANTIGRAVITY: return antigravity.native_record_count(data) + if target_format == TargetFormat.ANTIGRAVITY_DESKTOP: + return antigravity_desktop.native_record_count(data) if target_format == TargetFormat.CURSOR: return cursor.native_record_count(data) if target_format == TargetFormat.VIBE: diff --git a/src/session_migrate/discovery.py b/src/session_migrate/discovery.py index 0545fed..0f9c31a 100644 --- a/src/session_migrate/discovery.py +++ b/src/session_migrate/discovery.py @@ -57,7 +57,7 @@ def locate_session( "--source-cwd applies only to Claude/Pi/OMP/Cursor/Vibe/Qwen/Kimi discovery" ) matches = [home / "session-state" / normalized_id / "events.jsonl"] - elif source_format == AgentFormat.ANTIGRAVITY: + elif source_format in {AgentFormat.ANTIGRAVITY, AgentFormat.ANTIGRAVITY_DESKTOP}: if cwd is not None: raise SessionMigrateError( "--source-cwd applies only to Claude/Pi/OMP/Cursor/Vibe/Qwen/Kimi discovery" diff --git a/src/session_migrate/formats/__init__.py b/src/session_migrate/formats/__init__.py index 1600409..dc3e932 100644 --- a/src/session_migrate/formats/__init__.py +++ b/src/session_migrate/formats/__init__.py @@ -2,6 +2,7 @@ from session_migrate.formats import ( antigravity, + antigravity_desktop, claude, codex, copilot, @@ -23,6 +24,7 @@ __all__ = [ "antigravity", + "antigravity_desktop", "claude", "codex", "copilot", diff --git a/src/session_migrate/formats/antigravity.py b/src/session_migrate/formats/antigravity.py index c98e56f..b5269ca 100644 --- a/src/session_migrate/formats/antigravity.py +++ b/src/session_migrate/formats/antigravity.py @@ -44,13 +44,16 @@ ) PINNED_ANTIGRAVITY_LINUX_X86_64_SIZE = 205_545_512 -MAX_NATIVE_BYTES = 256 * 1024 * 1024 +MAX_NATIVE_BYTES = 512 * 1024 * 1024 MAX_STEPS = 100_000 MAX_PROTO_FIELDS = 100_000 MAX_PROTO_VALUE_BYTES = 64 * 1024 * 1024 MAX_JSON_NODES = 100_000 MAX_TEXT_BYTES = 64 * 1024 * 1024 PROJECT_ID = "default-cli-project" +PROJECT_ID_DESKTOP = "outside-of-project" +SOURCE_CLI = 17 +SOURCE_DESKTOP = 1 STEP_STATUS_DONE = 3 STEP_STATUS_ERROR = 7 @@ -649,6 +652,8 @@ def _build_database( conversation_id: str, trajectory_id: str, started_at: str, + source: int = SOURCE_CLI, + project_id: str = PROJECT_ID, ) -> bytes: with tempfile.TemporaryDirectory(prefix="session-migrate-antigravity-build-") as directory: path = Path(directory) / "conversation.db" @@ -684,8 +689,8 @@ def _build_database( """ ) db.execute( - "INSERT INTO trajectory_meta VALUES(?,?,4,17)", - (trajectory_id, conversation_id), + "INSERT INTO trajectory_meta VALUES(?,?,4,?)", + (trajectory_id, conversation_id, source), ) db.executemany( "INSERT INTO steps(" @@ -700,7 +705,7 @@ def _build_database( trajectory_metadata = ( _field_bytes(2, _field_varint(1, seconds)) + _field_text(6, conversation_id) - + _field_text(18, PROJECT_ID) + + _field_text(18, project_id) ) db.execute( "INSERT INTO trajectory_metadata_blob(id,data) VALUES('main',?)", @@ -720,14 +725,26 @@ def _build_database( def _parse_database_bytes( - data: bytes, *, expected_session_id: str | None, generated: bool = False + data: bytes, + *, + expected_session_id: str | None, + generated: bool = False, + expected_source: int = SOURCE_CLI, + expected_project_id: str = PROJECT_ID, + is_desktop: bool = False, + cli_version: str = PINNED_ANTIGRAVITY_VERSION, ) -> ParsedAntigravitySession: if not data or len(data) > MAX_NATIVE_BYTES: raise SessionMigrateError("Antigravity conversation violates the database safety limit") digest = hashlib.sha256(data).hexdigest() with _database_from_bytes(data) as db: conversation_id, trajectory_id, rows, started_at, metadata_losses = _validate_database( - db, expected_session_id=expected_session_id, generated=generated + db, + expected_session_id=expected_session_id, + generated=generated, + expected_source=expected_source, + expected_project_id=expected_project_id, + is_desktop=is_desktop, ) events = list(_project_events(rows)) for reason, count in metadata_losses.items(): @@ -744,7 +761,7 @@ def _parse_database_bytes( trajectory_id=trajectory_id, cwd=None, started_at=started_at, - cli_version=PINNED_ANTIGRAVITY_VERSION, + cli_version=cli_version, model=None, title=None, events=tuple(events), @@ -780,7 +797,13 @@ def _database_from_bytes(data: bytes) -> Iterator[sqlite3.Connection]: def _validate_database( - db: sqlite3.Connection, *, expected_session_id: str | None, generated: bool + db: sqlite3.Connection, + *, + expected_session_id: str | None, + generated: bool, + expected_source: int = SOURCE_CLI, + expected_project_id: str = PROJECT_ID, + is_desktop: bool = False, ) -> tuple[str, str, tuple[_StepRow, ...], str | None, dict[str, int]]: try: page_size = int(db.execute("PRAGMA page_size").fetchone()[0]) @@ -794,10 +817,11 @@ def _validate_database( trajectory_rows = db.execute( "SELECT trajectory_id,cascade_id,trajectory_type,source FROM trajectory_meta" ).fetchall() - main_rows = [row for row in trajectory_rows if row[2:] == (4, 17)] + main_rows = [row for row in trajectory_rows if row[2:] == (4, expected_source)] if len(main_rows) != 1: + desc = "Desktop" if is_desktop else "CLI" raise SessionMigrateError( - "Antigravity database must contain one CLI cascade trajectory" + f"Antigravity database must contain one {desc} cascade trajectory" ) if generated and len(trajectory_rows) != 1: raise SessionMigrateError("generated Antigravity database has auxiliary trajectories") @@ -808,8 +832,9 @@ def _validate_database( _require_uuid4(conversation_id, "Antigravity conversation ID") if expected_session_id is not None and conversation_id != expected_session_id: raise SessionMigrateError("Antigravity database conversation ID does not match target") - if trajectory_type != 4 or source != 17: - raise SessionMigrateError("Antigravity trajectory is not a pinned CLI cascade") + if trajectory_type != 4 or source != expected_source: + desc = "Desktop" if is_desktop else "CLI" + raise SessionMigrateError(f"Antigravity trajectory is not a pinned {desc} cascade") raw_metadata = db.execute( "SELECT id,data FROM trajectory_metadata_blob ORDER BY id" @@ -821,7 +846,11 @@ def _validate_database( ): raise SessionMigrateError("Antigravity trajectory metadata is missing or ambiguous") started_at, trajectory_metadata_loss = _validate_trajectory_metadata( - raw_metadata[0][1], conversation_id, generated=generated + raw_metadata[0][1], + conversation_id, + generated=generated, + expected_project_id=expected_project_id, + is_desktop=is_desktop, ) count = int(db.execute("SELECT count(*) FROM steps").fetchone()[0]) @@ -912,17 +941,29 @@ def _validate_conversation_schema(db: sqlite3.Connection) -> None: def _validate_trajectory_metadata( - data: bytes, conversation_id: str, *, generated: bool + data: bytes, + conversation_id: str, + *, + generated: bool, + expected_project_id: str = PROJECT_ID, + is_desktop: bool = False, ) -> tuple[str | None, bool]: fields = _decode_message(data) root = _optional_text(fields, 6) - if root is not None and root != conversation_id: - raise SessionMigrateError("Antigravity trajectory metadata root ID is inconsistent") + if root is not None: + try: + _require_uuid4(root, "Antigravity trajectory metadata root ID") + except SessionMigrateError: + raise SessionMigrateError( + "Antigravity trajectory metadata root ID is inconsistent" + ) from None + if generated and root != conversation_id: + raise SessionMigrateError("Antigravity trajectory metadata root ID is inconsistent") incomplete = root is None if generated and incomplete: raise SessionMigrateError("generated Antigravity trajectory metadata is incomplete") project = _optional_text(fields, 18) - if project is not None and project != PROJECT_ID: + if project is not None and project != expected_project_id: if generated: raise SessionMigrateError("Antigravity trajectory metadata project is unsupported") incomplete = True @@ -951,6 +992,9 @@ def _validate_step(data: bytes, *, step_type: int, status: int, generated: bool) raise SessionMigrateError("Antigravity step payload status disagrees with its SQLite row") if status not in {1, 2, 3, 4, 5, 6, 7, 8, 9, 11, 12}: raise SessionMigrateError("Antigravity step status is outside pinned 1.1.16") + if status == 5: + # CLEARED status (compacted/cleared step): payload is empty or metadata only + return expected_payload_field = _STEP_PAYLOAD_FIELDS.get(step_type) if generated and step_type not in { STEP_TYPE_USER_INPUT, @@ -968,7 +1012,7 @@ def _validate_step(data: bytes, *, step_type: int, status: int, generated: bool) def _validate_known_step_payload(step_type: int, data: bytes, *, generated: bool) -> None: fields = _decode_message(data) if step_type == STEP_TYPE_USER_INPUT: - if _optional_text(fields, 2) is None and _optional_text(fields, 1) is None: + if generated and _optional_text(fields, 2) is None and _optional_text(fields, 1) is None: raise SessionMigrateError("Antigravity user step contains no readable text") elif step_type == STEP_TYPE_PLANNER_RESPONSE: response = _optional_text(fields, 1) @@ -981,14 +1025,19 @@ def _validate_known_step_payload(step_type: int, data: bytes, *, generated: bool for entry in _bytes_values(fields, 1): entry_fields = _decode_message(entry) _required_text(entry_fields, 1, "generic argument key") - _required_text(entry_fields, 2, "generic argument value") - result = _required_bytes(fields, 2, "generic tool result") - result_fields = _decode_message(result) - _required_text(result_fields, 1, "generic result text", allow_empty=True) - for entry in _bytes_values(result_fields, 2): - metadata_fields = _decode_message(entry) - _required_text(metadata_fields, 1, "generic result metadata key") - _required_text(metadata_fields, 2, "generic result metadata value", allow_empty=True) + _required_text(entry_fields, 2, "generic argument value", allow_empty=True) + result = _optional_bytes(fields, 2) + if result is not None: + result_fields = _decode_message(result) + _required_text(result_fields, 1, "generic result text", allow_empty=True) + for entry in _bytes_values(result_fields, 2): + metadata_fields = _decode_message(entry) + _required_text(metadata_fields, 1, "generic result metadata key") + _required_text( + metadata_fields, 2, "generic result metadata value", allow_empty=True + ) + elif generated: + raise SessionMigrateError("Antigravity generic tool result is missing") elif step_type == STEP_TYPE_MCP_TOOL: call = _optional_bytes(fields, 2) if call is not None: @@ -1002,6 +1051,10 @@ def _project_events(rows: Sequence[_StepRow]) -> tuple[Event, ...]: pending: deque[tuple[str, str]] = deque() emitted_calls: Counter[str] = Counter() for row in rows: + if row.status == 5: + timestamp = _step_timestamp(row.metadata) + events.append(_opaque_event(row, "antigravity_cleared_step", timestamp)) + continue outer = _decode_message(row.payload) payload_number = _STEP_PAYLOAD_FIELDS.get(row.step_type) payload = _optional_bytes(outer, payload_number) if payload_number else None @@ -1020,6 +1073,8 @@ def _project_events(rows: Sequence[_StepRow]) -> tuple[Event, ...]: provenance=provenance, ) ) + else: + events.append(_opaque_event(row, "antigravity_user_action", timestamp)) if _bytes_values(fields, 3) or _bytes_values(fields, 9): events.append(_opaque_event(row, "antigravity_user_context", timestamp)) continue @@ -1073,9 +1128,17 @@ def _project_events(rows: Sequence[_StepRow]) -> tuple[Event, ...]: continue if row.step_type == STEP_TYPE_GENERIC and payload is not None: fields = _decode_message(payload) - result_fields = _decode_message(_required_bytes(fields, 2, "generic result")) - result_text = _required_text(result_fields, 1, "generic result text", allow_empty=True) - linked_call_id = _generic_result_call_id(result_fields) + result_bytes = _optional_bytes(fields, 2) + if result_bytes is not None: + result_fields = _decode_message(result_bytes) + result_text = _required_text( + result_fields, 1, "generic result text", allow_empty=True + ) + linked_call_id = _generic_result_call_id(result_fields) + else: + result_fields = () + result_text = None + linked_call_id = None pending_index = next( ( index @@ -1532,6 +1595,10 @@ def _summary_values( timestamp: str, cwd: Path, last_user_input_index: int, + status: str = "", + source: str = "antigravity-cli", + project_id: str = PROJECT_ID, + app_data_dir: str = "antigravity-cli", ) -> tuple[Any, ...]: bounded_title = title[:4_096] bounded_preview = preview[:16_384] @@ -1543,9 +1610,9 @@ def _summary_values( step_count, _parse_timestamp(timestamp).isoformat(sep=" "), json.dumps([workspace_uri], ensure_ascii=False, separators=(",", ":")), - "", - "antigravity-cli", - PROJECT_ID, + status, + source, + project_id, "", "", 0, @@ -1555,7 +1622,7 @@ def _summary_values( 0, _parse_timestamp(timestamp).isoformat(sep=" "), last_user_input_index, - "antigravity-cli", + app_data_dir, ) @@ -1685,7 +1752,7 @@ def _validate_summary_schema(db: sqlite3.Connection) -> None: if object_row != ("table",): raise SessionMigrateError("Antigravity summary table is missing") columns = tuple(row[1] for row in db.execute("PRAGMA table_info('conversation_summaries')")) - if columns != _SUMMARY_COLUMNS: + if columns not in (_SUMMARY_COLUMNS, _SUMMARY_COLUMNS + ("raw_summary", "group_id")): raise SessionMigrateError("Antigravity summary columns do not match pinned 1.1.16") for index_name, expected_column in ( ("idx_conversation_summaries_last_user_input_time", "last_user_input_time"), diff --git a/src/session_migrate/formats/antigravity_desktop.py b/src/session_migrate/formats/antigravity_desktop.py new file mode 100644 index 0000000..4266a00 --- /dev/null +++ b/src/session_migrate/formats/antigravity_desktop.py @@ -0,0 +1,611 @@ +"""Antigravity Desktop 2.18.1 SQLite/protobuf session adapter. + +This module adapts conversations to and from the Antigravity Desktop application +(macOS/Electron), which stores individual cascade conversations as SQLite databases +under ``~/.gemini/antigravity/conversations/.db`` and registers metadata in +``~/.gemini/antigravity/conversation_summaries.db``. + +While the table schemas match the Antigravity CLI format, Desktop differs in: + - ``trajectory_meta.source``: 1 (IDE / Hub) rather than 17 (CLI). + - ``trajectory_metadata_blob.project_id``: ``"outside-of-project"`` (or project UUID) + rather than ``"default-cli-project"``. + - ``conversation_summaries``: + - ``source``: ``""`` + - ``app_data_dir``: ``"antigravity"`` + - ``project_id``: ``"outside-of-project"`` + - ``status``: ``"CASCADE_RUN_STATUS_IDLE"`` + - Subagents: field 6 of ``trajectory_metadata_blob`` points to the root cascade ID, + so subagent conversations have ``root != conversation_id`` and ``nesting_depth >= 1``. +""" + +from __future__ import annotations + +import os +import shutil +import sqlite3 +import stat +import sys +from collections import Counter +from collections.abc import Mapping +from dataclasses import replace +from pathlib import Path +from typing import Any +from urllib.parse import quote + +from session_migrate.errors import SessionMigrateError +from session_migrate.formats import antigravity +from session_migrate.formats.antigravity import ( + _SUMMARY_COLUMNS, + STEP_STATUS_DONE, + STEP_STATUS_ERROR, + STEP_TYPE_GENERIC, + STEP_TYPE_PLANNER_RESPONSE, + STEP_TYPE_USER_INPUT, + InstalledAntigravitySession, + ParsedAntigravitySession, + _absolute_no_follow, + _database_from_bytes, + _ensure_private_directory, + _ensure_summary_database, + _event_timestamp, + _field_bytes, + _field_text, + _field_varint, + _generic_argument_entries, + _guard_matches_path, + _last_user_input_index, + _omission_key, + _open_identity_guard, + _read_summary_metadata, + _require_uuid4, + _session_id_from_path, + _StepRow, + _stream_sha256, + _summary_values, + _unlink_if_same_file, + _utc_now, + _validate_summary_schema, + content_text, + valid_rfc3339, +) +from session_migrate.jsonl import write_private_atomic +from session_migrate.model import AgentFormat, EventKind, Role, Session + +PINNED_ANTIGRAVITY_DESKTOP_VERSION = "2.18.1" + +# macOS arm64 +PINNED_ANTIGRAVITY_DESKTOP_MACOS_ARM64_SHA256 = ( + "300ee20f3108a511be1149602b91ba84cbbdcc42213067151e255584129be30f" +) +PINNED_ANTIGRAVITY_DESKTOP_MACOS_ARM64_SIZE = 148_990_608 + +# Linux x64 +PINNED_ANTIGRAVITY_DESKTOP_LINUX_X64_SHA256 = ( + "ab4937445fa3817bc374a1db71062de7daecdb093cbfb6bd36b80f443198670e" +) +PINNED_ANTIGRAVITY_DESKTOP_LINUX_X64_SIZE = 181_432_528 + +# Windows x64 +PINNED_ANTIGRAVITY_DESKTOP_WINDOWS_X64_SHA256 = ( + "1ed84e6a1d1e51064d80c9f382ab3a519eb2775cb91d552c63064a18cfdf3cf2" +) +PINNED_ANTIGRAVITY_DESKTOP_WINDOWS_X64_SIZE = 163_640_320 + +PINNED_DESKTOP_SPECS: dict[str, tuple[int, str]] = { + "darwin": ( + PINNED_ANTIGRAVITY_DESKTOP_MACOS_ARM64_SIZE, + PINNED_ANTIGRAVITY_DESKTOP_MACOS_ARM64_SHA256, + ), + "linux": ( + PINNED_ANTIGRAVITY_DESKTOP_LINUX_X64_SIZE, + PINNED_ANTIGRAVITY_DESKTOP_LINUX_X64_SHA256, + ), + "win32": ( + PINNED_ANTIGRAVITY_DESKTOP_WINDOWS_X64_SIZE, + PINNED_ANTIGRAVITY_DESKTOP_WINDOWS_X64_SHA256, + ), +} + +DEFAULT_MACOS_LANGUAGE_SERVER_PATH = Path( + "/Applications/Antigravity.app/Contents/Resources/bin/language_server" +) +DEFAULT_LINUX_LANGUAGE_SERVER_PATH = Path("/opt/Antigravity/resources/bin/language_server") +DEFAULT_WINDOWS_LANGUAGE_SERVER_PATH = Path( + "Programs/antigravity/resources/bin/language_server.exe" +) + + +def default_language_server_path( + platform: str | None = None, + environ: Mapping[str, str] | None = None, + home: Path | None = None, +) -> Path: + """Resolve the default language server binary path for the target platform. + + Can be overridden via the ``SESSION_MIGRATE_ANTIGRAVITY_DESKTOP_BIN`` + environment variable. + """ + env = os.environ if environ is None else environ + override = env.get("SESSION_MIGRATE_ANTIGRAVITY_DESKTOP_BIN") + if override: + return Path(override).expanduser() + + current_platform = sys.platform if platform is None else platform + if current_platform == "darwin": + return DEFAULT_MACOS_LANGUAGE_SERVER_PATH + if current_platform == "win32": + local_app_data = env.get("LOCALAPPDATA") + if local_app_data: + for sub in ( + "Programs/antigravity", + "Programs/Antigravity", + "programs/antigravity", + "programs/Antigravity", + ): + candidate = Path(local_app_data) / sub / "resources" / "bin" / "language_server.exe" + if candidate.is_file(): + return candidate + prog_files = env.get("PROGRAMFILES") + if prog_files: + for sub in ("antigravity", "Antigravity"): + candidate = Path(prog_files) / sub / "resources" / "bin" / "language_server.exe" + if candidate.is_file(): + return candidate + if local_app_data: + return Path(local_app_data) / DEFAULT_WINDOWS_LANGUAGE_SERVER_PATH + return Path("C:\\Program Files\\antigravity\\resources\\bin\\language_server.exe") + + # Linux / other Unix: check PATH launcher, then common installation directories + launcher = shutil.which("antigravity", path=env.get("PATH")) + if launcher: + cand = Path(launcher).resolve().parent / "resources" / "bin" / "language_server" + if cand.is_file(): + return cand + user_home = (home or Path.home()).expanduser() + for cand in ( + Path("/opt/Antigravity/resources/bin/language_server"), + Path("/opt/Antigravity-x64/resources/bin/language_server"), + Path("/opt/antigravity/resources/bin/language_server"), + user_home / "Antigravity-x64/resources/bin/language_server", + user_home / ".local/share/Antigravity/resources/bin/language_server", + user_home / ".local/share/antigravity/resources/bin/language_server", + Path("/usr/lib/antigravity/resources/bin/language_server"), + ): + if cand.is_file(): + return cand + return DEFAULT_LINUX_LANGUAGE_SERVER_PATH + + +def electron_user_data_dir( + platform: str | None = None, + environ: Mapping[str, str] | None = None, + home: Path | None = None, +) -> Path: + """Return the Electron userData directory where app_storage.json resides.""" + env = os.environ if environ is None else environ + current_platform = sys.platform if platform is None else platform + user_home = (home or Path.home()).expanduser() + + if current_platform == "darwin": + return user_home / "Library" / "Application Support" / "Antigravity" + if current_platform == "win32": + app_data = env.get("APPDATA") + if app_data: + return Path(app_data) / "Antigravity" + return user_home / "AppData" / "Roaming" / "Antigravity" + + # Linux / XDG standard + xdg_config = env.get("XDG_CONFIG_HOME") + if xdg_config: + return Path(xdg_config) / "Antigravity" + return user_home / ".config" / "Antigravity" + + +MAX_NATIVE_BYTES = antigravity.MAX_NATIVE_BYTES +PROJECT_ID = antigravity.PROJECT_ID_DESKTOP # "outside-of-project" +SOURCE_DESKTOP = antigravity.SOURCE_DESKTOP # 1 + +session_relative_path = antigravity.session_relative_path +snapshot_database_bytes = antigravity.snapshot_database_bytes + + +def app_data_home(home: Path | None = None) -> Path: + """Return the Antigravity Desktop store below a chosen process HOME.""" + return (home or Path.home()).expanduser() / ".gemini" / "antigravity" + + +def serialize( + session: Session, + *, + session_id: str, + cwd: Path, + cli_version: str = PINNED_ANTIGRAVITY_DESKTOP_VERSION, + model: str | None = None, + timestamp: str | None = None, + trajectory_id: str | None = None, +) -> tuple[bytes, dict[str, int]]: + """Serialize portable conversation history as an Antigravity Desktop SQLite DB.""" + del cli_version, model + _require_uuid4(session_id, "Antigravity conversation ID") + import uuid + + target_trajectory_id = trajectory_id or str(uuid.uuid4()) + _require_uuid4(target_trajectory_id, "Antigravity trajectory ID") + if target_trajectory_id == session_id: + raise SessionMigrateError("Antigravity conversation and trajectory IDs must differ") + + started_at = valid_rfc3339(timestamp) or valid_rfc3339(session.started_at) or _utc_now() + dropped: Counter[str] = Counter() + rows: list[_StepRow] = [] + pending_calls: list[tuple[str | None, str, str, Any]] = [] + generated_call_number = 0 + + def append_step(step_type: int, status: int, native_payload: bytes) -> None: + outer = _field_varint(1, step_type) + _field_varint(4, status) + outer += _field_bytes(antigravity._STEP_PAYLOAD_FIELDS[step_type], native_payload) + rows.append(_StepRow(len(rows), step_type, status, None, outer)) + + for event in session.events: + _event_timestamp(event, started_at, dropped) + if event.kind == EventKind.MESSAGE and event.role == Role.USER: + if event.text: + append_step(STEP_TYPE_USER_INPUT, STEP_STATUS_DONE, _field_text(2, event.text)) + continue + if event.kind == EventKind.MESSAGE and event.role == Role.ASSISTANT: + if event.text: + planner = _field_text(1, event.text) + _field_text(6, str(uuid.uuid4())) + append_step(STEP_TYPE_PLANNER_RESPONSE, STEP_STATUS_DONE, planner) + continue + if event.kind == EventKind.TOOL_CALL and event.role == Role.ASSISTANT: + call_id = event.tool_call_id or f"session-migrate-{session_id}-{len(rows)}" + name = event.tool_name or "unknown_tool" + raw_input = event.payload.get("input", {}) + import json + + arguments_json = json.dumps( + raw_input if isinstance(raw_input, dict) else {}, + ensure_ascii=False, + separators=(",", ":"), + ) + native_call = ( + _field_text(1, call_id) + _field_text(2, name) + _field_text(3, arguments_json) + ) + planner = _field_text(6, str(uuid.uuid4())) + _field_bytes(7, native_call) + pending_calls.append((event.tool_call_id, call_id, name, raw_input)) + append_step(STEP_TYPE_PLANNER_RESPONSE, STEP_STATUS_DONE, planner) + continue + if event.kind == EventKind.TOOL_RESULT and event.role == Role.TOOL: + match_index: int | None = None + if event.tool_call_id: + matching_indices = [ + index + for index, pending in enumerate(pending_calls) + if pending[0] == event.tool_call_id + ] + if event.tool_name: + named_indices = [ + index + for index in matching_indices + if pending_calls[index][2] == event.tool_name + ] + if named_indices: + matching_indices = named_indices + match_index = next(iter(matching_indices), None) + elif pending_calls: + match_index = 0 + dropped["tool_result:missing_id"] += 1 + if match_index is not None: + _, call_id, tool_name, tool_input = pending_calls.pop(match_index) + else: + generated_call_number += 1 + call_id = event.tool_call_id or ( + f"session-migrate-{session_id}-{generated_call_number}" + ) + tool_name = event.tool_name or "unknown_tool" + tool_input = {} + import json + + native_call = ( + _field_text(1, call_id) + _field_text(2, tool_name) + _field_text(3, "{}") + ) + append_step( + STEP_TYPE_PLANNER_RESPONSE, + STEP_STATUS_DONE, + _field_text(6, str(uuid.uuid4())) + _field_bytes(7, native_call), + ) + dropped["tool_result:orphan_id"] += 1 + generic = b"".join(_generic_argument_entries(tool_input, dropped)) + result_text = event.text or content_text(event.payload.get("content_blocks")) + linkage_entry = _field_text(1, "session_migrate_call_id") + _field_text(2, call_id) + generic_result = _field_text(1, result_text or "") + _field_bytes(2, linkage_entry) + generic += _field_bytes(2, generic_result) + append_step( + STEP_TYPE_GENERIC, + STEP_STATUS_ERROR if event.payload.get("is_error") is True else STEP_STATUS_DONE, + generic, + ) + blocks = event.payload.get("content_blocks") + if isinstance(blocks, list): + for block in blocks: + if not isinstance(block, dict) or block.get("type") != "text": + dropped["tool_result:non_text_block"] += 1 + continue + if event.kind == EventKind.THINKING: + dropped["thinking:private"] += 1 + elif event.kind == EventKind.COMPACTION: + dropped["compaction:no_stored_native_equivalent"] += 1 + elif event.kind == EventKind.MESSAGE: + dropped["message:privileged_role"] += 1 + else: + dropped[_omission_key(event)] += 1 + + if not any(row.step_type == STEP_TYPE_USER_INPUT for row in rows): + raise SessionMigrateError( + "Antigravity Desktop target requires at least one portable user message" + ) + + data = antigravity._build_database( + rows, + conversation_id=session_id, + trajectory_id=target_trajectory_id, + started_at=started_at, + source=SOURCE_DESKTOP, + project_id=PROJECT_ID, + ) + validate_native_bytes(data, session_id) + return data, dict(sorted(dropped.items())) + + +def parse(path: Path) -> ParsedAntigravitySession: + """Read one Desktop conversation DB through a consistent SQLite backup snapshot.""" + snapshot = snapshot_database_bytes(path) + parsed = antigravity._parse_database_bytes( + snapshot, + expected_session_id=_session_id_from_path(path), + expected_source=SOURCE_DESKTOP, + expected_project_id=PROJECT_ID, + is_desktop=True, + cli_version=PINNED_ANTIGRAVITY_DESKTOP_VERSION, + ) + title, cwd = _read_summary_metadata(path, parsed.session_id) + return replace(parsed, title=title, cwd=cwd) + + +def parse_session(path: Path) -> Session: + """Parse Antigravity Desktop as a first-class source.""" + parsed = parse(path) + return Session( + source_format=AgentFormat.ANTIGRAVITY_DESKTOP, + source_path=path.resolve(), + source_sha256=parsed.snapshot_sha256, + session_id=parsed.session_id, + cwd=parsed.cwd, + started_at=parsed.started_at, + cli_version=parsed.cli_version, + model=parsed.model, + title=parsed.title, + events=parsed.events, + raw_record_count=parsed.raw_record_count, + model_provider="google", + ) + + +def validate_native_bytes(data: bytes, session_id: str) -> None: + """Strictly validate a generated Antigravity Desktop conversation database.""" + _require_uuid4(session_id, "Antigravity conversation ID") + parsed = antigravity._parse_database_bytes( + data, + expected_session_id=session_id, + generated=True, + expected_source=SOURCE_DESKTOP, + expected_project_id=PROJECT_ID, + is_desktop=True, + cli_version=PINNED_ANTIGRAVITY_DESKTOP_VERSION, + ) + if not any( + event.kind == EventKind.MESSAGE and event.role == Role.USER for event in parsed.events + ): + raise SessionMigrateError("generated Antigravity Desktop session contains no user message") + + +def native_record_count(data: bytes) -> int: + """Return the number of stored steps after full byte validation.""" + with _database_from_bytes(data) as db: + antigravity._validate_database( + db, + expected_session_id=None, + generated=True, + expected_source=SOURCE_DESKTOP, + expected_project_id=PROJECT_ID, + is_desktop=True, + ) + return int(db.execute("SELECT count(*) FROM steps").fetchone()[0]) + + +def verify_pinned_desktop( + executable: Path | None = None, + *, + platform: str | None = None, + environ: Mapping[str, str] | None = None, +) -> Path: + """Resolve and verify the Antigravity Desktop language server binary.""" + values = dict(os.environ if environ is None else environ) + current_platform = sys.platform if platform is None else platform + candidate: str | None = None + if executable: + candidate = str(executable) + elif values.get("SESSION_MIGRATE_ANTIGRAVITY_DESKTOP_BIN"): + candidate = values["SESSION_MIGRATE_ANTIGRAVITY_DESKTOP_BIN"] + elif values.get("ANTIGRAVITY_DESKTOP_BIN"): + candidate = values["ANTIGRAVITY_DESKTOP_BIN"] + elif values.get("ANTIGRAVITY_LANGUAGE_SERVER_BIN"): + candidate = values["ANTIGRAVITY_LANGUAGE_SERVER_BIN"] + elif default_language_server_path(platform=current_platform, environ=values).is_file(): + candidate = str(default_language_server_path(platform=current_platform, environ=values)) + else: + candidate = shutil.which("language_server", path=values.get("PATH")) + + if not candidate: + raise SessionMigrateError("Antigravity Desktop executable 'language_server' was not found") + path = Path(candidate).expanduser().resolve() + try: + info = path.stat() + except OSError as exc: + raise SessionMigrateError("cannot inspect the Antigravity Desktop executable") from exc + if not stat.S_ISREG(info.st_mode): + raise SessionMigrateError("Antigravity Desktop executable is not a regular file") + + if values.get("SESSION_MIGRATE_UNVALIDATED_DESKTOP_BIN") == "1": + return path + + if current_platform not in PINNED_DESKTOP_SPECS: + raise SessionMigrateError( + f"Antigravity Desktop native binary verification is not supported on " + f"{current_platform}; supported platforms are darwin, linux, win32. " + "Set SESSION_MIGRATE_UNVALIDATED_DESKTOP_BIN=1 to proceed with an unvalidated binary." + ) + + expected_size, expected_digest = PINNED_DESKTOP_SPECS[current_platform] + if info.st_size != expected_size: + raise SessionMigrateError( + f"Antigravity Desktop binary size mismatch on {current_platform}: expected " + f"{expected_size} bytes, observed {info.st_size}" + ) + digest = _stream_sha256(path, maximum=expected_size) + if digest != expected_digest: + raise SessionMigrateError( + f"Antigravity Desktop binary digest mismatch on {current_platform}; " + "refusing private-store installation" + ) + return path + + +def install_database( + data: bytes, + *, + session_id: str, + cwd: Path, + timestamp: str, + title: str | None, + target_home: Path, + target_cli: Path | None = None, + dry_run: bool = False, + environ: Mapping[str, str] | None = None, + verify_binary: bool = True, +) -> InstalledAntigravitySession: + """Install a validated conversation and its desktop summary without overwrite.""" + validate_native_bytes(data, session_id) + if verify_binary: + verify_pinned_desktop(target_cli, environ=environ) + root = _absolute_no_follow(target_home) + conversation_path = root / session_relative_path(session_id) + summaries_path = root / "conversation_summaries.db" + cwd = cwd.expanduser().resolve() + summary_timestamp = valid_rfc3339(timestamp) or _utc_now() + parsed = antigravity._parse_database_bytes( + data, + expected_session_id=session_id, + generated=True, + expected_source=SOURCE_DESKTOP, + expected_project_id=PROJECT_ID, + is_desktop=True, + cli_version=PINNED_ANTIGRAVITY_DESKTOP_VERSION, + ) + preview = next( + ( + event.text + for event in parsed.events + if event.kind == EventKind.MESSAGE and event.role == Role.USER and event.text + ), + "Imported conversation", + ) + summary = _summary_values( + session_id=session_id, + title=title or "Imported conversation", + preview=preview, + step_count=parsed.raw_record_count, + timestamp=summary_timestamp, + cwd=cwd, + last_user_input_index=_last_user_input_index(data), + status="CASCADE_RUN_STATUS_IDLE", + source="", + project_id=PROJECT_ID, + app_data_dir="antigravity", + ) + + if dry_run: + if os.path.lexists(conversation_path): + raise SessionMigrateError( + "Antigravity Desktop conversation ID already exists; refusing to overwrite it" + ) + if os.path.lexists(summaries_path): + if stat.S_ISLNK(summaries_path.lstat().st_mode): + raise SessionMigrateError( + "Antigravity Desktop summary database must not be a symlink" + ) + try: + uri = f"file:{quote(str(summaries_path), safe='/')}?mode=ro" + with sqlite3.connect(uri, uri=True, timeout=5) as summaries: + summaries.execute("PRAGMA trusted_schema=OFF") + _validate_summary_schema(summaries) + collision = summaries.execute( + "SELECT 1 FROM conversation_summaries WHERE conversation_id=?", + (session_id,), + ).fetchone() + except sqlite3.Error as exc: + raise SessionMigrateError( + "Antigravity Desktop summary database cannot be checked safely" + ) from exc + if collision: + raise SessionMigrateError( + "Antigravity Desktop conversation ID already exists; refusing to overwrite it" + ) + return InstalledAntigravitySession(conversation_path, summaries_path) + + _ensure_private_directory(root) + _ensure_private_directory(conversation_path.parent) + _ensure_summary_database(summaries_path) + created_identity: tuple[int, int] | None = None + conversation_guard: int | None = None + summary_guard: int | None = None + try: + summary_guard = _open_identity_guard(summaries_path, writable=True) + with sqlite3.connect(summaries_path, timeout=15, isolation_level=None) as summaries: + summaries.execute("PRAGMA trusted_schema=OFF") + _validate_summary_schema(summaries) + summaries.execute("BEGIN IMMEDIATE") + collision = summaries.execute( + "SELECT 1 FROM conversation_summaries WHERE conversation_id=?", (session_id,) + ).fetchone() + if collision or os.path.lexists(conversation_path): + summaries.execute("ROLLBACK") + raise SessionMigrateError( + "Antigravity Desktop conversation ID already exists; refusing to overwrite it" + ) + created_identity = write_private_atomic(conversation_path, data) + conversation_guard = _open_identity_guard( + conversation_path, expected_identity=created_identity + ) + summaries.execute( + f"INSERT INTO conversation_summaries({','.join(_SUMMARY_COLUMNS)}) " + f"VALUES({','.join('?' for _ in _SUMMARY_COLUMNS)})", + summary, + ) + if not _guard_matches_path(conversation_guard, conversation_path) or not ( + _guard_matches_path(summary_guard, summaries_path) + ): + summaries.execute("ROLLBACK") + raise SessionMigrateError( + "Antigravity Desktop install paths changed during transaction" + ) + summaries.execute("COMMIT") + except BaseException: + if created_identity is not None: + _unlink_if_same_file(conversation_path, created_identity) + raise + finally: + if conversation_guard is not None: + os.close(conversation_guard) + if summary_guard is not None: + os.close(summary_guard) + return InstalledAntigravitySession(conversation_path, summaries_path) diff --git a/src/session_migrate/inspection.py b/src/session_migrate/inspection.py index a02edd3..2caa44a 100644 --- a/src/session_migrate/inspection.py +++ b/src/session_migrate/inspection.py @@ -12,6 +12,7 @@ from session_migrate.errors import FormatDetectionError, JsonlError, SessionMigrateError from session_migrate.formats import ( antigravity, + antigravity_desktop, cursor, devin, grok, @@ -124,6 +125,9 @@ def inspect_session(path: Path, *, source_format: AgentFormat | None = None) -> if source_format == AgentFormat.ANTIGRAVITY: parsed = antigravity.parse_session(path) return _inspect_portable_database(parsed) + if source_format == AgentFormat.ANTIGRAVITY_DESKTOP: + parsed = antigravity_desktop.parse_session(path) + return _inspect_portable_database(parsed) if source_format == AgentFormat.CURSOR: parsed = cursor.project_session(cursor.parse(path), source_format=AgentFormat.CURSOR) return _inspect_portable_database(parsed) @@ -154,6 +158,8 @@ def inspect_session(path: Path, *, source_format: AgentFormat | None = None) -> detected = _detect_sqlite_path(path) if detected == AgentFormat.ANTIGRAVITY: parsed = antigravity.parse_session(path) + elif detected == AgentFormat.ANTIGRAVITY_DESKTOP: + parsed = antigravity_desktop.parse_session(path) elif detected == AgentFormat.CURSOR: parsed = cursor.project_session(cursor.parse(path), source_format=AgentFormat.CURSOR) elif detected == AgentFormat.HERMES: @@ -525,6 +531,7 @@ def detect_path_format(path: Path) -> AgentFormat: def _detect_sqlite_path(path: Path) -> AgentFormat: probes = ( (AgentFormat.ANTIGRAVITY, lambda: antigravity.parse(path)), + (AgentFormat.ANTIGRAVITY_DESKTOP, lambda: antigravity_desktop.parse(path)), (AgentFormat.CURSOR, lambda: cursor.parse(path)), (AgentFormat.HERMES, lambda: hermes.list_sessions(path)), (AgentFormat.MASTRACODE, lambda: mastracode.list_sessions(path)), diff --git a/src/session_migrate/model.py b/src/session_migrate/model.py index 14eead0..d9969c9 100644 --- a/src/session_migrate/model.py +++ b/src/session_migrate/model.py @@ -17,6 +17,7 @@ class AgentFormat(StrEnum): OPENCODE = "opencode" COPILOT = "copilot" ANTIGRAVITY = "antigravity" + ANTIGRAVITY_DESKTOP = "antigravity-desktop" CURSOR = "cursor" VIBE = "vibe" MUSE = "muse" @@ -40,6 +41,7 @@ class TargetFormat(StrEnum): OPENCODE = "opencode" COPILOT = "copilot" ANTIGRAVITY = "antigravity" + ANTIGRAVITY_DESKTOP = "antigravity-desktop" CURSOR = "cursor" VIBE = "vibe" MUSE = "muse" diff --git a/tests/fixtures/antigravity-desktop-2.18.1/basic.json b/tests/fixtures/antigravity-desktop-2.18.1/basic.json new file mode 100644 index 0000000..127022d --- /dev/null +++ b/tests/fixtures/antigravity-desktop-2.18.1/basic.json @@ -0,0 +1,32 @@ +{ + "producer": "Antigravity Desktop 2.18.1", + "conversation_id": "33333333-3333-4333-8333-333333333333", + "trajectory_id": "44444444-4444-4444-8444-444444444444", + "created_at": "2026-08-20T12:00:00Z", + "steps": [ + { + "idx": 0, + "step_type": 14, + "status": 3, + "step_payload_hex": "080e20039a0120121e4147595f4445534b544f505f464958545552455f555345525f414c504841" + }, + { + "idx": 1, + "step_type": 15, + "status": 3, + "step_payload_hex": "080f2003a2014b0a234147595f4445534b544f505f464958545552455f415353495354414e545f4f4d454741322466616435346364622d643630382d343161622d613265612d336461383038643364623235" + }, + { + "idx": 2, + "step_type": 15, + "status": 3, + "step_payload_hex": "080f2003a2016e322465383063306436642d303039612d343162302d613331312d3563616538386336356361303a460a126167792d6465736b746f702d63616c6c2d31120b6563686f5f6d61726b65721a237b2274657874223a224445534b544f505f544f4f4c5f494e5055545f414c504841227d" + }, + { + "idx": 3, + "step_type": 132, + "status": 3, + "step_payload_hex": "0884012003e208700a220a0474657874121a224445534b544f505f544f4f4c5f494e5055545f414c50484122124a0a194445534b544f505f544f4f4c5f524553554c545f4f4d454741122d0a1773657373696f6e5f6d6967726174655f63616c6c5f696412126167792d6465736b746f702d63616c6c2d31" + } + ] +} diff --git a/tests/test_antigravity_desktop_format.py b/tests/test_antigravity_desktop_format.py new file mode 100644 index 0000000..71a1d02 --- /dev/null +++ b/tests/test_antigravity_desktop_format.py @@ -0,0 +1,535 @@ +import json +import sqlite3 +from pathlib import Path + +import pytest + +from session_migrate.errors import SessionMigrateError +from session_migrate.formats import antigravity, antigravity_desktop +from session_migrate.model import AgentFormat, Event, EventKind, Provenance, Role, Session + +TARGET_ID = "33333333-3333-4333-8333-333333333333" +TRAJECTORY_ID = "44444444-4444-4444-8444-444444444444" +FIXTURE = Path(__file__).parent / "fixtures" / "antigravity-desktop-2.18.1" / "basic.json" + + +def fixture_database(tmp_path: Path) -> Path: + value = json.loads(FIXTURE.read_text()) + rows = tuple( + antigravity._StepRow( + row["idx"], + row["step_type"], + row["status"], + None, + bytes.fromhex(row["step_payload_hex"]), + ) + for row in value["steps"] + ) + data = antigravity._build_database( + rows, + conversation_id=value["conversation_id"], + trajectory_id=value["trajectory_id"], + started_at=value["created_at"], + source=antigravity_desktop.SOURCE_DESKTOP, + project_id=antigravity_desktop.PROJECT_ID, + ) + path = tmp_path / f"{value['conversation_id']}.db" + path.write_bytes(data) + return path + + +def test_sanitized_native_source_fixture_loads_and_parses(tmp_path: Path) -> None: + path = fixture_database(tmp_path) + parsed = antigravity_desktop.parse(path) + assert parsed.session_id == "33333333-3333-4333-8333-333333333333" + assert parsed.trajectory_id == "44444444-4444-4444-8444-444444444444" + assert len(parsed.events) == 4 + session = antigravity_desktop.parse_session(path) + assert session.source_format == AgentFormat.ANTIGRAVITY_DESKTOP + assert session.session_id == "33333333-3333-4333-8333-333333333333" + + +def portable_session(tmp_path: Path) -> Session: + events = ( + Event( + kind=EventKind.MESSAGE, + role=Role.USER, + text="AGY_DESKTOP_USER_ALPHA", + timestamp="2026-08-20T12:00:00Z", + provenance=Provenance(0, "user"), + ), + Event( + kind=EventKind.MESSAGE, + role=Role.ASSISTANT, + text="AGY_DESKTOP_ASSISTANT_OMEGA", + timestamp="2026-08-20T12:00:01Z", + provenance=Provenance(1, "assistant"), + ), + Event( + kind=EventKind.TOOL_CALL, + role=Role.ASSISTANT, + tool_name="echo_marker", + tool_call_id="agy-call-1", + timestamp="2026-08-20T12:00:02Z", + payload={"input": {"text": "DESKTOP_INPUT_ALPHA", "count": 2}}, + provenance=Provenance(2, "tool_call"), + ), + Event( + kind=EventKind.TOOL_RESULT, + role=Role.TOOL, + text="DESKTOP_RESULT_OMEGA", + tool_name="echo_marker", + tool_call_id="agy-call-1", + timestamp="2026-08-20T12:00:03Z", + payload={ + "is_error": False, + "content_blocks": [{"type": "text", "text": "DESKTOP_RESULT_OMEGA"}], + }, + provenance=Provenance(3, "tool_result"), + ), + Event( + kind=EventKind.THINKING, + role=Role.ASSISTANT, + text="PRIVATE_THINKING_MUST_NOT_SURVIVE", + provenance=Provenance(4, "thinking"), + ), + ) + return Session( + source_format=AgentFormat.CLAUDE, + source_path=tmp_path / "source.jsonl", + source_sha256="0" * 64, + session_id="11111111-1111-4111-8111-111111111111", + cwd=tmp_path, + started_at="2026-08-20T12:00:00Z", + cli_version="2.1.209", + model="fixture-model", + title="Antigravity Desktop fixture", + events=events, + raw_record_count=len(events), + ) + + +def event_signature(events: tuple[Event, ...]) -> list[tuple[object, ...]]: + result = [] + for event in events: + if event.kind == EventKind.THINKING: + continue + payload = event.payload.get("input") if event.kind == EventKind.TOOL_CALL else None + result.append( + ( + event.kind, + event.role, + event.text, + event.tool_name, + event.tool_call_id, + json.dumps(payload, sort_keys=True), + ) + ) + return result + + +def test_desktop_round_trip_preserves_messages_and_tools(tmp_path: Path) -> None: + source = portable_session(tmp_path) + data, dropped = antigravity_desktop.serialize( + source, + session_id=TARGET_ID, + trajectory_id=TRAJECTORY_ID, + cwd=tmp_path, + timestamp="2026-08-20T12:00:00Z", + ) + path = tmp_path / f"{TARGET_ID}.db" + path.write_bytes(data) + + antigravity_desktop.validate_native_bytes(data, TARGET_ID) + parsed = antigravity_desktop.parse(path) + + assert dropped == {"thinking:private": 1} + assert b"PRIVATE_THINKING_MUST_NOT_SURVIVE" not in data + assert antigravity_desktop.native_record_count(data) == 4 + assert event_signature(parsed.events) == event_signature(source.events) + + # Verify Desktop wire differences + with sqlite3.connect(path) as conn: + meta = conn.execute( + "SELECT trajectory_id, cascade_id, trajectory_type, source FROM trajectory_meta" + ).fetchone() + assert meta == (TRAJECTORY_ID, TARGET_ID, 4, 1) # Desktop source is 1, not 17! + + +def test_desktop_parse_session_attributes(tmp_path: Path) -> None: + source = portable_session(tmp_path) + data, _ = antigravity_desktop.serialize( + source, + session_id=TARGET_ID, + trajectory_id=TRAJECTORY_ID, + cwd=tmp_path, + ) + path = tmp_path / f"{TARGET_ID}.db" + path.write_bytes(data) + + session = antigravity_desktop.parse_session(path) + assert session.source_format == AgentFormat.ANTIGRAVITY_DESKTOP + assert session.session_id == TARGET_ID + assert session.model_provider == "google" + assert session.cli_version == antigravity_desktop.PINNED_ANTIGRAVITY_DESKTOP_VERSION + assert len(session.events) == 4 + + +def test_desktop_installation_updates_summary_database( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr( + antigravity_desktop, + "verify_pinned_desktop", + lambda *args, **kwargs: Path("language_server"), + ) + source = portable_session(tmp_path) + data, _ = antigravity_desktop.serialize( + source, + session_id=TARGET_ID, + trajectory_id=TRAJECTORY_ID, + cwd=tmp_path, + timestamp="2026-08-20T12:00:00Z", + ) + target_home = tmp_path / "antigravity" + + # Dry run check + installed_dry = antigravity_desktop.install_database( + data, + session_id=TARGET_ID, + cwd=tmp_path, + timestamp="2026-08-20T12:00:00Z", + title="Test Desktop Session", + target_home=target_home, + dry_run=True, + ) + assert not installed_dry.conversation_path.exists() + assert not installed_dry.summaries_path.exists() + + # Actual installation + installed = antigravity_desktop.install_database( + data, + session_id=TARGET_ID, + cwd=tmp_path, + timestamp="2026-08-20T12:00:00Z", + title="Test Desktop Session", + target_home=target_home, + dry_run=False, + ) + assert installed.conversation_path.is_file() + assert installed.summaries_path.is_file() + + # Verify summary columns + with sqlite3.connect(installed.summaries_path) as conn: + row = conn.execute( + "SELECT conversation_id, title, status, source, project_id, app_data_dir " + "FROM conversation_summaries WHERE conversation_id = ?", + (TARGET_ID,), + ).fetchone() + assert row == ( + TARGET_ID, + "Test Desktop Session", + "CASCADE_RUN_STATUS_IDLE", + "", + "outside-of-project", + "antigravity", + ) + + # Re-installation collision check + with pytest.raises(SessionMigrateError, match="already exists"): + antigravity_desktop.install_database( + data, + session_id=TARGET_ID, + cwd=tmp_path, + timestamp="2026-08-20T12:00:00Z", + title="Duplicate", + target_home=target_home, + dry_run=False, + ) + + +def test_desktop_installation_requires_binary_verification_by_default( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + source = portable_session(tmp_path) + data, _ = antigravity_desktop.serialize( + source, + session_id=TARGET_ID, + trajectory_id=TRAJECTORY_ID, + cwd=tmp_path, + timestamp="2026-08-20T12:00:00Z", + ) + target_home = tmp_path / "antigravity_unverified" + + def fail_verification(*args, **kwargs): + raise SessionMigrateError("Mocked binary verification failure") + + monkeypatch.setattr(antigravity_desktop, "verify_pinned_desktop", fail_verification) + + # By default, verify_binary=True, so installation must fail + with pytest.raises(SessionMigrateError, match="Mocked binary verification failure"): + antigravity_desktop.install_database( + data, + session_id=TARGET_ID, + cwd=tmp_path, + timestamp="2026-08-20T12:00:00Z", + title="Unverified Desktop Session", + target_home=target_home, + ) + + # With explicit verify_binary=False bypass, installation succeeds + installed = antigravity_desktop.install_database( + data, + session_id=TARGET_ID, + cwd=tmp_path, + timestamp="2026-08-20T12:00:00Z", + title="Unverified Desktop Session", + target_home=target_home, + verify_binary=False, + ) + assert installed.conversation_path.is_file() + assert installed.summaries_path.is_file() + + +def test_desktop_and_cli_databases_do_not_falsely_detect_each_other(tmp_path: Path) -> None: + source = portable_session(tmp_path) + cli_data, _ = antigravity.serialize( + source, + session_id=TARGET_ID, + trajectory_id=TRAJECTORY_ID, + cwd=tmp_path, + ) + desktop_data, _ = antigravity_desktop.serialize( + source, + session_id=TARGET_ID, + trajectory_id=TRAJECTORY_ID, + cwd=tmp_path, + ) + + cli_path = tmp_path / "cli.db" + desktop_path = tmp_path / "desktop.db" + cli_path.write_bytes(cli_data) + desktop_path.write_bytes(desktop_data) + + # CLI parser rejects desktop DB + with pytest.raises(SessionMigrateError, match="CLI cascade trajectory"): + antigravity.parse(desktop_path) + + # Desktop parser rejects CLI DB + with pytest.raises(SessionMigrateError, match="Desktop cascade trajectory"): + antigravity_desktop.parse(cli_path) + + +def test_desktop_handles_cleared_step(tmp_path: Path) -> None: + # A compacted conversation can contain step status 5 (CLEARED) with minimal payload + data, _ = antigravity_desktop.serialize( + portable_session(tmp_path), + session_id=TARGET_ID, + trajectory_id=TRAJECTORY_ID, + cwd=tmp_path, + ) + path = tmp_path / f"{TARGET_ID}.db" + path.write_bytes(data) + + # Inject a CLEARED step + with sqlite3.connect(path) as conn: + cleared_payload = antigravity._field_varint( + 1, antigravity.STEP_TYPE_PLANNER_RESPONSE + ) + antigravity._field_varint(4, 5) + conn.execute( + "INSERT INTO steps(idx, step_type, status, has_subtrajectory, metadata, " + "step_payload, step_format) VALUES(?, ?, 5, 0, NULL, ?, 0)", + (4, antigravity.STEP_TYPE_PLANNER_RESPONSE, cleared_payload), + ) + + parsed = antigravity_desktop.parse(path) + assert any( + event.kind == EventKind.OPAQUE and event.payload.get("reason") == "antigravity_cleared_step" + for event in parsed.events + ) + + +def test_format_detection_identifies_desktop(tmp_path: Path) -> None: + from session_migrate.inspection import detect_path_format + + data, _ = antigravity_desktop.serialize( + portable_session(tmp_path), + session_id=TARGET_ID, + trajectory_id=TRAJECTORY_ID, + cwd=tmp_path, + ) + path = tmp_path / f"{TARGET_ID}.db" + path.write_bytes(data) + + detected = detect_path_format(path) + assert detected == AgentFormat.ANTIGRAVITY_DESKTOP + + +def test_conversion_pipeline_cli_to_desktop_and_back( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(antigravity, "verify_pinned_cli", lambda *args, **kwargs: Path("agy")) + monkeypatch.setattr( + antigravity_desktop, + "verify_pinned_desktop", + lambda *args, **kwargs: Path("language_server"), + ) + from session_migrate.conversion import ( + ConversionOptions, + convert_session, + install_antigravity_desktop_artifact, + load_session, + ) + from session_migrate.model import TargetFormat + + # 1. Start with a CLI session + cli_data, _ = antigravity.serialize( + portable_session(tmp_path), + session_id=TARGET_ID, + trajectory_id=TRAJECTORY_ID, + cwd=tmp_path, + timestamp="2026-08-20T12:00:00Z", + ) + cli_path = tmp_path / f"{TARGET_ID}.db" + cli_path.write_bytes(cli_data) + loaded_cli = load_session(cli_path) + assert loaded_cli.source_format == AgentFormat.ANTIGRAVITY + + # 2. Convert CLI -> Desktop + desktop_id = "55555555-5555-4555-8555-555555555555" + artifact = convert_session( + loaded_cli, + ConversionOptions( + target_format=TargetFormat.ANTIGRAVITY_DESKTOP, + session_id=desktop_id, + cwd=tmp_path, + ), + ) + assert artifact.target_format == TargetFormat.ANTIGRAVITY_DESKTOP + desktop_home = tmp_path / "dest_desktop" + native_path, manifest_path = install_antigravity_desktop_artifact( + artifact, + target_home=desktop_home, + ) + assert native_path.is_file() + assert manifest_path.is_file() + + # 3. Load Desktop -> Convert back to CLI + loaded_desktop = load_session(native_path) + assert loaded_desktop.source_format == AgentFormat.ANTIGRAVITY_DESKTOP + assert loaded_desktop.session_id == desktop_id + + cli_dest_id = "66666666-6666-4666-8666-666666666666" + cli_artifact = convert_session( + loaded_desktop, + ConversionOptions( + target_format=TargetFormat.ANTIGRAVITY, + session_id=cli_dest_id, + cwd=tmp_path, + ), + ) + assert cli_artifact.target_format == TargetFormat.ANTIGRAVITY + assert cli_artifact.native_record_count == artifact.native_record_count + + +def test_default_language_server_path_platform_matrix(tmp_path: Path) -> None: + # 1. Environment override + custom_bin = tmp_path / "custom_server" + resolved = antigravity_desktop.default_language_server_path( + platform="darwin", + environ={"SESSION_MIGRATE_ANTIGRAVITY_DESKTOP_BIN": str(custom_bin)}, + ) + assert resolved == custom_bin + + # 2. Darwin default + resolved_mac = antigravity_desktop.default_language_server_path( + platform="darwin", + environ={}, + ) + assert resolved_mac == antigravity_desktop.DEFAULT_MACOS_LANGUAGE_SERVER_PATH + + # 3. Linux default + resolved_linux = antigravity_desktop.default_language_server_path( + platform="linux", + environ={}, + ) + assert resolved_linux == antigravity_desktop.DEFAULT_LINUX_LANGUAGE_SERVER_PATH + + # 4. Windows default with LOCALAPPDATA + resolved_win = antigravity_desktop.default_language_server_path( + platform="win32", + environ={"LOCALAPPDATA": "C:\\Users\\tester\\AppData\\Local"}, + ) + assert "language_server.exe" in str(resolved_win) + + +def test_electron_user_data_dir_platform_matrix(tmp_path: Path) -> None: + fake_home = tmp_path / "fakehome" + + # Darwin + mac_dir = antigravity_desktop.electron_user_data_dir( + platform="darwin", + home=fake_home, + ) + assert mac_dir == fake_home / "Library" / "Application Support" / "Antigravity" + + # Windows with APPDATA + win_dir = antigravity_desktop.electron_user_data_dir( + platform="win32", + environ={"APPDATA": "C:\\Users\\tester\\AppData\\Roaming"}, + home=fake_home, + ) + assert win_dir == Path("C:\\Users\\tester\\AppData\\Roaming") / "Antigravity" + + # Windows fallback + win_fallback = antigravity_desktop.electron_user_data_dir( + platform="win32", + environ={}, + home=fake_home, + ) + assert win_fallback == fake_home / "AppData" / "Roaming" / "Antigravity" + + # Linux with XDG_CONFIG_HOME + linux_xdg = antigravity_desktop.electron_user_data_dir( + platform="linux", + environ={"XDG_CONFIG_HOME": "/tmp/custom_config"}, + home=fake_home, + ) + assert linux_xdg == Path("/tmp/custom_config/Antigravity") + + # Linux default fallback + linux_fallback = antigravity_desktop.electron_user_data_dir( + platform="linux", + environ={}, + home=fake_home, + ) + assert linux_fallback == fake_home / ".config" / "Antigravity" + + +def test_verify_pinned_desktop_platform_guards(tmp_path: Path) -> None: + fake_bin = tmp_path / "language_server" + fake_bin.write_bytes(b"x" * antigravity_desktop.PINNED_ANTIGRAVITY_DESKTOP_MACOS_ARM64_SIZE) + + # Size mismatch on Linux because fake_bin has macOS size + with pytest.raises(SessionMigrateError, match="binary size mismatch on linux"): + antigravity_desktop.verify_pinned_desktop( + fake_bin, + platform="linux", + environ={}, + ) + + # Unsupported platform raises clear message + with pytest.raises(SessionMigrateError, match="not supported on freebsd"): + antigravity_desktop.verify_pinned_desktop( + fake_bin, + platform="freebsd", + environ={}, + ) + + # With SESSION_MIGRATE_UNVALIDATED_DESKTOP_BIN=1, it should accept the path + bypassed = antigravity_desktop.verify_pinned_desktop( + fake_bin, + platform="linux", + environ={"SESSION_MIGRATE_UNVALIDATED_DESKTOP_BIN": "1"}, + ) + assert bypassed == fake_bin.resolve() diff --git a/tests/test_antigravity_desktop_native.py b/tests/test_antigravity_desktop_native.py new file mode 100644 index 0000000..c48154e --- /dev/null +++ b/tests/test_antigravity_desktop_native.py @@ -0,0 +1,235 @@ +import json +import os +import ssl +import subprocess +import time +import urllib.request +from pathlib import Path + +import pytest + +from session_migrate.formats import antigravity_desktop +from session_migrate.model import AgentFormat, Event, EventKind, Provenance, Role, Session + +TARGET_ID = "88888888-9999-4aaa-8bbb-cccccccccccc" +TRAJECTORY_ID = "dddddddd-eeee-4fff-8000-111111111111" + + +def exact_desktop_binary() -> Path: + env_bin = os.environ.get("SESSION_MIGRATE_ANTIGRAVITY_DESKTOP_BIN") + selected_in_ci = ( + bool(env_bin) or os.environ.get("SESSION_MIGRATE_RUN_ANTIGRAVITY_DESKTOP_NATIVE") == "1" + ) + + if env_bin: + candidate = Path(env_bin).expanduser().resolve() + if not candidate.is_file(): + pytest.fail( + f"Configured SESSION_MIGRATE_ANTIGRAVITY_DESKTOP_BIN not found: {candidate}" + ) + return antigravity_desktop.verify_pinned_desktop(candidate) + + default_binary = antigravity_desktop.default_language_server_path() + if not default_binary.is_file(): + if selected_in_ci: + pytest.fail( + f"Antigravity Desktop language_server not found at default path: {default_binary}" + ) + pytest.skip("Antigravity Desktop language_server is not installed on this machine") + + try: + return antigravity_desktop.verify_pinned_desktop(default_binary) + except Exception as exc: + if selected_in_ci: + pytest.fail(f"Antigravity Desktop binary verification failed: {exc}") + pytest.skip(f"Antigravity Desktop binary verification failed: {exc}") + + +def test_native_desktop_language_server_loads_adapter_database(tmp_path: Path) -> None: + tmp_path = tmp_path.resolve() + binary = exact_desktop_binary() + + # Create a portable test session + events = ( + Event( + kind=EventKind.MESSAGE, + role=Role.USER, + text="Hello Antigravity Desktop from session-migrate", + timestamp="2026-09-28T12:00:00Z", + provenance=Provenance(0, "user"), + ), + Event( + kind=EventKind.MESSAGE, + role=Role.ASSISTANT, + text="Hello! Native desktop language server validation passed.", + timestamp="2026-09-28T12:00:01Z", + provenance=Provenance(1, "assistant"), + ), + ) + session = Session( + source_format=AgentFormat.ANTIGRAVITY_DESKTOP, + source_path=tmp_path / "source.db", + source_sha256="0" * 64, + session_id=TARGET_ID, + cwd=tmp_path, + started_at="2026-09-28T12:00:00Z", + cli_version=antigravity_desktop.PINNED_ANTIGRAVITY_DESKTOP_VERSION, + model="gemini-2.5-pro", + title="Native Integration Test", + events=events, + raw_record_count=len(events), + ) + + data, _ = antigravity_desktop.serialize( + session, + session_id=TARGET_ID, + trajectory_id=TRAJECTORY_ID, + cwd=tmp_path, + timestamp="2026-09-28T12:00:00Z", + ) + + gemini_dir = tmp_path / ".gemini" + app_state = gemini_dir / "antigravity" + antigravity_desktop.install_database( + data, + session_id=TARGET_ID, + cwd=tmp_path, + timestamp="2026-09-28T12:00:00Z", + title="Native Integration Test", + target_home=app_state, + dry_run=False, + ) + + csrf_token = "01234567-89ab-cdef-0123-456789abcdef" + + # Spawn language_server in the isolated directory + cmd = [ + str(binary), + "--standalone", + "--override_ide_name", + "antigravity", + "--subclient_type", + "hub", + "--override_ide_version", + "2.18.1", + "--override_user_agent_name", + "antigravity", + "--https_server_port", + "0", + "--csrf_token", + csrf_token, + "-gemini_dir", + str(gemini_dir), + "-app_data_dir", + "antigravity", + ] + env = dict(os.environ) + env["HOME"] = str(tmp_path) + + proc = subprocess.Popen( + cmd, + cwd=str(app_state), + env=env, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + ) + assert proc.stdin is not None + proc.stdin.close() + + port = None + try: + import re + + for _ in range(50): + line = proc.stdout.readline() + if not line: + break + m = re.search(r"listening on \w+ port at (\d+) for HTTP", line, re.I) + if m: + port = int(m.group(1)) + break + + assert port is not None, "Failed to capture language_server port" + + # Query Connect-RPC GetCascadeTrajectorySteps + url = f"https://127.0.0.1:{port}/exa.language_server_pb.LanguageServerService/GetCascadeTrajectorySteps" + ctx = ssl.create_default_context() + ctx.check_hostname = False + ctx.verify_mode = ssl.CERT_NONE + + req_body = json.dumps({"cascadeId": TARGET_ID}).encode() + req = urllib.request.Request( + url, + data=req_body, + headers={ + "Content-Type": "application/json", + "X-Codeium-Csrf-Token": csrf_token, + }, + method="POST", + ) + + res = None + for _attempt in range(30): + try: + with urllib.request.urlopen(req, context=ctx, timeout=5) as resp: + if resp.status == 200: + data = json.loads(resp.read().decode()) + s = data.get("steps", []) + if len(s) == 2 and "userInput" in s[0] and "plannerResponse" in s[1]: + res = data + break + except (urllib.error.HTTPError, urllib.error.URLError): + pass + time.sleep(0.2) + + assert res is not None, "Failed to get response from language server" + assert "steps" in res + steps = res["steps"] + assert len(steps) == 2 + # Step 0 is user input + assert steps[0].get("type") == "CORTEX_STEP_TYPE_USER_INPUT" + assert steps[0].get("status") == "CORTEX_STEP_STATUS_DONE" + # Step 1 is planner response + assert steps[1].get("type") == "CORTEX_STEP_TYPE_PLANNER_RESPONSE" + assert steps[1].get("status") == "CORTEX_STEP_STATUS_DONE" + + finally: + proc.terminate() + try: + proc.wait(timeout=3) + except subprocess.TimeoutExpired: + proc.kill() + + +def test_exact_client_gate_fails_when_selected_in_ci( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + # 1. Nonexistent binary specified via env var must fail rather than skip + monkeypatch.setenv("SESSION_MIGRATE_ANTIGRAVITY_DESKTOP_BIN", str(tmp_path / "nonexistent")) + with pytest.raises( + pytest.fail.Exception, match="Configured SESSION_MIGRATE_ANTIGRAVITY_DESKTOP_BIN not found" + ): + exact_desktop_binary() + + # 2. Mismatched binary specified via env var must fail rather than skip + fake_binary = tmp_path / "fake_language_server" + fake_binary.write_bytes(b"invalid binary content") + monkeypatch.setenv("SESSION_MIGRATE_ANTIGRAVITY_DESKTOP_BIN", str(fake_binary)) + with pytest.raises(Exception) as exc_info: + exact_desktop_binary() + assert exc_info.type in {pytest.fail.Exception, antigravity_desktop.SessionMigrateError} + + # 3. Selected in CI via RUN flag with missing binary must fail rather than skip + monkeypatch.delenv("SESSION_MIGRATE_ANTIGRAVITY_DESKTOP_BIN", raising=False) + monkeypatch.setenv("SESSION_MIGRATE_RUN_ANTIGRAVITY_DESKTOP_NATIVE", "1") + monkeypatch.setattr( + antigravity_desktop, + "default_language_server_path", + lambda *args, **kwargs: tmp_path / "missing", + ) + with pytest.raises( + pytest.fail.Exception, match="Antigravity Desktop language_server not found" + ): + exact_desktop_binary() diff --git a/tests/test_route_matrix.py b/tests/test_route_matrix.py index d898563..3f11ee3 100644 --- a/tests/test_route_matrix.py +++ b/tests/test_route_matrix.py @@ -8,6 +8,7 @@ from session_migrate.conversion import ConversionOptions, convert_session from session_migrate.formats import ( antigravity, + antigravity_desktop, claude, codex, copilot, @@ -76,6 +77,28 @@ def source_sessions(tmp_path: Path) -> dict[str, Session]: antigravity_path = tmp_path / f"{antigravity_id}.db" antigravity_path.write_bytes(antigravity_bytes) sessions["antigravity"] = antigravity.parse_session(antigravity_path) + desktop_value = json.loads((FIXTURES / "antigravity-desktop-2.18.1" / "basic.json").read_text()) + desktop_rows = tuple( + antigravity._StepRow( + row["idx"], + row["step_type"], + row["status"], + None, + bytes.fromhex(row["step_payload_hex"]), + ) + for row in desktop_value["steps"] + ) + desktop_bytes = antigravity._build_database( + desktop_rows, + conversation_id=desktop_value["conversation_id"], + trajectory_id=desktop_value["trajectory_id"], + started_at=desktop_value["created_at"], + source=antigravity_desktop.SOURCE_DESKTOP, + project_id=antigravity_desktop.PROJECT_ID, + ) + desktop_path = tmp_path / f"{desktop_value['conversation_id']}.db" + desktop_path.write_bytes(desktop_bytes) + sessions["antigravity_desktop"] = antigravity_desktop.parse_session(desktop_path) cursor_id = "cccccccc-cccc-4ccc-8ccc-cccccccccccc" cursor_bytes, _ = cursor.serialize( sessions["claude"], @@ -288,6 +311,8 @@ def parse_target(path: Path, target: TargetFormat) -> Session: return mastracode.parse_session(path, TARGET_UUID) if target == TargetFormat.DEVIN: return devin.parse_session(path, TARGET_UUID) + if target == TargetFormat.ANTIGRAVITY_DESKTOP: + return antigravity_desktop.parse_session(path) return antigravity.parse_session(path) @@ -361,6 +386,7 @@ def hermes_bundle_signature(data: bytes, session_id: str) -> list[Any]: "hermes", "mastracode", "devin", + "antigravity_desktop", ), ) @pytest.mark.parametrize( @@ -374,6 +400,7 @@ def hermes_bundle_signature(data: bytes, session_id: str) -> list[Any]: "opencode-v2", TargetFormat.COPILOT, TargetFormat.ANTIGRAVITY, + TargetFormat.ANTIGRAVITY_DESKTOP, TargetFormat.CURSOR, TargetFormat.VIBE, TargetFormat.MUSE, @@ -410,7 +437,7 @@ def test_every_supported_source_to_target_route_preserves_portable_timeline( output.parent.mkdir() elif target in {TargetFormat.OPENCODE, TargetFormat.KILO}: output = tmp_path / "target.json" - elif target == TargetFormat.ANTIGRAVITY: + elif target in {TargetFormat.ANTIGRAVITY, TargetFormat.ANTIGRAVITY_DESKTOP}: output = tmp_path / f"{artifact.session_id}.db" elif target == TargetFormat.CURSOR: output = tmp_path / artifact.session_id / "store.db" @@ -475,12 +502,14 @@ def test_every_supported_source_to_target_route_preserves_portable_timeline( include_compaction = target not in { TargetFormat.CLAUDE, TargetFormat.ANTIGRAVITY, + TargetFormat.ANTIGRAVITY_DESKTOP, TargetFormat.CURSOR, TargetFormat.MUSE, TargetFormat.QWEN, } include_images = target not in { TargetFormat.ANTIGRAVITY, + TargetFormat.ANTIGRAVITY_DESKTOP, TargetFormat.CURSOR, TargetFormat.MUSE, } @@ -488,6 +517,7 @@ def test_every_supported_source_to_target_route_preserves_portable_timeline( # is text-only and counts non-text result blocks in the manifest. include_result_images = target not in { TargetFormat.ANTIGRAVITY, + TargetFormat.ANTIGRAVITY_DESKTOP, TargetFormat.CURSOR, TargetFormat.MUSE, TargetFormat.GROK, diff --git a/tests/test_target_integration.py b/tests/test_target_integration.py index a3b165c..15551c4 100644 --- a/tests/test_target_integration.py +++ b/tests/test_target_integration.py @@ -132,6 +132,7 @@ def test_source_and_target_enums_are_deliberately_separate() -> None: AgentFormat.OPENCODE, AgentFormat.COPILOT, AgentFormat.ANTIGRAVITY, + AgentFormat.ANTIGRAVITY_DESKTOP, AgentFormat.CURSOR, AgentFormat.VIBE, AgentFormat.MUSE, @@ -152,6 +153,7 @@ def test_source_and_target_enums_are_deliberately_separate() -> None: TargetFormat.OPENCODE, TargetFormat.COPILOT, TargetFormat.ANTIGRAVITY, + TargetFormat.ANTIGRAVITY_DESKTOP, TargetFormat.CURSOR, TargetFormat.VIBE, TargetFormat.MUSE,