Skip to content
Merged
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -588,7 +588,7 @@ kcap plugin install --antigravity # install the kcap plugin to ~/.gemi
kcap plugin remove --antigravity # remove the kcap plugin
```

Antigravity fires a distinct control hook per lifecycle/tool event; kcap acts on the first `PreInvocation` of a conversation (POSTs lifecycle + spawns a watcher tailing that conversation's `transcript_full.jsonl`, `vendor=antigravity`) — so kcap must be on `PATH`. Antigravity is a GUI whose process outlives any one conversation (like the Codex desktop app), so there is no per-conversation exit signal: the watcher ends a session after it goes idle (default 60 min; override with `KCAP_ANTIGRAVITY_IDLE_MINUTES`), and a later turn reactivates it. Token/model usage lives in each conversation's sibling SQLite db (`conversations/<id>.db`), not the JSONL, so the watcher decodes it and streams the per-generation cost (priced on read; cost is never stored). **Subagents** (Antigravity's nested agents) are separate conversations; in *live* capture they're recorded as their own sessions (nesting them under the parent is a follow-up), while historical `kcap import --antigravity` nests them under the parent (the parent↔child linkage is complete on disk at import time). Historical import reads `~/.gemini/antigravity/brain/*/…/transcript_full.jsonl` and backfills sessions from before the hooks were installed; it's watermark-idempotent (safe to re-run) and leaves the working dir empty (Antigravity records no machine-readable cwd in the transcript — live capture gets it from the hook payload). Imported sessions currently carry content but not cost (cost injection on import is a follow-up). To import one conversation, `kcap import --antigravity --session <id>` accepts the id in **either** form — the dashed brain-dir conversation id or its dashless canonical form (the id kcap shows for the session) — because import canonicalizes to the same dashless id that live capture uses.
Antigravity fires a distinct control hook per lifecycle/tool event; kcap acts on the first `PreInvocation` of a conversation (POSTs lifecycle + spawns a watcher tailing that conversation's `transcript_full.jsonl`, `vendor=antigravity`) — so kcap must be on `PATH`. Antigravity is a GUI whose process outlives any one conversation (like the Codex desktop app), so there is no per-conversation exit signal: the watcher ends a session after it goes idle (default 60 min; override with `KCAP_ANTIGRAVITY_IDLE_MINUTES`), and a later turn reactivates it. Token/model usage lives in each conversation's sibling SQLite db (`conversations/<id>.db`), not the JSONL, so the watcher decodes it and streams the per-generation cost (priced on read; cost is never stored). **Subagents** (Antigravity's nested agents) are separate conversations; both *live* capture and historical `kcap import --antigravity` nest them under the parent, derived from the `INVOKE_SUBAGENT` step in the parent's `transcript_full.jsonl` (the spawn-time linkage signal). Live capture POSTs a subagent-link as each child is spawned; import reads the same `INVOKE_SUBAGENT` steps across all conversations on disk. Historical import reads `~/.gemini/antigravity/brain/*/…/transcript_full.jsonl` and backfills sessions from before the hooks were installed; it's watermark-idempotent (safe to re-run) and leaves the working dir empty (Antigravity records no machine-readable cwd in the transcript — live capture gets it from the hook payload). Imported sessions currently carry content but not cost (cost injection on import is a follow-up). To import one conversation, `kcap import --antigravity --session <id>` accepts the id in **either** form — the dashed brain-dir conversation id or its dashless canonical form (the id kcap shows for the session) — because import canonicalizes to the same dashless id that live capture uses.

Cursor uses a single user-scope `hooks.json`; there is no project-scope variant.

Expand Down
178 changes: 141 additions & 37 deletions src/Capacitor.Cli.Core/Antigravity/AntigravitySubagents.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,23 @@
namespace Capacitor.Cli.Core.Antigravity;

/// <summary>
/// Resolves Antigravity's subagent → parent conversation linkage for historical import
/// (AI-1160). A subagent is a SEPARATE conversation; the parent's brain dir records the
/// linkage as inter-agent messages under
/// <c>brain/&lt;parent&gt;/.system_generated/messages/*.json</c>, each
/// <c>{ "sender": "&lt;child&gt;", "recipient": "&lt;parent&gt;" }</c>. This linkage is only
/// written when a child reports back, so it is unreliable at live child-start (nesting is
/// deferred there) — but at IMPORT time every message file is on disk, so the full map is
/// resolvable. Roots are conversations that are never a <c>sender</c>.
/// Resolves Antigravity's subagent → parent conversation linkage for historical import. A
/// subagent is a SEPARATE conversation; AI-1218 redesign: the linkage is derived from the
/// parent transcript's <c>INVOKE_SUBAGENT</c> steps (the spawn-time signal — same source the
/// live watcher now uses, see <see cref="ChildConversationIdsFromLine"/>), which is available
/// even for a child that never reports back. This replaces the pre-AI-1218 (AI-1160)
/// child-reports-back scan of <c>brain/&lt;parent&gt;/.system_generated/messages/*.json</c>,
/// which silently dropped errored/never-reporting children (see d9956b89) and had the
/// sender/recipient direction inverted in some captures (see 7f8d9d93 → a1204f98). Roots are
/// conversations that never appear as an invoked child.
/// </summary>
public static class AntigravitySubagents {
/// <summary>
/// Builds child-conversation-id → parent-conversation-id by scanning every brain dir's
/// messages. Best-effort: unreadable / malformed message files are skipped. If a child maps
/// to MULTIPLE parents (pathological / non-tree data), the lexicographically-smallest parent
/// wins so the map is deterministic regardless of directory enumeration order.
/// <c>transcript_full.jsonl</c> for INVOKE_SUBAGENT steps. Best-effort: unreadable /
/// truncated transcripts are skipped. If a child is invoked by MULTIPLE parents
/// (pathological / non-tree data), the lexicographically-smallest parent wins so the map is
/// deterministic regardless of directory enumeration order.
/// </summary>
public static IReadOnlyDictionary<string, string> BuildParentMap(
string? home = null, string? geminiCliHome = null, CancellationToken ct = default) {
Expand All @@ -26,43 +28,40 @@ public static IReadOnlyDictionary<string, string> BuildParentMap(
var brainRoot = Path.Combine(AntigravityPaths.Root(home, geminiCliHome), "brain");
if (!Directory.Exists(brainRoot)) return map;

// Building the full map requires scanning every brain dir's messages (a child records its
// parent, so finding any root's descendants means reading all links). That's O(history) IO,
// so honour cancellation between dirs and files — a targeted `--session` import or a Ctrl+C
// must be able to interrupt it (AI-1160 review).
foreach (var brainDir in Directory.EnumerateDirectories(brainRoot)) {
ct.ThrowIfCancellationRequested();
var messages = Path.Combine(brainDir, ".system_generated", "messages");
if (!Directory.Exists(messages)) continue;
var parent = Path.GetFileName(brainDir);
var transcript = Path.Combine(brainDir, ".system_generated", "logs", "transcript_full.jsonl");
if (!File.Exists(transcript)) continue;

foreach (var file in Directory.EnumerateFiles(messages, "*.json")) {
foreach (var line in EnumerateLinesSafe(transcript)) {
ct.ThrowIfCancellationRequested();
try {
using var doc = JsonDocument.Parse(File.ReadAllText(file));
var root = doc.RootElement;
if (root.ValueKind != JsonValueKind.Object) continue;

var sender = root.TryGetProperty("sender", out var s) ? s.GetString() : null;
var recipient = root.TryGetProperty("recipient", out var r) ? r.GetString() : null;

if (string.IsNullOrEmpty(sender)
|| string.IsNullOrEmpty(recipient)
|| string.Equals(sender, recipient, StringComparison.Ordinal))
continue;

// Deterministic on conflict: keep the smallest parent (Ordinal).
if (!map.TryGetValue(sender!, out var existing)
|| string.CompareOrdinal(recipient!, existing) < 0)
map[sender!] = recipient!;
} catch {
// Skip a malformed / unreadable message file — never abort the whole map.
foreach (var child in ChildConversationIdsFromLine(line)) {
if (string.Equals(child, parent, StringComparison.Ordinal)) continue; // self-guard
// First-wins on conflict, deterministic: keep the smallest parent (Ordinal).
if (!map.TryGetValue(child, out var existing)
|| string.CompareOrdinal(parent, existing) < 0)
map[child] = parent;
}
}
}

return map;
}

static IEnumerable<string> EnumerateLinesSafe(string path) {
IEnumerator<string> e;
try { e = File.ReadLines(path).GetEnumerator(); }
catch { yield break; } // unreadable transcript — skip the whole file
using (e) {
while (true) {
try { if (!e.MoveNext()) yield break; }
catch { yield break; } // truncated/locked mid-read — stop, keep what we have
yield return e.Current;
}
}
}

/// <summary>Parent conversation id for a child, or null when it is a root / unlinked.</summary>
public static string? ResolveParent(string childConversationId, string? home = null, string? geminiCliHome = null) =>
BuildParentMap(home, geminiCliHome).TryGetValue(childConversationId, out var parent) ? parent : null;
Expand Down Expand Up @@ -105,4 +104,109 @@ public static IReadOnlyDictionary<string, List<string>> BuildRootDescendants(

return byRoot;
}

/// <summary>
/// AI-1218 redesign — the authoritative spawn-time parent→child signal. A parent's transcript
/// records an INVOKE_SUBAGENT step whose <c>content</c> embeds JSON listing the child
/// conversation(s) it spawned (<c>{ "conversationId": "&lt;child&gt;", … }</c>, one object or an
/// array). Returns the child ids for a single transcript line — empty for any non-INVOKE_SUBAGENT,
/// blank, or malformed line. Strict: the id is read ONLY from the step's content payload (parsed
/// as JSON), not matched by a regex over the whole line, and each id must be GUID-shaped.
/// </summary>
public static IReadOnlyList<string> ChildConversationIdsFromLine(string line) {
if (!TryReadInvokeContent(line, out var content)) return [];

var raw = new List<string>();
// The content can embed more than one top-level JSON value (e.g. several
// "{ \"conversationId\": … }" objects separated by narrative text/newlines), so scan for
// every balanced block rather than stopping at the first.
foreach (var json in ExtractJsonBlocks(content)) {
try {
using var doc = JsonDocument.Parse(json);
CollectConversationIds(doc.RootElement, raw);
} catch {
// Skip an unparsable block; a sibling block may still be valid.
}
}

var seen = new HashSet<string>(StringComparer.Ordinal);
var result = new List<string>();
foreach (var id in raw) {
if (!Guid.TryParse(id, out _)) continue; // GUID-shaped only; brain-dir ids are dashed
if (seen.Add(id)) result.Add(id);
}
return result;
}

/// <summary>True iff the line is a structurally-valid INVOKE_SUBAGENT step (used by callers to
/// log a drift diagnostic when such a step yields no parseable child id).</summary>
public static bool IsInvokeSubagentLine(string line) => TryReadInvokeContent(line, out _);

static bool TryReadInvokeContent(string line, out string content) {
content = "";
if (string.IsNullOrWhiteSpace(line)) return false;
try {
using var doc = JsonDocument.Parse(line);
var root = doc.RootElement;
if (root.ValueKind != JsonValueKind.Object) return false;
if (!root.TryGetProperty("type", out var t) || t.ValueKind != JsonValueKind.String
|| t.GetString() != "INVOKE_SUBAGENT") return false;
if (!root.TryGetProperty("content", out var c) || c.ValueKind != JsonValueKind.String) {
content = ""; // an INVOKE step with no string content is still an invoke line
return true;
}
content = c.GetString() ?? "";
return true;
} catch {
return false;
}
}

/// <summary>Every balanced {…} or […] block found in <paramref name="s"/>, scanning left to
/// right (content can embed several sibling JSON values, not just one). String-aware so braces
/// inside quoted text don't break balancing. An unbalanced/truncated trailing block is dropped
/// rather than yielded partially.</summary>
static List<string> ExtractJsonBlocks(string s) {
var blocks = new List<string>();
var i = 0;
while (i < s.Length) {
var start = s.IndexOfAny(['{', '['], i);
if (start < 0) break;
char open = s[start], close = open == '{' ? '}' : ']';
int depth = 0; bool inStr = false, esc = false;
var end = -1;
for (var j = start; j < s.Length; j++) {
var ch = s[j];
if (inStr) {
if (esc) esc = false;
else if (ch == '\\') esc = true;
else if (ch == '"') inStr = false;
continue;
}
if (ch == '"') inStr = true;
else if (ch == open) depth++;
else if (ch == close && --depth == 0) { end = j; break; }
}
if (end < 0) break; // unbalanced / truncated — stop scanning
blocks.Add(s.Substring(start, end - start + 1));
i = end + 1;
}
return blocks;
}

static void CollectConversationIds(JsonElement el, List<string> into) {
switch (el.ValueKind) {
case JsonValueKind.Object:
foreach (var p in el.EnumerateObject()) {
if (p.NameEquals("conversationId") && p.Value.ValueKind == JsonValueKind.String)
into.Add(p.Value.GetString()!);
else
CollectConversationIds(p.Value, into);
}
break;
case JsonValueKind.Array:
foreach (var item in el.EnumerateArray()) CollectConversationIds(item, into);
break;
}
}
}
5 changes: 5 additions & 0 deletions src/Capacitor.Cli.Core/Models.cs
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,11 @@ class WatchState {
// the Codex PendingCodexToolCalls guard).
public int PendingAntigravityToolCalls { get; set; }

// Antigravity live subagent nesting (AI-1218): child conversation ids already POSTed to
// /hooks/antigravity/subagent-link for this parent watcher. A child stays OUT of this set
// until its link POST succeeds, so a failed POST retries on the next scan (fail-open).
public HashSet<string> PostedSubagentLinks { get; } = new(StringComparer.Ordinal);

public const int TranscriptThreshold = 10;
}

Expand Down
16 changes: 15 additions & 1 deletion src/Capacitor.Cli/Commands/AntigravityImportSource.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ namespace Capacitor.Cli.Commands;
/// one stream, and so <c>kcap import --antigravity --session &lt;id&gt;</c> filtering is
/// format-insensitive (AI-1238). The dashed conversation id is kept only for filesystem paths
/// (the brain-dir transcript). Subagents are separate conversations linked via
/// <c>messages/*.json</c> (see <see cref="AntigravitySubagents"/>); roots are conversations
/// <c>INVOKE_SUBAGENT</c> steps in the parent's transcript (the spawn-time signal;
/// see <see cref="AntigravitySubagents"/>); roots are conversations
/// that are never a child, and their children are imported as subagents under them — each with
/// the DASHLESS child conversation id as its <c>agent_id</c> (the server canonicalizes agent_id
/// to dashless on both ingest and watermark read, so this matches live routing; mirrors
Expand Down Expand Up @@ -53,6 +54,8 @@ public AntigravityImportSource(string? home = null, string? geminiCliHome = null
public bool IsAvailable => Directory.Exists(BrainRoot);
public bool SupportsTitleGeneration => false; // server computes a fallback title at session-end

static void Log(string message) => Console.Error.WriteLine($"[{DateTimeOffset.Now:HH:mm:ss.fff}] [antigravity-import] {message}");

public Task<IReadOnlyList<DiscoveredSession>> DiscoverAsync(DiscoveryFilters filters, CancellationToken ct) {
var result = new List<DiscoveredSession>();
if (!Directory.Exists(BrainRoot)) return Task.FromResult<IReadOnlyList<DiscoveredSession>>(result);
Expand All @@ -65,6 +68,17 @@ public Task<IReadOnlyList<DiscoveredSession>> DiscoverAsync(DiscoveryFilters fil
var convIds = Directory.EnumerateDirectories(BrainRoot).Select(Path.GetFileName).OfType<string>().ToList();
var byRoot = AntigravitySubagents.BuildRootDescendants(convIds, parentMap);

// AI-1218 drift observability: surface format drift without a messages fallback.
// HashSet membership so the counters stay O(n), not O(n²), on large brain trees (qodo #285).
var allConversationIds = convIds.ToHashSet(StringComparer.Ordinal);
var linkedChildIds = parentMap.Values.ToHashSet(StringComparer.Ordinal);
var invokeEdges = parentMap.Count;
var danglingChild = parentMap.Keys.Count(c => !allConversationIds.Contains(c));
var msgButNoInvoke = convIds.Count(id =>
Directory.Exists(AntigravityPaths.MessagesDir(id, _home, _geminiCliHome))
&& !parentMap.ContainsKey(id) && !linkedChildIds.Contains(id));
Log($"Antigravity import: {invokeEdges} invoke edge(s); {danglingChild} invoked child id(s) with no conversation dir; {msgButNoInvoke} conversation(s) with messages/ but no invoke edge");
Comment thread
qodo-code-review[bot] marked this conversation as resolved.

// Normalize the --session filter to the dashless canonical form so it matches the
// dashless session id we surface, whether the user passed a dashed or dashless id
// (mirrors GeminiImportSource) (AI-1238).
Expand Down
Loading
Loading