f99010fae1
Desktop Artifacts / Desktop Build (Linux) (push) Waiting to run
Desktop Artifacts / Desktop Build (Windows) (push) Waiting to run
Desktop Artifacts / Desktop Build (Linux (arm64)) (push) Waiting to run
Desktop Artifacts (macOS) / Desktop Build (macOS (aarch64)) (push) Waiting to run
Desktop Artifacts (macOS) / Desktop Build (macOS (x86_64)) (push) Waiting to run
CI / lint (push) Failing after 1s
CI / frontend (push) Failing after 1s
CI / scripts (push) Failing after 1s
CI / Go Test (ubuntu-latest) (push) Failing after 0s
CI / frontend-node-25 (push) Failing after 1s
CI / docs (push) Failing after 0s
CI / coverage (push) Failing after 0s
CI / e2e (push) Failing after 0s
Docker / build-and-push (push) Failing after 1s
CI / integration (push) Failing after 4m43s
CI / Go Test (windows-latest) (push) Has been cancelled
CI / Desktop Unit Tests (Windows) (push) Has been cancelled
1651 lines
47 KiB
Go
1651 lines
47 KiB
Go
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 <uuid>.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/<uuid>.db SQLite trajectory DB (newer CLI releases)
|
|
// conversations/<uuid>.pb AES-encrypted conversation stream
|
|
// implicit/<uuid>.pb AES-encrypted implicit conversation
|
|
// brain/<uuid>/*.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/<uuid>.pb from
|
|
// conversations/<uuid>.pb in the storage ID. Both can exist for
|
|
// the same UUID; without a tag they collide as
|
|
// "antigravity-cli:<uuid>" 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/<id>.db, conversations/<id>.pb, or implicit/<id>.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/<id>/*.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/<id> 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/<id>/*.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 <uuid>.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
|
|
}
|