|
| 1 | +"""SkillOpt-Sleep — harvest Google Antigravity conversation stores. |
| 2 | +
|
| 3 | +Antigravity persists each conversation as a SQLite "trajectory" database in |
| 4 | +``~/.gemini/antigravity/conversations/<uuid>.db``. The ``steps`` table holds |
| 5 | +protobuf-encoded step payloads; without the proprietary schema we extract the |
| 6 | +human-readable content with a conservative protobuf walker that collects |
| 7 | +UTF-8 string fields: |
| 8 | +
|
| 9 | + * step_type 14 -> user messages (the typed prompt, e.g. "/goal ...") |
| 10 | + * step_type 5 -> artifact/answer content the agent produced |
| 11 | + * step_type 33 -> tool calls (JSON with toolSummary/toolAction) |
| 12 | +
|
| 13 | +That is enough to build the same ``SessionDigest`` the Claude/Codex |
| 14 | +harvesters produce: user prompts, assistant finals, tools used, feedback |
| 15 | +signals. Databases may be locked by a live Antigravity process, so each file |
| 16 | +is copied to a temp path before opening (read-only URI otherwise). |
| 17 | +
|
| 18 | +Heuristic by design: if Antigravity's schema changes, the walker degrades to |
| 19 | +returning fewer strings — never to crashing the night (a session that yields |
| 20 | +no user prompts is simply skipped, same as an empty transcript). |
| 21 | +""" |
| 22 | +from __future__ import annotations |
| 23 | + |
| 24 | +import json |
| 25 | +import os |
| 26 | +import re |
| 27 | +import shutil |
| 28 | +import sqlite3 |
| 29 | +import tempfile |
| 30 | +import time |
| 31 | +from typing import List, Optional |
| 32 | + |
| 33 | +from skillopt_sleep.harvest import _detect_feedback, _is_meta_prompt |
| 34 | +from skillopt_sleep.types import SessionDigest |
| 35 | + |
| 36 | +DEFAULT_CONVERSATIONS_DIR = os.path.expanduser( |
| 37 | + "~/.gemini/antigravity/conversations") |
| 38 | + |
| 39 | +_USER_STEP_TYPES = {14} |
| 40 | +_ARTIFACT_STEP_TYPES = {5} |
| 41 | +_TOOL_STEP_TYPES = {33} |
| 42 | + |
| 43 | +_UUID_RE = re.compile( |
| 44 | + r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$") |
| 45 | + |
| 46 | +# Antigravity injects a system wrapper around /goal tasks, and user steps also |
| 47 | +# carry a permission-history of tool echoes like ``read_url(github.com)`` — |
| 48 | +# neither is the user's own words. |
| 49 | +_BOILERPLATE_MARKERS = ( |
| 50 | + "marked this task with /goal", |
| 51 | + "The system will force you to continue", |
| 52 | +) |
| 53 | +_TOOL_ECHO_RE = re.compile(r"^[\w$.\\/-]+\([^()]*\)$") |
| 54 | + |
| 55 | + |
| 56 | +# ── generic protobuf string extraction ──────────────────────────────────────── |
| 57 | + |
| 58 | +def _read_varint(buf: bytes, i: int): |
| 59 | + val = 0 |
| 60 | + shift = 0 |
| 61 | + n = len(buf) |
| 62 | + while i < n: |
| 63 | + b = buf[i] |
| 64 | + i += 1 |
| 65 | + val |= (b & 0x7F) << shift |
| 66 | + shift += 7 |
| 67 | + if not b & 0x80: |
| 68 | + return val, i |
| 69 | + if shift > 63: |
| 70 | + break |
| 71 | + return None, i |
| 72 | + |
| 73 | + |
| 74 | +def _proto_strings(buf: bytes, depth: int = 0, out: Optional[List[str]] = None) -> List[str]: |
| 75 | + """Collect plausible UTF-8 string fields from a protobuf blob (schema-less).""" |
| 76 | + if out is None: |
| 77 | + out = [] |
| 78 | + if depth > 6 or len(out) > 400: |
| 79 | + return out |
| 80 | + i, n = 0, len(buf) |
| 81 | + while i < n: |
| 82 | + tag, i = _read_varint(buf, i) |
| 83 | + if tag is None: |
| 84 | + break |
| 85 | + wire = tag & 7 |
| 86 | + if wire == 0: |
| 87 | + _v, i = _read_varint(buf, i) |
| 88 | + if _v is None: |
| 89 | + break |
| 90 | + elif wire == 1: |
| 91 | + i += 8 |
| 92 | + elif wire == 5: |
| 93 | + i += 4 |
| 94 | + elif wire == 2: |
| 95 | + ln, i = _read_varint(buf, i) |
| 96 | + if ln is None or ln < 0 or i + ln > n: |
| 97 | + break |
| 98 | + chunk = buf[i:i + ln] |
| 99 | + i += ln |
| 100 | + text = None |
| 101 | + try: |
| 102 | + text = chunk.decode("utf-8") |
| 103 | + except UnicodeDecodeError: |
| 104 | + text = None |
| 105 | + if text is not None and len(text) >= 16 and _looks_natural(text): |
| 106 | + out.append(text) |
| 107 | + else: |
| 108 | + # possibly a nested message — recurse; a failed walk just |
| 109 | + # contributes nothing |
| 110 | + _proto_strings(chunk, depth + 1, out) |
| 111 | + else: # unknown/deprecated wire types: bail out of this blob |
| 112 | + break |
| 113 | + return out |
| 114 | + |
| 115 | + |
| 116 | +def _looks_natural(text: str) -> bool: |
| 117 | + """Keep human/markdown text; drop ids, uuids, base64 runs, file URIs.""" |
| 118 | + t = text.strip() |
| 119 | + if not t or _UUID_RE.match(t): |
| 120 | + return False |
| 121 | + if t.startswith(("file:///", "http://", "https://")) and " " not in t: |
| 122 | + return False |
| 123 | + if " " not in t and len(t) > 40: # long spaceless token: id/base64 |
| 124 | + return False |
| 125 | + letters = sum(c.isalpha() or c.isspace() for c in t) |
| 126 | + return letters / max(1, len(t)) > 0.55 |
| 127 | + |
| 128 | + |
| 129 | +# ── per-database digestion ──────────────────────────────────────────────────── |
| 130 | + |
| 131 | +def _clean_user_prompt(text: str) -> str: |
| 132 | + t = text.strip() |
| 133 | + for prefix in ("/goal ", "/task ", "/ask "): |
| 134 | + if t.lower().startswith(prefix): |
| 135 | + t = t[len(prefix):] |
| 136 | + return t.strip() |
| 137 | + |
| 138 | + |
| 139 | +def _digest_db(path: str, project: str) -> Optional[SessionDigest]: |
| 140 | + tmp = os.path.join(tempfile.gettempdir(), |
| 141 | + f"skillopt_agy_{os.path.basename(path)}") |
| 142 | + try: |
| 143 | + shutil.copy2(path, tmp) |
| 144 | + except OSError: |
| 145 | + return None |
| 146 | + try: |
| 147 | + con = sqlite3.connect(f"file:{tmp}?mode=ro", uri=True) |
| 148 | + rows = con.execute( |
| 149 | + "SELECT idx, step_type, step_payload FROM steps ORDER BY idx" |
| 150 | + ).fetchall() |
| 151 | + con.close() |
| 152 | + except Exception: |
| 153 | + return None |
| 154 | + finally: |
| 155 | + try: |
| 156 | + os.unlink(tmp) |
| 157 | + except OSError: |
| 158 | + pass |
| 159 | + |
| 160 | + prompts: List[str] = [] |
| 161 | + finals: List[str] = [] |
| 162 | + tools: List[str] = [] |
| 163 | + for _idx, stype, payload in rows: |
| 164 | + blob = payload if isinstance(payload, bytes) else str(payload or "").encode() |
| 165 | + if not blob: |
| 166 | + continue |
| 167 | + if stype in _USER_STEP_TYPES: |
| 168 | + strs = [ |
| 169 | + s for s in _proto_strings(blob) |
| 170 | + if not s.startswith("{") |
| 171 | + and not any(m in s for m in _BOILERPLATE_MARKERS) |
| 172 | + and not _TOOL_ECHO_RE.match(s.strip()) |
| 173 | + ] |
| 174 | + if strs: |
| 175 | + p = _clean_user_prompt(max(strs, key=len)) |
| 176 | + if p and not _is_meta_prompt(p): |
| 177 | + prompts.append(p) |
| 178 | + elif stype in _ARTIFACT_STEP_TYPES: |
| 179 | + strs = _proto_strings(blob) |
| 180 | + # prefer the artifact body over its ArtifactMetadata JSON envelope |
| 181 | + body = [s for s in strs if not s.lstrip().startswith("{")] |
| 182 | + if body or strs: |
| 183 | + finals.append(max(body or strs, key=len)) |
| 184 | + elif stype in _TOOL_STEP_TYPES: |
| 185 | + for s in _proto_strings(blob): |
| 186 | + if s.startswith("{"): |
| 187 | + try: |
| 188 | + obj = json.loads(s) |
| 189 | + name = obj.get("toolSummary") or obj.get("toolAction") |
| 190 | + if name and name not in tools: |
| 191 | + tools.append(str(name)) |
| 192 | + except Exception: |
| 193 | + pass |
| 194 | + |
| 195 | + if not prompts: |
| 196 | + return None |
| 197 | + mtime = os.path.getmtime(path) |
| 198 | + iso = time.strftime("%Y-%m-%dT%H:%M:%S", time.localtime(mtime)) |
| 199 | + feedback = _detect_feedback(" \n".join(prompts)) |
| 200 | + return SessionDigest( |
| 201 | + session_id=os.path.splitext(os.path.basename(path))[0], |
| 202 | + project=project, |
| 203 | + started_at=iso, ended_at=iso, |
| 204 | + user_prompts=prompts, |
| 205 | + assistant_finals=finals[-3:], |
| 206 | + tools_used=tools[:12], |
| 207 | + feedback_signals=feedback, |
| 208 | + ) |
| 209 | + |
| 210 | + |
| 211 | +def harvest_antigravity( |
| 212 | + conversations_dir: str = "", |
| 213 | + *, |
| 214 | + invoked_project: str = "", |
| 215 | + since_iso: Optional[str] = None, |
| 216 | + limit: int = 0, |
| 217 | +) -> List[SessionDigest]: |
| 218 | + """Digest the most recent Antigravity conversations (newest first).""" |
| 219 | + root = os.path.expanduser(conversations_dir or DEFAULT_CONVERSATIONS_DIR) |
| 220 | + if not os.path.isdir(root): |
| 221 | + return [] |
| 222 | + dbs = [os.path.join(root, f) for f in os.listdir(root) if f.endswith(".db")] |
| 223 | + dbs.sort(key=os.path.getmtime, reverse=True) |
| 224 | + out: List[SessionDigest] = [] |
| 225 | + for path in dbs: |
| 226 | + if limit and len(out) >= limit: |
| 227 | + break |
| 228 | + if since_iso: |
| 229 | + mtime_iso = time.strftime( |
| 230 | + "%Y-%m-%dT%H:%M:%S", time.localtime(os.path.getmtime(path))) |
| 231 | + if mtime_iso < since_iso: |
| 232 | + continue |
| 233 | + d = _digest_db(path, project=invoked_project or "antigravity") |
| 234 | + if d is not None: |
| 235 | + out.append(d) |
| 236 | + return out |
0 commit comments