package parser import ( "bufio" "crypto/sha256" "database/sql" "encoding/json" "fmt" "io" "os" "path/filepath" "sort" "strconv" "strings" "time" "github.com/tidwall/gjson" ) // maxTrajectorySidecarBytes caps how much of a .trajectory.json // sidecar the parser will read. Real transcripts observed at the time // of writing are well under 1 MB; the cap is a defense against a // buggy or hostile sidecar writer dropping a multi-GB file in the // session directory. See SECURITY.md ("Imports and new readers"): // sidecars are treated as untrusted structured input. const maxTrajectorySidecarBytes = 64 << 20 // Antigravity CLI sessions live under ~/.gemini/antigravity-cli/: // // conversations/.db SQLite trajectory DB (newer CLI releases) // conversations/.pb AES-encrypted conversation stream // implicit/.pb AES-encrypted implicit conversation // brain//*.md(+.json) plaintext task/plan/walkthrough docs // history.jsonl user-prompt log (one row per turn) // cache/last_conversations.json workspace -> conversationId // // Without the AES key (ANTIGRAVITY_KEY) we still produce a useful // session from the brain/ artifacts and the matching history.jsonl // rows. With the key we additionally append the decrypted transcript // preview (raw extracted strings) as a synthetic assistant message. const ( antigravityCLIIDPrefix = "antigravity-cli:" // antigravityImplicitTag distinguishes implicit/.pb from // conversations/.pb in the storage ID. Both can exist for // the same UUID; without a tag they collide as // "antigravity-cli:" and one record overwrites the other. // Hyphen keeps the tag inside the IsValidSessionID charset. antigravityImplicitTag = "implicit-" ) func antigravityCLIPathID(name string) (string, string, bool) { for _, ext := range []string{".db", ".pb"} { if !strings.HasSuffix(name, ext) { continue } id := strings.TrimSuffix(name, ext) if IsValidSessionID(id) { return id, ext, true } } return "", "", false } // AntigravityCLIParseStatus carries sync-relevant parser metadata // that is not part of the canonical session/message shape. type AntigravityCLIParseStatus struct { // NeedsRetry is true when a high-resolution source failed to // decode and the parser returned lower-resolution fallback // messages instead. Sync can store the fallback while leaving // data_version stale so the next pass retries. NeedsRetry bool } // parseSessionWithStatus parses one CLI session into the canonical // ParsedSession + messages shape and reports whether the result should be // retried on the next sync. It is owned by the antigravityCLIProvider; the // package-level ParseAntigravityCLISessionWithStatus entrypoint was folded onto // the provider. func (p *antigravityCLIProvider) parseSessionWithStatus( path, project, machine string, ) (*ParsedSession, []ParsedMessage, []ParsedUsageEvent, AntigravityCLIParseStatus, error) { var status AntigravityCLIParseStatus info, err := os.Stat(path) if err != nil { return nil, nil, nil, status, fmt.Errorf("stat %s: %w", path, err) } id, ext, ok := antigravityCLIPathID(filepath.Base(path)) if !ok { return nil, nil, nil, status, fmt.Errorf( "invalid Antigravity CLI session filename: %s", path, ) } // Root = two levels up from the source file // (conversations/.db, conversations/.pb, or implicit/.pb). root := filepath.Dir(filepath.Dir(path)) // Tag implicit/ sessions so they don't collide with the // conversations/ entry that may share the same UUID. storageID := id if filepath.Base(filepath.Dir(path)) == "implicit" { storageID = antigravityImplicitTag + id } var messages []ParsedMessage var usageEvents []ParsedUsageEvent var hasTrajectory bool // hasGenMetadata records whether the .db source carried gen_metadata // rows. Combined with the FINAL usageEvents (after the sidecar gap-fill // below) it flags a gen_metadata table that decoded into zero usage. var hasGenMetadata bool transcriptFidelity := TranscriptFidelitySummary // sourceVersion is the schema-fingerprint label of the .db source; it // stays empty for legacy .pb and sidecar-only sessions where no schema // is available, so SourceVersion is never fabricated. var sourceVersion string if ext == ".db" { dbResult, dbErr := loadAntigravityCLIDBSteps(path) sourceVersion = dbResult.sourceVersion hasGenMetadata = dbResult.hasGenMetadata // gen_metadata token usage describes the session's actual // consumption no matter which transcript source wins below. // The sidecar also extracts generatorMetadata usage, but // gen_metadata events win and sidecar events only fill the gap // (unreadable DB or missing gen_metadata table) so the same // generation is never counted twice. usageEvents = dbResult.usageEvents dbOK := dbErr == nil && hasDisplayableAntigravityCLITrajectoryMessage(dbResult.messages) // Prefer the agy-reader trajectory sidecar: it is the daemon's own // decode, with structured tool calls/results and thinking, where the // heuristic DB decode only recovers loose strings. Selection is // content-based, not mtime-based: the sidecar wins when it covers at // least as many steps as the raw DB decode, so a sidecar lagging // behind a live session loses until agy-reader catches up. Coverage // is required whenever the DB loaded with raw rows -- even rows the // heuristic cannot decode -- so a partial sidecar is never persisted // as a current transcript. sidecarPath := strings.TrimSuffix(path, ".db") + ".trajectory.json" tRes, tErr := parseAntigravityCLITrajectory(sidecarPath) sidecarOK := tErr == nil && hasDisplayableAntigravityCLITrajectoryMessage(tRes.messages) sidecarCovers := dbErr != nil || dbResult.rawStepCount == 0 || tRes.rawSteps >= dbResult.rawStepCount switch { case sidecarOK && sidecarCovers: messages = tRes.messages hasTrajectory = true transcriptFidelity = TranscriptFidelityFull case dbOK: messages = mergeAntigravityDBHistoryMessages( dbResult.messages, collectAntigravityHistoryMessages( filepath.Join(root, "history.jsonl"), id, ), ) hasTrajectory = true case sidecarOK: // Partial sidecar and an undecodable DB: store the best // available transcript but leave the row stale so the next // pass re-parses once agy-reader catches up. messages = tRes.messages hasTrajectory = true status.NeedsRetry = true case dbErr != nil || dbResult.rawStepCount > 0: status.NeedsRetry = true } // Coverage gates usage just like the transcript: a lagging // sidecar carries only the generations it has seen, and // persisting those would underreport totals on a row that // looks current. sidecarCovers stays true when the DB offers // no coverage signal (unreadable or zero rows), so gap-fill // still applies there. if len(usageEvents) == 0 && tErr == nil && sidecarCovers { usageEvents = tRes.usageEvents } } else { // Legacy .pb: the file itself is AES-encrypted, so the sidecar is // the only high-fidelity decode and every fallback (history rows, // brain docs, decrypt preview) is strictly lower fidelity. Use any // sidecar that parses with displayable content -- no mtime gate; // .pb files are no longer produced, so their sidecars are final, // and even a sidecar older than the .pb beats the fallbacks. sidecarPath := strings.TrimSuffix(path, ".pb") + ".trajectory.json" if tRes, err := parseAntigravityCLITrajectory(sidecarPath); err == nil { // Usage events flow whenever the sidecar parses, even when // no message is displayable, matching the message-less // usage stance of the .db branch. The displayable gate // below applies to the transcript decision only. A // metadata-only sidecar can therefore pair usage events // with a history/decrypt transcript; events without a // generation timestamp bucket at session start in daily // usage (occurred_at stored as NULL, COALESCEd to // started_at). usageEvents = tRes.usageEvents if hasDisplayableAntigravityCLITrajectoryMessage(tRes.messages) { messages = tRes.messages hasTrajectory = true transcriptFidelity = TranscriptFidelityFull } } } if !hasTrajectory { messages = collectAntigravityHistoryMessages( filepath.Join(root, "history.jsonl"), id, ) } messages = append(messages, collectAntigravityBrainMessages( filepath.Join(root, "brain", id), )..., ) // Last resort for legacy .pb archives: AES decrypt via // ANTIGRAVITY_KEY; the sidecar and history fallbacks above // take precedence. if ext == ".pb" && !hasTrajectory && hasAntigravityKey() { if extra, ok := decryptAntigravityCLITranscript(path); ok { messages = append(messages, extra) } } sort.SliceStable(messages, func(i, j int) bool { return messages[i].Timestamp.Before(messages[j].Timestamp) }) for i := range messages { messages[i].Ordinal = i } if project == "" { project = inferAntigravityProject( filepath.Join(root, "history.jsonl"), id, ) if project == "" { project = inferAntigravityProjectFromHistoryFallback( filepath.Join(root, "history.jsonl"), messages, info.ModTime(), ) } } var firstMessage string var userCount int var startedAt, endedAt time.Time for _, m := range messages { if m.Role == RoleUser { userCount++ if firstMessage == "" && m.Content != "" { firstMessage = truncate( strings.ReplaceAll(m.Content, "\n", " "), 300, ) } } if !m.Timestamp.IsZero() { if startedAt.IsZero() || m.Timestamp.Before(startedAt) { startedAt = m.Timestamp } if m.Timestamp.After(endedAt) { endedAt = m.Timestamp } } } if startedAt.IsZero() { startedAt = info.ModTime() } if endedAt.IsZero() { endedAt = info.ModTime() } var size int64 var mtime int64 effInfo, statErr := AntigravityCLIFileInfo(path) if statErr == nil { size = effInfo.Size() mtime = effInfo.ModTime().UnixNano() } else { size = info.Size() mtime = info.ModTime().UnixNano() } sess := &ParsedSession{ ID: antigravityCLIIDPrefix + storageID, Project: project, Machine: machine, Agent: AgentAntigravityCLI, FirstMessage: firstMessage, StartedAt: startedAt, EndedAt: endedAt, MessageCount: len(messages), UserMessageCount: userCount, SourceVersion: sourceVersion, TranscriptFidelity: transcriptFidelity, File: FileInfo{ Path: path, Size: size, Mtime: mtime, }, } accumulateMessageTokenUsage(sess, messages) applyUsageEventTokenTotals(sess, usageEvents) // Flag gen_metadata that decoded into zero usage. Computed from the FINAL // usageEvents, so a session whose gen_metadata failed to decode but whose // trajectory sidecar supplied usage via the gap-fill above is not flagged. sess.GenMetadataWithoutUsage = hasGenMetadata && len(usageEvents) == 0 for i := range usageEvents { usageEvents[i].SessionID = sess.ID } if len(messages) == 0 { // Usage events still flow for message-less parses (e.g. an // undecodable DB with gen_metadata) so daily usage analytics // match the event-derived session totals stamped above. return sess, nil, usageEvents, status, nil } return sess, messages, usageEvents, status, nil } func loadAntigravityCLIDBSteps( path string, ) (antigravityStepLoadResult, error) { dsn := "file:" + sqliteURIPath(path) + "?mode=ro&immutable=0" db, err := sql.Open("sqlite3", dsn) if err != nil { return antigravityStepLoadResult{}, fmt.Errorf( "open antigravity cli db %s: %w", path, err, ) } defer db.Close() // Schema-fingerprint label for the producing agy build, derived from the // same shared helper the IDE path uses so both classify identically. // Computed before step loading so a readable schema still carries the // agy-schema marker even when the step query fails and the parser falls // back to the trajectory sidecar (antigravitySourceVersion returns "" when // the schema itself is unreadable, so an undecodable .db is never labeled). sourceVersion := antigravitySourceVersion(db) result, err := loadAntigravityStepsWithRawCount(db) result.sourceVersion = sourceVersion if err != nil { return result, err } return result, nil } func mergeAntigravityDBHistoryMessages( dbMessages []ParsedMessage, history []ParsedMessage, ) []ParsedMessage { if len(dbMessages) == 0 || len(history) == 0 { return dbMessages } history = nonEmptyAntigravityHistoryMessages(history) if len(history) == 0 { return dbMessages } userCount := 0 for _, msg := range dbMessages { if msg.Role == RoleUser { userCount++ } } if userCount != len(history) { return appendMissingAntigravityHistoryMessages( dbMessages, history, ) } historyIdx := 0 for i := range dbMessages { if dbMessages[i].Role != RoleUser { continue } if historyIdx >= len(history) { return appendMissingAntigravityHistoryMessages( dbMessages, history, ) } dbMessages[i].Content = history[historyIdx].Content dbMessages[i].ContentLength = len(history[historyIdx].Content) if !history[historyIdx].Timestamp.IsZero() { dbMessages[i].Timestamp = history[historyIdx].Timestamp } historyIdx++ } return dbMessages } func appendMissingAntigravityHistoryMessages( dbMessages []ParsedMessage, history []ParsedMessage, ) []ParsedMessage { seen := make(map[string]struct{}) for _, msg := range dbMessages { if msg.Role != RoleUser { continue } if key := normalizedAntigravityPrompt(msg.Content); key != "" { seen[key] = struct{}{} } } out := dbMessages for _, msg := range history { key := normalizedAntigravityPrompt(msg.Content) if key == "" { continue } if _, ok := seen[key]; ok { continue } out = append(out, msg) seen[key] = struct{}{} } return out } func normalizedAntigravityPrompt(s string) string { return strings.Join(strings.Fields(s), " ") } func nonEmptyAntigravityHistoryMessages( history []ParsedMessage, ) []ParsedMessage { var out []ParsedMessage for _, msg := range history { if strings.TrimSpace(msg.Content) == "" { continue } out = append(out, msg) } return out } func hasDisplayableAntigravityCLITrajectoryMessage( msgs []ParsedMessage, ) bool { for _, m := range msgs { if strings.TrimSpace(m.Content) != "" || m.HasThinking || len(m.ToolCalls) > 0 { return true } } return false } // buildAntigravityProjectMap indirects antigravityProjectMapFromHistory so the // discovery path can build the map once per root and tests can observe how // often it runs. var buildAntigravityProjectMap = antigravityProjectMapFromHistory // antigravityProjectMapFromHistory reads history.jsonl and returns a // map of conversationId -> workspace path. func antigravityProjectMapFromHistory(path string) map[string]string { out := make(map[string]string) f, err := os.Open(path) if err != nil { return out } defer f.Close() sc := bufio.NewScanner(f) sc.Buffer(make([]byte, 64*1024), 4*1024*1024) for sc.Scan() { line := sc.Bytes() if len(line) == 0 { continue } id := gjson.GetBytes(line, "conversationId").Str ws := gjson.GetBytes(line, "workspace").Str if id != "" && ws != "" { out[id] = ws } } return out } func inferAntigravityProject(path, id string) string { m := buildAntigravityProjectMap(path) return m[id] } func inferAntigravityProjectFromHistoryFallback( historyPath string, messages []ParsedMessage, dbMtime time.Time, ) string { var sessionPrompt string var sessionTime time.Time for _, msg := range messages { if msg.Role == RoleUser && msg.Content != "" { sessionPrompt = msg.Content sessionTime = msg.Timestamp break } } if sessionPrompt == "" { return "" } if sessionTime.IsZero() { sessionTime = dbMtime } normSession := strings.ToLower(strings.Join(strings.Fields(sessionPrompt), " ")) if len(normSession) < 3 { return "" } f, err := os.Open(historyPath) if err != nil { return "" } defer f.Close() var bestWorkspace string var minDiff time.Duration var found bool var ambiguous bool sc := bufio.NewScanner(f) sc.Buffer(make([]byte, 64*1024), 4*1024*1024) for sc.Scan() { line := sc.Bytes() if len(line) == 0 { continue } if gjson.GetBytes(line, "conversationId").Str != "" { continue } display := gjson.GetBytes(line, "display").Str workspace := gjson.GetBytes(line, "workspace").Str tsMS := gjson.GetBytes(line, "timestamp").Int() if display == "" || workspace == "" || tsMS == 0 { continue } normRow := strings.ToLower(strings.Join(strings.Fields(display), " ")) if normRow != normSession { continue } rowTime := time.UnixMilli(tsMS) diff := rowTime.Sub(sessionTime) if diff < 0 { diff = -diff } if diff > 60*time.Second { continue } if !found { bestWorkspace = workspace minDiff = diff found = true ambiguous = false } else if diff < minDiff { bestWorkspace = workspace minDiff = diff ambiguous = false } else if diff == minDiff && workspace != bestWorkspace { ambiguous = true } } if found && !ambiguous { return bestWorkspace } return "" } // collectAntigravityHistoryMessages returns one user ParsedMessage // per history.jsonl row matching the conversationId. func collectAntigravityHistoryMessages( path, id string, ) []ParsedMessage { f, err := os.Open(path) if err != nil { return nil } defer f.Close() var out []ParsedMessage sc := bufio.NewScanner(f) sc.Buffer(make([]byte, 64*1024), 4*1024*1024) for sc.Scan() { line := sc.Bytes() if len(line) == 0 { continue } cid := gjson.GetBytes(line, "conversationId").Str if cid != id { continue } display := gjson.GetBytes(line, "display").Str if display == "" { continue } tsMS := gjson.GetBytes(line, "timestamp").Int() out = append(out, ParsedMessage{ Role: RoleUser, Content: display, ContentLength: len(display), Timestamp: time.UnixMilli(tsMS), }) } return out } // collectAntigravityBrainMessages reads brain//*.md plus // sibling .metadata.json files and emits one assistant message // per artifact. func collectAntigravityBrainMessages(dir string) []ParsedMessage { entries, err := os.ReadDir(dir) if err != nil { return nil } var out []ParsedMessage for _, e := range entries { if e.IsDir() { continue } name := e.Name() if !strings.HasSuffix(name, ".md") { continue } body, err := os.ReadFile(filepath.Join(dir, name)) if err != nil { continue } ts, summary, artifactType := readAntigravityArtifactMeta( filepath.Join(dir, name+".metadata.json"), ) header := "[" + name + "]" if artifactType != "" { header = "[" + artifactType + " — " + name + "]" } var content string if summary != "" { content = header + "\n" + summary + "\n\n" + string(body) } else { content = header + "\n" + string(body) } out = append(out, ParsedMessage{ Role: RoleAssistant, Content: content, ContentLength: len(content), Timestamp: ts, }) } return out } func readAntigravityArtifactMeta( path string, ) (ts time.Time, summary, artifactType string) { data, err := os.ReadFile(path) if err != nil { return time.Time{}, "", "" } summary = gjson.GetBytes(data, "summary").Str artifactType = gjson.GetBytes(data, "artifactType").Str if s := gjson.GetBytes(data, "updatedAt").Str; s != "" { if t, err := time.Parse(time.RFC3339Nano, s); err == nil { ts = t } } return ts, summary, artifactType } // decryptAntigravityCLITranscript attempts to decrypt a .pb file // and returns a single assistant ParsedMessage holding the raw // strings extracted from the plaintext. Returns ok=false when // decryption fails or yields no usable text. Last-resort decode // path: sidecar and history fallbacks take precedence; see the // header of antigravity_crypto.go for the retention rationale. func decryptAntigravityCLITranscript( path string, ) (ParsedMessage, bool) { data, err := os.ReadFile(path) if err != nil { return ParsedMessage{}, false } plain, err := decryptAntigravity(data) if err != nil || plain == nil { return ParsedMessage{}, false } fields, err := agProtoParse(plain) if err != nil || len(fields) == 0 { return ParsedMessage{}, false } strs := agProtoCollectStrings(fields, 40) if len(strs) == 0 { return ParsedMessage{}, false } body := strings.Join(strs, "\n\n---\n\n") header := "[Antigravity CLI: decrypted transcript preview]\n" + "(field schema unknown; strings extracted by wire walker)\n\n" content := header + body info, statErr := os.Stat(path) var ts time.Time if statErr == nil { ts = info.ModTime() } return ParsedMessage{ Role: RoleAssistant, Content: content, ContentLength: len(content), Timestamp: ts, }, true } // AntigravityCLIFileInfo returns a fake os.FileInfo whose size and // mtime combine the session file with everything else the parser // renders: SQLite WAL/SHM siblings, the .trajectory.json sidecar, // history.jsonl, and the brain/ artifacts. History stays here while // legacy sync skip checks use this effective file info; provider hashes // additionally scope tagged history rows by conversation ID. func AntigravityCLIFileInfo(path string) (os.FileInfo, error) { info, err := os.Stat(path) if err != nil { return nil, err } return antigravityCLICombinedFileInfo( info, antigravityCLICompanionPaths(path)..., ), nil } func antigravityCLICompanionPaths(path string) []string { root := filepath.Dir(filepath.Dir(path)) historyPath := filepath.Join(root, "history.jsonl") if base, ok := strings.CutSuffix(path, ".db"); ok { // The trajectory sidecar is a transcript source for .db sessions // too, so an agy-reader sync must change the fingerprint even when // the database files themselves are untouched. companions := []string{ historyPath, path + "-wal", path + "-shm", base + ".trajectory.json", } return append(companions, antigravityBrainCompanions( filepath.Join(root, "brain", filepath.Base(base)), )...) } id := strings.TrimSuffix(filepath.Base(path), ".pb") companions := []string{ historyPath, strings.TrimSuffix(path, ".pb") + ".trajectory.json", } return append(companions, antigravityBrainCompanions( filepath.Join(root, "brain", id), )...) } // antigravityBrainCompanions lists the brain artifact files the // parsers render as messages (brain//*.md plus their // .metadata.json sidecars) so composite fingerprints change on // brain-only adds, edits, and deletes. func antigravityBrainCompanions(dir string) []string { entries, err := os.ReadDir(dir) if err != nil { return nil } var out []string for _, e := range entries { if e.IsDir() { continue } name := e.Name() if !strings.HasSuffix(name, ".md") && !strings.HasSuffix(name, ".md.metadata.json") { continue } out = append(out, filepath.Join(dir, name)) } return out } func antigravityCLICombinedFileInfo( base os.FileInfo, companions ...string, ) os.FileInfo { size := base.Size() mtime := base.ModTime().UnixNano() for _, p := range companions { info, err := os.Stat(p) if err != nil { continue } size += info.Size() if info.ModTime().UnixNano() > mtime { mtime = info.ModTime().UnixNano() } } return fakeFileInfo{ name: base.Name(), size: size, mtime: mtime, } } func antigravityCompositeHash(path string, companions ...string) (string, error) { return antigravityCompositeHashWithExtra(path, companions, nil) } func antigravityCompositeHashWithExtra( path string, companions []string, extra func(interface{ Write([]byte) (int, error) }) error, ) (string, error) { info, err := os.Stat(path) if err != nil { return "", fmt.Errorf("stat %s: %w", path, err) } if info.IsDir() { return "", fmt.Errorf("stat %s: source is a directory", path) } h := sha256.New() if err := addAntigravityFingerprintPart(h, "source", path, info); err != nil { return "", err } sort.Strings(companions) var prev string for _, companion := range companions { if companion == "" || companion == prev { continue } prev = companion info, err := os.Stat(companion) if err != nil || info.IsDir() { continue } if err := addAntigravityFingerprintPart( h, "companion", companion, info, ); err != nil { continue } } if extra != nil { if err := extra(h); err != nil { return "", err } } return fmt.Sprintf("%x", h.Sum(nil)), nil } func antigravityCLICompositeHash(path, id string) (string, error) { return antigravityCompositeHashWithExtra( path, antigravityCLIProviderCompanionPaths(path), func(h interface{ Write([]byte) (int, error) }) error { return addAntigravityCLIHistoryFingerprintPart( h, filepath.Join(filepath.Dir(filepath.Dir(path)), "history.jsonl"), strings.TrimPrefix(id, antigravityImplicitTag), ) }, ) } func antigravityCLIProviderCompanionPaths(path string) []string { historyPath := filepath.Join(filepath.Dir(filepath.Dir(path)), "history.jsonl") companions := antigravityCLICompanionPaths(path) filtered := companions[:0] for _, companion := range companions { if samePath(companion, historyPath) { continue } filtered = append(filtered, companion) } return filtered } func addAntigravityCLIHistoryFingerprintPart( h interface{ Write([]byte) (int, error) }, historyPath string, id string, ) error { f, err := os.Open(historyPath) if err != nil { if os.IsNotExist(err) { return nil } return fmt.Errorf("open %s: %w", historyPath, err) } defer f.Close() sc := bufio.NewScanner(f) sc.Buffer(make([]byte, 64*1024), 4*1024*1024) for sc.Scan() { line := sc.Bytes() if len(line) == 0 { continue } cid := gjson.GetBytes(line, "conversationId").Str if cid != "" && cid != id { continue } label := "history" if cid == "" { // Untagged rows are used by the project fallback matcher, whose // source cannot be known from the row alone. label = "history-untagged" } if _, err := fmt.Fprintf(h, "%s\x00%d\x00", label, len(line)); err != nil { return err } if _, err := h.Write(line); err != nil { return err } if _, err := h.Write([]byte{0}); err != nil { return err } } if err := sc.Err(); err != nil { return fmt.Errorf("scan %s: %w", historyPath, err) } return nil } func addAntigravityFingerprintPart( h interface{ Write([]byte) (int, error) }, label string, path string, info os.FileInfo, ) error { if _, err := fmt.Fprintf( h, "%s\x00%s\x00%d\x00%d\x00", label, path, info.Size(), info.ModTime().UnixNano(), ); err != nil { return err } f, err := os.Open(path) if err != nil { return fmt.Errorf("open %s: %w", path, err) } defer f.Close() if _, err := io.Copy(h, f); err != nil { return fmt.Errorf("hash %s: %w", path, err) } if _, err := h.Write([]byte{0}); err != nil { return err } return nil } type fakeFileInfo struct { name string size int64 mtime int64 } func (f fakeFileInfo) Name() string { return f.name } func (f fakeFileInfo) Size() int64 { return f.size } func (f fakeFileInfo) Mode() os.FileMode { return 0 } func (f fakeFileInfo) ModTime() time.Time { return time.Unix(0, f.mtime) } func (f fakeFileInfo) IsDir() bool { return false } func (f fakeFileInfo) Sys() any { return nil } // The following agy* structs represent the decrypted trajectory schema generated by agy-reader. // Note: These structs use the "agy" prefix to name them after the consumer tool (agy-reader), // not the underlying Antigravity protocol formats. // // SCHEMA STABILITY & POLICY: // The schema is daemon-defined and has historically drifted (e.g., CombinedOutput/ActionResult shapes). // We parse this trajectory JSON on a best-effort basis. If the JSON contains unknown step types, // they are silently ignored/skipped in the switch statements of the parser. type agyTrajectory struct { TrajectoryID string `json:"trajectoryId"` CascadeID string `json:"cascadeId"` Steps []agyStep `json:"steps"` GeneratorMetadata []agyGeneratorMetadata `json:"generatorMetadata"` } // agyGeneratorMetadata records one model generation: the trajectory // step indices it produced and the chat-model accounting for the call. // Empirically (26 real sidecars / 1839 generations at the time of // writing) stepIndices[0] is always a PLANNER_RESPONSE step and no // planner step is claimed by two generations. type agyGeneratorMetadata struct { StepIndices []int `json:"stepIndices"` ChatModel *agyChatModel `json:"chatModel"` } type agyChatModel struct { Model string `json:"model"` Usage *agyChatModelUsage `json:"usage"` ChatStartMetadata *agyChatStartMetadata `json:"chatStartMetadata"` } type agyChatStartMetadata struct { CreatedAt string `json:"createdAt"` } // agyChatModelUsage carries the daemon's own token accounting for one // generation. Empirically OutputTokens already INCLUDES // ThinkingOutputTokens, so reasoning must NOT be re-folded into output // (unlike the .db gen_metadata path, which splits candidates and // thoughts Gemini-style). responseOutputTokens is deliberately // omitted: it is derivable (output - thinking) and unused. // chatModel.retryInfos[] per-attempt usage is intentionally not // summed - an accepted undercount on retried generations. type agyChatModelUsage struct { Model string `json:"model"` InputTokens agyTokenCount `json:"inputTokens"` OutputTokens agyTokenCount `json:"outputTokens"` ThinkingOutputTokens agyTokenCount `json:"thinkingOutputTokens"` CacheReadTokens agyTokenCount `json:"cacheReadTokens"` } // agyTokenCount decodes a token count the daemon serializes as a JSON // string ("19733"), tolerating bare numbers as well. Garbage, null, or // empty values decode to 0 WITHOUT returning an error: the sidecar is // untrusted structured input (see the trust-posture note above), and // an unmarshal error here would fail the whole trajectory decode and // destroy the transcript. type agyTokenCount int func (c *agyTokenCount) UnmarshalJSON(data []byte) error { *c = 0 s := strings.TrimSpace(string(data)) if s == "" || s == "null" { return nil } if unquoted, err := strconv.Unquote(s); err == nil { s = unquoted } n, err := strconv.ParseInt(strings.TrimSpace(s), 10, 64) if err != nil || n < 0 { // Negative counts are garbage too: emitting them would // subtract from session and daily usage totals. return nil } *c = agyTokenCount(n) return nil } type agyStep struct { Type string `json:"type"` Status string `json:"status"` Metadata agyStepMetadata `json:"metadata"` UserInput *agyUserInput `json:"userInput"` PlannerResponse *agyPlannerResponse `json:"plannerResponse"` RunCommand *agyRunCommand `json:"runCommand"` ViewFile *agyViewFile `json:"viewFile"` CodeAction *agyCodeAction `json:"codeAction"` GrepSearch *agyGrepSearch `json:"grepSearch"` ErrorMessage *agyErrorMessage `json:"errorMessage"` SystemMessage *agySystemMessage `json:"systemMessage"` Checkpoint *agyCheckpoint `json:"checkpoint"` ListDirectory *agyListDirectory `json:"listDirectory"` } type agyStepMetadata struct { CreatedAt string `json:"createdAt"` ExecutionID string `json:"executionId"` } type agyUserInput struct { UserResponse string `json:"userResponse"` } type agyPlannerResponse struct { Thinking string `json:"thinking"` Response string `json:"response"` ToolCalls []agyToolCall `json:"toolCalls"` } type agyToolCall struct { Name string `json:"name"` ArgumentsJSON string `json:"argumentsJson"` ID string `json:"id"` } type agyRunCommand struct { CommandLine string `json:"commandLine"` ProposedCommandLine string `json:"proposedCommandLine"` Cwd string `json:"cwd"` ExitCode *int `json:"exitCode"` CombinedOutput json.RawMessage `json:"combinedOutput"` } func (rc *agyRunCommand) CombinedOutputString() string { if rc == nil || len(rc.CombinedOutput) == 0 { return "" } var s string if err := json.Unmarshal(rc.CombinedOutput, &s); err == nil { return s } var obj map[string]json.RawMessage if err := json.Unmarshal(rc.CombinedOutput, &obj); err == nil { parts := make([]string, 0, 4) for _, key := range []string{"stdout", "stderr", "output", "text", "full"} { if raw, ok := obj[key]; ok { var v string if json.Unmarshal(raw, &v) == nil && v != "" { parts = append(parts, v) } } } if len(parts) > 0 { return strings.Join(parts, "\n") } } return string(rc.CombinedOutput) } type agyViewFile struct { AbsolutePathURI string `json:"absolutePathUri"` StartLine int `json:"startLine"` EndLine int `json:"endLine"` Content string `json:"content"` } type agyCodeAction struct { Description string `json:"description"` ActionSpec json.RawMessage `json:"actionSpec"` ActionResult json.RawMessage `json:"actionResult"` } type agyCodeActionResult struct { Edit *agyCodeActionEdit `json:"edit"` } type agyCodeActionEdit struct { Diff *agyCodeActionDiff `json:"diff"` } type agyCodeActionDiff struct { UnifiedDiff *agyCodeActionUD `json:"unifiedDiff"` } type agyCodeActionUD struct { Lines []agyCodeActionDiffLine `json:"lines"` } type agyCodeActionDiffLine struct { Text string `json:"text"` Type string `json:"type"` } func (ca *agyCodeAction) FormattedDiff() string { if ca == nil || len(ca.ActionResult) == 0 { return "" } var res agyCodeActionResult if err := json.Unmarshal(ca.ActionResult, &res); err != nil { return "" } if res.Edit == nil || res.Edit.Diff == nil || res.Edit.Diff.UnifiedDiff == nil { return "" } lines := res.Edit.Diff.UnifiedDiff.Lines if len(lines) == 0 { return "" } var sb strings.Builder sb.WriteString("```diff\n") for _, line := range lines { switch line.Type { case "UNIFIED_DIFF_LINE_TYPE_INSERT": sb.WriteString("+" + line.Text + "\n") case "UNIFIED_DIFF_LINE_TYPE_DELETE": sb.WriteString("-" + line.Text + "\n") default: sb.WriteString(" " + line.Text + "\n") } } sb.WriteString("```") return sb.String() } type agyGrepSearch struct { SearchPathURI string `json:"searchPathUri"` Query string `json:"query"` } type agyErrorMessage struct { Error agyErrorMessageError `json:"error"` } type agyErrorMessageError struct { UserErrorMessage string `json:"userErrorMessage"` ModelErrorMessage string `json:"modelErrorMessage"` } type agySystemMessage struct { Message string `json:"message"` } type agyCheckpoint struct { UserRequests []string `json:"userRequests"` SessionSummary string `json:"sessionSummary"` } type agyListDirectory struct { DirectoryPathURI string `json:"directoryPathUri"` } func parseTrajectoryTime(ts string) time.Time { if ts == "" { return time.Time{} } if t, err := time.Parse(time.RFC3339Nano, ts); err == nil { return t } if t, err := time.Parse(time.RFC3339, ts); err == nil { return t } return time.Time{} } func agyToolDetail(name, inputJSON string) string { if !strings.HasPrefix(strings.TrimSpace(inputJSON), "{") { return name } var input map[string]json.RawMessage if err := json.Unmarshal([]byte(inputJSON), &input); err != nil { return name } getString := func(keys ...string) string { for _, k := range keys { v, ok := input[k] if !ok { continue } var s string if err := json.Unmarshal(v, &s); err == nil { if t := strings.TrimSpace(s); t != "" { return t } } } return "" } switch name { case "view_file": if p := getString("AbsolutePathURI", "AbsolutePath", "path"); p != "" { p = strings.TrimPrefix(p, "file://") return filepath.Base(p) } case "write_to_file", "replace_file_content", "multi_replace_file_content": if p := getString("TargetFile", "file_path", "path"); p != "" { return filepath.Base(p) } case "invoke_subagent", "define_subagent": if p := getString("name", "TypeName"); p != "" { return p } case "send_message": if p := getString("Recipient"); p != "" { return "to " + p } case "manage_task": if action := getString("Action"); action != "" { if id := getString("TaskId"); id != "" { return action + " " + id } return action } case "search_web": if q := getString("query"); q != "" { return q } case "read_url_content": if u := getString("Url"); u != "" { return u } case "generate_image": if n := getString("ImageName"); n != "" { return n } case "ask_question": if s := getString("toolSummary"); s != "" { return s } case "schedule": if p := getString("Prompt"); p != "" { return p } } return name } // agyTrajectoryParseResult is the decoded output of one trajectory // sidecar: the transcript messages, the raw step count (used by // callers to compare coverage against other decode sources for the // same session), and the usage events extracted from // generatorMetadata. type agyTrajectoryParseResult struct { messages []ParsedMessage rawSteps int usageEvents []ParsedUsageEvent } // parseAntigravityCLITrajectory reads a .trajectory.json sidecar // produced out-of-process by agy-reader and returns the decoded // transcript as ParsedMessages. // // Trust posture (see SECURITY.md, "Imports and new readers" row of the // Trust boundaries table): the sidecar is treated as untrusted // structured input — same posture as any other agent session file. // The read is size-capped (maxTrajectorySidecarBytes) and unknown step // types are silently skipped further down. No content from the sidecar // is executed or echoed back over any outbound channel. func parseAntigravityCLITrajectory( trajectoryPath string, ) (agyTrajectoryParseResult, error) { f, err := os.Open(trajectoryPath) if err != nil { return agyTrajectoryParseResult{}, err } defer f.Close() data, err := io.ReadAll(io.LimitReader(f, maxTrajectorySidecarBytes+1)) if err != nil { return agyTrajectoryParseResult{}, err } if int64(len(data)) > maxTrajectorySidecarBytes { return agyTrajectoryParseResult{}, fmt.Errorf( "trajectory sidecar %s exceeds %d-byte cap", trajectoryPath, maxTrajectorySidecarBytes, ) } var traj agyTrajectory if err := json.Unmarshal(data, &traj); err != nil { return agyTrajectoryParseResult{}, err } var msgs []ParsedMessage // plannerMsgIdx maps a PLANNER_RESPONSE step index to the index of // the assistant message it produced in msgs, so generatorMetadata // usage can be attributed to the right message. plannerMsgIdx := make(map[int]int) var pendingResults []ParsedToolResult var pendingResultsTime time.Time flushPendingResults := func() { if len(pendingResults) == 0 { return } // NOTE: We emit a synthetic User message with empty content containing these tool results. // This relies on the sync engine's internal contract (specifically pairToolResults and // pairAndFilter in engine.go) which matches tool results to tool calls by ToolUseID, // and then filters out empty-content synthetic User messages from final display. // Future maintainers: do not "clean up" this empty message behavior as it is critical // for correct UI rendering. msgs = append(msgs, ParsedMessage{ Role: RoleUser, Content: "", Timestamp: pendingResultsTime, ToolResults: pendingResults, }) pendingResults = nil } for stepIdx, step := range traj.Steps { stepTime := parseTrajectoryTime(step.Metadata.CreatedAt) switch step.Type { case "CORTEX_STEP_TYPE_USER_INPUT": flushPendingResults() if step.UserInput == nil { continue } msgs = append(msgs, ParsedMessage{ Role: RoleUser, Content: step.UserInput.UserResponse, ContentLength: len(step.UserInput.UserResponse), Timestamp: stepTime, }) case "CORTEX_STEP_TYPE_PLANNER_RESPONSE": flushPendingResults() if step.PlannerResponse == nil { continue } pr := step.PlannerResponse var toolCalls []ParsedToolCall var toolHeaders []string for _, tc := range pr.ToolCalls { cat := NormalizeToolCategory(tc.Name) detail := agyToolDetail(tc.Name, tc.ArgumentsJSON) header := formatToolHeader(cat, detail) toolHeaders = append(toolHeaders, header) toolCalls = append(toolCalls, ParsedToolCall{ ToolUseID: tc.ID, ToolName: tc.Name, Category: cat, InputJSON: tc.ArgumentsJSON, }) } content := pr.Response if content == "" && len(toolHeaders) > 0 { content = strings.Join(toolHeaders, "\n") } msg := ParsedMessage{ Role: RoleAssistant, Content: content, ContentLength: len(content), Timestamp: stepTime, ToolCalls: toolCalls, HasToolUse: len(toolCalls) > 0, } if pr.Thinking != "" { msg.ThinkingText = pr.Thinking msg.HasThinking = true } msgs = append(msgs, msg) plannerMsgIdx[stepIdx] = len(msgs) - 1 case "CORTEX_STEP_TYPE_RUN_COMMAND", "CORTEX_STEP_TYPE_VIEW_FILE", "CORTEX_STEP_TYPE_CODE_ACTION", "CORTEX_STEP_TYPE_GREP_SEARCH", "CORTEX_STEP_TYPE_LIST_DIRECTORY", "CORTEX_STEP_TYPE_ERROR_MESSAGE": tuid := step.Metadata.ExecutionID if tuid == "" { continue } var resultText string switch step.Type { case "CORTEX_STEP_TYPE_RUN_COMMAND": if step.RunCommand != nil { resultText = step.RunCommand.CombinedOutputString() } case "CORTEX_STEP_TYPE_VIEW_FILE": if step.ViewFile != nil { resultText = step.ViewFile.Content } case "CORTEX_STEP_TYPE_CODE_ACTION": if step.CodeAction != nil { diff := step.CodeAction.FormattedDiff() if diff != "" { resultText = diff } else { var actRes string if len(step.CodeAction.ActionResult) > 0 { if err := json.Unmarshal(step.CodeAction.ActionResult, &actRes); err == nil { resultText = actRes } else { resultText = string(step.CodeAction.ActionResult) } } } } case "CORTEX_STEP_TYPE_GREP_SEARCH": if step.GrepSearch != nil { resultText = fmt.Sprintf("Search for query %q in path %s", step.GrepSearch.Query, step.GrepSearch.SearchPathURI) } case "CORTEX_STEP_TYPE_LIST_DIRECTORY": if step.ListDirectory != nil { resultText = fmt.Sprintf("List directory: %s", step.ListDirectory.DirectoryPathURI) } case "CORTEX_STEP_TYPE_ERROR_MESSAGE": if step.ErrorMessage != nil { em := step.ErrorMessage if em.Error.UserErrorMessage != "" { resultText = em.Error.UserErrorMessage } if em.Error.ModelErrorMessage != "" { if resultText != "" { resultText += "\n" } resultText += "Model Error: " + em.Error.ModelErrorMessage } } } resJSON, _ := json.Marshal(resultText) pendingResults = append(pendingResults, ParsedToolResult{ ToolUseID: tuid, ContentRaw: string(resJSON), ContentLength: len(resultText), }) if pendingResultsTime.IsZero() || stepTime.After(pendingResultsTime) { pendingResultsTime = stepTime } case "CORTEX_STEP_TYPE_SYSTEM_MESSAGE": flushPendingResults() if step.SystemMessage == nil { continue } msgs = append(msgs, ParsedMessage{ Role: RoleUser, IsSystem: true, Content: step.SystemMessage.Message, ContentLength: len(step.SystemMessage.Message), Timestamp: stepTime, }) case "CORTEX_STEP_TYPE_CHECKPOINT": flushPendingResults() if step.Checkpoint == nil { continue } cp := step.Checkpoint var parts []string if len(cp.UserRequests) > 0 { parts = append(parts, fmt.Sprintf("User Requests: %s", strings.Join(cp.UserRequests, ", "))) } if cp.SessionSummary != "" { parts = append(parts, cp.SessionSummary) } if len(parts) > 0 { content := "[Checkpoint]\n" + strings.Join(parts, "\n") msgs = append(msgs, ParsedMessage{ Role: RoleUser, IsSystem: true, Content: content, ContentLength: len(content), Timestamp: stepTime, }) } } } flushPendingResults() return agyTrajectoryParseResult{ messages: msgs, rawSteps: len(traj.Steps), usageEvents: extractAgyGeneratorUsage(traj, plannerMsgIdx, msgs), }, nil } // extractAgyGeneratorUsage turns trajectory generatorMetadata entries // into usage events and attributes each generation's tokens to the // planner message it produced (the first stepIndices entry that maps // to a PLANNER_RESPONSE message; empirically always stepIndices[0]). // // Per-message attribution sets Model, ContextTokens (input + // cacheRead, the full context window, matching the Claude parser's // semantics), and OutputTokens, but deliberately never sets // msg.TokenUsage raw JSON: the usage analytics count message rows with // a non-empty token_usage and that would double-count against the // emitted usage_events (the .db gen_metadata path leaves it empty for // the same reason). // // MessageOrdinal is left nil: ordinals are reassigned after the // timestamp re-sort and brain-doc merge in // ParseAntigravityCLISessionWithStatus, so any ordinal computed here // would be wrong. Cost fields stay zero/empty - MODEL_PLACEHOLDER_* // models are unpriced. func extractAgyGeneratorUsage( traj agyTrajectory, plannerMsgIdx map[int]int, msgs []ParsedMessage, ) []ParsedUsageEvent { var events []ParsedUsageEvent for _, gen := range traj.GeneratorMetadata { if gen.ChatModel == nil || gen.ChatModel.Usage == nil { continue } usage := gen.ChatModel.Usage input := int(usage.InputTokens) output := int(usage.OutputTokens) thinking := int(usage.ThinkingOutputTokens) cacheRead := int(usage.CacheReadTokens) if input == 0 && output == 0 && cacheRead == 0 { // Empty usage:{} marks a failed/retried generation. continue } // Keep MODEL_PLACEHOLDER_* verbatim: it is lossless and // groupable in the Usage view. isNoisyAntigravityStepString // filters MODEL_PLACEHOLDER_ from message CONTENT only; the // Model field is data. model := gen.ChatModel.Model if model == "" { model = usage.Model } // Find the planner message this generation produced. var targetMsg *ParsedMessage fallbackStep := -1 for _, si := range gen.StepIndices { k, ok := plannerMsgIdx[si] if !ok || k < 0 || k >= len(msgs) { continue } targetMsg = &msgs[k] fallbackStep = si break } var occurred time.Time if gen.ChatModel.ChatStartMetadata != nil { occurred = parseTrajectoryTime( gen.ChatModel.ChatStartMetadata.CreatedAt, ) } if occurred.IsZero() && fallbackStep >= 0 && fallbackStep < len(traj.Steps) { occurred = parseTrajectoryTime( traj.Steps[fallbackStep].Metadata.CreatedAt, ) } var occurredAt string if !occurred.IsZero() { occurredAt = occurred.Format(time.RFC3339Nano) } // Attribute tokens to the planner message unless another // generation already claimed it (should not happen: no planner // step is claimed by two generations in observed sidecars). // Either way the usage event below is still emitted. if targetMsg != nil && !targetMsg.HasContextTokens && !targetMsg.HasOutputTokens { if model != "" { targetMsg.Model = model } targetMsg.ContextTokens = input + cacheRead targetMsg.OutputTokens = output targetMsg.HasContextTokens = input+cacheRead > 0 targetMsg.HasOutputTokens = output > 0 } // OutputTokens already includes thinking (empirical invariant) // so reasoning is NOT re-folded into output here. events = append(events, ParsedUsageEvent{ Source: "sidecar", Model: model, InputTokens: input, OutputTokens: output, ReasoningTokens: thinking, CacheReadInputTokens: cacheRead, OccurredAt: occurredAt, }) } return events }