Skip to content

Commit e100762

Browse files
committed
feat(sleep): Antigravity conversation harvester + full SessionDigest in evidence/dashboard
* harvest_antigravity.py — digests Google Antigravity trajectory DBs (~/.gemini/antigravity/conversations/*.db): schema-less protobuf string walker extracts user prompts (step_type 14), artifact finals (5) and tool calls (33); filters the /goal system wrapper and tool-echo noise; copies each DB before opening to dodge live-writer locks. New transcript_source value: 'antigravity' (config + --source choice). * harvest evidence event now records the COMPLETE SessionDigest (all prompts/finals/tools/files/feedback), and the dashboard's Harvest section renders it as a full expandable digest view. * dashboard config grid: transcript source + max sessions/night.
1 parent fd6dc42 commit e100762

6 files changed

Lines changed: 284 additions & 14 deletions

File tree

skillopt_sleep/__main__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,8 @@ def _add_common(p: argparse.ArgumentParser) -> None:
7676
p.add_argument("--codex-path", default="", help="path to the real @openai/codex binary")
7777
p.add_argument("--claude-home", default="", help="override ~/.claude (also isolates state)")
7878
p.add_argument("--codex-home", default="", help="override ~/.codex for archived session harvest")
79-
p.add_argument("--source", default="", choices=["", "claude", "codex", "auto"],
79+
p.add_argument("--source", default="",
80+
choices=["", "claude", "codex", "antigravity", "auto"],
8081
help="session transcript source")
8182
p.add_argument("--lookback-hours", type=int, default=None,
8283
help="harvest window in hours; 0 = scan full history")

skillopt_sleep/config.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,8 @@
2525
# ── scope ──────────────────────────────────────────────────────────────
2626
"claude_home": CLAUDE_HOME,
2727
"codex_home": CODEX_HOME,
28-
"transcript_source": "claude", # "claude" | "codex" | "auto"
28+
"transcript_source": "claude", # "claude" | "codex" | "antigravity" | "auto"
29+
"antigravity_conversations_dir": "", # "" => ~/.gemini/antigravity/conversations
2930
"projects": "invoked", # "invoked" | "all" | [list of abs paths]
3031
"invoked_project": "", # filled at runtime (cwd) when projects == "invoked"
3132
"lookback_hours": 72, # harvest window when no prior sleep recorded

skillopt_sleep/cycle.py

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -212,15 +212,19 @@ def run_sleep_cycle(
212212
n_sessions = len(digests)
213213
_progress(cfg, f"harvest done: sessions={n_sessions}")
214214
if ev is not None:
215-
# The transcript end of the evidentiary chain: which sessions were
216-
# even considered, and what signals they carried into mining.
215+
# The transcript end of the evidentiary chain: the COMPLETE
216+
# SessionDigest for every considered session (per-field truncation
217+
# is handled by the evidence log's max_chars cap).
217218
for d in digests:
218219
ev.log("harvest", "session", session_id=d.session_id,
219-
project=d.project,
220+
project=d.project, source=cfg.get("transcript_source"),
221+
started_at=d.started_at, ended_at=d.ended_at,
222+
raw_path=d.raw_path,
220223
n_user_prompts=len(d.user_prompts),
221-
user_prompts_head=[p[:200] for p in d.user_prompts[:6]],
222-
assistant_final_head=(d.assistant_finals[-1][:300]
223-
if d.assistant_finals else ""),
224+
user_prompts=list(d.user_prompts),
225+
assistant_finals=list(d.assistant_finals),
226+
tools_used=list(d.tools_used or []),
227+
files_touched=list(d.files_touched or []),
224228
feedback_signals=list(d.feedback_signals or []))
225229
# When a real backend is configured, use it to mine checkable tasks from
226230
# the transcripts (rubric/rule judges); otherwise fall back to the

skillopt_sleep/dashboard.html

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -319,13 +319,31 @@ <h2 class="sec">Pipeline — one sleep night, left to right</h2>
319319
function rHarvest(){
320320
const ss = ev("harvest","session");
321321
if(!ss.length) return "<div class='empty'>No harvest events for this night (seeded tasks, or evidence predates this upgrade).</div>";
322-
return ss.map(s => `<details><summary><b>${esc(s.session_id)}</b>
323-
<span class='kv'>${s.n_user_prompts} prompts · feedback: ${esc((s.feedback_signals||[]).join(", ")||"none")}</span></summary>
322+
return ss.map(s => {
323+
// full SessionDigest view (new format) with fallback to the old head-only fields
324+
const prompts = s.user_prompts || s.user_prompts_head || [];
325+
const finals = s.assistant_finals || (s.assistant_final_head ? [s.assistant_final_head] : []);
326+
return `<details open><summary><b>SessionDigest — ${esc(s.session_id)}</b>
327+
<span class='kv'>${s.source?esc(s.source)+" · ":""}${s.n_user_prompts??prompts.length} prompts ·
328+
${finals.length} finals · feedback: ${esc((s.feedback_signals||[]).join(", ")||"none")}</span></summary>
324329
<div class='inner'>
325-
<div class='kv'>user prompts (head):</div>
326-
<pre>${esc((s.user_prompts_head||[]).map(p=>"• "+p).join("\n"))}</pre>
327-
<div class='kv'>assistant final (head):</div><pre>${esc(s.assistant_final_head||"(none)")}</pre>
328-
</div></details>`).join("");
330+
<table><tr><th>field</th><th>value</th></tr>
331+
<tr><td>session_id</td><td>${esc(s.session_id)}</td></tr>
332+
<tr><td>project</td><td>${esc(s.project||"")}</td></tr>
333+
<tr><td>started / ended</td><td>${esc(s.started_at||"—")}${esc(s.ended_at||"—")}</td></tr>
334+
${s.raw_path?`<tr><td>raw_path</td><td>${esc(s.raw_path)}</td></tr>`:""}
335+
<tr><td>tools_used</td><td>${esc((s.tools_used||[]).join(", ")||"—")}</td></tr>
336+
<tr><td>files_touched</td><td>${esc((s.files_touched||[]).join(", ")||"—")}</td></tr>
337+
<tr><td>feedback_signals</td><td>${esc((s.feedback_signals||[]).join(", ")||"—")}</td></tr>
338+
</table>
339+
<div class='kv'>user_prompts (${prompts.length}):</div>
340+
${prompts.map((p,i)=>`<details><summary><span class='kv'>prompt ${i+1}${esc(String(p).slice(0,110))}${String(p).length>110?"…":""}</span></summary>
341+
<div class='inner'><pre>${esc(p)}</pre></div></details>`).join("")}
342+
<div class='kv' style='margin-top:6px'>assistant_finals (${finals.length}):</div>
343+
${finals.length ? finals.map((f,i)=>`<details><summary><span class='kv'>final ${i+1}${esc(String(f).slice(0,110))}${String(f).length>110?"…":""}</span></summary>
344+
<div class='inner'><pre>${esc(f)}</pre></div></details>`).join("") : "<div class='kv'>(none captured)</div>"}
345+
</div></details>`;
346+
}).join("");
329347
}
330348
function checksTable(checks){
331349
if(!checks || !checks.length) return "<div class='kv'>no programmatic checks (rubric task)</div>";
@@ -506,6 +524,8 @@ <h2 class='sec' style='margin-top:13px'>Evidence — night ${esc(SEL||"—")}</h
506524
["target_backend","target backend (override)","select",["","mock","claude","codex","copilot","azure_openai"]],
507525
["target_model","target model","text"],
508526
["azure_endpoint","azure / compat endpoint","text"],
527+
["transcript_source","transcript source","select",["claude","codex","antigravity","auto"]],
528+
["max_sessions_per_night","max sessions / night","number"],
509529
["gate_mode","gate mode","select",["on","off"]],
510530
["gate_metric","gate metric","select",["mixed","hard","soft"]],
511531
["gate_mixed_weight","mixed weight (soft share)","number"],
Lines changed: 236 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,236 @@
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

skillopt_sleep/harvest_sources.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,14 @@ def harvest_for_config(cfg, *, since_iso: Optional[str] = None, limit: int = 0)
1313
scope = cfg.get("projects", "invoked")
1414
invoked_project = cfg.get("invoked_project", "")
1515

16+
if source == "antigravity":
17+
from skillopt_sleep.harvest_antigravity import harvest_antigravity
18+
return harvest_antigravity(
19+
cfg.get("antigravity_conversations_dir", ""),
20+
invoked_project=invoked_project,
21+
since_iso=since_iso,
22+
limit=limit,
23+
)
1624
if source == "codex":
1725
return harvest_codex(
1826
cfg.codex_archived_sessions_dir,

0 commit comments

Comments
 (0)