Files
wehub-resource-sync f99010fae1
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
Desktop Artifacts / Desktop Build (Linux (arm64)) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Linux) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Windows) (push) Has been cancelled
Desktop Artifacts (macOS) / Desktop Build (macOS (aarch64)) (push) Has been cancelled
Desktop Artifacts (macOS) / Desktop Build (macOS (x86_64)) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:30:36 +08:00

1074 lines
32 KiB
Go

package parser
import (
"database/sql"
"fmt"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"time"
"unicode"
_ "github.com/mattn/go-sqlite3"
)
// Antigravity IDE sessions live under ~/.gemini/antigravity/:
//
// conversations/<uuid>.db SQLite, one per session
// annotations/<uuid>.pbtxt last_user_view_time + flags
// brain/<uuid>/*.md(+.json) plaintext task/plan artifacts
// implicit/<uuid>.pb encrypted (handled like CLI)
//
// We treat the .db as the canonical session file (like Gemini's
// per-session JSON). Each row of `steps` becomes one ParsedMessage.
const antigravityIDPrefix = "antigravity:"
var antigravityUUIDLikeRE = regexp.MustCompile(
`^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$`,
)
// AntigravityFileInfo returns the effective file info for an IDE
// session .db, combining the main file with its -wal/-shm sidecars,
// the annotations/<id>.pbtxt sidecar, and the brain/<id> artifacts
// the parse renders as messages. WAL-only commits and annotation or
// brain updates do not touch the main file, so skip checks and
// persisted file metadata must use this composite or live sessions
// never reparse.
func AntigravityFileInfo(path string) (os.FileInfo, error) {
info, err := os.Stat(path)
if err != nil {
return nil, err
}
return antigravityCLICombinedFileInfo(
info,
antigravityIDECompanionPaths(path)...,
), nil
}
func antigravityIDECompanionPaths(path string) []string {
id := strings.TrimSuffix(filepath.Base(path), ".db")
root := filepath.Dir(filepath.Dir(path))
companions := []string{
path + "-wal",
path + "-shm",
filepath.Join(root, "annotations", id+".pbtxt"),
// The agy-reader trajectory sidecar is a transcript source for
// IDE sessions too (see parseSession), so a sidecar write must
// change the fingerprint even when the database files themselves
// are untouched.
strings.TrimSuffix(path, ".db") + ".trajectory.json",
}
return append(companions, antigravityBrainCompanions(
filepath.Join(root, "brain", id),
)...)
}
// parseSession parses one IDE session DB. It is owned by the
// antigravityProvider; the package-level ParseAntigravitySession
// entrypoint was folded onto the provider.
func (p *antigravityProvider) parseSession(
path, project, machine string,
) (*ParsedSession, []ParsedMessage, []ParsedUsageEvent, error) {
info, err := os.Stat(path)
if err != nil {
return nil, nil, nil, fmt.Errorf("stat %s: %w", path, err)
}
id := strings.TrimSuffix(filepath.Base(path), ".db")
if !IsValidSessionID(id) {
return nil, nil, nil, fmt.Errorf(
"invalid Antigravity IDE session filename: %s", path,
)
}
root := filepath.Dir(filepath.Dir(path))
// Open read-only; SQLite session files have WAL/SHM
// sidecars that the driver expects in the same dir.
dsn := "file:" + sqliteURIPath(path) + "?mode=ro&immutable=0"
db, err := sql.Open("sqlite3", dsn)
if err != nil {
return nil, nil, nil, fmt.Errorf(
"open antigravity db %s: %w", path, err,
)
}
defer db.Close()
// Schema-fingerprint label for the producing agy build. Computed from
// the open DB so IDE and CLI classify identically; empty when the
// schema cannot be read.
sourceVersion := antigravitySourceVersion(db)
dbResult, err := loadAntigravityStepsWithRawCount(db)
if err != nil {
// Fail closed on an unreadable steps table, deliberately: a
// covering sidecar cannot rescue an unreadable DB because
// coverage is unprovable without the DB's raw step count (a
// displayable sidecar may lag a live session), and this
// provider force-replaces on success (the engine's
// shouldReplaceFullParseMessages plus the unconditional
// ForceReplace outcome), so any rescue would risk overwriting a
// previously complete stored transcript with a stale sidecar
// (roborev jobs 1982 and 2112, both high). Safe rescue needs
// engine-level no-clobber support, tracked separately. The
// parse error preserves stored data and the engine retries
// failed files.
return nil, nil, nil, err
}
messages := dbResult.messages
// gen_metadata token usage describes the session's actual
// consumption no matter which transcript source wins below. The
// trajectory sidecar also extracts generatorMetadata usage, but the
// .db gen_metadata events win and sidecar events only fill the gap
// (missing gen_metadata table) so the same generation is never
// counted twice -- mirroring the CLI path's merge behavior.
usageEvents := dbResult.usageEvents
hasGenMetadata := dbResult.hasGenMetadata
// TranscriptFidelity is left empty (treated as full) for the heuristic
// decode, matching prior IDE behavior; a covering sidecar sets it to
// TranscriptFidelityFull explicitly below.
transcriptFidelity := ""
// 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 only 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. When the
// sidecar is absent, malformed, or fails the coverage gate the parser
// falls back to the heuristic decode exactly as before.
sidecarPath := strings.TrimSuffix(path, ".db") + ".trajectory.json"
tRes, tErr := parseAntigravityCLITrajectory(sidecarPath)
sidecarOK := tErr == nil &&
hasDisplayableAntigravityCLITrajectoryMessage(tRes.messages)
sidecarCovers := dbResult.rawStepCount == 0 ||
tRes.rawSteps >= dbResult.rawStepCount
if sidecarOK && sidecarCovers {
messages = tRes.messages
transcriptFidelity = TranscriptFidelityFull
}
// Coverage gates usage just like the transcript: a lagging sidecar
// carries only the generations it has seen, so persisting those would
// underreport totals on a row that looks current. sidecarCovers stays
// true when the DB offers no coverage signal (zero rows), so gap-fill
// still applies there.
if len(usageEvents) == 0 && tErr == nil && sidecarCovers {
usageEvents = tRes.usageEvents
}
messages = append(messages,
collectAntigravityBrainMessages(
filepath.Join(root, "brain", id),
)...,
)
sort.SliceStable(messages, func(i, j int) bool {
return messages[i].Timestamp.Before(messages[j].Timestamp)
})
for i := range messages {
messages[i].Ordinal = i
}
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 ann := readAntigravityAnnotation(
filepath.Join(root, "annotations", id+".pbtxt"),
); !ann.IsZero() && ann.After(endedAt) {
endedAt = ann
}
if startedAt.IsZero() {
startedAt = info.ModTime()
}
if endedAt.IsZero() {
endedAt = info.ModTime()
}
var size int64
var mtime int64
if effInfo, statErr := AntigravityFileInfo(path); statErr == nil {
size = effInfo.Size()
mtime = effInfo.ModTime().UnixNano()
} else {
size = info.Size()
mtime = info.ModTime().UnixNano()
}
sess := &ParsedSession{
ID: antigravityIDPrefix + id,
Project: project,
Machine: machine,
Agent: AgentAntigravity,
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)
// gen_metadata rows with zero decoded usage events flag a possible
// token-block wire-format change. Derived from the final usageEvents.
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, nil
}
return sess, messages, usageEvents, nil
}
type antigravityStepLoadResult struct {
messages []ParsedMessage
usageEvents []ParsedUsageEvent
rawStepCount int
// hasGenMetadata reports whether the steps DB carried a non-empty
// gen_metadata table. Paired with an empty usageEvents slice it flags a
// session whose gen_metadata rows failed to decode into usage -- an early
// warning that a newer agy build changed the token-block wire format.
hasGenMetadata bool
// sourceVersion is the schema-fingerprint label of the .db, set by the
// CLI loader while the DB is open. The IDE path computes it directly
// from its own handle via antigravitySourceVersion, so both classify
// identically.
sourceVersion string
}
type antigravityStepKind int
const (
antigravityStepKindUserInput antigravityStepKind = 14
antigravityStepKindPlannerResponse antigravityStepKind = 15
)
type antigravityStep struct {
idx int
kind antigravityStepKind
fields []agProtoField
timestamp time.Time
role RoleType
}
func newAntigravityStep(
idx, stepType int, payload []byte,
) (antigravityStep, bool) {
if len(payload) == 0 {
return antigravityStep{}, false
}
fields, err := agProtoParse(payload)
if err != nil || len(fields) == 0 {
return antigravityStep{}, false
}
kind := antigravityStepKindFromProto(fields, stepType)
role := roleForAntigravityStepKind(kind)
return antigravityStep{
idx: idx,
kind: kind,
fields: fields,
timestamp: earliestAntigravityTimestamp(fields),
role: role,
}, true
}
func antigravityStepKindFromProto(
fields []agProtoField, fallbackStepType int,
) antigravityStepKind {
if f, ok := agProtoFind(fields, 1); ok && f.Wire == pbWireVarint {
return antigravityStepKind(f.Varint)
}
return antigravityStepKind(fallbackStepType)
}
func roleForAntigravityStepKind(kind antigravityStepKind) RoleType {
switch kind {
case antigravityStepKindUserInput:
return RoleUser
case antigravityStepKindPlannerResponse:
return RoleAssistant
default:
return RoleAssistant
}
}
func loadAntigravityStepsWithRawCount(
db *sql.DB,
) (antigravityStepLoadResult, error) {
rows, err := db.Query(
`SELECT idx, step_type, step_payload FROM steps ` +
`ORDER BY idx`,
)
if err != nil {
return antigravityStepLoadResult{}, fmt.Errorf("query steps: %w", err)
}
defer rows.Close()
// Gracefully query gen_metadata if the table exists
var genMeta map[int][]byte
if genRows, err := db.Query("SELECT idx, data FROM gen_metadata"); err == nil {
defer genRows.Close()
genMeta = make(map[int][]byte)
for genRows.Next() {
var idx int
var data []byte
if err := genRows.Scan(&idx, &data); err == nil {
genMeta[idx] = data
}
}
}
var result antigravityStepLoadResult
result.hasGenMetadata = len(genMeta) > 0
for rows.Next() {
var (
idx int
stepType int
payload []byte
)
if err := rows.Scan(&idx, &stepType, &payload); err != nil {
return antigravityStepLoadResult{}, fmt.Errorf("scan step: %w", err)
}
result.rawStepCount++
msg, decoded := decodeAntigravityStep(idx, stepType, payload)
if data, ok := genMeta[idx]; ok {
msg = result.appendGenMetadataUsage(data, msg, decoded)
}
if !decoded {
continue
}
result.messages = append(result.messages, msg)
}
if err := rows.Err(); err != nil {
return antigravityStepLoadResult{}, fmt.Errorf("iterate steps: %w", err)
}
return result, nil
}
// appendGenMetadataUsage records a usage event from one gen_metadata
// payload and, when the step decoded into a message, attaches token
// counts and the model name to the returned copy. Usage extraction is
// deliberately independent of message decoding: a step the heuristic
// cannot render can still be rescued by the CLI trajectory sidecar
// transcript, and its usage must not be dropped.
func (r *antigravityStepLoadResult) appendGenMetadataUsage(
data []byte, msg ParsedMessage, decoded bool,
) ParsedMessage {
genModel := extractModelName(data)
block, okUsage := extractTokenUsage(data)
if okUsage {
// gen_metadata field semantics (cross-validated against sidecar
// generatorMetadata ground truth in 550/550 blocks):
// f2 = uncached input (inputTokens)
// f3 = total output including thinking (outputTokens)
// f5 = cache-read (cacheReadTokens, absent when no cache hits)
// f4 = always 0/absent, ignored
// No per-field reasoning breakdown is available; f3 already
// includes thinking tokens.
context := block.UncachedInput + block.CacheRead
eventModel := genModel
var occurredAt string
if decoded {
if eventModel == "" {
eventModel = msg.Model
}
if !msg.Timestamp.IsZero() {
occurredAt = msg.Timestamp.Format(time.RFC3339Nano)
}
msg.ContextTokens = context
msg.OutputTokens = block.TotalOutput
msg.HasContextTokens = context > 0
msg.HasOutputTokens = block.TotalOutput > 0
}
r.usageEvents = append(r.usageEvents, ParsedUsageEvent{
Source: "generation",
Model: eventModel,
InputTokens: block.UncachedInput,
OutputTokens: block.TotalOutput,
CacheReadInputTokens: block.CacheRead,
ReasoningTokens: 0, // not available in gen_metadata
OccurredAt: occurredAt,
})
}
if decoded && genModel != "" {
msg.Model = genModel
}
return msg
}
// agTokenBlock carries the decoded token usage extracted from one
// gen_metadata blob. Field semantics are cross-validated against sidecar
// ground truth (generatorMetadata[].chatModel.usage matches in 550/550
// blocks):
//
// UncachedInput = f2 (inputTokens, tokens not served from cache)
// TotalOutput = f3 (outputTokens, includes thinking)
// CacheRead = f5 (cacheReadTokens, absent/zero for cache-miss sessions)
//
// No per-field reasoning breakdown is available in gen_metadata;
// TotalOutput already includes thinking tokens.
type agTokenBlock struct {
UncachedInput int // f2: tokens not served from cache
TotalOutput int // f3: total output including thinking
CacheRead int // f5: cache-read tokens (0 when absent)
}
// maxPlausibleTokens caps the token values accepted by the heuristic.
// Other nested messages can coincidentally satisfy field1 ∈ [1000, 5000)
// while carrying large integers (e.g. a nanosecond latency).
// No real LLM generation involves more than a few million tokens,
// so blocks with values above this threshold are treated as false
// positives and skipped.
const maxPlausibleTokens = 2_000_000
func extractTokenUsage(data []byte) (agTokenBlock, bool) {
fields, err := agProtoParse(data)
if err != nil {
return agTokenBlock{}, false
}
var found bool
var block agTokenBlock
var walk func([]agProtoField)
walk = func(fs []agProtoField) {
if found {
return
}
if b, ok := tokenBlockFrom(fs); ok {
block = b
found = true
return
}
for _, f := range fs {
if f.Nested != nil {
walk(f.Nested)
}
}
}
walk(fields)
return block, found
}
// tokenBlockFrom reports whether fs is a plausible token usage block.
//
// Field semantics are cross-validated against sidecar ground truth
// (generatorMetadata[].chatModel.usage matches in 550/550 blocks):
//
// f1 = model-kind varint in [1000, 5000)
// f2 = uncached input (inputTokens)
// f3 = total output including thinking (outputTokens)
// f4 = always 0/absent, ignored
// f5 = cache-read (cacheReadTokens, absent when no cache hits)
//
// No per-field reasoning breakdown is available in gen_metadata;
// the reasoning return value is always 0.
//
// f2 and f3 are required. f5 is optional: proto3 omits zero-valued
// fields, and a fresh session with no cache hits omits f5 entirely.
// Requiring f5 (the previous heuristic) caused the parser to miss
// token blocks in such sessions, which is why the single-generation
// June-11 archives all have no extracted block under the old mapping.
func tokenBlockFrom(fs []agProtoField) (agTokenBlock, bool) {
f1, ok1 := agProtoFind(fs, 1)
f2, ok2 := agProtoFind(fs, 2)
f3, ok3 := agProtoFind(fs, 3)
// f5 (cache-read) is optional: proto3 omits zero-valued fields, and
// cache-read is absent when a session has no cache hits.
f5, hasF5 := agProtoFind(fs, 5)
if !ok1 || !ok2 || !ok3 ||
f1.Wire != pbWireVarint || f2.Wire != pbWireVarint ||
f3.Wire != pbWireVarint {
return agTokenBlock{}, false
}
if f1.Varint < 1000 || f1.Varint >= 5000 {
return agTokenBlock{}, false
}
if f2.Varint > maxPlausibleTokens || f3.Varint > maxPlausibleTokens {
return agTokenBlock{}, false
}
// f2 (input) and f3 (output) are independent quantities, but an
// implausibly large combined footprint (input + output > cap)
// signals a decoy block where both values individually pass the
// per-field cap but are collectively implausible for a single
// generation.
if f2.Varint+f3.Varint > maxPlausibleTokens {
return agTokenBlock{}, false
}
// f4 is consistently absent/zero in real blocks and carries no
// semantics. Tolerate its presence but ignore the value.
if f4, hasF4 := agProtoFind(fs, 4); hasF4 {
if f4.Wire != pbWireVarint || f4.Varint > maxPlausibleTokens {
return agTokenBlock{}, false
}
}
if hasF5 {
if f5.Wire != pbWireVarint || f5.Varint > maxPlausibleTokens {
return agTokenBlock{}, false
}
}
block := agTokenBlock{
UncachedInput: int(f2.Varint),
TotalOutput: int(f3.Varint),
}
if hasF5 {
block.CacheRead = int(f5.Varint)
}
return block, true
}
// extractModelName recursively walks fields to extract the model name from Field 21 or Field 19.
func extractModelName(data []byte) string {
fields, err := agProtoParse(data)
if err != nil {
return ""
}
var model string
var walk func([]agProtoField)
walk = func(fs []agProtoField) {
if model != "" {
return
}
if f21, ok := agProtoFind(fs, 21); ok {
if s, ok := agProtoString(f21); ok &&
isPlausibleModelName(s) {
model = s
return
}
}
if f19, ok := agProtoFind(fs, 19); ok {
if s, ok := agProtoString(f19); ok &&
isPlausibleModelName(s) {
model = s
return
}
}
for _, f := range fs {
if f.Nested != nil {
walk(f.Nested)
}
}
}
walk(fields)
return model
}
// isPlausibleModelName reports whether s looks like a human-readable
// model identifier. Field 21/19 sometimes carries a nested protobuf
// message whose low bytes (tags, varints, NULs) are valid UTF-8 --
// agProtoString cannot tell those apart from text, and the raw bytes
// previously leaked into messages.model (and broke `pg push`, which
// rejects NUL bytes). Require every rune to be printable, at least
// one letter to be present, and a reasonable length (<= 64 chars).
func isPlausibleModelName(s string) bool {
if s == "" || len(s) > 64 {
return false
}
hasLetter := false
for _, r := range s {
if !unicode.IsPrint(r) {
return false
}
if unicode.IsLetter(r) {
hasLetter = true
}
}
return hasLetter
}
// decodeAntigravityStep extracts a ParsedMessage from one step's
// protobuf payload. Without an upstream .proto we use heuristics:
// - role: protobuf field 1 carries CortexStepType when present;
// USER_INPUT (14) is user, and PLANNER_RESPONSE (15) plus other
// non-user step kinds are assistant.
// - content: best-effort human-facing strings found in the
// payload tree. Internal ids, local Antigravity config paths,
// model placeholders, and duplicate payload echoes are filtered
// out. User-input steps prefer a single prompt-like string.
// - timestamp: earliest google.protobuf.Timestamp-shaped field.
// - tool calls: assistant steps whose payloads contain known tool
// name strings emit structured ParsedToolCall entries so that
// the timing panel can compute turns, categories, and counts.
func decodeAntigravityStep(
idx, stepType int, payload []byte,
) (ParsedMessage, bool) {
step, ok := newAntigravityStep(idx, stepType, payload)
if !ok {
return ParsedMessage{}, false
}
// Extract tool calls for assistant steps before the content guard
// so that tool-only steps (no displayable text) are not silently
// dropped.
var calls []ParsedToolCall
if step.role == RoleAssistant {
calls = extractAntigravityToolCalls(step.idx, step.fields)
}
strs, urlOnly := cleanAntigravityStepStrings(step)
// A non-user step whose only displayable content is a URL would
// otherwise vanish: the URL noise filter drops it and there are no
// tool calls to carry the step. Keep the URL rather than losing the
// message. Steps with other prose or a tool call keep the URL
// suppressed, since the noise filter still applies there.
if len(strs) == 0 && len(calls) == 0 {
strs = urlOnly
}
// Emit the message if it has displayable content OR tool calls.
// Tool-only assistant steps (empty prose) are valid.
if len(strs) == 0 && len(calls) == 0 {
return ParsedMessage{}, false
}
content := strings.Join(strs, "\n\n")
msg := ParsedMessage{
Role: step.role,
Content: content,
ContentLength: len(content),
Timestamp: step.timestamp,
}
if len(calls) > 0 {
msg.ToolCalls = calls
msg.HasToolUse = true
}
return msg, true
}
// knownAntigravityToolNames is the set of tool names that Antigravity
// actually uses. Only strings present in this set are accepted as tool
// calls; generic taxonomy matches without a known Antigravity name
// are rejected. This prevents generic strings like "read", "write",
// "message", or "process" from being falsely matched.
var knownAntigravityToolNames = map[string]bool{
// Antigravity-specific tools
"view_file": true,
"read_url_content": true,
"replace_file_content": true,
"multi_replace_file_content": true,
"write_to_file": true,
"define_subagent": true,
"invoke_subagent": true,
"manage_subagents": true,
"send_message": true,
"manage_task": true,
"ask_permission": true,
"ask_question": true,
"schedule": true,
"search_web": true,
"generate_image": true,
// Gemini/Antigravity shared tools (also appear in CLI variant)
"run_command": true,
"execute_command": true,
"run_shell_command": true,
"grep_search": true,
"search_files": true,
"list_directory": true,
// Known CLI JSON structure tool names
"edit_file": true,
"read_file": true,
"write_file": true,
}
// isAntigravityToolName reports whether s is a known Antigravity tool
// name. Only strings present in knownAntigravityToolNames are accepted;
// generic taxonomy matches are rejected.
func isAntigravityToolName(s string) bool {
return knownAntigravityToolNames[s]
}
// extractAntigravityToolCalls walks the decoded protobuf field tree
// and returns one ParsedToolCall per tool invocation found. Uses the
// same heuristic-walker approach as extractTokenUsage / extractModelName:
// we identify strings that exactly match known tool names, collect any
// adjacent UUID-like string as the ToolUseID, and any adjacent JSON
// object string as the InputJSON.
//
// When no UUID-like ID is found, a synthetic deterministic ID is
// generated so the timing pipeline still has a stable key per call.
//
// Only strings matching Antigravity-known tool names are accepted.
func extractAntigravityToolCalls(
stepIdx int, fields []agProtoField,
) []ParsedToolCall {
// Collect all string values reachable from this step's field tree.
// minLen=1 so we catch even short tool names like "Bash" or "Read".
all := agProtoCollectStrings(fields, 1)
var calls []ParsedToolCall
seen := map[string]bool{}
for i, s := range all {
// Reject generic taxonomy matches that are not known Antigravity tools.
if !isAntigravityToolName(s) {
continue
}
cat := NormalizeToolCategory(s)
// Look for an adjacent UUID-like string to use as ToolUseID.
// We scan the neighbouring strings (within a small window on
// either side) since the proto walker returns siblings in
// encounter order. Prefer following siblings so a flat sequence
// of tools doesn't mistakenly pick up previous IDs.
toolUseID := ""
for _, offset := range []int{1, 2, -1, -2} {
j := i + offset
if j < 0 || j >= len(all) {
continue
}
// Check for intervening tool names to avoid stealing UUID of another tool call
interveningTool := false
if offset > 0 {
for k := i + 1; k < j; k++ {
if isAntigravityToolName(all[k]) {
interveningTool = true
break
}
}
} else {
for k := j + 1; k < i; k++ {
if isAntigravityToolName(all[k]) {
interveningTool = true
break
}
}
}
if interveningTool {
continue
}
if antigravityUUIDLikeRE.MatchString(all[j]) {
toolUseID = all[j]
break
}
}
// Look for an adjacent JSON-object string to use as InputJSON.
inputJSON := ""
for _, offset := range []int{1, 2, -1} {
j := i + offset
if j < 0 || j >= len(all) {
continue
}
// Check for intervening tool names to avoid stealing InputJSON of another tool call
interveningTool := false
if offset > 0 {
for k := i + 1; k < j; k++ {
if isAntigravityToolName(all[k]) {
interveningTool = true
break
}
}
} else {
for k := j + 1; k < i; k++ {
if isAntigravityToolName(all[k]) {
interveningTool = true
break
}
}
}
if interveningTool {
continue
}
if strings.HasPrefix(strings.TrimSpace(all[j]), "{") {
inputJSON = all[j]
break
}
}
// Assign a synthetic ID when no UUID was found in the payload,
// using the string index to make each invocation unique.
if toolUseID == "" {
toolUseID = fmt.Sprintf("ag-step-%d-%d", stepIdx, i)
}
// Avoid emitting duplicate tool hits from the same payload
// (the walker may surface the same string via multiple paths).
// We deduplicate by tool name + ID + Input JSON to avoid collapsing
// multiple distinct invocations of the same tool in one step.
// This runs after synthetic-ID assignment so that calls without
// adjacent UUIDs still get position-unique keys.
dedupKey := s + ":" + toolUseID + ":" + inputJSON
if seen[dedupKey] {
continue
}
seen[dedupKey] = true
calls = append(calls, ParsedToolCall{
ToolUseID: toolUseID,
ToolName: s,
Category: cat,
InputJSON: inputJSON,
})
}
return calls
}
func dedupeStrings(in []string) []string {
seen := make(map[string]struct{}, len(in))
out := make([]string, 0, len(in))
for _, s := range in {
if _, ok := seen[s]; ok {
continue
}
seen[s] = struct{}{}
out = append(out, s)
}
return out
}
// cleanAntigravityStepStrings returns the displayable strings for a step
// and, separately, any bare-URL strings that the non-user noise filter
// removed. Callers fall back to urlOnly when a step would otherwise have
// no content, so URL-only assistant messages are not silently dropped.
func cleanAntigravityStepStrings(step antigravityStep) (cleaned, urlOnly []string) {
for _, s := range dedupeStrings(agProtoCollectStrings(step.fields, 20)) {
s = strings.TrimSpace(s)
if isNoisyAntigravityStepString(s) {
continue
}
if step.role != RoleUser && isNoisyAntigravityNonUserStepString(s) {
continue
}
cleaned = append(cleaned, s)
}
cleaned = dedupeStrings(cleaned)
bareURLs := collectAntigravityBareURLs(step.fields)
if step.role == RoleUser {
// A short URL-only prompt (e.g. "https://go.dev") falls below the
// 20-rune prose threshold, so include bare URLs as prompt
// candidates; prose, when present, still outscores a bare link.
candidates := append(append([]string{}, cleaned...), bareURLs...)
if prompt := bestAntigravityUserPrompt(candidates); prompt != "" {
return []string{prompt}, nil
}
return cleaned, nil
}
return cleaned, bareURLs
}
// collectAntigravityBareURLs returns bare-URL strings from the step
// tree regardless of the 20-rune prose threshold used for general
// content. Short links such as "https://go.dev" fall below that
// threshold yet are real assistant content, so a URL-only step needs a
// dedicated low-threshold pass to survive the content guard.
func collectAntigravityBareURLs(fields []agProtoField) []string {
var out []string
for _, s := range agProtoCollectStrings(fields, 1) {
s = strings.TrimSpace(s)
if isNoisyAntigravityNonUserStepString(s) {
out = append(out, s)
}
}
return dedupeStrings(out)
}
func isNoisyAntigravityStepString(s string) bool {
if s == "" {
return true
}
if antigravityUUIDLikeRE.MatchString(s) {
return true
}
if strings.HasPrefix(s, "MODEL_PLACEHOLDER_") {
return true
}
if strings.HasPrefix(s, "{") &&
(strings.Contains(s, `"toolAction"`) ||
strings.Contains(s, `"toolSummary"`) ||
strings.Contains(s, `"DirectoryPath"`)) {
return true
}
if looksLikeAntigravityOpaqueID(s) {
return true
}
if strings.HasPrefix(s, "file:///home/") {
return true
}
if strings.HasPrefix(s, "/home/") &&
strings.Contains(s, "/.gemini/") {
return true
}
if strings.HasPrefix(s, "/Users/") &&
strings.Contains(s, "/.gemini/") {
return true
}
if strings.HasPrefix(s, `C:\Users\`) &&
strings.Contains(s, `\.gemini\`) {
return true
}
if strings.HasPrefix(s, "command(") ||
strings.HasPrefix(s, "execute_url(") ||
strings.HasPrefix(s, "read_url(") ||
strings.HasPrefix(s, "mcp(") {
return true
}
return false
}
func isNoisyAntigravityNonUserStepString(s string) bool {
if !strings.HasPrefix(s, "http://") &&
!strings.HasPrefix(s, "https://") {
return false
}
// Only a bare URL is metadata noise (the target echoed by tool
// actions). Assistant prose that merely begins with a link, which
// always contains whitespace, is real content and must be kept.
return !strings.ContainsAny(s, " \t\n")
}
func looksLikeAntigravityOpaqueID(s string) bool {
if strings.ContainsAny(s, " \n\t") {
return false
}
if len(s) < 16 || len(s) > 128 {
return false
}
var alpha, digit, symbol int
for _, r := range s {
switch {
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z':
alpha++
case r >= '0' && r <= '9':
digit++
case r == '_' || r == '-' || r == '.':
symbol++
default:
return false
}
}
if alpha+digit+symbol != len(s) {
return false
}
if digit == len(s) || digit+symbol == len(s) {
return true
}
return alpha > 0 && digit > 0
}
func bestAntigravityUserPrompt(strs []string) string {
var best string
bestScore := -1
for _, s := range strs {
score := antigravityPromptScore(s)
if score > bestScore {
best = s
bestScore = score
}
}
if bestScore <= 0 {
return ""
}
return best
}
func antigravityPromptScore(s string) int {
trimmed := strings.TrimSpace(s)
if trimmed == "" || isNoisyAntigravityStepString(trimmed) {
return -1
}
score := len(trimmed)
if strings.ContainsAny(trimmed, " \n\t") {
score += 50
}
if strings.HasPrefix(trimmed, "{") || strings.HasPrefix(trimmed, "[") {
score -= 100
}
if strings.HasPrefix(trimmed, "/") || strings.HasPrefix(trimmed, "file://") {
score -= 100
}
if !strings.ContainsAny(trimmed, "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ") {
score -= 100
}
return score
}
// earliestAntigravityTimestamp walks the field tree and returns
// the earliest plausible google.protobuf.Timestamp value.
// Plausible = seconds field in the year 2000..2100 range.
func earliestAntigravityTimestamp(
fields []agProtoField,
) time.Time {
var best time.Time
var walk func([]agProtoField)
walk = func(fs []agProtoField) {
for _, f := range fs {
if f.Nested != nil {
if sec, nanos, ok := agProtoTimestamp(f.Nested); ok {
if sec > 946_684_800 && sec < 4_102_444_800 {
t := time.Unix(sec, int64(nanos))
if best.IsZero() || t.Before(best) {
best = t
}
}
}
walk(f.Nested)
}
}
}
walk(fields)
return best
}
// readAntigravityAnnotation parses last_user_view_time from a
// pbtxt annotation file. Returns zero time on any failure.
func readAntigravityAnnotation(path string) time.Time {
data, err := os.ReadFile(path)
if err != nil {
return time.Time{}
}
// last_user_view_time:{seconds:1779326586 nanos:959000000}
i := strings.Index(string(data), "last_user_view_time")
if i < 0 {
return time.Time{}
}
rest := string(data[i:])
j := strings.Index(rest, "seconds:")
if j < 0 {
return time.Time{}
}
rest = rest[j+len("seconds:"):]
end := strings.IndexAny(rest, " \n\t}")
if end < 0 {
return time.Time{}
}
var sec int64
if _, err := fmt.Sscanf(rest[:end], "%d", &sec); err != nil {
return time.Time{}
}
return time.Unix(sec, 0)
}