Skip to main content

txcript/harness/
antigravity.rs

1//! Antigravity CLI (`agy`) conversations:
2//! `~/.gemini/antigravity-cli/conversations/<id>.db`.
3//!
4//! Antigravity persists a conversation as a small `SQLite` database of
5//! protobuf-encoded trajectory steps (`gemini_coder.Step` — a step-type tag,
6//! a `CortexStepMetadata` envelope carrying timestamps / the tool call /
7//! model usage, and one payload message per step kind), plus a `brain/<id>/`
8//! working directory whose `.system_generated/logs/transcript*.jsonl` files
9//! mirror the conversation for display. The database is the resume carrier:
10//! `agy --conversation=<id>` rebuilds model context from the steps alone and
11//! silently starts a fresh session when the database is missing, while a
12//! missing `brain/<id>/` directory wedges the CLI — `save` therefore always
13//! writes the database *and* creates the brain directory.
14//!
15//! Losslessness is at the blob level: every table row round-trips byte-for-
16//! byte through [`AntigravityDb`]; only the step payloads the codec
17//! understands are interpreted, with hand-rolled protobuf read/write for the
18//! handful of field numbers involved (recovered from the descriptors embedded
19//! in the `agy` binary).
20//!
21//! Known representational losses through `Common`:
22//! - `toolAction`/`toolSummary` display strings inside typed tool arguments
23//!   (and their `CortexStepMetadata` mirrors) are dropped; `Tool::Raw`
24//!   passthrough keeps them.
25//! - Per-tool-call binary `thinking_signature` blobs are not representable
26//!   and are regenerated as absent.
27//! - Edit/Write tool results are derived data in Antigravity (diff chunks,
28//!   uris); free-form result text from other harnesses is replaced by the
29//!   canonical derived JSON (`{"file": …, "created"|"edited": true}`).
30//! - Assistant-side images have no native slot and flatten to placeholder
31//!   text; user images ride `CortexStepUserInput.images`.
32//! - Numeric model ids surface as `model-<n>` strings; foreign model names
33//!   cannot be mapped back to Antigravity's enum and are omitted on write.
34
35use std::collections::HashMap;
36use std::path::PathBuf;
37
38use chrono::{DateTime, Utc};
39use serde::{Deserialize, Serialize};
40use serde_json::{Map, Value, json};
41use uuid::Uuid;
42
43use crate::common::{Block, ImageSource, Message, Meta, Role, StopReason, Tool, ToolOutput, Usage};
44use crate::error::{Error, Result};
45use crate::transcript::{Codec, Common, Discovered, Harness, Saved, Store, TextCodec, Transcript};
46
47#[cfg(feature = "opencode")]
48use rusqlite::{Connection, OpenFlags, params};
49#[cfg(feature = "opencode")]
50use std::fs;
51#[cfg(feature = "opencode")]
52use std::path::Path;
53
54/// The Antigravity harness marker.
55#[derive(Debug, Clone, Copy, PartialEq, Eq)]
56pub struct Antigravity;
57
58impl Harness for Antigravity {
59    const NAME: &'static str = "antigravity";
60    type Body = AntigravityDb;
61}
62
63/// Faithful in-memory representation of one `conversations/<id>.db`, plus the
64/// brain transcript sidecar logs when present.
65#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
66pub struct AntigravityDb {
67    pub trajectory_meta: Vec<TrajectoryMetaRow>,
68    pub steps: Vec<StepRow>,
69    #[serde(default, skip_serializing_if = "Vec::is_empty")]
70    pub gen_metadata: Vec<SizedBlobRow>,
71    #[serde(default, skip_serializing_if = "Vec::is_empty")]
72    pub executor_metadata: Vec<IndexedBlobRow>,
73    #[serde(default, skip_serializing_if = "Vec::is_empty")]
74    pub parent_references: Vec<IndexedBlobRow>,
75    #[serde(default, skip_serializing_if = "Vec::is_empty")]
76    pub battle_mode_infos: Vec<IndexedBlobRow>,
77    #[serde(default, skip_serializing_if = "Vec::is_empty")]
78    pub trajectory_metadata_blob: Vec<KeyedBlobRow>,
79    /// `brain/<id>/.system_generated/logs/transcript.jsonl`, verbatim.
80    #[serde(default, skip_serializing_if = "Option::is_none")]
81    pub transcript: Option<String>,
82    /// `brain/<id>/.system_generated/logs/transcript_full.jsonl`, verbatim.
83    #[serde(default, skip_serializing_if = "Option::is_none")]
84    pub transcript_full: Option<String>,
85}
86
87/// One row of `trajectory_meta`.
88#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
89pub struct TrajectoryMetaRow {
90    pub trajectory_id: String,
91    pub cascade_id: String,
92    pub trajectory_type: i64,
93    pub source: i64,
94}
95
96/// One row of `steps`. Blob columns hold protobuf bytes, hex-encoded in the
97/// text form.
98#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
99pub struct StepRow {
100    pub idx: i64,
101    pub step_type: i64,
102    pub status: i64,
103    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
104    pub has_subtrajectory: bool,
105    #[serde(with = "serde_hex")]
106    pub metadata: Vec<u8>,
107    #[serde(with = "serde_hex", default, skip_serializing_if = "Vec::is_empty")]
108    pub error_details: Vec<u8>,
109    #[serde(with = "serde_hex", default, skip_serializing_if = "Vec::is_empty")]
110    pub permissions: Vec<u8>,
111    #[serde(with = "serde_hex", default, skip_serializing_if = "Vec::is_empty")]
112    pub task_details: Vec<u8>,
113    #[serde(with = "serde_hex", default, skip_serializing_if = "Vec::is_empty")]
114    pub render_info: Vec<u8>,
115    #[serde(with = "serde_hex")]
116    pub step_payload: Vec<u8>,
117    #[serde(default)]
118    pub step_format: i64,
119}
120
121/// One row of `gen_metadata` (has an extra `size` column).
122#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
123pub struct SizedBlobRow {
124    pub idx: i64,
125    #[serde(with = "serde_hex")]
126    pub data: Vec<u8>,
127    pub size: i64,
128}
129
130/// One row of `executor_metadata` / `parent_references` / `battle_mode_infos`.
131#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
132pub struct IndexedBlobRow {
133    pub idx: i64,
134    #[serde(with = "serde_hex")]
135    pub data: Vec<u8>,
136}
137
138/// One row of `trajectory_metadata_blob` (`id` is `"main"` in practice).
139#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
140pub struct KeyedBlobRow {
141    pub id: String,
142    #[serde(with = "serde_hex")]
143    pub data: Vec<u8>,
144}
145
146// -- step-type and enum constants (gemini_coder.Step / exa.cortex_pb) -----
147
148const STEP_VIEW_FILE: i64 = 8;
149const STEP_LIST_DIRECTORY: i64 = 9;
150const STEP_USER_INPUT: i64 = 14;
151const STEP_PLANNER_RESPONSE: i64 = 15;
152const STEP_RUN_COMMAND: i64 = 21;
153const STEP_CHECKPOINT: i64 = 23;
154const STEP_MCP_TOOL: i64 = 38;
155const STEP_CODE_ACTION: i64 = 5;
156const STEP_GREP_SEARCH: i64 = 7;
157const STEP_CONVERSATION_HISTORY: i64 = 98;
158const STEP_SYSTEM_MESSAGE: i64 = 101;
159const STEP_EPHEMERAL_MESSAGE: i64 = 90;
160const STEP_GENERIC: i64 = 132;
161
162const STATUS_PENDING: i64 = 1;
163const STATUS_DONE: i64 = 3;
164const STATUS_CANCELED: i64 = 6;
165const STATUS_ERROR: i64 = 7;
166
167// Payload field number inside `gemini_coder.Step` per step type.
168fn payload_field(step_type: i64) -> Option<u32> {
169    match step_type {
170        STEP_VIEW_FILE => Some(14),
171        STEP_LIST_DIRECTORY => Some(15),
172        STEP_USER_INPUT => Some(19),
173        STEP_PLANNER_RESPONSE => Some(20),
174        STEP_RUN_COMMAND => Some(28),
175        STEP_CHECKPOINT => Some(30),
176        STEP_MCP_TOOL => Some(47),
177        STEP_CODE_ACTION => Some(10),
178        STEP_GREP_SEARCH => Some(13),
179        STEP_CONVERSATION_HISTORY => Some(111),
180        STEP_SYSTEM_MESSAGE => Some(114),
181        STEP_EPHEMERAL_MESSAGE => Some(103),
182        STEP_GENERIC => Some(140),
183        _ => None,
184    }
185}
186
187impl Codec for Antigravity {
188    fn to_common(transcript: &Transcript<Self>) -> Result<Transcript<Common>> {
189        Ok(Transcript::new(
190            transcript.meta.clone(),
191            db_to_messages(&transcript.body, &transcript.meta),
192        ))
193    }
194
195    fn from_common(transcript: &Transcript<Common>) -> Result<Transcript<Self>> {
196        Ok(Transcript::new(
197            transcript.meta.clone(),
198            db_from_messages(&transcript.meta, &transcript.body),
199        ))
200    }
201}
202
203impl TextCodec for Antigravity {
204    fn from_text(text: &str) -> Result<Transcript<Self>> {
205        let body: AntigravityDb = serde_json::from_str(text)?;
206        let meta = meta_from_db(&body);
207        Ok(Transcript::new(meta, body))
208    }
209
210    fn to_text(transcript: &Transcript<Self>) -> Result<String> {
211        Ok(serde_json::to_string_pretty(&transcript.body)?)
212    }
213}
214
215// -- protobuf wire helpers -------------------------------------------------
216
217/// One decoded protobuf field value.
218#[derive(Debug, Clone, PartialEq, Eq)]
219enum PbValue<'a> {
220    Varint(u64),
221    Bytes(&'a [u8]),
222    Fixed32([u8; 4]),
223    Fixed64([u8; 8]),
224}
225
226/// Decode the fields of one message. Tolerant: on any malformed byte the
227/// fields parsed so far are returned — a corrupt blob yields a shorter view,
228/// never an error.
229fn pb_fields(buf: &[u8]) -> Vec<(u32, PbValue<'_>)> {
230    let mut out = Vec::new();
231    let mut i = 0usize;
232    while i < buf.len() {
233        let Some((tag, next)) = pb_read_varint(buf, i) else {
234            break;
235        };
236        let field = u32::try_from(tag >> 3).unwrap_or(0);
237        let parsed = match tag & 7 {
238            0 => pb_read_varint(buf, next).map(|(v, j)| (PbValue::Varint(v), j)),
239            2 => pb_read_varint(buf, next).and_then(|(len, j)| {
240                let end = j.checked_add(usize::try_from(len).ok()?)?;
241                (end <= buf.len()).then(|| (PbValue::Bytes(&buf[j..end]), end))
242            }),
243            5 => buf
244                .get(next..next + 4)
245                .and_then(|b| <[u8; 4]>::try_from(b).ok())
246                .map(|b| (PbValue::Fixed32(b), next + 4)),
247            1 => buf
248                .get(next..next + 8)
249                .and_then(|b| <[u8; 8]>::try_from(b).ok())
250                .map(|b| (PbValue::Fixed64(b), next + 8)),
251            _ => None,
252        };
253        match parsed {
254            Some((value, j)) if field > 0 => {
255                out.push((field, value));
256                i = j;
257            }
258            // A zero field number or malformed body ends the readable prefix.
259            Some(_) | None => i = buf.len(),
260        }
261    }
262    out
263}
264
265fn pb_read_varint(buf: &[u8], mut i: usize) -> Option<(u64, usize)> {
266    let mut value: u64 = 0;
267    let mut shift = 0u32;
268    while shift < 64 {
269        let byte = *buf.get(i)?;
270        value |= u64::from(byte & 0x7f) << shift;
271        i += 1;
272        if byte & 0x80 == 0 {
273            return Some((value, i));
274        }
275        shift += 7;
276    }
277    None
278}
279
280/// First occurrence of a length-delimited subfield.
281fn pb_sub<'a>(fields: &[(u32, PbValue<'a>)], field: u32) -> Option<&'a [u8]> {
282    fields.iter().find_map(|(f, v)| match v {
283        PbValue::Bytes(b) if *f == field => Some(*b),
284        _ => None,
285    })
286}
287
288/// Every occurrence of a length-delimited subfield, in order.
289fn pb_subs<'a>(fields: &[(u32, PbValue<'a>)], field: u32) -> Vec<&'a [u8]> {
290    fields
291        .iter()
292        .filter_map(|(f, v)| match v {
293            PbValue::Bytes(b) if *f == field => Some(*b),
294            _ => None,
295        })
296        .collect()
297}
298
299fn pb_str(fields: &[(u32, PbValue<'_>)], field: u32) -> Option<String> {
300    pb_sub(fields, field).and_then(|b| std::str::from_utf8(b).ok().map(String::from))
301}
302
303fn pb_uint(fields: &[(u32, PbValue<'_>)], field: u32) -> Option<u64> {
304    fields.iter().find_map(|(f, v)| match v {
305        PbValue::Varint(n) if *f == field => Some(*n),
306        _ => None,
307    })
308}
309
310/// `google.protobuf.Timestamp {1: seconds, 2: nanos}`.
311fn pb_timestamp(buf: &[u8]) -> Option<DateTime<Utc>> {
312    let fields = pb_fields(buf);
313    let secs = i64::try_from(pb_uint(&fields, 1)?).ok()?;
314    let nanos = u32::try_from(pb_uint(&fields, 2).unwrap_or(0)).unwrap_or(0);
315    DateTime::from_timestamp(secs, nanos)
316}
317
318fn pb_key(out: &mut Vec<u8>, field: u32, wire_type: u8) {
319    pb_varint(out, (u64::from(field) << 3) | u64::from(wire_type));
320}
321
322// The casts take the low 7 bits by construction — varint encoding.
323#[allow(clippy::cast_possible_truncation)]
324fn pb_varint(out: &mut Vec<u8>, mut value: u64) {
325    while value >= 0x80 {
326        out.push((value as u8) | 0x80);
327        value >>= 7;
328    }
329    out.push(value as u8);
330}
331
332fn pb_varint_field(out: &mut Vec<u8>, field: u32, value: u64) {
333    pb_key(out, field, 0);
334    pb_varint(out, value);
335}
336
337fn pb_len(out: &mut Vec<u8>, field: u32, bytes: &[u8]) {
338    pb_key(out, field, 2);
339    pb_varint(out, bytes.len() as u64);
340    out.extend(bytes);
341}
342
343fn pb_string(out: &mut Vec<u8>, field: u32, value: &str) {
344    if !value.is_empty() {
345        pb_len(out, field, value.as_bytes());
346    }
347}
348
349fn pb_timestamp_field(out: &mut Vec<u8>, field: u32, ts: DateTime<Utc>) {
350    let mut inner = Vec::new();
351    let secs = ts.timestamp();
352    if let Ok(secs) = u64::try_from(secs)
353        && secs > 0
354    {
355        pb_varint_field(&mut inner, 1, secs);
356    }
357    let nanos = ts.timestamp_subsec_nanos();
358    if nanos > 0 {
359        pb_varint_field(&mut inner, 2, u64::from(nanos));
360    }
361    pb_len(out, field, &inner);
362}
363
364// -- native -> common ------------------------------------------------------
365
366fn db_to_messages(db: &AntigravityDb, meta: &Meta) -> Vec<Message> {
367    let fallback_ts = meta.timestamp;
368    let mut steps: Vec<&StepRow> = db.steps.iter().collect();
369    steps.sort_by_key(|s| s.idx);
370
371    let mut messages = Vec::new();
372    for step in steps {
373        let payload = pb_fields(&step.step_payload);
374        let meta_fields = pb_sub(&payload, 5).map(pb_fields).unwrap_or_default();
375        let ts = pb_sub(&meta_fields, 1)
376            .and_then(pb_timestamp)
377            .unwrap_or(fallback_ts);
378        match step.step_type {
379            STEP_USER_INPUT => {
380                messages.extend(user_input_message(&payload, ts));
381            }
382            STEP_PLANNER_RESPONSE => {
383                messages.extend(planner_message(&payload, &meta_fields, ts));
384            }
385            // Bookkeeping: summaries, context markers, injected notices.
386            STEP_CHECKPOINT
387            | STEP_CONVERSATION_HISTORY
388            | STEP_SYSTEM_MESSAGE
389            | STEP_EPHEMERAL_MESSAGE => {}
390            _ => {
391                messages.extend(tool_result_message(step, &payload, &meta_fields, ts));
392            }
393        }
394    }
395    messages
396}
397
398/// `CortexStepUserInput`: clean text in `user_response` (2) / `items[].text`
399/// (3.1) / `query` (1); inline images in `images` (5).
400fn user_input_message(payload: &[(u32, PbValue<'_>)], ts: DateTime<Utc>) -> Option<Message> {
401    let input = pb_sub(payload, 19).map(pb_fields)?;
402    let text = pb_str(&input, 2)
403        .filter(|s| !s.trim().is_empty())
404        .or_else(|| {
405            pb_subs(&input, 3)
406                .into_iter()
407                .find_map(|item| pb_str(&pb_fields(item), 1).filter(|s| !s.trim().is_empty()))
408        })
409        .or_else(|| pb_str(&input, 1).filter(|s| !s.trim().is_empty()));
410
411    let mut content = Vec::new();
412    if let Some(text) = text {
413        content.push(Block::Text { text });
414    }
415    for image in pb_subs(&input, 5) {
416        let fields = pb_fields(image);
417        if let Some(data) = pb_str(&fields, 1) {
418            content.push(Block::Image {
419                source: ImageSource {
420                    source_type: "base64".to_string(),
421                    media_type: pb_str(&fields, 2).unwrap_or_else(|| "image/png".to_string()),
422                    data,
423                },
424            });
425        }
426    }
427    (!content.is_empty()).then_some(Message {
428        role: Role::User,
429        content,
430        timestamp: ts,
431        model: None,
432        stop_reason: None,
433        usage: None,
434    })
435}
436
437/// `CortexStepPlannerResponse`: thinking (3, signature 4), response text (1),
438/// tool calls (7); model/usage from the step metadata envelope.
439fn planner_message(
440    payload: &[(u32, PbValue<'_>)],
441    meta_fields: &[(u32, PbValue<'_>)],
442    ts: DateTime<Utc>,
443) -> Option<Message> {
444    let planner = pb_sub(payload, 20).map(pb_fields).unwrap_or_default();
445
446    let mut content = Vec::new();
447    let thinking = pb_str(&planner, 3).unwrap_or_default();
448    if !thinking.trim().is_empty() {
449        content.push(Block::Thinking {
450            text: thinking,
451            signature: pb_str(&planner, 4).filter(|s| !s.is_empty()),
452            encrypted: None,
453        });
454    }
455    if let Some(text) = pb_str(&planner, 1).filter(|s| !s.trim().is_empty()) {
456        content.push(Block::Text { text });
457    }
458    for call in pb_subs(&planner, 7) {
459        content.extend(tool_use_block(call));
460    }
461
462    let usage = pb_sub(meta_fields, 9).map(pb_fields).and_then(|u| {
463        let input_tokens = pb_uint(&u, 2).unwrap_or(0);
464        let output_tokens = pb_uint(&u, 3).unwrap_or(0);
465        let cache_read = pb_uint(&u, 5);
466        let cache_write = pb_uint(&u, 4);
467        (input_tokens > 0 || output_tokens > 0).then_some(Usage {
468            input_tokens,
469            output_tokens,
470            cache_read_input_tokens: cache_read.filter(|n| *n > 0),
471            cache_creation_input_tokens: cache_write.filter(|n| *n > 0),
472        })
473    });
474
475    (!content.is_empty()).then(|| Message {
476        role: Role::Assistant,
477        content,
478        timestamp: ts,
479        model: pb_uint(meta_fields, 11).map(model_name),
480        stop_reason: pb_uint(&planner, 12).map(stop_reason_from_native),
481        usage,
482    })
483}
484
485/// `exa.codeium_common_pb.ChatToolCall {1: id, 2: name, 3: arguments_json}`.
486fn tool_use_block(call: &[u8]) -> Option<Block> {
487    let fields = pb_fields(call);
488    let id = pb_str(&fields, 1)?;
489    let name = pb_str(&fields, 2)?;
490    let args = pb_str(&fields, 3)
491        .and_then(|s| serde_json::from_str::<Value>(&s).ok())
492        .unwrap_or_else(|| Value::Object(Map::new()));
493    Some(Block::ToolUse {
494        id,
495        tool: normalize_tool(&name, args),
496    })
497}
498
499/// Any step whose metadata carries a tool call is that call's result record.
500fn tool_result_message(
501    step: &StepRow,
502    payload: &[(u32, PbValue<'_>)],
503    meta_fields: &[(u32, PbValue<'_>)],
504    ts: DateTime<Utc>,
505) -> Option<Message> {
506    let call = pb_sub(meta_fields, 4).map(pb_fields)?;
507    let tool_use_id = pb_str(&call, 1)?;
508    // A step still pending or running carries no result yet.
509    let done = matches!(step.status, STATUS_DONE | STATUS_ERROR | STATUS_CANCELED);
510    if !done {
511        return None;
512    }
513
514    let error_text = error_details_text(&step.error_details);
515    let (content, tool_error) = result_output(step.step_type, payload);
516    let is_error = step.status == STATUS_ERROR
517        || step.status == STATUS_CANCELED
518        || error_text.is_some()
519        || tool_error;
520    let content = match error_text {
521        Some(text) => ToolOutput::Text(text),
522        None => content,
523    };
524
525    Some(Message {
526        role: Role::User,
527        content: vec![Block::ToolResult {
528            tool_use_id,
529            content,
530            is_error,
531        }],
532        timestamp: ts,
533        model: None,
534        stop_reason: None,
535        usage: None,
536    })
537}
538
539/// `exa.cortex_pb.CortexErrorDetails`: the model-facing message wins.
540fn error_details_text(error_details: &[u8]) -> Option<String> {
541    let fields = pb_fields(error_details);
542    pb_str(&fields, 9)
543        .or_else(|| pb_str(&fields, 1))
544        .or_else(|| pb_str(&fields, 2))
545        .filter(|s| !s.trim().is_empty())
546}
547
548/// Extract the result content of one tool step. Returns the output plus a
549/// tool-level error flag (a nonzero command exit code).
550fn result_output(step_type: i64, payload: &[(u32, PbValue<'_>)]) -> (ToolOutput, bool) {
551    let field = payload_field(step_type);
552    let body = field
553        .and_then(|f| pb_sub(payload, f))
554        .map(pb_fields)
555        .unwrap_or_default();
556    match step_type {
557        // CortexStepViewFile: content (4), else raw_content (9).
558        STEP_VIEW_FILE => {
559            let text = pb_str(&body, 4)
560                .or_else(|| pb_str(&body, 9))
561                .unwrap_or_default();
562            (ToolOutput::Text(text), false)
563        }
564        // CortexStepRunCommand: combined_output.full, exit_code (6).
565        STEP_RUN_COMMAND => {
566            let text = pb_sub(&body, 21)
567                .and_then(|out| pb_str(&pb_fields(out), 1))
568                .or_else(|| pb_str(&body, 4))
569                .unwrap_or_default();
570            let exit = pb_uint(&body, 6).unwrap_or(0);
571            (ToolOutput::Text(text), exit != 0)
572        }
573        // CortexStepListDirectory: results (3) as a JSON listing.
574        STEP_LIST_DIRECTORY => {
575            let entries: Vec<Value> = pb_subs(&body, 3)
576                .into_iter()
577                .filter_map(|entry| {
578                    let fields = pb_fields(entry);
579                    let name = pb_str(&fields, 1)?;
580                    let mut obj = json!({ "name": name });
581                    if pb_uint(&fields, 2) == Some(1) {
582                        obj["is_dir"] = Value::Bool(true);
583                    }
584                    if let Some(size) = pb_uint(&fields, 4) {
585                        obj["size_bytes"] = Value::from(size);
586                    }
587                    Some(obj)
588                })
589                .collect();
590            let path = pb_str(&body, 1)
591                .as_deref()
592                .map(strip_file_uri)
593                .unwrap_or_default();
594            (
595                ToolOutput::Json(json!({ "path": path, "entries": entries })),
596                false,
597            )
598        }
599        // CortexStepGrepSearch: raw_output (3); grep_error (5) marks failure.
600        STEP_GREP_SEARCH => {
601            let error = pb_str(&body, 5).filter(|s| !s.trim().is_empty());
602            match error {
603                Some(text) => (ToolOutput::Text(text), true),
604                None => (
605                    ToolOutput::Text(pb_str(&body, 3).unwrap_or_default()),
606                    false,
607                ),
608            }
609        }
610        // CortexStepCodeAction: results are derived; surface the canonical
611        // derived form.
612        STEP_CODE_ACTION => {
613            let spec = pb_sub(&body, 1).map(pb_fields).unwrap_or_default();
614            let created = pb_sub(&spec, 2).is_some();
615            let uri = pb_sub(&body, 2)
616                .map(pb_fields)
617                .and_then(|result| {
618                    pb_sub(&result, 1)
619                        .map(pb_fields)
620                        .and_then(|edit| pb_str(&edit, 8))
621                })
622                .or_else(|| spec_target_uri(&spec))
623                .unwrap_or_default();
624            let key = if created { "created" } else { "edited" };
625            (
626                ToolOutput::Json(json!({ "file": strip_file_uri(&uri), key: true })),
627                false,
628            )
629        }
630        // CortexStepMcpTool: result_string (3).
631        STEP_MCP_TOOL => (
632            ToolOutput::Text(pb_str(&body, 3).unwrap_or_default()),
633            false,
634        ),
635        // CortexStepGeneric: result.result (2.1).
636        STEP_GENERIC => {
637            let text = pb_sub(&body, 2)
638                .and_then(|result| pb_str(&pb_fields(result), 1))
639                .unwrap_or_default();
640            (parse_result_text(text), false)
641        }
642        // Unmodeled tool steps: the call is preserved, the payload is not
643        // interpretable — an empty result rather than a dropped record.
644        _ => (ToolOutput::Text(String::new()), false),
645    }
646}
647
648/// `ActionSpec.command.file.absolute_uri` / `ActionSpec.create_file.path.absolute_uri`.
649fn spec_target_uri(spec: &[(u32, PbValue<'_>)]) -> Option<String> {
650    let from_path_scope = |buf: &[u8]| pb_str(&pb_fields(buf), 5);
651    pb_sub(spec, 2)
652        .map(pb_fields)
653        .and_then(|create| pb_sub(&create, 2).and_then(from_path_scope))
654        .or_else(|| {
655            pb_sub(spec, 1)
656                .map(pb_fields)
657                .and_then(|command| pb_sub(&command, 4).and_then(from_path_scope))
658        })
659}
660
661/// Result strings written by `from_common` may carry structured JSON; objects
662/// and arrays parse back to [`ToolOutput::Json`], anything else stays text.
663fn parse_result_text(text: String) -> ToolOutput {
664    serde_json::from_str::<Value>(&text)
665        .ok()
666        .filter(|v| v.is_object() || v.is_array())
667        .map_or(ToolOutput::Text(text), ToolOutput::Json)
668}
669
670fn strip_file_uri(uri: &str) -> String {
671    uri.strip_prefix("file://").unwrap_or(uri).to_string()
672}
673
674fn file_uri(path: &str) -> String {
675    if path.starts_with("file://") {
676        path.to_string()
677    } else {
678        format!("file://{path}")
679    }
680}
681
682fn model_name(id: u64) -> String {
683    format!("model-{id}")
684}
685
686fn model_id(name: &str) -> Option<u64> {
687    name.strip_prefix("model-").and_then(|n| n.parse().ok())
688}
689
690/// `exa.codeium_common_pb.StopReason` -> canonical.
691fn stop_reason_from_native(value: u64) -> StopReason {
692    match value {
693        2 => StopReason::EndTurn,
694        3 => StopReason::MaxTokens,
695        10 => StopReason::ToolUse,
696        13 => StopReason::Error,
697        16 => StopReason::Aborted,
698        other => StopReason::Other(format!("stop-{other}")),
699    }
700}
701
702fn stop_reason_to_native(reason: &StopReason) -> Option<u64> {
703    match reason {
704        StopReason::EndTurn | StopReason::StopSequence => Some(2),
705        StopReason::MaxTokens => Some(3),
706        StopReason::ToolUse => Some(10),
707        StopReason::Error => Some(13),
708        StopReason::Aborted => Some(16),
709        StopReason::Other(s) => s.strip_prefix("stop-").and_then(|n| n.parse().ok()),
710    }
711}
712
713// -- tool normalization ----------------------------------------------------
714
715/// Display-only argument keys mirrored from the step metadata; stripped when
716/// (and only when) the remainder maps onto a typed tool.
717const DISPLAY_KEYS: [&str; 2] = ["toolAction", "toolSummary"];
718
719// One arm per native tool name; each documents its own key mapping.
720#[allow(clippy::too_many_lines)]
721fn normalize_tool(name: &str, args: Value) -> Tool {
722    let raw = || Tool::Raw {
723        tool_name: name.to_string(),
724        input: args.clone(),
725    };
726    match name {
727        "run_command" => typed_args(&args, |obj| {
728            let command = string_key(obj, "CommandLine")?;
729            let workdir = string_key(obj, "Cwd");
730            let mut extra = non_display_keys(obj);
731            extra.retain(|k| !matches!(k.as_str(), "CommandLine" | "Cwd"));
732            // The CLI stamps a fixed 2s async threshold on every command; a
733            // non-default value is not representable and stays Raw.
734            if obj
735                .get("WaitMsBeforeAsync")
736                .is_some_and(|v| v == &json!(2000))
737            {
738                extra.retain(|k| k != "WaitMsBeforeAsync");
739            }
740            let mut input = json!({ "command": command });
741            if let Some(workdir) = workdir {
742                input["workdir"] = Value::String(workdir);
743            }
744            extra
745                .is_empty()
746                .then(|| Tool::from_canonical("Bash", input))
747        })
748        .unwrap_or_else(raw),
749        "view_file" => typed_args(&args, |obj| {
750            let file_path = string_key(obj, "AbsolutePath")?;
751            let start = uint_key(obj, "StartLine");
752            let end = uint_key(obj, "EndLine");
753            let mut extra = non_display_keys(obj);
754            extra.retain(|k| !matches!(k.as_str(), "AbsolutePath" | "StartLine" | "EndLine"));
755            let limit = match (start, end) {
756                (Some(s), Some(e)) if e >= s => Some(e - s + 1),
757                (None, Some(e)) => Some(e),
758                (_, None) => None,
759                // An inverted range is not representable; keep it Raw.
760                (Some(_), Some(_)) => {
761                    extra.push(String::from("EndLine"));
762                    None
763                }
764            };
765            let mut input = json!({ "file_path": file_path });
766            if let Some(offset) = start {
767                input["offset"] = Value::from(offset);
768            }
769            if let Some(limit) = limit {
770                input["limit"] = Value::from(limit);
771            }
772            extra
773                .is_empty()
774                .then(|| Tool::from_canonical("Read", input))
775        })
776        .unwrap_or_else(raw),
777        "write_to_file" => typed_args(&args, |obj| {
778            let file_path = string_key(obj, "TargetFile")?;
779            let content = string_key(obj, "CodeContent")?;
780            let mut extra = non_display_keys(obj);
781            // `Description`/`Instruction` are model-authored display strings
782            // with no canonical slot; `Overwrite` is the CLI's constant.
783            extra.retain(|k| {
784                !matches!(
785                    k.as_str(),
786                    "TargetFile" | "CodeContent" | "Overwrite" | "Description" | "Instruction"
787                )
788            });
789            extra.is_empty().then(|| {
790                Tool::from_canonical(
791                    "Write",
792                    json!({ "file_path": file_path, "content": content }),
793                )
794            })
795        })
796        .unwrap_or_else(raw),
797        "replace_file_content" => typed_args(&args, |obj| {
798            let file_path = string_key(obj, "TargetFile")?;
799            let old_string = string_key(obj, "TargetContent")?;
800            let new_string = string_key(obj, "ReplacementContent")?;
801            let replace_all = obj
802                .get("AllowMultiple")
803                .and_then(Value::as_bool)
804                .unwrap_or(false);
805            let mut extra = non_display_keys(obj);
806            extra.retain(|k| {
807                !matches!(
808                    k.as_str(),
809                    "TargetFile"
810                        | "TargetContent"
811                        | "ReplacementContent"
812                        | "AllowMultiple"
813                        | "StartLine"
814                        | "EndLine"
815                        | "Description"
816                        | "Instruction"
817                )
818            });
819            let mut input = json!({
820                "file_path": file_path,
821                "old_string": old_string,
822                "new_string": new_string,
823            });
824            if replace_all {
825                input["replace_all"] = Value::Bool(true);
826            }
827            extra
828                .is_empty()
829                .then(|| Tool::from_canonical("Edit", input))
830        })
831        .unwrap_or_else(raw),
832        // Canonical names arriving back from a generic carrier, and anything
833        // else (list_dir, grep_search, mcp tools, …) passes through.
834        other => Tool::from_canonical(other, args),
835    }
836}
837
838/// Run `f` over the args object; `None` (non-object args or a failed match)
839/// means "keep Raw".
840fn typed_args(args: &Value, f: impl FnOnce(&Map<String, Value>) -> Option<Tool>) -> Option<Tool> {
841    args.as_object().and_then(f)
842}
843
844fn string_key(obj: &Map<String, Value>, key: &str) -> Option<String> {
845    obj.get(key).and_then(Value::as_str).map(String::from)
846}
847
848fn uint_key(obj: &Map<String, Value>, key: &str) -> Option<u64> {
849    obj.get(key).and_then(Value::as_u64)
850}
851
852fn non_display_keys(obj: &Map<String, Value>) -> Vec<String> {
853    obj.keys()
854        .filter(|k| !DISPLAY_KEYS.contains(&k.as_str()))
855        .cloned()
856        .collect()
857}
858
859/// The native tool name and argument JSON for a canonical tool. The inverse
860/// of [`normalize_tool`] over its typed range.
861fn denormalize_tool(tool: &Tool) -> (String, Value) {
862    match tool {
863        Tool::Bash {
864            command, workdir, ..
865        } => {
866            let mut args = json!({
867                "CommandLine": command,
868                "WaitMsBeforeAsync": 2000,
869            });
870            if let Some(workdir) = workdir {
871                args["Cwd"] = Value::String(workdir.clone());
872            }
873            ("run_command".to_string(), args)
874        }
875        Tool::Read {
876            file_path,
877            offset,
878            limit,
879        } => {
880            let mut args = json!({ "AbsolutePath": file_path });
881            match (offset, limit) {
882                (Some(start), Some(len)) => {
883                    args["StartLine"] = Value::from(*start);
884                    args["EndLine"] = Value::from(start + len.saturating_sub(1));
885                }
886                (Some(start), None) => {
887                    args["StartLine"] = Value::from(*start);
888                }
889                (None, Some(len)) => {
890                    args["EndLine"] = Value::from(*len);
891                }
892                (None, None) => {}
893            }
894            ("view_file".to_string(), args)
895        }
896        Tool::Write { file_path, content } => (
897            "write_to_file".to_string(),
898            json!({
899                "TargetFile": file_path,
900                "CodeContent": content,
901                "Overwrite": true,
902            }),
903        ),
904        Tool::Edit {
905            file_path,
906            old_string,
907            new_string,
908            replace_all,
909        } => {
910            let mut args = json!({
911                "TargetFile": file_path,
912                "TargetContent": old_string,
913                "ReplacementContent": new_string,
914            });
915            if *replace_all {
916                args["AllowMultiple"] = Value::Bool(true);
917            }
918            ("replace_file_content".to_string(), args)
919        }
920        // MultiEdit, Raw and user commands ride the generic carrier under
921        // canonical names.
922        Tool::MultiEdit { .. } | Tool::Raw { .. } | Tool::Command { .. } => tool.to_canonical(),
923    }
924}
925
926// -- common -> native ------------------------------------------------------
927
928const NS: Uuid = Uuid::from_bytes([
929    0x5e, 0x1f, 0x9a, 0x27, 0xb3, 0x44, 0x4c, 0x8e, 0x92, 0x6d, 0x0a, 0x71, 0xc5, 0x3f, 0x88, 0x14,
930]);
931
932#[derive(Debug, Clone)]
933struct PendingResult {
934    content: ToolOutput,
935    is_error: bool,
936    timestamp: DateTime<Utc>,
937}
938
939// A linear walk over every message role/block combination; splitting it
940// would scatter the step-index bookkeeping.
941#[allow(clippy::too_many_lines)]
942fn db_from_messages(meta: &Meta, messages: &[Message]) -> AntigravityDb {
943    // Generating a fresh id for an id-less session is the sole permitted
944    // non-determinism.
945    let session_id = if meta.id.is_empty() {
946        Uuid::new_v4().to_string()
947    } else {
948        meta.id.clone()
949    };
950    let trajectory_id =
951        Uuid::new_v5(&NS, format!("{session_id}:trajectory").as_bytes()).to_string();
952
953    let results: HashMap<String, PendingResult> = messages
954        .iter()
955        .flat_map(|m| m.content.iter().map(move |b| (m.timestamp, b)))
956        .filter_map(|(timestamp, block)| match block {
957            Block::ToolResult {
958                tool_use_id,
959                content,
960                is_error,
961            } => Some((
962                tool_use_id.clone(),
963                PendingResult {
964                    content: content.clone(),
965                    is_error: *is_error,
966                    timestamp,
967                },
968            )),
969            // Only results feed the pairing map.
970            Block::Text { .. }
971            | Block::Thinking { .. }
972            | Block::ToolUse { .. }
973            | Block::Image { .. } => None,
974        })
975        .collect();
976    let known_uses: std::collections::HashSet<String> = messages
977        .iter()
978        .flat_map(|m| m.content.iter())
979        .filter_map(|block| match block {
980            Block::ToolUse { id, .. } => Some(id.clone()),
981            // Only uses mint ids.
982            Block::Text { .. }
983            | Block::Thinking { .. }
984            | Block::ToolResult { .. }
985            | Block::Image { .. } => None,
986        })
987        .collect();
988
989    let mut steps = Vec::new();
990    let mut turn: u64 = 0;
991    for message in messages {
992        match message.role {
993            Role::User => {
994                let orphan_text = orphan_result_text(&message.content, &known_uses);
995                let user_text = user_message_text(&message.content, &orphan_text);
996                let images = user_images(&message.content);
997                if !user_text.is_empty() || !images.is_empty() {
998                    turn += 1;
999                    steps.push(user_input_step(
1000                        &session_id,
1001                        &trajectory_id,
1002                        steps.len() as u64,
1003                        turn,
1004                        &user_text,
1005                        &images,
1006                        message.timestamp,
1007                    ));
1008                }
1009            }
1010            Role::Assistant => {
1011                let idx = steps.len() as u64;
1012                steps.push(planner_step(
1013                    &session_id,
1014                    &trajectory_id,
1015                    idx,
1016                    turn,
1017                    message,
1018                ));
1019                for block in &message.content {
1020                    if let Block::ToolUse { id, tool } = block {
1021                        steps.push(tool_step(
1022                            &session_id,
1023                            &trajectory_id,
1024                            steps.len() as u64,
1025                            turn,
1026                            id,
1027                            tool,
1028                            results.get(id),
1029                            message.timestamp,
1030                        ));
1031                    }
1032                }
1033            }
1034        }
1035    }
1036
1037    let transcript = render_transcript(&steps, messages);
1038    AntigravityDb {
1039        trajectory_meta: vec![TrajectoryMetaRow {
1040            trajectory_id: trajectory_id.clone(),
1041            cascade_id: session_id.clone(),
1042            trajectory_type: 4,
1043            source: 17,
1044        }],
1045        steps,
1046        gen_metadata: Vec::new(),
1047        executor_metadata: Vec::new(),
1048        parent_references: Vec::new(),
1049        battle_mode_infos: Vec::new(),
1050        trajectory_metadata_blob: vec![KeyedBlobRow {
1051            id: "main".to_string(),
1052            data: trajectory_metadata_proto(meta, &session_id),
1053        }],
1054        transcript: Some(transcript.clone()),
1055        transcript_full: Some(transcript),
1056    }
1057}
1058
1059fn user_message_text(blocks: &[Block], orphan_text: &str) -> String {
1060    let mut parts: Vec<String> = blocks
1061        .iter()
1062        .filter_map(|block| match block {
1063            Block::Text { text } if !text.trim().is_empty() => Some(text.trim().to_string()),
1064            Block::Thinking { text, .. } if !text.trim().is_empty() => {
1065                Some(text.trim().to_string())
1066            }
1067            // Images ride their own payload field; results ride tool steps.
1068            Block::Text { .. }
1069            | Block::Thinking { .. }
1070            | Block::ToolUse { .. }
1071            | Block::ToolResult { .. }
1072            | Block::Image { .. } => None,
1073        })
1074        .collect();
1075    if !orphan_text.is_empty() {
1076        parts.push(orphan_text.to_string());
1077    }
1078    parts.join("\n\n")
1079}
1080
1081fn user_images(blocks: &[Block]) -> Vec<&ImageSource> {
1082    blocks
1083        .iter()
1084        .filter_map(|block| match block {
1085            Block::Image { source } => Some(source),
1086            // Only images carry image data.
1087            Block::Text { .. }
1088            | Block::Thinking { .. }
1089            | Block::ToolUse { .. }
1090            | Block::ToolResult { .. } => None,
1091        })
1092        .collect()
1093}
1094
1095/// Results whose calls are absent from the transcript render as user text —
1096/// the same downgrade Cursor applies.
1097fn orphan_result_text(blocks: &[Block], known_uses: &std::collections::HashSet<String>) -> String {
1098    blocks
1099        .iter()
1100        .filter_map(|block| match block {
1101            Block::ToolResult { tool_use_id, .. } if known_uses.contains(tool_use_id) => None,
1102            Block::ToolResult {
1103                tool_use_id,
1104                content,
1105                is_error,
1106            } => {
1107                let status = if *is_error { "error" } else { "result" };
1108                Some(format!(
1109                    "Tool {status} for {tool_use_id}:\n{}",
1110                    tool_output_text(content)
1111                ))
1112            }
1113            // Other blocks are not tool results.
1114            Block::Text { .. }
1115            | Block::Thinking { .. }
1116            | Block::ToolUse { .. }
1117            | Block::Image { .. } => None,
1118        })
1119        .collect::<Vec<_>>()
1120        .join("\n\n")
1121}
1122
1123fn tool_output_text(out: &ToolOutput) -> String {
1124    match out {
1125        ToolOutput::Text(s) => s.clone(),
1126        ToolOutput::Json(v) => v.to_string(),
1127    }
1128}
1129
1130// Step assembly. Each helper returns a full `StepRow`: envelope fields, the
1131// metadata mirror column, and the type-specific payload.
1132
1133struct StepParts {
1134    step_type: i64,
1135    status: i64,
1136    metadata: Vec<u8>,
1137    payload_field: u32,
1138    payload_body: Vec<u8>,
1139}
1140
1141fn assemble_step(idx: u64, parts: StepParts) -> StepRow {
1142    let mut step_payload = Vec::new();
1143    pb_varint_field(
1144        &mut step_payload,
1145        1,
1146        u64::try_from(parts.step_type).unwrap_or(0),
1147    );
1148    pb_varint_field(
1149        &mut step_payload,
1150        4,
1151        u64::try_from(parts.status).unwrap_or(3),
1152    );
1153    pb_len(&mut step_payload, 5, &parts.metadata);
1154    pb_len(&mut step_payload, parts.payload_field, &parts.payload_body);
1155    StepRow {
1156        idx: i64::try_from(idx).unwrap_or(0),
1157        step_type: parts.step_type,
1158        status: parts.status,
1159        has_subtrajectory: false,
1160        metadata: parts.metadata,
1161        error_details: Vec::new(),
1162        permissions: Vec::new(),
1163        task_details: Vec::new(),
1164        render_info: Vec::new(),
1165        step_payload,
1166        step_format: 0,
1167    }
1168}
1169
1170/// `CortexStepMetadata` for one emitted step.
1171#[allow(clippy::too_many_arguments)]
1172fn step_metadata_proto(
1173    session_id: &str,
1174    trajectory_id: &str,
1175    idx: u64,
1176    turn: u64,
1177    source: u64,
1178    ts: DateTime<Utc>,
1179    tool_call: Option<&[u8]>,
1180    message: Option<&Message>,
1181) -> Vec<u8> {
1182    let mut out = Vec::new();
1183    pb_timestamp_field(&mut out, 1, ts);
1184    pb_varint_field(&mut out, 3, source);
1185    if let Some(call) = tool_call {
1186        pb_len(&mut out, 4, call);
1187    }
1188    if let Some(usage) = message.and_then(|m| m.usage.as_ref()) {
1189        let mut stats = Vec::new();
1190        if let Some(model) = message.and_then(|m| m.model.as_deref()).and_then(model_id) {
1191            pb_varint_field(&mut stats, 1, model);
1192        }
1193        pb_varint_field(&mut stats, 2, usage.input_tokens);
1194        pb_varint_field(&mut stats, 3, usage.output_tokens);
1195        if let Some(cache_write) = usage.cache_creation_input_tokens {
1196            pb_varint_field(&mut stats, 4, cache_write);
1197        }
1198        if let Some(cache_read) = usage.cache_read_input_tokens {
1199            pb_varint_field(&mut stats, 5, cache_read);
1200        }
1201        pb_len(&mut out, 9, &stats);
1202    }
1203    if let Some(model) = message.and_then(|m| m.model.as_deref()).and_then(model_id) {
1204        pb_varint_field(&mut out, 11, model);
1205    }
1206    let execution_id =
1207        Uuid::new_v5(&NS, format!("{session_id}:{turn}:exec").as_bytes()).to_string();
1208    pb_string(&mut out, 12, &execution_id);
1209    let mut step_info = Vec::new();
1210    pb_string(&mut step_info, 1, trajectory_id);
1211    if idx > 0 {
1212        pb_varint_field(&mut step_info, 2, idx);
1213    }
1214    if turn > 0 {
1215        pb_varint_field(&mut step_info, 3, turn.saturating_sub(1));
1216    }
1217    pb_string(&mut step_info, 4, session_id);
1218    pb_len(&mut out, 20, &step_info);
1219    pb_varint_field(&mut out, 21, 1);
1220    out
1221}
1222
1223fn user_input_step(
1224    session_id: &str,
1225    trajectory_id: &str,
1226    idx: u64,
1227    turn: u64,
1228    text: &str,
1229    images: &[&ImageSource],
1230    ts: DateTime<Utc>,
1231) -> StepRow {
1232    let mut body = Vec::new();
1233    pb_string(&mut body, 2, text);
1234    if !text.is_empty() {
1235        let mut item = Vec::new();
1236        pb_string(&mut item, 1, text);
1237        pb_len(&mut body, 3, &item);
1238    }
1239    for image in images {
1240        let mut data = Vec::new();
1241        pb_string(&mut data, 1, &image.data);
1242        pb_string(&mut data, 2, &image.media_type);
1243        pb_len(&mut body, 5, &data);
1244    }
1245    let metadata = step_metadata_proto(session_id, trajectory_id, idx, turn, 4, ts, None, None);
1246    assemble_step(
1247        idx,
1248        StepParts {
1249            step_type: STEP_USER_INPUT,
1250            status: STATUS_DONE,
1251            metadata,
1252            payload_field: 19,
1253            payload_body: body,
1254        },
1255    )
1256}
1257
1258fn planner_step(
1259    session_id: &str,
1260    trajectory_id: &str,
1261    idx: u64,
1262    turn: u64,
1263    message: &Message,
1264) -> StepRow {
1265    let mut body = Vec::new();
1266    let text = message
1267        .content
1268        .iter()
1269        .filter_map(|block| match block {
1270            Block::Text { text } if !text.trim().is_empty() => Some(text.trim().to_string()),
1271            Block::Image { source } => Some(format!("[image: {}]", source.media_type)),
1272            // Thinking and tool calls have their own slots; results ride
1273            // tool steps.
1274            Block::Text { .. }
1275            | Block::Thinking { .. }
1276            | Block::ToolUse { .. }
1277            | Block::ToolResult { .. } => None,
1278        })
1279        .collect::<Vec<_>>()
1280        .join("\n\n");
1281    pb_string(&mut body, 1, &text);
1282    let thinking = message.content.iter().find_map(|block| match block {
1283        Block::Thinking {
1284            text, signature, ..
1285        } if !text.trim().is_empty() => Some((text.clone(), signature.clone())),
1286        // Only substantive thinking has a native slot.
1287        Block::Text { .. }
1288        | Block::Thinking { .. }
1289        | Block::ToolUse { .. }
1290        | Block::ToolResult { .. }
1291        | Block::Image { .. } => None,
1292    });
1293    if let Some((thinking, signature)) = thinking {
1294        pb_string(&mut body, 3, &thinking);
1295        if let Some(signature) = signature {
1296            pb_string(&mut body, 4, &signature);
1297        }
1298    }
1299    let message_id = Uuid::new_v5(&NS, format!("{session_id}:{idx}:msg").as_bytes());
1300    pb_string(&mut body, 6, &format!("bot-{message_id}"));
1301    for block in &message.content {
1302        if let Block::ToolUse { id, tool } = block {
1303            pb_len(&mut body, 7, &chat_tool_call_proto(id, tool));
1304        }
1305    }
1306    pb_string(&mut body, 8, &text);
1307    if let Some(stop) = message.stop_reason.as_ref().and_then(stop_reason_to_native) {
1308        pb_varint_field(&mut body, 12, stop);
1309    }
1310    let metadata = step_metadata_proto(
1311        session_id,
1312        trajectory_id,
1313        idx,
1314        turn,
1315        2,
1316        message.timestamp,
1317        None,
1318        Some(message),
1319    );
1320    assemble_step(
1321        idx,
1322        StepParts {
1323            step_type: STEP_PLANNER_RESPONSE,
1324            status: STATUS_DONE,
1325            metadata,
1326            payload_field: 20,
1327            payload_body: body,
1328        },
1329    )
1330}
1331
1332/// `exa.codeium_common_pb.ChatToolCall` with the tool denormalized to its
1333/// native name and argument keys.
1334fn chat_tool_call_proto(id: &str, tool: &Tool) -> Vec<u8> {
1335    let (name, args) = denormalize_tool(tool);
1336    let mut out = Vec::new();
1337    pb_string(&mut out, 1, id);
1338    pb_string(&mut out, 2, &name);
1339    pb_string(&mut out, 3, &args.to_string());
1340    pb_string(&mut out, 9, &name);
1341    out
1342}
1343
1344#[allow(clippy::too_many_arguments)]
1345fn tool_step(
1346    session_id: &str,
1347    trajectory_id: &str,
1348    idx: u64,
1349    turn: u64,
1350    call_id: &str,
1351    tool: &Tool,
1352    result: Option<&PendingResult>,
1353    call_ts: DateTime<Utc>,
1354) -> StepRow {
1355    let ts = result.map_or(call_ts, |r| r.timestamp);
1356    let call = chat_tool_call_proto(call_id, tool);
1357    let (step_type, payload_field_no, body, status) = tool_step_payload(tool, result);
1358    let metadata = step_metadata_proto(
1359        session_id,
1360        trajectory_id,
1361        idx,
1362        turn,
1363        2,
1364        ts,
1365        Some(&call),
1366        None,
1367    );
1368    assemble_step(
1369        idx,
1370        StepParts {
1371            step_type,
1372            status,
1373            metadata,
1374            payload_field: payload_field_no,
1375            payload_body: body,
1376        },
1377    )
1378}
1379
1380/// The native payload for one call+result pair. Typed tools regenerate the
1381/// step kind Antigravity itself writes; everything else rides
1382/// `CortexStepGeneric` with the flattened result text.
1383// One arm per typed tool; each writes a different observed payload shape.
1384#[allow(clippy::too_many_lines)]
1385fn tool_step_payload(tool: &Tool, result: Option<&PendingResult>) -> (i64, u32, Vec<u8>, i64) {
1386    match tool {
1387        // A completed read/edit/write whose result content the native step
1388        // cannot carry (an error message, or free-form text where the payload
1389        // has no result slot) rides the generic carrier so the text survives.
1390        Tool::Read { .. } if result.is_some_and(|r| r.is_error) => generic_step_payload(result),
1391        Tool::Write { file_path, .. }
1392            if result.is_some_and(|r| {
1393                r.is_error || !is_derived_edit_result(r, file_path, "created")
1394            }) =>
1395        {
1396            generic_step_payload(result)
1397        }
1398        Tool::Edit { file_path, .. }
1399            if result
1400                .is_some_and(|r| r.is_error || !is_derived_edit_result(r, file_path, "edited")) =>
1401        {
1402            generic_step_payload(result)
1403        }
1404        Tool::Bash {
1405            command, workdir, ..
1406        } => {
1407            let mut body = Vec::new();
1408            pb_string(&mut body, 2, workdir.as_deref().unwrap_or_default());
1409            if let Some(result) = result {
1410                pb_varint_field(&mut body, 6, u64::from(result.is_error));
1411            }
1412            pb_varint_field(&mut body, 11, 1);
1413            pb_varint_field(&mut body, 12, 2000);
1414            if let Some(result) = result {
1415                let mut output = Vec::new();
1416                pb_string(&mut output, 1, &tool_output_text(&result.content));
1417                pb_len(&mut body, 21, &output);
1418            }
1419            pb_string(&mut body, 23, command);
1420            pb_string(&mut body, 25, command);
1421            // A failed command is a completed step; the exit code carries
1422            // the error.
1423            let status = if result.is_some() {
1424                STATUS_DONE
1425            } else {
1426                STATUS_PENDING
1427            };
1428            (STEP_RUN_COMMAND, 28, body, status)
1429        }
1430        Tool::Read {
1431            file_path,
1432            offset,
1433            limit,
1434        } => {
1435            let mut body = Vec::new();
1436            pb_string(&mut body, 1, &file_uri(file_path));
1437            if let Some(start) = offset {
1438                pb_varint_field(&mut body, 2, *start);
1439            }
1440            match (offset, limit) {
1441                (Some(start), Some(len)) => {
1442                    pb_varint_field(&mut body, 3, start + len.saturating_sub(1));
1443                }
1444                (None, Some(len)) => pb_varint_field(&mut body, 3, *len),
1445                (_, None) => {}
1446            }
1447            let status = match result {
1448                // Errored reads took the generic arm above.
1449                Some(r) => {
1450                    let text = tool_output_text(&r.content);
1451                    pb_string(&mut body, 4, &text);
1452                    pb_varint_field(&mut body, 11, text.lines().count() as u64);
1453                    pb_varint_field(&mut body, 12, text.len() as u64);
1454                    STATUS_DONE
1455                }
1456                None => STATUS_PENDING,
1457            };
1458            (STEP_VIEW_FILE, 14, body, status)
1459        }
1460        Tool::Write { file_path, content } => {
1461            let uri = file_uri(file_path);
1462            let mut create = Vec::new();
1463            pb_string(&mut create, 1, content);
1464            let mut path = Vec::new();
1465            pb_string(&mut path, 5, &uri);
1466            pb_len(&mut create, 2, &path);
1467            pb_varint_field(&mut create, 4, 1);
1468            let mut spec = Vec::new();
1469            pb_len(&mut spec, 2, &create);
1470
1471            let mut body = Vec::new();
1472            pb_len(&mut body, 1, &spec);
1473            if result.is_some() {
1474                let mut edit = Vec::new();
1475                pb_string(&mut edit, 8, &uri);
1476                pb_varint_field(&mut edit, 11, 1);
1477                let mut action_result = Vec::new();
1478                pb_len(&mut action_result, 1, &edit);
1479                pb_len(&mut body, 2, &action_result);
1480            }
1481            pb_varint_field(&mut body, 4, 1);
1482            // Errored and free-form results took the generic arm above.
1483            let status = if result.is_some() {
1484                STATUS_DONE
1485            } else {
1486                STATUS_PENDING
1487            };
1488            (STEP_CODE_ACTION, 10, body, status)
1489        }
1490        Tool::Edit {
1491            file_path,
1492            old_string,
1493            new_string,
1494            replace_all,
1495        } => {
1496            let uri = file_uri(file_path);
1497            let mut chunk = Vec::new();
1498            pb_string(&mut chunk, 1, old_string);
1499            pb_string(&mut chunk, 2, new_string);
1500            if *replace_all {
1501                pb_varint_field(&mut chunk, 3, 1);
1502            }
1503            let mut command = Vec::new();
1504            pb_varint_field(&mut command, 2, 1);
1505            let mut path = Vec::new();
1506            pb_string(&mut path, 5, &uri);
1507            pb_len(&mut command, 4, &path);
1508            pb_varint_field(&mut command, 8, 1);
1509            pb_len(&mut command, 9, &chunk);
1510            let mut spec = Vec::new();
1511            pb_len(&mut spec, 1, &command);
1512
1513            let mut body = Vec::new();
1514            pb_len(&mut body, 1, &spec);
1515            if result.is_some() {
1516                let mut edit = Vec::new();
1517                pb_string(&mut edit, 8, &uri);
1518                pb_string(&mut edit, 10, old_string);
1519                let mut action_result = Vec::new();
1520                pb_len(&mut action_result, 1, &edit);
1521                pb_len(&mut body, 2, &action_result);
1522            }
1523            pb_varint_field(&mut body, 4, 1);
1524            // Errored and free-form results took the generic arm above.
1525            let status = if result.is_some() {
1526                STATUS_DONE
1527            } else {
1528                STATUS_PENDING
1529            };
1530            (STEP_CODE_ACTION, 10, body, status)
1531        }
1532        Tool::MultiEdit { .. } | Tool::Raw { .. } | Tool::Command { .. } => {
1533            generic_step_payload(result)
1534        }
1535    }
1536}
1537
1538/// `CortexStepGeneric` carrying the flattened result text; the call itself
1539/// rides the step metadata's `tool_call`.
1540fn generic_step_payload(result: Option<&PendingResult>) -> (i64, u32, Vec<u8>, i64) {
1541    let mut body = Vec::new();
1542    let status = match result {
1543        Some(r) => {
1544            let mut generic_result = Vec::new();
1545            pb_string(&mut generic_result, 1, &tool_output_text(&r.content));
1546            pb_len(&mut body, 2, &generic_result);
1547            if r.is_error {
1548                STATUS_ERROR
1549            } else {
1550                STATUS_DONE
1551            }
1552        }
1553        None => STATUS_PENDING,
1554    };
1555    (STEP_GENERIC, 140, body, status)
1556}
1557
1558/// Whether a result is exactly the canonical derived form `to_common`
1559/// produces for a native edit step — the only content `CortexStepCodeAction`
1560/// can represent.
1561fn is_derived_edit_result(result: &PendingResult, file_path: &str, key: &str) -> bool {
1562    matches!(&result.content, ToolOutput::Json(v) if *v == json!({ "file": file_path, key: true }))
1563}
1564
1565/// `exa.cortex_pb.CortexTrajectoryMetadata` for the `main` row.
1566fn trajectory_metadata_proto(meta: &Meta, session_id: &str) -> Vec<u8> {
1567    let mut out = Vec::new();
1568    if let Some(cwd) = meta.cwd.as_deref() {
1569        let uri = file_uri(cwd);
1570        let mut workspace = Vec::new();
1571        pb_string(&mut workspace, 1, &uri);
1572        pb_string(&mut workspace, 2, &uri);
1573        if let Some(branch) = meta.git_branch.as_deref() {
1574            pb_string(&mut workspace, 4, branch);
1575        }
1576        pb_len(&mut out, 1, &workspace);
1577        pb_string(&mut out, 7, &uri);
1578    }
1579    pb_timestamp_field(&mut out, 2, meta.timestamp);
1580    pb_string(&mut out, 6, session_id);
1581    pb_string(&mut out, 18, "default-cli-project");
1582    out
1583}
1584
1585/// The display-log mirror of the emitted steps, one JSON object per line —
1586/// best-effort historical reconstruction of what the CLI itself logs.
1587fn render_transcript(steps: &[StepRow], messages: &[Message]) -> String {
1588    let _ = messages;
1589    let mut out = String::new();
1590    for step in steps {
1591        let payload = pb_fields(&step.step_payload);
1592        let meta_fields = pb_sub(&payload, 5).map(pb_fields).unwrap_or_default();
1593        let ts = pb_sub(&meta_fields, 1).and_then(pb_timestamp);
1594        let (source, type_name) = match step.step_type {
1595            STEP_USER_INPUT => ("USER_EXPLICIT", "USER_INPUT"),
1596            STEP_PLANNER_RESPONSE => ("MODEL", "PLANNER_RESPONSE"),
1597            STEP_RUN_COMMAND => ("MODEL", "RUN_COMMAND"),
1598            STEP_VIEW_FILE => ("MODEL", "VIEW_FILE"),
1599            STEP_LIST_DIRECTORY => ("MODEL", "LIST_DIRECTORY"),
1600            STEP_CODE_ACTION => ("MODEL", "CODE_ACTION"),
1601            STEP_GREP_SEARCH => ("MODEL", "GREP_SEARCH"),
1602            STEP_MCP_TOOL => ("MODEL", "MCP_TOOL"),
1603            STEP_GENERIC => ("MODEL", "GENERIC"),
1604            _ => ("SYSTEM", "UNKNOWN"),
1605        };
1606        let mut line = json!({
1607            "step_index": step.idx,
1608            "source": source,
1609            "type": type_name,
1610            "status": if step.status == STATUS_ERROR { "ERROR" } else { "DONE" },
1611        });
1612        if let Some(ts) = ts {
1613            line["created_at"] = Value::String(ts.format("%Y-%m-%dT%H:%M:%SZ").to_string());
1614        }
1615        let content = match step.step_type {
1616            STEP_USER_INPUT => pb_sub(&payload, 19)
1617                .map(pb_fields)
1618                .and_then(|input| pb_str(&input, 2)),
1619            STEP_PLANNER_RESPONSE => pb_sub(&payload, 20)
1620                .map(pb_fields)
1621                .and_then(|planner| pb_str(&planner, 1)),
1622            _ => {
1623                let (output, _) = result_output(step.step_type, &payload);
1624                Some(tool_output_text(&output))
1625            }
1626        };
1627        if let Some(content) = content.filter(|c| !c.is_empty()) {
1628            line["content"] = Value::String(content);
1629        }
1630        out.push_str(&line.to_string());
1631        out.push('\n');
1632    }
1633    out
1634}
1635
1636// -- metadata --------------------------------------------------------------
1637
1638fn meta_from_db(db: &AntigravityDb) -> Meta {
1639    let main_blob = db
1640        .trajectory_metadata_blob
1641        .iter()
1642        .find(|row| row.id == "main")
1643        .map(|row| pb_fields(&row.data))
1644        .unwrap_or_default();
1645
1646    let id = db
1647        .trajectory_meta
1648        .first()
1649        .map(|row| row.cascade_id.clone())
1650        .filter(|id| !id.is_empty())
1651        .or_else(|| pb_str(&main_blob, 6))
1652        .unwrap_or_default();
1653
1654    let workspace = pb_sub(&main_blob, 1).map(pb_fields).unwrap_or_default();
1655    let cwd = pb_str(&workspace, 1)
1656        .or_else(|| pb_str(&main_blob, 7))
1657        .as_deref()
1658        .map(strip_file_uri)
1659        .filter(|s| !s.is_empty());
1660    let git_branch = pb_str(&workspace, 4).filter(|s| !s.is_empty());
1661
1662    let mut steps: Vec<&StepRow> = db.steps.iter().collect();
1663    steps.sort_by_key(|s| s.idx);
1664
1665    let timestamp = pb_sub(&main_blob, 2)
1666        .and_then(pb_timestamp)
1667        .or_else(|| {
1668            steps.iter().find_map(|step| {
1669                let payload = pb_fields(&step.step_payload);
1670                let meta_fields = pb_sub(&payload, 5).map(pb_fields)?;
1671                pb_sub(&meta_fields, 1).and_then(pb_timestamp)
1672            })
1673        })
1674        .unwrap_or_else(|| DateTime::from_timestamp_millis(0).unwrap_or_else(Utc::now));
1675
1676    // The latest checkpoint's title wins; the first user message seeds a
1677    // fallback.
1678    let title = steps
1679        .iter()
1680        .rev()
1681        .filter(|step| step.step_type == STEP_CHECKPOINT)
1682        .find_map(|step| {
1683            let payload = pb_fields(&step.step_payload);
1684            let checkpoint = pb_sub(&payload, 30).map(pb_fields)?;
1685            pb_str(&checkpoint, 10)
1686                .filter(|s| !s.trim().is_empty())
1687                .or_else(|| pb_str(&checkpoint, 4).filter(|s| !s.trim().is_empty()))
1688        })
1689        .or_else(|| {
1690            steps
1691                .iter()
1692                .filter(|step| step.step_type == STEP_USER_INPUT)
1693                .find_map(|step| {
1694                    let payload = pb_fields(&step.step_payload);
1695                    let input = pb_sub(&payload, 19).map(pb_fields)?;
1696                    pb_str(&input, 2).filter(|s| !s.trim().is_empty())
1697                })
1698                .map(|text| truncate_title(&text))
1699        });
1700
1701    let model = steps
1702        .iter()
1703        .rev()
1704        .filter(|step| step.step_type == STEP_PLANNER_RESPONSE)
1705        .find_map(|step| {
1706            let payload = pb_fields(&step.step_payload);
1707            let meta_fields = pb_sub(&payload, 5).map(pb_fields)?;
1708            pb_uint(&meta_fields, 11).map(model_name)
1709        });
1710
1711    Meta {
1712        id,
1713        timestamp,
1714        cwd,
1715        git_branch,
1716        title,
1717        cli_version: None,
1718        model,
1719    }
1720}
1721
1722fn truncate_title(text: &str) -> String {
1723    const MAX: usize = 80;
1724    let one_line = text.split_whitespace().collect::<Vec<_>>().join(" ");
1725    if one_line.chars().count() <= MAX {
1726        one_line
1727    } else {
1728        let mut t: String = one_line.chars().take(MAX.saturating_sub(1)).collect();
1729        t.push('…');
1730        t
1731    }
1732}
1733
1734// -- store -----------------------------------------------------------------
1735
1736/// Reads and writes Antigravity conversations under a CLI data root
1737/// (default `~/.gemini/antigravity-cli`).
1738#[derive(Debug, Clone)]
1739pub struct AntigravityStore {
1740    pub root: PathBuf,
1741}
1742
1743impl AntigravityStore {
1744    pub fn new(root: impl Into<PathBuf>) -> Self {
1745        Self { root: root.into() }
1746    }
1747
1748    /// The default data root, `~/.gemini/antigravity-cli`.
1749    #[must_use]
1750    pub fn default_root() -> Option<Self> {
1751        super::home_dir().map(|home| Self::new(home.join(".gemini").join("antigravity-cli")))
1752    }
1753
1754    #[cfg(feature = "opencode")]
1755    fn conversations_dir(&self) -> PathBuf {
1756        self.root.join("conversations")
1757    }
1758
1759    #[cfg(feature = "opencode")]
1760    fn brain_dir(&self, id: &str) -> PathBuf {
1761        self.root.join("brain").join(id)
1762    }
1763}
1764
1765#[cfg(feature = "opencode")]
1766impl Store for AntigravityStore {
1767    type H = Antigravity;
1768    type Ref = PathBuf;
1769
1770    fn discover(&self) -> Result<Vec<Discovered<PathBuf>>> {
1771        // A missing root has no sessions; unreadable entries are skipped.
1772        Ok(fs::read_dir(self.conversations_dir())
1773            .into_iter()
1774            .flatten()
1775            .flatten()
1776            .map(|entry| entry.path())
1777            .filter(|path| path.extension().is_some_and(|ext| ext == "db"))
1778            .filter_map(|path| {
1779                // Pre-`.db` `.pb` files and foreign SQLite files don't parse.
1780                let body = read_db(&path, &self.root).ok()?;
1781                let mut meta = meta_from_db(&body);
1782                backfill_meta(&mut meta, &path);
1783                Some(Discovered {
1784                    meta,
1785                    reference: path,
1786                })
1787            })
1788            .collect())
1789    }
1790
1791    fn load(&self, reference: &PathBuf) -> Result<Transcript<Antigravity>> {
1792        let body = read_db(reference, &self.root)?;
1793        let mut meta = meta_from_db(&body);
1794        backfill_meta(&mut meta, reference);
1795        Ok(Transcript::new(meta, body))
1796    }
1797
1798    fn save(&self, transcript: &Transcript<Antigravity>) -> Result<Saved<PathBuf>> {
1799        let id = [
1800            transcript.meta.id.clone(),
1801            transcript
1802                .body
1803                .trajectory_meta
1804                .first()
1805                .map(|row| row.cascade_id.clone())
1806                .unwrap_or_default(),
1807        ]
1808        .into_iter()
1809        .find(|candidate| !candidate.is_empty())
1810        .unwrap_or_else(|| Uuid::new_v4().to_string());
1811        super::checked_id_component(Antigravity::NAME, &id)?;
1812
1813        let conversations = self.conversations_dir();
1814        fs::create_dir_all(&conversations)?;
1815        let db_path = conversations.join(format!("{id}.db"));
1816        write_db(&db_path, &transcript.body)?;
1817
1818        // The CLI wedges on resume when `brain/<id>/` is absent; contents are
1819        // optional.
1820        let logs_dir = self.brain_dir(&id).join(".system_generated").join("logs");
1821        fs::create_dir_all(&logs_dir)?;
1822        if let Some(text) = transcript.body.transcript.as_deref() {
1823            fs::write(logs_dir.join("transcript.jsonl"), text)?;
1824        }
1825        if let Some(text) = transcript.body.transcript_full.as_deref() {
1826            fs::write(logs_dir.join("transcript_full.jsonl"), text)?;
1827        }
1828
1829        Ok(Saved {
1830            id,
1831            reference: db_path,
1832        })
1833    }
1834
1835    /// The `Ref` is the conversation database; deleting removes it, its WAL
1836    /// sidecars, and the session's brain directory. Guarded on shape and
1837    /// containment: the database must resolve (symlinks and all) to a file
1838    /// directly under `<root>/conversations`, and its stem — which names the
1839    /// brain directory — must be a plain path component, so a stale or
1840    /// foreign reference never removes files outside the configured root.
1841    fn delete(&self, reference: &PathBuf) -> Result<()> {
1842        let id = reference
1843            .file_stem()
1844            .map(|s| s.to_string_lossy().into_owned())
1845            .filter(|_| reference.extension().is_some_and(|ext| ext == "db"))
1846            .ok_or_else(|| Error::Malformed {
1847                harness: Antigravity::NAME,
1848                detail: format!("not a conversation db path: {}", reference.display()),
1849            })?;
1850        super::checked_id_component(Antigravity::NAME, &id)?;
1851        let canon = reference.canonicalize()?;
1852        let conversations = self.conversations_dir().canonicalize()?;
1853        if canon.parent() != Some(conversations.as_path()) {
1854            return Err(Error::Malformed {
1855                harness: Antigravity::NAME,
1856                detail: format!(
1857                    "refusing to delete outside the conversations root: {}",
1858                    reference.display()
1859                ),
1860            });
1861        }
1862        fs::remove_file(&canon)?;
1863        for suffix in ["db-wal", "db-shm"] {
1864            let sidecar = canon.with_extension(suffix);
1865            if sidecar.exists() {
1866                fs::remove_file(sidecar)?;
1867            }
1868        }
1869        let brain = self.brain_dir(&id);
1870        if brain.is_dir() {
1871            fs::remove_dir_all(brain)?;
1872        }
1873        Ok(())
1874    }
1875
1876    fn fingerprints(&self, refs: &[PathBuf]) -> Result<HashMap<String, String>> {
1877        let mut out = HashMap::with_capacity(refs.len());
1878        for path in refs {
1879            out.insert(path.to_string_lossy().into_owned(), file_fingerprint(path));
1880        }
1881        Ok(out)
1882    }
1883}
1884
1885#[cfg(not(feature = "opencode"))]
1886impl Store for AntigravityStore {
1887    type H = Antigravity;
1888    type Ref = PathBuf;
1889
1890    fn discover(&self) -> Result<Vec<Discovered<PathBuf>>> {
1891        Ok(Vec::new())
1892    }
1893
1894    fn load(&self, _reference: &PathBuf) -> Result<Transcript<Antigravity>> {
1895        Err(sqlite_unavailable())
1896    }
1897
1898    fn save(&self, _transcript: &Transcript<Antigravity>) -> Result<Saved<PathBuf>> {
1899        Err(sqlite_unavailable())
1900    }
1901
1902    fn delete(&self, _reference: &PathBuf) -> Result<()> {
1903        Err(sqlite_unavailable())
1904    }
1905}
1906
1907#[cfg(not(feature = "opencode"))]
1908fn sqlite_unavailable() -> Error {
1909    Error::Unconvertible {
1910        harness: Antigravity::NAME,
1911        detail: "Antigravity store support requires the `opencode` feature for SQLite".to_string(),
1912    }
1913}
1914
1915#[cfg(feature = "opencode")]
1916fn backfill_meta(meta: &mut Meta, db_path: &Path) {
1917    if meta.id.is_empty() {
1918        meta.id = db_path
1919            .file_stem()
1920            .map(|s| s.to_string_lossy().into_owned())
1921            .unwrap_or_default();
1922    }
1923    if meta.timestamp.timestamp_millis() == 0 {
1924        meta.timestamp = fs::metadata(db_path)
1925            .ok()
1926            .and_then(|m| m.modified().ok())
1927            .map_or_else(Utc::now, DateTime::from);
1928    }
1929}
1930
1931// One straight-line SELECT per table; splitting hides the schema.
1932#[allow(clippy::too_many_lines)]
1933#[cfg(feature = "opencode")]
1934fn read_db(db_path: &Path, root: &Path) -> Result<AntigravityDb> {
1935    let conn = Connection::open_with_flags(
1936        db_path,
1937        OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
1938    )
1939    .map_err(sqlite_err)?;
1940
1941    let trajectory_meta = {
1942        let mut stmt = conn
1943            .prepare(
1944                "SELECT trajectory_id, cascade_id, trajectory_type, source \
1945                 FROM trajectory_meta ORDER BY trajectory_id",
1946            )
1947            .map_err(sqlite_err)?;
1948        let rows = stmt
1949            .query_map([], |row| {
1950                Ok(TrajectoryMetaRow {
1951                    trajectory_id: row.get(0)?,
1952                    cascade_id: row.get(1)?,
1953                    trajectory_type: row.get(2)?,
1954                    source: row.get(3)?,
1955                })
1956            })
1957            .map_err(sqlite_err)?;
1958        rows.collect::<std::result::Result<Vec<_>, _>>()
1959            .map_err(sqlite_err)?
1960    };
1961
1962    let steps = {
1963        let mut stmt = conn
1964            .prepare(
1965                "SELECT idx, step_type, status, has_subtrajectory, metadata, error_details, \
1966                 permissions, task_details, render_info, step_payload, step_format \
1967                 FROM steps ORDER BY idx",
1968            )
1969            .map_err(sqlite_err)?;
1970        let rows = stmt
1971            .query_map([], |row| {
1972                Ok(StepRow {
1973                    idx: row.get(0)?,
1974                    step_type: row.get(1)?,
1975                    status: row.get(2)?,
1976                    has_subtrajectory: row.get::<_, Option<bool>>(3)?.unwrap_or(false),
1977                    metadata: row.get::<_, Option<Vec<u8>>>(4)?.unwrap_or_default(),
1978                    error_details: row.get::<_, Option<Vec<u8>>>(5)?.unwrap_or_default(),
1979                    permissions: row.get::<_, Option<Vec<u8>>>(6)?.unwrap_or_default(),
1980                    task_details: row.get::<_, Option<Vec<u8>>>(7)?.unwrap_or_default(),
1981                    render_info: row.get::<_, Option<Vec<u8>>>(8)?.unwrap_or_default(),
1982                    step_payload: row.get::<_, Option<Vec<u8>>>(9)?.unwrap_or_default(),
1983                    step_format: row.get::<_, Option<i64>>(10)?.unwrap_or(0),
1984                })
1985            })
1986            .map_err(sqlite_err)?;
1987        rows.collect::<std::result::Result<Vec<_>, _>>()
1988            .map_err(sqlite_err)?
1989    };
1990
1991    let sized_rows = |table: &str| -> Result<Vec<SizedBlobRow>> {
1992        let mut stmt = conn
1993            .prepare(&format!("SELECT idx, data, size FROM {table} ORDER BY idx"))
1994            .map_err(sqlite_err)?;
1995        let rows = stmt
1996            .query_map([], |row| {
1997                Ok(SizedBlobRow {
1998                    idx: row.get(0)?,
1999                    data: row.get::<_, Option<Vec<u8>>>(1)?.unwrap_or_default(),
2000                    size: row.get::<_, Option<i64>>(2)?.unwrap_or(0),
2001                })
2002            })
2003            .map_err(sqlite_err)?;
2004        rows.collect::<std::result::Result<Vec<_>, _>>()
2005            .map_err(sqlite_err)
2006    };
2007    let indexed_rows = |table: &str| -> Result<Vec<IndexedBlobRow>> {
2008        let mut stmt = conn
2009            .prepare(&format!("SELECT idx, data FROM {table} ORDER BY idx"))
2010            .map_err(sqlite_err)?;
2011        let rows = stmt
2012            .query_map([], |row| {
2013                Ok(IndexedBlobRow {
2014                    idx: row.get(0)?,
2015                    data: row.get::<_, Option<Vec<u8>>>(1)?.unwrap_or_default(),
2016                })
2017            })
2018            .map_err(sqlite_err)?;
2019        rows.collect::<std::result::Result<Vec<_>, _>>()
2020            .map_err(sqlite_err)
2021    };
2022
2023    let trajectory_metadata_blob = {
2024        let mut stmt = conn
2025            .prepare("SELECT id, data FROM trajectory_metadata_blob ORDER BY id")
2026            .map_err(sqlite_err)?;
2027        let rows = stmt
2028            .query_map([], |row| {
2029                Ok(KeyedBlobRow {
2030                    id: row.get(0)?,
2031                    data: row.get::<_, Option<Vec<u8>>>(1)?.unwrap_or_default(),
2032                })
2033            })
2034            .map_err(sqlite_err)?;
2035        rows.collect::<std::result::Result<Vec<_>, _>>()
2036            .map_err(sqlite_err)?
2037    };
2038
2039    let id = db_path
2040        .file_stem()
2041        .map(|s| s.to_string_lossy().into_owned())
2042        .unwrap_or_default();
2043    let logs = root
2044        .join("brain")
2045        .join(&id)
2046        .join(".system_generated")
2047        .join("logs");
2048    let transcript = fs::read_to_string(logs.join("transcript.jsonl")).ok();
2049    let transcript_full = fs::read_to_string(logs.join("transcript_full.jsonl")).ok();
2050
2051    Ok(AntigravityDb {
2052        trajectory_meta,
2053        steps,
2054        gen_metadata: sized_rows("gen_metadata")?,
2055        executor_metadata: indexed_rows("executor_metadata")?,
2056        parent_references: indexed_rows("parent_references")?,
2057        battle_mode_infos: indexed_rows("battle_mode_infos")?,
2058        trajectory_metadata_blob,
2059        transcript,
2060        transcript_full,
2061    })
2062}
2063
2064#[cfg(feature = "opencode")]
2065fn write_db(db_path: &Path, body: &AntigravityDb) -> Result<()> {
2066    let mut conn = Connection::open(db_path).map_err(sqlite_err)?;
2067    // One transaction around the wipe and every insert: a mid-write failure
2068    // (disk full, the CLI holding the database busy) rolls back to the
2069    // original store instead of leaving it truncated.
2070    let tx = conn.transaction().map_err(sqlite_err)?;
2071    tx.execute_batch(
2072        // user_version 1 matches the CLI's own migration stamp; a zero
2073        // version makes the CLI treat the database as uninitialized.
2074        "PRAGMA user_version = 1;\n\
2075         CREATE TABLE IF NOT EXISTS `trajectory_meta` (`trajectory_id` text,`cascade_id` text,\
2076         `trajectory_type` integer,`source` integer,PRIMARY KEY (`trajectory_id`));\n\
2077         CREATE TABLE IF NOT EXISTS `steps` (`idx` integer,`step_type` integer NOT NULL DEFAULT 0,\
2078         `status` integer NOT NULL DEFAULT 0,`has_subtrajectory` numeric NOT NULL DEFAULT false,\
2079         `metadata` blob,`error_details` blob,`permissions` blob,`task_details` blob,\
2080         `render_info` blob,`step_payload` blob,`step_format` integer NOT NULL DEFAULT 0,\
2081         PRIMARY KEY (`idx`));\n\
2082         CREATE INDEX IF NOT EXISTS `idx_steps_status` ON `steps`(`status`);\n\
2083         CREATE INDEX IF NOT EXISTS `idx_steps_step_type` ON `steps`(`step_type`);\n\
2084         CREATE TABLE IF NOT EXISTS `gen_metadata` (`idx` integer,`data` blob,\
2085         `size` integer NOT NULL DEFAULT 0,PRIMARY KEY (`idx`));\n\
2086         CREATE TABLE IF NOT EXISTS `executor_metadata` (`idx` integer,`data` blob,\
2087         PRIMARY KEY (`idx`));\n\
2088         CREATE TABLE IF NOT EXISTS `parent_references` (`idx` integer,`data` blob,\
2089         PRIMARY KEY (`idx`));\n\
2090         CREATE TABLE IF NOT EXISTS `trajectory_metadata_blob` (`id` text DEFAULT \"main\",\
2091         `data` blob,PRIMARY KEY (`id`));\n\
2092         CREATE TABLE IF NOT EXISTS `battle_mode_infos` (`idx` integer,`data` blob,\
2093         PRIMARY KEY (`idx`));\n\
2094         DELETE FROM trajectory_meta; DELETE FROM steps; DELETE FROM gen_metadata;\n\
2095         DELETE FROM executor_metadata; DELETE FROM parent_references;\n\
2096         DELETE FROM trajectory_metadata_blob; DELETE FROM battle_mode_infos;",
2097    )
2098    .map_err(sqlite_err)?;
2099
2100    for row in &body.trajectory_meta {
2101        tx.execute(
2102            "INSERT OR REPLACE INTO trajectory_meta \
2103             (trajectory_id, cascade_id, trajectory_type, source) VALUES (?1, ?2, ?3, ?4)",
2104            params![
2105                row.trajectory_id,
2106                row.cascade_id,
2107                row.trajectory_type,
2108                row.source
2109            ],
2110        )
2111        .map_err(sqlite_err)?;
2112    }
2113    for step in &body.steps {
2114        tx.execute(
2115            "INSERT OR REPLACE INTO steps (idx, step_type, status, has_subtrajectory, metadata, \
2116             error_details, permissions, task_details, render_info, step_payload, step_format) \
2117             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
2118            params![
2119                step.idx,
2120                step.step_type,
2121                step.status,
2122                step.has_subtrajectory,
2123                step.metadata,
2124                step.error_details,
2125                step.permissions,
2126                step.task_details,
2127                step.render_info,
2128                step.step_payload,
2129                step.step_format,
2130            ],
2131        )
2132        .map_err(sqlite_err)?;
2133    }
2134    for row in &body.gen_metadata {
2135        tx.execute(
2136            "INSERT OR REPLACE INTO gen_metadata (idx, data, size) VALUES (?1, ?2, ?3)",
2137            params![row.idx, row.data, row.size],
2138        )
2139        .map_err(sqlite_err)?;
2140    }
2141    let indexed = [
2142        ("executor_metadata", &body.executor_metadata),
2143        ("parent_references", &body.parent_references),
2144        ("battle_mode_infos", &body.battle_mode_infos),
2145    ];
2146    for (table, rows) in indexed {
2147        for row in rows {
2148            tx.execute(
2149                &format!("INSERT OR REPLACE INTO {table} (idx, data) VALUES (?1, ?2)"),
2150                params![row.idx, row.data],
2151            )
2152            .map_err(sqlite_err)?;
2153        }
2154    }
2155    for row in &body.trajectory_metadata_blob {
2156        tx.execute(
2157            "INSERT OR REPLACE INTO trajectory_metadata_blob (id, data) VALUES (?1, ?2)",
2158            params![row.id, row.data],
2159        )
2160        .map_err(sqlite_err)?;
2161    }
2162    tx.commit().map_err(sqlite_err)
2163}
2164
2165// Passed point-free to `map_err`, which hands over the error by value.
2166#[allow(clippy::needless_pass_by_value)]
2167#[cfg(feature = "opencode")]
2168fn sqlite_err(e: rusqlite::Error) -> Error {
2169    Error::Malformed {
2170        harness: Antigravity::NAME,
2171        detail: e.to_string(),
2172    }
2173}
2174
2175#[cfg(feature = "opencode")]
2176fn file_fingerprint(path: &Path) -> String {
2177    // An unreadable file fingerprints as empty.
2178    fs::metadata(path).map_or_else(
2179        |_| String::new(),
2180        |meta| {
2181            let mtime = meta
2182                .modified()
2183                .ok()
2184                .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
2185                .map_or(0, |d| d.as_nanos());
2186            format!("{mtime}:{}", meta.len())
2187        },
2188    )
2189}
2190
2191// -- hex -------------------------------------------------------------------
2192
2193fn hex_encode(bytes: &[u8]) -> String {
2194    const HEX: &[u8; 16] = b"0123456789abcdef";
2195    let mut out = String::with_capacity(bytes.len() * 2);
2196    for &byte in bytes {
2197        out.push(char::from(HEX[(byte >> 4) as usize]));
2198        out.push(char::from(HEX[(byte & 0x0f) as usize]));
2199    }
2200    out
2201}
2202
2203fn hex_decode(s: &str) -> std::result::Result<Vec<u8>, String> {
2204    let bytes = s.as_bytes();
2205    if bytes.len().is_multiple_of(2) {
2206        bytes
2207            .chunks_exact(2)
2208            .map(|pair| Ok((hex_value(pair[0])? << 4) | hex_value(pair[1])?))
2209            .collect()
2210    } else {
2211        Err("hex string has odd length".to_string())
2212    }
2213}
2214
2215fn hex_value(b: u8) -> std::result::Result<u8, String> {
2216    match b {
2217        b'0'..=b'9' => Ok(b - b'0'),
2218        b'a'..=b'f' => Ok(b - b'a' + 10),
2219        b'A'..=b'F' => Ok(b - b'A' + 10),
2220        _ => Err(format!("invalid hex byte `{}`", char::from(b))),
2221    }
2222}
2223
2224mod serde_hex {
2225    use serde::{Deserialize, Deserializer, Serializer, de};
2226
2227    pub fn serialize<S>(bytes: &[u8], serializer: S) -> std::result::Result<S::Ok, S::Error>
2228    where
2229        S: Serializer,
2230    {
2231        serializer.serialize_str(&super::hex_encode(bytes))
2232    }
2233
2234    pub fn deserialize<'de, D>(deserializer: D) -> std::result::Result<Vec<u8>, D::Error>
2235    where
2236        D: Deserializer<'de>,
2237    {
2238        let s = String::deserialize(deserializer)?;
2239        super::hex_decode(&s).map_err(de::Error::custom)
2240    }
2241}