Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 12:30:36 +08:00

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
}