v1.6.29
feat(cursor): watch store.db directly so prompt rows work on Windows + backfill
Operator report: the system-prompt + prompt-budget rows worked on WSL
Cursor CLI but not on Windows (CLI or IDE). Root cause was NOT a
Windows bug — the rows were emitted only while parsing a transcript,
and the watcher only re-parses on transcript growth. Sessions parsed
before the feature shipped sit at EOF and never re-enter, so they
never got the rows; the WSL sessions that worked were created fresh
after deploy, the Windows ones were older. (Verified: Windows CLI
session 90dd2512 HAS a store.db but had zero rows.)
Fix: the cursor adapter now watches the store.db blob stores
directly as their own session files, instead of reading them as a
sibling on the transcript-parse path.
WatchPathsadds<home>/.cursor/chatsalongside…/projects(for
every crossmount home → covers WSL + Windows /mnt/c).IsSessionFilematches.cursor/chats/<ws-hash>/<conv>/store.db
(matchesStoreDBShape; excludes the -wal/-shm sidecars).ParseSessionFiledispatches store.db paths toparseStoreDBFile,
which emits the system-prompt + prompt-budget rows; the transcript
path no longer emits them.- Because the store.db files were never watched before, the watcher's
full-scan discovers them as never-seen files and parses from offset
0 — which auto-backfills every existing CLI session (Windows AND
WSL) without a manual rescan, and picks up new ones + budget
refreshes as the store.db grows. Decoupled from transcript growth. - Project root for these rows is resolved from the sibling transcript's
slug (projectRootForStoreDB) so they share the exact project
attribution of the session's activity rows (the chats/ path doesn't
encode the workspace, and the root blob's embedded workspace field
proved unreliable across surfaces).
Tests reworked to parse the store.db path directly
(TestParseSessionFile_EmitsSystemPromptFromStoreDB /
…EmitsPromptBudgetSections, plus an IsSessionFile store.db
assertion and project-root-from-slug coverage). Verified end-to-end
against the operator's real Windows store.db (90dd2512: system prompt
- tools/rules/skills/subagents rows, correct project attribution,
zero token events).
Cursor IDE — investigated, partially infeasible. The IDE does NOT
write ~/.cursor/chats/store.db; its agent conversations live in
AppData/Roaming/Cursor/User/globalStorage/state.vscdb under a
cursorDiskKV table, in the SAME content-addressed blob model
(agentKv:blob:<hash> + bubbleId:<conv>). The system prompt IS
there ({"role":"system",...} blobs), but the prompt-budget
section index is NOT (zero "Tool definitions" markers — the IDE
doesn't persist the per-section token budget). So the IDE could get a
system-prompt row eventually but never the prompt-context budget rows,
and even the system prompt needs reverse-engineering the
bubbleId:<conv> → blob mapping for per-conversation attribution
(the store is shared across all workspaces). Deferred as a separate
task; IDE tokens + activity remain captured via the hook path
regardless.
feat(platform): pathnorm — unified never-fail path normalizer
New internal/platform/pathnorm package that consolidates every
foreign-path translation observer has accumulated across adapters
(file:// URIs, Windows extended-length \\?\ prefixes, UNC-to-WSL
rewrites, Git Bash /c/... → /mnt/c/..., Windows drive-letter
absolutes, ~/ expansion, surrounding-quote stripping) into a
single sequential pipeline. crossmount.TranslateForeignPath is
now a 3-line wrapper, so every existing caller auto-upgrades.
Bug fixes carried by the migration:
- cline + gemini (Class B): both adapters called
git.Resolve
on a Windows-shaped cwd without translating, walking up from
observer's own CWD and landing on observer's.gitin the worst
case. Now route throughpathnorm.Normalizefirst. - copilot:
decodeFileURIrewritten in terms of
pathnorm.NormalizeWithFormatwith theFormatFileURIgate.
Windows-side workspace URIs now canonicalise to/mnt/d/...on
Linux instead ofd:/...— strict improvement (stat-able / git-
resolvable from a Linux observer). - antigravity
decodeFileURIToRoot(used by recovery output)
migrated;decodeFileURIToPath(display-only) kept hand-rolled
per existing test pins.
Package contract: every layer has a pass-through fallback — no
error returns. 56 subtests across 6 test functions pin the format
matrix (FormatFileURI, FormatWindowsDrive, FormatQuoted, …).
feat(antigravity): fold agy CLI layout into the existing adapter
Google Antigravity now ships two surfaces — the desktop IDE and the
agy CLI — that both write conversation .pb files under
~/.gemini/, but to different subtrees:
desktop : ~/.gemini/antigravity/conversations/<uuid>.pb
cli : ~/.gemini/antigravity-cli/conversations/<uuid>.pb
Pre-1.6.29 the adapter only matched the desktop subtree. CLI
sessions were silently invisible to the watcher — operators who
installed agy saw no rows in the dashboard regardless of how
many conversations they recorded.
Implementation (single feature, no new adapter package — agy
is folded into the existing internal/adapter/antigravity):
- Layout classifier (
adapter.go) — newLayoutenum +
classifyLayout()exhaustively returnsLayoutDesktop/
LayoutCLI/LayoutUnknown. CLI is checked before desktop
because the desktop substring/.gemini/antigravity/is a prefix
of the CLI form/.gemini/antigravity-cli/.matchesSessionShapedefaultRootscover both layouts under every crossmount home.
- CLI metadata resolver (
metadata_cli.go) — CLI installs ship
no desktop-stylestate.vscdb; metadata is synthesised by
parsing~/.gemini/antigravity-cli/log/cli-*.logfor the
conversation_uuid → project_uuid binding (the CLI emits two
consecutivegloglines:Conversation using project ID: <uuid>Created conversation <uuid>), then reading the project's
name+gitFolder.folderUrifrom
~/.gemini/config/projects/<project-uuid>.json. Memoised by
log-dir mtime via the existingindexCachemap under a"cli:"
key prefix.lookupIndexEntrynow dispatches by layout —
desktop hitsstate.vscdbas before, CLI hits the new path.
agygRPC discovery (process.go) —discoverNativeLinux
/discoverNativeMac/discoverViaPowerShellextended to
recogniseagy/agy.exe/antigravity/antigravity.exe
basenames in addition tolanguage_server_*. A new
requiresCSRFpolicy gates the--csrf_tokencmdline check:
desktoplanguage_serverprocesses always carry it; CLI
embedded servers may not (the CLI uses a different localhost
auth). Empty-CSRF rows for CLI processes now pass through to
the gRPC fan-out instead of being dropped at parse time.RetrySuggestedadapter signal (internal/adapter/adapter.gointernal/watcher/watcher.go) — new bool onParseResult.
The watcher now persists the cursor (MAX-of-old-and-new) when
it's set, so fresh CLI.pbfiles that fail decrypt + gRPC on
the first attempt stay visible topollCursorsfor retry on the
next tick. Antigravity sets it for CLI files within a 24h
freshness window; stale failures still mark-unrecoverable +
advance the cursor so backfill doesn't burn ~30s per file
forever.
- Backfill + rescan coverage —
antigravity.AntigravityConversationsDirs(homes)now returns
the desktop AND CLI conversation dirs per home. Existing
--antigravity-project-root/--antigravity-rescanflags
inherit; no new flags. - Diagnostics (
internal/diag/doctor.go) — new
antigravity.familycheck reports presence +.pbcounts of
both layouts plus the configurednetwork_recoverysetting.
Emits StatusWarn when CLI.pbfiles exist but
network_recoveryis off (the CLI doesn't ship an oscrypt
secret equivalent to the desktop install, so local decrypt
cannot succeed for CLI files — gRPC fallback is the only
practical recovery). A startup nudge incmd/observer/main.go
emits the same warning at daemon-start time so the operator sees
the gap before opening the dashboard. - UI auto-refetch (
web/src/lib/useApi.ts) —useApi()gains
optionalrefreshMs+refreshWhenHiddenopts. Pages now poll
while the tab is visible: Sessions = 5s, SessionDetailPanel +
messages = 2s while the slide-over is open, TopBar status = 5s,
Sidebar status = 5s. Lets fresh CLI conversations land in the
dashboard without manual reload.
Caveat preserved from the audit: true local-decrypt parity for
CLI .pb files was not solved in this ship (operator explicitly
descoped the decrypt research path). Practical recovery on this WSL
host depends on agy being alive long enough for the embedded gRPC
server's GetCascadeTrajectory to succeed, OR an antigravity-bridge
hop to a Windows-side agy.exe. Both paths now have the discovery
plumbing to find the server; the gRPC call itself reuses the
existing fan-out code.
Newly-observed agy-side limitation (2026-05-24): empirically,
agy's ConvertTrajectoryToMarkdown gRPC endpoint surfaces ONLY the
first persisted turn pair for an ongoing conversation, even when
agy is alive and actively producing assistant responses (verified
via text_drip log lines for subsequent turns). The .pb file on
disk grows with each turn but the additional bytes are encrypted
content agy reads back only through its own in-memory cache;
ConvertTrajectoryToMarkdown apparently reads from a separate
persisted-state view that lags. The bridge output is also NOT
monotonic — for one conversation observer captured "hey" →
"Hey! How can I help you today?" via an earlier bridge call, then
on a later call the same endpoint returned the user message but
not the assistant response.
Fallback: transcript.jsonl synthesis (2026-05-24, supersedes
history.jsonl as primary). agy's CLI writes a per-conversation
plaintext trace at
~/.gemini/antigravity-cli/brain/<uuid>/.system_generated/logs/transcript.jsonl
containing both user inputs AND assistant responses (plus every
tool invocation). This is the canonical source of truth for what
the conversation actually contains — strictly richer than the
bridge's ConvertTrajectoryToMarkdown output. Adapter now reads it
on every CLI parse and synthesises:
user_promptevents forsource=USER_EXPLICIT/type=USER_INPUT
entries (with the<USER_REQUEST>...</USER_REQUEST>wrapper
stripped viaextractUserRequestText)task_completeevents forsource=MODEL/type=PLANNER_RESPONSE
entries with non-emptycontent(assistant text replies)
Tool-call breadcrumbs (MODEL/GREP_SEARCH, MODEL/RUN_COMMAND,
MODEL/VIEW_FILE, MODEL/LIST_DIRECTORY) are deferred to v2 — the
bridge's structured payload already covers them on the happy path
and parsing the tool-args / per-type result shape is a larger
surface.
SourceEventID is keyed off (conversation_uuid, step_index) so
re-parses are idempotent — the same transcript entry always
generates the same ID, and the
(source_file, source_event_id) UNIQUE constraint dedupes at the
store. Bridge-surfaced events and transcript-surfaced events use
distinct prefixes (antigravity-struct-payload:... vs
antigravity-cli-transcript:...), so the application-level
Target-text dedup is what prevents duplicate rows for the same
turn from both sources.
history.jsonl downgraded to safety net. Kept as a fallback for
the (so far unobserved) case where brain/<uuid>/ is missing —
catches user-side messages only, no assistant content. Three exit points were wired:
- decrypt-success path: tops up emitted events with any
history entries the classifier missed (effectively no-op for
the desktop layout —cliRootsForreturns empty there). - gRPC-success path: tops up the bridge's markdown +
structured merge with history entries the bridge didn't expose.
This is the primary win: every CLI session that bridges
partially gets the user-typed turns filled in. - decrypt + gRPC double-failure path: if history.jsonl has
any entries for the conversation, return them as the entire
ParseResultinstead of marking the file unrecoverable. New
historyOnlyResulthelper. Brand-new sessions where the
bridge hasn't booted yet still surface user inputs.
The constraint that assistant responses depend on the bridge
remains — history.jsonl only records user-typed text. Observer
now captures: every user message reliably (from history.jsonl) +
every assistant response the bridge happens to surface at the
moment of the parse. This is the best obtainable result without
solving the CLI .pb decrypt cipher.
Verified against the operator's live host: conversation 739cbb33
previously had 2 actions in the DB (the original "Just say ok" /
"ok") and zero rows for the three follow-up messages typed in
agy. After the fallback ships, probe-cli returns 5 ToolEvents
for the same file — 2 from the bridge plus 3 history.user_input
events for "my name is bash 2", "22", and "23". Same idempotency
test passed across re-parses: same input → same SourceEventIDs →
zero duplicate inserts.
RetrySuggested gating refinement (also 2026-05-24): the initial
v1.6.29 implementation set RetrySuggested=true for any fresh CLI
file when decrypt + gRPC both missed. That produced a tight 2-second
retry loop on Linux-side CLI files whose originating agy session
had exited (no Linux-native server hosts the conversation, no path
to recovery short of the user restarting agy). Replaced with a
shouldRetryCLIFailure helper that only flags retry when the host
is WSL AND the path is Windows-side (/mnt/c/...) — the only
failure mode with plausible transient recovery (bridge invocation,
agy.exe boot, network blip across WSL interop). Everything else
falls back to markUnrecoverable + cursor advance.
Tests: +12 new test functions / 56+ subtests across
adapter_test.go, metadata_cli_test.go, process_test.go, and
watcher_test.go covering layout classification, CLI metadata
resolution against a fixture cli log, agy exe matching, CSRF
optionality, and the RetrySuggested wire-up.
perf(antigravity): per-conversation bridge endpoint cache
Operator-reported regression against v1.6.29: capture latency for an
actively-written CLI conversation was "a couple of minutes" rather
than the dashboard's 8-second poll cadence. Root-caused to the
Windows-side bridge: every pollCursors tick re-invoked the bridge
twice per .pb file (convert + structured), and each invocation
cold-started powershell.exe, ran a fresh Win32 + Get-NetTCPConnection
discovery against ~17 candidate processes (3 agy.exe + 7 Antigravity.exe
- 7 language_server_*.exe on the operator's host), then serially
fanned out across each server's endpoints with a 5s timeout per failed
attempt. Steady-state cost was 50–60s per file.
The bridge now accepts --endpoint <url> / --csrf <token> flags;
when set, discover() is skipped and the gRPC call goes directly to
the named server. On any successful invocation (pinned OR cold-cache),
the bridge emits a bridge-endpoint=<url>\tbridge-csrf=<token> line
to stderr.
The adapter (internal/adapter/antigravity/adapter.go) gains a
convEndpointCache keyed by conversation UUID. Bridge wrappers
(callBridgeConvertCached / callBridgeStructuredCached) read the
cache before invoking, pass the cached pin via the new flags on hit,
and parse the stderr hint to populate / refresh the cache on every
success. On pinned-call failure the entry is invalidated and the
call retried via full discovery — so server restarts and workspace
closures self-heal.
Both bridge subcommands share the cache: a successful convert
populates the same entry that the immediately-following structured
call reads. Pre-fix that meant two ~30s discoveries per file; post-fix
it's one cold discovery (first invocation only) + N ~200-500ms pinned
calls. Empirically ~50× steady-state speedup for live conversations
under continuous polling.
Tests: cmd/antigravity-bridge/main_test.go covers the new flag
parser (space-separated, =-form, empty-CSRF, probe positional
ordering). internal/adapter/antigravity/bridge_test.go covers the
stderr hint parser (single line, amidst noise, CRLF, empty CSRF,
absent hint, empty stderr) and the cache round-trip (cold → remember
→ retrieve → overwrite → invalidate, plus refusal to store empty
endpoints / conversation IDs).
fix(antigravity): desktop IDE plaintext brain/overview.txt fallback
Operator-reported (2026-05-24): "Antigravity IDE chats are not being
updated in the sessions data — earlier this was getting updated."
Root-caused to a silent format change in the desktop IDE: the .pb
encryption cipher rotated to one oscrypt can't currently decrypt
(same blocker that gated CLI recovery), AND the IDE now writes a
plaintext per-turn trace to a path observer wasn't reading:
<gemini-root>/antigravity/brain/<uuid>/.system_generated/logs/overview.txt
This is the desktop counterpart of the CLI's
brain/<uuid>/.system_generated/logs/transcript.jsonl — identical
JSONL schema (step_index, source, type, status, created_at,
content, tool_calls), just renamed. The CLI transcript reader
handles both verbatim; only the path resolver needed extending.
Three sites updated:
desktopRootForwalker (metadata_cli.go) — mirrors the existing
cliRootsForfor the desktop layout. Returns the
<gemini-root>/antigravityparent of both the encrypted
conversations/and plaintextbrain/subtrees.transcriptPathForresolver (metadata_cli.go) — single entry
point that pickstranscript.jsonlvsoverview.txtby
classifyLayout. Callers no longer repeat the conditional.augmentResultFromHistory+historyOnlyResult(history_cli.go)
— both stopped gating onLayoutCLI. Thedecrypt → augment/
decrypt+gRPC fail → escape hatchpaths now surface desktop
overview.txt content.history.jsonlfallback remains CLI-only
(desktop has no analogue).- Caller gates at
adapter.go:431+:507(if classifyLayout(path) == LayoutCLI) removed so desktop sessions reach the augmentation
path.
Verified on the operator's host via probe-cli: conversation
c15725c6 (a /mnt/c desktop file the bridge couldn't reach due to a
file-lock race on the cached .exe) went from 0 events / "no cipher
mode produced a validating plaintext" to 3 events surfaced from
overview.txt (1 user prompt + 2 assistant responses), with a
warning naming the source. 115 desktop brain/ dirs exist on the
operator's host — all now eligible for surfacing when their
overview.txt arrives.
fix(antigravity): transient bridge errors must not poison the unrecoverable cache
Operator-reported follow-up to the c15725c6 case: a one-off bridge
failure (copy bridge to /mnt/c/.../antigravity-bridge.exe: open ...: permission denied — Windows AV scan or another bridge invocation
briefly holding an exclusive lock on the cached .exe) wrote a sticky
unrecoverable marker keyed on the current (size, mtime).
lookupUnrecoverable short-circuited every subsequent poll tick
without re-attempting recovery — so even after the AV released the
file, the .pb stayed stranded until the operator either manually
deleted the marker or the conversation genuinely grew.
New ErrBridgeTransient sentinel in bridge.go wraps the two
environmental failure modes: powershell.exe missing from PATH,
and bridgePathForPowerShell errors (cache-copy permission denied,
no writable /mnt/c destination, file-lock races). onUnrecoverableFailure
gains a recoveryErr error parameter; when the recovery error is
errors.Is(recoveryErr, ErrBridgeTransient) the function holds the
cursor at fromOffset, sets RetrySuggested=true, and skips the
unrecoverable marker entirely. Next poll tick re-enters the recovery
path; if the environment has self-healed, the file lands normally.
Permanent decrypt failures (no cipher mode produced a validating plaintext) still mark unrecoverable as before — that's a per-file
fact, not an environment fact.
Tests: bridge_test.go adds TestOnUnrecoverableFailureHonorsTransientBridgeError
TestOnUnrecoverableFailurePermanentErrorMarks.desktop_overview_test.go
addsTestDesktopRootFor(4 subtests),TestTranscriptPathFor(4
subtests),TestDesktopOverviewTxtParsesAsTranscript(end-to-end:
write a desktop overview.txt fixture → resolve via
transcriptPathFor→ parse → synthesize → assert 2 ToolEvents),
TestHistoryOnlyResultDesktop(verifies the escape-hatch path for
desktop),TestHistoryOnlyResultDesktopNoOverviewReturnsNil(no
plaintext source → return nil so caller marks unrecoverable as
before).
Operator action to apply the fix to already-marked stale files:
sqlite3 ~/.observer/observer.db \
"DELETE FROM adapter_unrecoverable_files WHERE adapter='antigravity' \
AND reason LIKE '%permission denied%';"Going forward, transient bridge errors don't write markers in the
first place — no maintenance needed for new occurrences.
fix(antigravity): graceful copyIfNewer fallback on bridge-cache lock
Sibling-issue surfaced same session (2026-05-24): a separate operator
report — desktop conversation f7c9f417 plus CLI-from-bash session
56633544 weren't capturing token usage even though equivalent
WSL-Linux-side sessions worked. Root cause: same Windows AV /
in-use-by-running-powershell lock on the /mnt/c/.../observer/ antigravity-bridge.exe cache copy that surfaced on c15725c6, but
this time falling AFTER the unrecoverable-marker fix. The bridge
call hard-errored before reaching the gRPC layer — no convert, no
structured, no tokens.
copyIfNewer now falls back to the existing cached .exe when the
overwrite fails (permission denied). The newest bridge code is
deferred to the next call when the lock releases; the older cached
binary still satisfies the invocation contract (subcommand args,
stdout payload, stderr endpoint hint) — every contract observer
relies on has been stable across recent changes, and the new
--endpoint/--csrf flags fall through to legacy behaviour when
the receiving binary doesn't understand them. Strict improvement
over the prior hard-fail.
feat(antigravity): derive desktop project root from overview.txt metadata
Sibling-issue to the above: f7c9f417 (a fresh desktop conversation
on the operator's Antigravity IDE) showed [antigravity] placeholder
in the dashboard's Project column instead of the actual workspace
path. Root cause: state.vscdb's trajectorySummaries blob (the
existing project-attribution source for desktop conversations) is
flushed by Antigravity periodically, not per-turn — fresh
conversations get no entry, lookupIndexEntry returns nil, and
projectRoot defaults to [antigravity].
The IDE's own ADDITIONAL_METADATA stamp inside every
USER_INPUT already carries the active workspace context:
Active Document: c:\programsx\regulation\regulation-saas\scripts\security\README.md (LANGUAGE_MARKDOWN)
Other open documents:
- c:\programsx\regulation\regulation-saas\src\app\...
- c:\programsx\regulation\clearpath-website\src\components\Footer.tsx
...
New internal/adapter/antigravity/project_root_metadata.go:
extractProjectRootFromTranscript parses USER_INPUT metadata, walks
each candidate document path (Active Document first, then Other
open documents in transcript order), translates via
pathnorm.Normalize to WSL-canonical form (c:\foo\bar →
/mnt/c/foo/bar), and runs git.FindRoot until a working tree
matches. Returns the first git root found; "" on miss.
Wired into both historyOnlyResult (the decrypt+bridge-fail escape
hatch) and augmentResultFromHistory (the post-success augmentation
path). Only fires when the existing projectRoot is the
[antigravity] placeholder, so authoritative state.vscdb data
still wins when present.
Verified end-to-end on the operator's f7c9f417: project went from
[antigravity] → /mnt/c/programsx/regulation/regulation-saas (the
nested git root closest to the active document, correctly preferred
over the parent /mnt/c/programsx/regulation). CLI-from-bash
sessions whose ADDITIONAL_METADATA block carries only the
timestamp (no Active Document) correctly no-op and keep their
existing project attribution from the config/projects/<uuid>.json
resolver.
Tests: project_root_metadata_test.go adds 6 functions:
TestCandidateDocumentPaths (regex parsing + dedup ordering),
TestCandidateDocumentPathsEmptyMetadata (CLI-from-bash no-op),
TestExtractProjectRootFromMetadata_WalksToGitRoot (end-to-end
fixture: writes .git/HEAD + walks up), TestExtractProjectRootFromMetadata_NoGitReturnsEmpty,
TestExtractProjectRootFromTranscript_PrefersFirstUserInput,
TestExtractProjectRootFromTranscript_NoUserInputReturnsEmpty.
fix(antigravity): CLI index cache invalidates on log file append
Operator-reported (2026-05-24): an agy session launched from
PowerShell (791a7ab9) landed with [antigravity] placeholder for
its Project column even though ~/.gemini/config/projects/<uuid>.json
existed and the corresponding cli-*.log carried the
Conversation using project ID: <uuid> binding line.
Root cause: lookupCLIIndexEntry's cache was keyed by the log
directory mtime. On Linux ext4 (and via the WSL2 9p protocol
over drvfs / virtiofs), appending to an existing file does NOT bump
the parent dir's mtime — only file create/rename/delete do. agy
writes the binding line to the open cli-*.log at the moment the
new .pb file is created, which is also the moment the watcher
fires. Race: if observer probed lookupCLIIndexEntry milliseconds
before the binding-line append (e.g. on a quick initial scan that
found the dir but not the binding), the cache held an empty result
keyed at the current dir mtime. Subsequent lookups got a cache hit
on the same dir mtime → returned nil → fell back to [antigravity]
forever.
Fix: new cliLogFreshnessUnix(logDir) helper returns max(dir mtime, latest cli-*.log file mtime). Captures both file-create AND
file-append events. Cost: 1 readdir + N stats per call (N ≤ 10 in
practice), well under the existing parseCLILogBindings pass on
cache miss.
Tests: cli_log_freshness_test.go adds 3 functions —
TracksFileAppends (the regression), AddsFileBumpsFreshness
(sanity), IgnoresNonCLIFiles (an unrelated readme.txt write must
not influence freshness above dir-mtime).
perf(antigravity): bump cold-cache bridge timeout to 90s + size-versioned cache filename
Operator-reported sibling issue: Windows-side session token capture
was failing across the board (f7c9f417 desktop, 56633544 and
791a7ab9 CLI). Two compounding causes:
- Cold bridge timeout too tight: the 30s timeout killed the
first bridge invocation before its serial fan-out across 10–17
candidate gRPC servers (3 agy.exe + 7 antigravity.exe IDE shell +
7 language_server_*.exe on the operator's host) could find the
workspace-matching server. Empirically the right server is
reached in ~30–60s on a busy host. Without a single successful
bridge call, the per-conversation endpoint cache stayed empty,
and every future poll paid the full fan-out cost again — tight
no-progress loop. - Cache file lock: the running observer constantly invokes the
cached/mnt/c/.../observer/antigravity-bridge.exevia
powershell, so the file is effectively always opened-for-execute.
Overwriting an open Windows .exe fails. Without versioning, every
newmake buildwould change the source size,copyIfNewer
would fail with permission-denied, the graceful-fallback would
keep using the OLD cached binary, and new bridge code (the
--endpoint/--csrfflags this session shipped, plus stderr
bridge-endpoint=hints for cache population) NEVER reached
production — defeating the conv-endpoint cache entirely.
Fix:
bridgeColdCallTimeout = 90 * time.Secondconstant for unpinned
calls; pinned (cache-hit) calls keep the original tighter timeout.windowsCacheDestinationFor(srcSize)returns
<dir>/antigravity-bridge-<size>.exe. Each source size lands in
its own filename, sidestepping the lock entirely. Old cached files
linger harmlessly until the OS clears the temp dir.
End-to-end verified on operator's 791a7ab9:
- Before: bridge
convertkilled at 30s; transcript fallback fired
→ 2 ToolEvents / 0 TokenEvents / project[antigravity]. - After: bridge
convertsucceeds in ~30s, bridgestructured
follows (cache hit, ~1s) → 2 ToolEvents / 1 TokenEvent /
project/programsx/superbased-observer.
Long-lived daemon process additionally benefits from the in-memory
conv-endpoint cache: only the FIRST poll-tick per conversation pays
the ~30s cold-call cost; subsequent ticks complete in ~1–2s.
fix(antigravity): DB-aware transcript dedup (Phase 1)
Closes a universal-duplication bug observed across every multi-turn
CLI conversation on the operator's host. Sample:
1e235146 6 turns → DB held 11 user_prompt rows (5 struct + 6 transcript)
bd6051ba 6 turns → DB held 8 user_prompt rows (2 struct + 6 transcript)
739cbb33 6 turns → DB held 7 user_prompt rows (1 struct + 6 transcript)
Root cause (verified by row-id ordering on 1e235146): the
existing dedup in synthesizeTranscriptEvents compared transcript
USER_INPUT Targets against res.ToolEvents of the current parse
cycle only. A later parse cycle that reached the transcript path
with an empty res.ToolEvents — typical when decrypt-fail led to
historyOnlyResult, or when decrypt-success emitted zero events —
saw no coverage and re-emitted every transcript entry. The
antigravity-cli-transcript:* SourceEventID namespace doesn't
collide with the prior antigravity-struct-payload:* rows, so the
UNIQUE constraint accepted them.
Fix: new TargetCoverageReader interface
(internal/adapter/antigravity/adapter.go) consulted before dedup;
internal/store/store.go::LoadActionTargets returns the distinct
(action_type, target) pairs already persisted for the source file;
cmd/observer/main.go::antigravityTargetCoverageShim wires it into
the adapter alongside the existing UnrecoverableTracker. Both
augmentResultFromHistory and historyOnlyResult now merge DB-loaded
targets with in-memory res.ToolEvents before calling synth, so
later cycles dedup correctly against rows written by earlier cycles.
Empirical: on 1e235146's 11→6 row reduction, every transcript
Target byte-matches an existing structured Target except the last
turn (Hellooooo, which was typed after agy.exe had stopped writing
the .pb file — correctly retained as the one transcript-only turn).
Tests: TestSynthesizeTranscriptDedupesAgainstExtraCoverage in
transcript_cli_test.go pins the new path; existing tests updated
for the new signature without behavior change.
feat(antigravity): structured-trajectory snapshot persistence (Phase 2)
agy.exe holds each conversation's cascade trajectory in memory only.
Once the originating instance terminates, GetCascadeTrajectory
against any sibling instance returns trajectory not found — and
observer's only fallback is the plaintext transcript path, which
has zero token data.
Fix: every successful bridge / native gRPC response is now
snapshotted to ~/.observer/antigravity-snapshots/<uuid>.bin.
Future polls that find the originating agy gone reach the
loadSnapshotEnrichment fallback in fetchStructuredEnrichmentWSL
fetchStructuredEnrichmentNativeAt, surfacing the most-recently
captured trajectory (model attribution, per-turn tokens, the works)
instead of empty enrichment.
Snapshot reconciliation (reconcileWithSnapshot): when the bridge
returns a fresh payload, parse both new + cached, keep whichever
yields more TokenEvents. Protects against the case where a random
fan-out lands on a "junior" agy.exe returning a valid-looking but
truncated 1-turn response — without reconciliation we'd demote the
better cached snapshot.
Storage: ~150 KB per conversation in steady state. An observer doctor prune step is queued separately.
perf(antigravity): originating-server pin via cli-*.log scan (Phase 3)
The bridge's previous routing was "fan out to every running agy.exe
instance via PowerShell + WMI + per-server timeout". That walked
10–17 candidates per cold call, costing 30–90s.
Fix: parse each cli-*.log file's startup lines to extract the
language_server PID + listening ports, plus every Created conversation <uuid> line. Result is a conv UUID → (PID, HTTPSPort, HTTPPort) map. Both callBridgeConvertCached +
callBridgeStructuredCached now try log-derived endpoint candidates
before falling through to fan-out. Cached by log-dir freshness
(cliLogFreshnessUnix), so steady-state polling pays a single map
lookup.
Live verification on operator's host (2026-05-24):
1e235146: fan-out fully bypassed → originating-server pin
http://...:63353 failed → https://...:63353 OK (~1s)
c79f9f41: TokenEvents went from 1 → 2; the originating-server pin
reached the agy.exe that actually hosted the conv, where
random fan-out had been landing on a junior instance.
Tests: log_scan_test.go (7 functions) pins the parser end-to-end
including PID/port extraction from the real-format log fixture and
the preferred-first endpoint candidate ordering.
Design doc: docs/antigravity-token-coverage-design-2026-05-24.md
covers all three phases plus the Phase 4 (cipher reverse-engineering)
groundwork for full historical backfill.
feat(web,api): on-demand full-text fetch + drop 4 KB ingest cap
Closes operator-reported gap in the Sessions → Messages table: the
clipboard buttons could only copy ≤4 KB (raw_tool_input) or ≤2 KB
(tool_output, FTS5 excerpt), even when the operator's prompt or the
captured tool result was much larger. The truncation was applied at
ingest time, so historical rows lost the bytes permanently.
Storage (migration 027 — actions.raw_tool_output TEXT):
- New column persists the full untruncated tool_result body per
action, mirroring the existingraw_tool_inputlength-merge
ON CONFLICT semantics so adapter re-scans keep the richer of the
in-flight vs final body without ever regressing. UpdateActionOutcomealso writes the column on post-hook merge
paths so hook-captured pre-completion bodies get upgraded by the
later JSONL adapter pass when the latter sees more bytes.
Capture (drop the legacy caps, add a 1 MiB safety belt):
- New
internal/contentcappackage —Cap(s, max)returns the body
with a trailing…(content truncated at N bytes)…marker if it
exceededmax.DefaultMaxBytes = 1 << 20(1 MiB). internal/scrub.MaxRawInputBytesbumped from 2 KiB → 1 MiB
(scrub.Truncatecallers across cowork / cline / claudecode /
codex / copilotcli pick up the new ceiling automatically).- Every adapter site that previously did
truncate(body, 4000)for
ToolOutputnow routes throughcontentcap.Cap: cowork
assistant_text + tool_result, cursor read-file + thinking + assistant
response + transcript walker, codex assistant_text + reasoning,
cline assistant_text, claudecode assistant_text, openclaw
assistant_text, opencode assistant_text + reasoning + subtask +
todo. The Claude CodeStophook incmd/observer/hook.go
matches. - Opencode's two 200-char
RawToolInputcaps (subtask /
todo) lifted to the 1 MiB ceiling for consistency. - The FTS5 indexer (
action_excerpts) keeps its 2 KiB excerpt cap
unchanged — search performance trumps copy-fidelity for that
surface.
Serving (new endpoint + /messages elision):
- New
GET /api/action/<id>/full_textreturns the untruncated
raw_tool_input+raw_tool_outputfor one action on demand.
Returns 404 on unknown id, 400 on malformed path. /api/session/<id>/messagesnow caps inlinefull_textat 4 KiB
(preview only), setsfull_text_elided: trueon rows that exceeded
the cap, and surfaceshas_full_output: truewhenever
actions.raw_tool_outputis non-empty. Timeline payload stays
bounded regardless of how large any single row's raw content grew
post-migration.- New
action_idfield on everytoolCallRowso the frontend can
call the on-demand endpoint without an extra round-trip.
UI (SessionDetailPanel.tsx::ToolCallRowView):
CopyOnClickgains an optionalresolveValuecallback awaited on
click; the primary + excerpt copy buttons now fetch the full body
via/api/action/<id>/full_textand copy the resolved text when
full_text_elided/has_full_outputis set. Cached per-row so
repeated clicks don't refetch.- New View button appears on rows with elided content; opens a
centered modal (z-[60], above the SessionDetailPanel slide-over)
showing bothraw_tool_input+raw_tool_outputside-by-side
with copy buttons inside. Escape + backdrop click close.
Tests:
internal/contentcap: 6 cases covering passthrough, exact-fit,
over-cap marker, zero/negative max default behavior.internal/store:TestInsertActions_RawToolOutputLengthMerge
pins ON CONFLICT length-merge — re-ingest with longer body
upgrades, shorter is ignored.internal/intelligence/dashboard:TestAPIActionFullTextcovers
happy path (returns untruncated input + output), 404 on unknown
id, 400 on malformed id, 404 on unsupported sub-resource.internal/adapter/cursor: existing 4000-cap test renamed +
rewritten asTestBuildEvent_BeforeReadFile_CapsAtContentcapDefault
to pin the new contract (under-cap passes through; over-cap gets
the contentcap marker).
fix(cost): net OpenAI-shape input against cached to stop double-billing
Closes operator-flagged cost-engine over-charge for codex /
OpenAI-shape sessions. Audit on session
019e5adb-9206-73c3-9465-acf799bb335f (2026-05-24) found the
dashboard reporting $0.01136 for a single 3299-net-input turn that
should have cost ~$0.00339 — a 3.4× overbill caused by the cached
portion being billed at BOTH the full input rate AND the discounted
cache_read rate.
Root cause: OpenAI / Codex prompt_tokens (a.k.a.
input_tokens) is the TOTAL prompt count INCLUDING the cached
portion; cached_tokens / cached_input_tokens is a SUBSET of it.
The Anthropic API reports input_tokens already-NET so the cost
engine's inputCost = Input × input_rate + CacheRead × cache_rate
math is correct for Anthropic but wrong for OpenAI — the cached
tokens get charged twice.
Fix (adapter-local, forward-only — historical rows stay
over-billed in dashboard rollups until they fall out of the window):
internal/adapter/codex/adapter.gomodern path
(event_msg/token_count): emitInputTokens = last.input_tokens - last.cached_input_tokens(clamped at 0).internal/adapter/codex/adapter.golegacy path (top-level
type=token_count): track NET cumulative input per session and
emit per-turn deltas of NET, not deltas of gross with a separate
cached subtraction (the prior shape gave negative-clamped-to-zero
deltas when cached grew faster than gross).internal/proxy/provider.goOpenAI non-streaming response shape:
shape.InputTokens = max(0, PromptTokens - CachedTokens).internal/proxy/streaming.go::applyOpenAIUsage: same netting,
with CacheReadTokens populated first so the netting step can read
it off the result struct.internal/adapter/copilotcli/log.go::emitTokenEvent(Tier-1 OTel
/ debug-log path): same netting. Docstring previously confessed
"InputTokens is the gross prompt total per Anthropic/OpenAI
convention" — that convention is OpenAI's, and the cost engine's
is Anthropic-net; updated to reflect the actual NET contract.internal/adapter/copilotcli/events.gosession.shutdown
modelMetrics handler: same netting (DB pattern matched the log
path's gross shape across all 9 sampled rows: input always >=
cache_read with 51-91% cache hit ratios).internal/adapter/copilotcli/events.gosession.compaction_complete
handler: same netting (compactionTokensUsed.inputTokens follows
the same OpenAI-gross convention as the rest of Copilot CLI's
token surfaces; tested against both old-schema {input,
cachedInput} and new-schema {inputTokens, cacheReadTokens}
variants).internal/adapter/cursor/adapter.go::BuildStopTokenEvent: same
netting. Cursor's stop-hook payload uses Anthropic-style field
NAMES (cache_read_tokens) but populatesinput_tokenswith the
OpenAI-style GROSS total — confirmed empirically against session
93bcba3c (2026-05-24): a trivial 2-turn "say hello" conversation
reported input_tokens=11615 with cache_read_tokens=11490 (99%),
which is only coherent if input_tokens is the total prompt and
the 11490 cached tokens are a subset (net new = 125, correct for
the turn). Resolves the cursor item flagged in commit 0a6a252.internal/intelligence/cost/engine.goTokenBundle.Input
docstring: pinned the NET contract explicitly so future provider
integrations don't reintroduce the bug.
Tests:
cost.TestComputeBreakdown_OpenAIShape_NoDoubleBill— pins the
contract using the exact 019e5adb numbers (13923/10624/17/10);
also asserts the computed total is significantly below the
pre-fix wrong total as an anti-regression.codex.TestParseTokenCount_NetsInputAgainstCached— covers both
parser paths (modern + legacy cumulative).- Updated existing tests (4 proxy + 4 codex + 6 copilot-cli) that
pinned the old gross-input contract — all now assert NET with
explanatory comments tying back to the cost-engine TokenBundle
docs.
Audit coverage (every TokenEvent emission site in the tree):
| Adapter | Shape | Status |
|---|---|---|
| claude-code, cline, cowork, claudecode-via-proxy | Anthropic native (cache_read_input_tokens) |
NET ✓ |
| antigravity (classify + structured) | Protobuf-derived, normalized to net | NET ✓ |
| opencode | Upstream-verbatim, normalized to net | NET ✓ |
| pi, openclaw | Anthropic-native (Claude chat models) | NET ✓ |
| copilot VSCode (legacy + modern) | Never sets cache_read | N/A — no bug possible |
| codex JSONL (modern + legacy) | OpenAI gross → fixed | NET ✓ |
| proxy OpenAI (non-streaming + streaming) | OpenAI gross → fixed | NET ✓ |
| copilot-cli (log + shutdown + compaction) | OpenAI gross → fixed | NET ✓ |
| cursor IDE (stop hook) | Anthropic field names but OpenAI-gross values → fixed | NET ✓ |
| gemini-cli (legacy + JSONL) | Gemini API native is gross-shape; flagged | Pending (0 rows in operator DB, no immediate impact) |
docs(cursor): WSL cursor-agent CLI first-session token-capture window
Investigation of WSL Cursor CLI session 251876cf (2026-05-24)
surfaced why it has conversation text but zero token rows —
and it is NOT a cursor-agent limitation. The session ran in the
22-minute window BEFORE observer registered hooks into the WSL-side
~/.cursor/hooks.json. Precise timeline (UTC):
19:12:57 cursor-agent CLI freshly installed on WSL
19:18:28 ~/.cursor/cli-config.json written (cursor-agent's config)
19:19:30 WSL CLI session 251876cf ran ← no Linux hooks.json yet
19:41:46 ~/.cursor/hooks.json created ← observer registers Linux
cursor hooks (detect-then-register, 22 min too late)
Corroborating evidence: every cursor hook token row in the DB has
project_root /c:/programsx/... (Windows) — all prior working
cursor hooks came from the Windows IDE (registerCursorWindows,
live since 2026-05-08). There had never been a Linux-native cursor
hook row because the WSL ~/.cursor/hooks.json didn't exist until
19:41:46. cursor-agent uses ~/.cursor/ as its home (store.db +
transcripts live there), so the hooks.json observer wrote at
19:41:46 is in the right place for future sessions.
Confirmed cursor-agent stores token usage NOWHERE on disk — the
content-addressed blob store (~/.cursor/chats/<ws>/<id>/store.db)
holds system_prompt / tools / conversation blobs with no numeric
usage fields, and no SQLite db under ~/.cursor has a token column.
So the stop hook is the only viable token-capture path for the
CLI, exactly as it is for the IDE.
No code change. This is the inherent detect-then-register latency of
the auto-register-on-start model: a freshly-installed tool's very
first session(s) run before observer has registered its hooks.
Confirmed self-resolved — a WSL cursor-agent session run after
19:41:46 (852adff6) fired the stop hook and captured tokens
(input=7696 net, cache_read=5018).
feat(cursor): capture system prompt from store.db blob store
Cursor sessions now surface a system_prompt row in the Messages
table, matching what codex and claude-code already show. Operator-
requested after noticing cursor's large per-turn input was opaque
(it's the agent harness — system prompt + tool defs + <user_info>
context — re-sent every turn, not the user's message).
Neither cursor's hook payloads (stop / beforeSubmitPrompt /
sessionStart / afterAgentResponse) nor the agent-transcript JSONL
carry the system prompt. The only on-disk source is cursor-agent's
per-session content-addressed blob store at
<home>/.cursor/chats/<workspace-hash>/<conv>/store.db, which holds
the prompt as a {"role":"system","content":"..."} blob (exactly one
per session; verified against the operator's live data — a 1959-char
"You are an AI coding assistant, powered by Composer…" prompt).
Implementation (internal/adapter/cursor/storedb.go +
scan.go::ParseSessionFile):
- The watcher derives the
.cursorroot from the agent-transcript
path it's already parsing (so a Windows-side cursor read from WSL
via /mnt/c works viacrossmount.TranslateForeignPath), globs
chats/*/<conv>/store.db, opens it read-only with a busy timeout,
and scans blobs for the singlerole:"system"JSON blob (binary
Merkle-node blobs are prefix-filtered out cheaply). - Emits an
ActionSystemPromptrow mirroring the codex shape: full
scrubbed body inraw_tool_input(now uncapped at 1 MiB per the
earlier full-text work), 200-char preview intarget,MessageID
system:<hash>, content-hashedSourceEventID
cursor-sysprompt:<conv>:<hash>so re-scans dedup via the store's
UNIQUE index. - Emitted BEFORE the watcher's hook-deferral gate, because the live
hook path captures tokens + activity but never the system prompt —
so the row lands whether or not the cursor hook is registered.
Tests: TestParseSessionFile_EmitsSystemPromptFromStoreDB (synthetic
store.db with system + binary + user blobs; pins extraction,
field shape, and re-scan idempotency). Verified end-to-end against
the operator's real store.db.
Prompt-budget breakdown rows (token reconciliation)
To answer "why is a turn's input so large for a trivial message," the
watcher also surfaces cursor's per-section prompt-token budget. The
root blob carries a protobuf section index — system_prompt, tools,
rules, skills, mcp, subagents, summarized_conversation,
conversation — recording each section's token + char COUNT (the
tool/rule/skill content itself is NOT persisted anywhere on disk;
verified 2026-05-25). For the operator's 3cedf573 session the
breakdown was system 476 / tools 6,122 / rules 4,426 / skills 940
/ subagents 174 / conversation 643 tokens — summing to ~12.8K, which
reconciles the ~12.5K gross turn-1 input. So the large input is the
agent harness (tools + rules dominate), not the user's message.
- New Go protobuf walker (
storedb.go::pbWalk/parseSectionIndex)
decodes the section index defensively (malformed/truncated fields
abort cleanly — the format is undocumented and may drift). - The current budget is pinned via
meta.latestRootBlobId(a session
accumulates multiple root blobs as it grows; picking by scan order
would be non-deterministic), with a marker-scan fallback. - Emits one zero-cost informational row per non-empty scaffolding
section (prompt_section.tools/.rules/.skills/
.subagents/ …), skippingsystem_prompt(own content row) and
conversation/summarized_conversation(the captured turns).
Token/char counts live intarget+ body; NOtoken_usagerow is
emitted — the tokens are already billed inside each turn's
input_tokens, so a separate token row would double-count.
Content-name-keyedSourceEventIDdedups on re-scan. - These rows use a NEW
prompt_contextaction type (not
system_prompt), so the dashboard renders them as "Prompt context"
rather than mislabeling a "Rules" / "Tool definitions" row as
"System prompt".models.ActionPromptContextadded; frontend
actions.tsregisters bothsystem_prompt("System prompt") and
prompt_context("Prompt context") explicitly (previously
system_promptonly got a humanized fallback label).
NB: cursor does NOT persist the tools/rules/skills/subagents
content anywhere on disk (verified across~/.cursor— no
workspace rules, no tool-schema blobs; skills exist as SKILL.md
files but cursor sends a token-budget index, not the files), so
these rows are inherently count-only. The system prompt is the lone
section whose content is stored. - Test:
TestParseSessionFile_EmitsPromptBudgetSections(synthetic
root blob built via protowire encoders; pins section emission,
the system_prompt/conversation/empty-section skips, and zero
token events). Verified end-to-end + deterministic across re-scans
against the operator's real store.db.
fix(cursor): canonicalize Windows cursor-agent CLI workspace roots
Windows cursor-agent CLI (cursor-agent.exe, invoked from WSL via
wsl.exe) sends workspace_roots as a raw Windows backslash path,
e.g. C:\programsx\superbased-observer (verified against session
90dd2512, 2026-05-24). decodeWorkspaceRoot returned that verbatim,
so the token row landed under a non-canonical project root that
isn't stat-able from a WSL observer and doesn't match the /mnt/c/...
form pathnorm produces everywhere else.
Fix: decodeWorkspaceRoot now routes its result through
crossmount.TranslateForeignPath (thin wrapper over
pathnorm.Normalize). C:\... and file:// URIs canonicalize to
/mnt/c/...; already-native Linux paths and the IDE's /c:/...
forward-slash form are left untouched (pathnorm doesn't recognize the
/c:/ prefix as foreign), so existing Cursor IDE rows don't
fragment. Test: TestBuildStopTokenEvent_WindowsCLIWorkspaceRoot.
Separately confirmed the Windows cursor-agent CLI token-capture path
is otherwise sound: the stop hook fires with full token data (BOM-
prefixed — observer strips it at internal/hook/cursor.go:38), and
the current handler captures it correctly. An earlier Windows CLI
session (90dd2512) missed its token row for an environmental/
transient reason (the hook is delivered through wsl.exe cold-spawn
and the stop handler reads the transcript across the /mnt/c
mount, pressuring the 250 ms ingest deadline under daemon WAL
contention) — NOT a logic bug; re-running the captured payload
through the current binary produces the correct row.
Historical impact (NOT backfilled): on 2026-05-24 there were
1,007 codex JSONL rows with cache_read_tokens > 0 plus all
OpenAI-shape proxy rows. These stay over-billed in any dashboard
view that includes them; new rows from this point on bill
correctly. Anthropic-shape adapters (claude-code, antigravity,
cowork, claude-via-proxy) are unaffected — their input_tokens
already-net.
fix(web): SessionDetailPanel copy buttons use full_text
Roll-forward of an earlier session's fix that landed in the working
tree but missed the CHANGELOG: the primary CopyOnClick in
ToolCallRowView now copies tc.full_text || tc.target || tc.raw_tool_name instead of the display-truncated tc.target. The
excerpt copy already preferred tc.full_text. Tooltip-show
condition also widened to trigger when primaryFull.length > primary.length so the operator sees the longer text on hover even
when display fits in 140 chars.
Downloads
Pre-built binaries for each supported platform are attached below. Linux variants bundle antigravity-bridge.exe next to the observer binary for WSL2 users of the Antigravity adapter.
| Platform | Asset |
|---|---|
| Linux x86_64 | observer-v1.6.29-linux-x64.tar.gz |
| Linux arm64 | observer-v1.6.29-linux-arm64.tar.gz |
| macOS x86_64 (Intel) | observer-v1.6.29-darwin-x64.tar.gz |
| macOS arm64 (Apple Silicon) | observer-v1.6.29-darwin-arm64.tar.gz |
| Windows x86_64 | observer-v1.6.29-win32-x64.zip |
Verify with sha256sum -c SHA256SUMS (or shasum -a 256 -c SHA256SUMS on macOS) from the directory containing the downloads.
Also available via npm: npm install -g @superbased/observer@1.6.29