interlatch 0.22.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (118) hide show
  1. chronicle/__init__.py +17 -0
  2. chronicle/__main__.py +3 -0
  3. chronicle/access.py +93 -0
  4. chronicle/agents.py +23 -0
  5. chronicle/analyze.py +498 -0
  6. chronicle/antigravity_parser.py +311 -0
  7. chronicle/artifacts.py +621 -0
  8. chronicle/bob_parser.py +259 -0
  9. chronicle/cases.py +75 -0
  10. chronicle/chat_import.py +325 -0
  11. chronicle/chatgpt_export.py +127 -0
  12. chronicle/claude_export.py +129 -0
  13. chronicle/cli.py +2980 -0
  14. chronicle/codex_cloud.py +138 -0
  15. chronicle/codex_parser.py +753 -0
  16. chronicle/config.py +627 -0
  17. chronicle/connectors.py +666 -0
  18. chronicle/container.py +202 -0
  19. chronicle/copilot_parser.py +494 -0
  20. chronicle/db.py +598 -0
  21. chronicle/desktop.py +543 -0
  22. chronicle/diagram.py +488 -0
  23. chronicle/digest.py +194 -0
  24. chronicle/export_md.py +210 -0
  25. chronicle/friction.py +841 -0
  26. chronicle/glossary.py +522 -0
  27. chronicle/groups.py +181 -0
  28. chronicle/hooks.py +169 -0
  29. chronicle/hub.py +2291 -0
  30. chronicle/i18n.py +37 -0
  31. chronicle/i18n_ja.py +421 -0
  32. chronicle/ingest.py +1190 -0
  33. chronicle/install.py +590 -0
  34. chronicle/instructions.py +699 -0
  35. chronicle/ladder.py +244 -0
  36. chronicle/learning.py +170 -0
  37. chronicle/llm.py +570 -0
  38. chronicle/mcp_server.py +403 -0
  39. chronicle/menubar.py +771 -0
  40. chronicle/migrate.py +580 -0
  41. chronicle/mirror.py +453 -0
  42. chronicle/news.py +110 -0
  43. chronicle/notify.py +92 -0
  44. chronicle/parser.py +908 -0
  45. chronicle/people.py +536 -0
  46. chronicle/pg.py +122 -0
  47. chronicle/pricing.py +168 -0
  48. chronicle/providers.py +591 -0
  49. chronicle/redact.py +50 -0
  50. chronicle/reviews.py +251 -0
  51. chronicle/screen.py +308 -0
  52. chronicle/search.py +238 -0
  53. chronicle/server.py +3009 -0
  54. chronicle/session_export.py +178 -0
  55. chronicle/statusline.py +258 -0
  56. chronicle/suggest.py +643 -0
  57. chronicle/synthesize.py +365 -0
  58. chronicle/systems.py +1265 -0
  59. chronicle/tailnet.py +97 -0
  60. chronicle/team_store.py +450 -0
  61. chronicle/update.py +404 -0
  62. chronicle/util.py +217 -0
  63. chronicle/views.py +249 -0
  64. chronicle/web/PLEX-OFL.txt +93 -0
  65. chronicle/web/app.css +2350 -0
  66. chronicle/web/app.js +7587 -0
  67. chronicle/web/art-book.webp +0 -0
  68. chronicle/web/art-camera.webp +0 -0
  69. chronicle/web/art-chip.webp +0 -0
  70. chronicle/web/art-clock.webp +0 -0
  71. chronicle/web/art-cloud.webp +0 -0
  72. chronicle/web/art-coins.webp +0 -0
  73. chronicle/web/art-container.webp +0 -0
  74. chronicle/web/art-dashboard.webp +0 -0
  75. chronicle/web/art-dex.webp +0 -0
  76. chronicle/web/art-document.webp +0 -0
  77. chronicle/web/art-flag.webp +0 -0
  78. chronicle/web/art-folder.webp +0 -0
  79. chronicle/web/art-gateway.webp +0 -0
  80. chronicle/web/art-gears.webp +0 -0
  81. chronicle/web/art-glitch.webp +0 -0
  82. chronicle/web/art-globe.webp +0 -0
  83. chronicle/web/art-laptop.webp +0 -0
  84. chronicle/web/art-log.webp +0 -0
  85. chronicle/web/art-lumi.webp +0 -0
  86. chronicle/web/art-magnifier.webp +0 -0
  87. chronicle/web/art-pip.webp +0 -0
  88. chronicle/web/art-plug.webp +0 -0
  89. chronicle/web/art-rack.webp +0 -0
  90. chronicle/web/art-tink.webp +0 -0
  91. chronicle/web/boot.js +25 -0
  92. chronicle/web/favicon.png +0 -0
  93. chronicle/web/i18n.js +50 -0
  94. chronicle/web/icon-180.png +0 -0
  95. chronicle/web/icon-192.png +0 -0
  96. chronicle/web/icon-512.png +0 -0
  97. chronicle/web/icon.png +0 -0
  98. chronicle/web/index.html +92 -0
  99. chronicle/web/ja.js +1880 -0
  100. chronicle/web/learning.js +438 -0
  101. chronicle/web/manifest.webmanifest +15 -0
  102. chronicle/web/panel.css +175 -0
  103. chronicle/web/panel.html +14 -0
  104. chronicle/web/panel.js +241 -0
  105. chronicle/web/plex-mono-400.woff2 +0 -0
  106. chronicle/web/plex-mono-500.woff2 +0 -0
  107. chronicle/web/plex-mono-600.woff2 +0 -0
  108. chronicle/web/plex-sans-400.woff2 +0 -0
  109. chronicle/web/plex-sans-500.woff2 +0 -0
  110. chronicle/web/plex-sans-600.woff2 +0 -0
  111. chronicle/web/plex-sans-700.woff2 +0 -0
  112. chronicle/web/rough.js +2 -0
  113. chronicle/worker.py +390 -0
  114. interlatch-0.22.0.dist-info/METADATA +221 -0
  115. interlatch-0.22.0.dist-info/RECORD +118 -0
  116. interlatch-0.22.0.dist-info/WHEEL +4 -0
  117. interlatch-0.22.0.dist-info/entry_points.txt +3 -0
  118. interlatch-0.22.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,311 @@
1
+ """Parse Google Antigravity conversations (~/.gemini/antigravity) into the same model as Claude Code sessions.
2
+
3
+ Antigravity keeps each conversation's own store in conversations/<id>.pb (encrypted; older versions) or
4
+ conversations/<id>.db (SQLite of protobuf blobs; newer versions). Both versions also write a plain step log next to
5
+ the agent's artifacts, which is what is read here:
6
+
7
+ brain/<id>/.system_generated/logs/transcript_full.jsonl one step per line, untruncated (newer versions)
8
+ brain/<id>/.system_generated/logs/transcript.jsonl the same with long fields cut (older versions: the only one)
9
+ brain/<id>/.system_generated/logs/chunks/<name>/*.jsonl a copy of each log in ~100 KB pieces, cut mid-line
10
+ annotations/<id>.pbtxt title:"..." (newer versions)
11
+
12
+ Steps are written as they finish, so they are sorted by step_index. A MODEL PLANNER_RESPONSE carries the reply, its
13
+ thinking, token usage and tool calls; the MODEL steps after it are those calls' results, in order. Where the .db
14
+ exists, its trajectory metadata adds the workspace folder, git remote and branch, and its generation metadata the
15
+ model id. Nothing in ~/.gemini is written to.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import codecs
21
+ import json
22
+ import logging
23
+ import re
24
+ import sqlite3
25
+ from collections import Counter, deque
26
+ from pathlib import Path
27
+
28
+ from .copilot_parser import _Builder, estimate_cost, finish_session, uri_path
29
+ from .parser import ApiCall, ParsedSession
30
+ from .pricing import normalize_model
31
+ from .util import one_line, parse_ts
32
+
33
+ log = logging.getLogger("chronicle.antigravity")
34
+
35
+ ANTIGRAVITY_PARSER_VERSION = 1
36
+ LOGS = Path(".system_generated") / "logs"
37
+ _REQUEST_RE = re.compile(r"<USER_REQUEST>(.*?)</USER_REQUEST>", re.S)
38
+ _BLOCK_RE = re.compile(r"<(USER_REQUEST|ADDITIONAL_METADATA|USER_SETTINGS_CHANGE)>.*?</\1>", re.S)
39
+ _ARTIFACT_URI_RE = re.compile(r"file://\S*?/brain/[^/\s]+/")
40
+ _MODEL_PICK_RE = re.compile(r"`Model Selection` from .*? to (.+?)\.(?:\s|$)", re.S)
41
+ _SYSTEM_RE = re.compile(r".*<SYSTEM_MESSAGE>\s*(?:\[Message\][^\n]*?content=)?(.*?)\s*</SYSTEM_MESSAGE>", re.S) # the last opening tag: the preamble names the tag too
42
+ _STAMPS_RE = re.compile(r"\s*Created At: (\S+)\s*(?:Completed At: (\S+))?\s*")
43
+ _TITLE_RE = re.compile(r'title:\s*"((?:[^"\\]|\\.)*)"')
44
+ _MODEL_ID_RE = re.compile(r"^[a-z][a-z0-9.-]*\d[a-z0-9.-]*$")
45
+ _PATH_KEYS = ("absolutePath", "targetFile", "file")
46
+
47
+
48
+ # ------------------------------------------------------------------ store layout
49
+ def conversation_dirs(home: Path) -> list[Path]:
50
+ """brain/<id> folders that have a step log."""
51
+ brain = home / "brain"
52
+ if not brain.is_dir():
53
+ return []
54
+ return sorted(d for d in brain.iterdir() if d.is_dir() and transcript_files(d))
55
+
56
+
57
+ def transcript_files(conv: Path) -> list[Path]:
58
+ """The step log (the untruncated one when there is one), or else its chunks, which read as one file joined in order."""
59
+ logs = conv / LOGS
60
+ for name in ("transcript_full", "transcript"):
61
+ if (logs / f"{name}.jsonl").exists():
62
+ return [logs / f"{name}.jsonl"]
63
+ chunks = sorted((logs / "chunks" / name).glob("*.jsonl"))
64
+ if chunks:
65
+ return chunks
66
+ return []
67
+
68
+
69
+ def stored_conversations(home: Path) -> list[str]:
70
+ """Ids of every conversation Antigravity keeps, readable or not."""
71
+ d = home / "conversations"
72
+ return sorted({p.stem for p in d.glob("*") if p.suffix in (".pb", ".db")}) if d.is_dir() else []
73
+
74
+
75
+ def load_title(home: Path, cid: str) -> str | None:
76
+ try:
77
+ text = (home / "annotations" / f"{cid}.pbtxt").read_text(errors="replace")
78
+ except OSError:
79
+ return None
80
+ m = _TITLE_RE.search(text)
81
+ if not m:
82
+ return None
83
+ try: # text-format protobuf: C escapes, octal for non-ASCII bytes
84
+ return codecs.escape_decode(m.group(1).encode())[0].decode("utf-8", "replace").strip() or None
85
+ except ValueError:
86
+ return m.group(1).strip() or None
87
+
88
+
89
+ # ------------------------------------------------------------------ conversation database (newer versions)
90
+ def _varint(b: bytes, i: int) -> tuple[int, int]:
91
+ out = shift = 0
92
+ while True:
93
+ byte = b[i]
94
+ i += 1
95
+ out |= (byte & 0x7F) << shift
96
+ shift += 7
97
+ if byte < 0x80:
98
+ return out, i
99
+
100
+
101
+ def _fields(b: bytes) -> dict[int, list]:
102
+ """One protobuf message's fields (number -> values; length-delimited ones stay bytes), or {} if it is not one."""
103
+ out: dict[int, list] = {}
104
+ i = 0
105
+ try:
106
+ while i < len(b):
107
+ key, i = _varint(b, i)
108
+ num, wire = key >> 3, key & 7
109
+ if wire == 0:
110
+ v, i = _varint(b, i)
111
+ elif wire == 2:
112
+ n, i = _varint(b, i)
113
+ v, i = b[i:i + n], i + n
114
+ elif wire in (1, 5):
115
+ n = 8 if wire == 1 else 4
116
+ v, i = b[i:i + n], i + n
117
+ else:
118
+ return {}
119
+ if num == 0 or i > len(b):
120
+ return {}
121
+ out.setdefault(num, []).append(v)
122
+ except IndexError:
123
+ return {}
124
+ return out
125
+
126
+
127
+ def _pb_text(blob, *path: int) -> str | None:
128
+ """The string at a field path (first value at each level), or None."""
129
+ for num in path:
130
+ vals = _fields(blob).get(num) if isinstance(blob, bytes) else None
131
+ if not vals or not isinstance(vals[0], bytes):
132
+ return None
133
+ blob = vals[0]
134
+ try:
135
+ return blob.decode() or None
136
+ except UnicodeDecodeError:
137
+ return None
138
+
139
+
140
+ def load_db_meta(db: Path) -> dict:
141
+ """Workspace folder, git remote and branch, and model id from a conversation database; {} if absent or unreadable."""
142
+ if not db.exists():
143
+ return {}
144
+ try:
145
+ conn = sqlite3.connect(f"file:{db}?mode=ro", uri=True, timeout=5)
146
+ try:
147
+ meta = conn.execute("SELECT data FROM trajectory_metadata_blob").fetchone()
148
+ gens = conn.execute("SELECT data FROM gen_metadata ORDER BY idx").fetchall()
149
+ finally:
150
+ conn.close()
151
+ except sqlite3.Error as exc:
152
+ log.warning("antigravity conversation db unreadable: %s: %s", db, exc)
153
+ return {}
154
+ out = {}
155
+ if meta and isinstance(meta[0], bytes):
156
+ out = {"workspace": uri_path(_pb_text(meta[0], 1, 1)), "remote": _pb_text(meta[0], 1, 3, 2),
157
+ "branch": _pb_text(meta[0], 1, 4)}
158
+ models = Counter(m for (g,) in gens if isinstance(g, bytes) and (m := _pb_text(g, 1, 19)) and _MODEL_ID_RE.match(m))
159
+ if models:
160
+ out["model"] = models.most_common(1)[0][0]
161
+ return {k: v for k, v in out.items() if v}
162
+
163
+
164
+ # ------------------------------------------------------------------ steps
165
+ def load_steps(conv: Path) -> tuple[list[dict], int]:
166
+ """The conversation's steps in order, and how many lines did not parse."""
167
+ data = b"".join(f.read_bytes() for f in transcript_files(conv)).decode("utf-8", "replace")
168
+ lines = data.split("\n")
169
+ if not data.endswith("\n"):
170
+ lines.pop() # a step still being written
171
+ steps: dict[int, dict] = {}
172
+ bad = 0
173
+ for line in lines:
174
+ if not line.strip():
175
+ continue
176
+ try:
177
+ row = json.loads(line)
178
+ except ValueError:
179
+ bad += 1
180
+ continue
181
+ if isinstance(row, dict) and isinstance(row.get("step_index"), int):
182
+ steps[row["step_index"]] = row
183
+ return [steps[k] for k in sorted(steps)], bad
184
+
185
+
186
+ def _prompt_text(content: str) -> str:
187
+ """The user's words: the USER_REQUEST block, after any preamble (an artifact review: "Comments on artifact URI ...")."""
188
+ m = _REQUEST_RE.search(content)
189
+ request = m.group(1).strip() if m else ""
190
+ preamble = _ARTIFACT_URI_RE.sub("", _BLOCK_RE.sub("", content)).strip()
191
+ return "\n\n".join(p for p in (preamble, request) if p)
192
+
193
+
194
+ def _model_from_label(label: str) -> str | None:
195
+ """'Gemini 3.8 Flash (High)' -> 'gemini-3.8-flash'."""
196
+ return re.sub(r"\s+", "-", re.sub(r"\(.*?\)", "", label).strip().lower()) or None
197
+
198
+
199
+ def _json_value(v):
200
+ try:
201
+ return json.loads(v)
202
+ except (TypeError, ValueError): # not JSON, or a long value the truncated log cut short
203
+ return v
204
+
205
+
206
+ def _args(raw) -> dict:
207
+ """Tool arguments under the key names tool_summary reads. Older versions JSON-encode every value; those are decoded."""
208
+ if not isinstance(raw, dict):
209
+ return {}
210
+ args = dict(raw)
211
+ if any(isinstance(v, str) and len(v) > 1 and v[0] == v[-1] == '"' and isinstance(_json_value(v), str) for v in args.values()):
212
+ args = {k: _json_value(v) for k, v in args.items()}
213
+ out = {("description" if k == "toolSummary" else k[:1].lower() + k[1:]): v for k, v in args.items() if k != "toolAction"}
214
+ path = next((out[k] for k in _PATH_KEYS if isinstance(out.get(k), str) and out[k]), None)
215
+ if path and "filePath" not in out:
216
+ out["filePath"] = path
217
+ elif isinstance(out.get("directoryPath"), str) and "path" not in out:
218
+ out["path"] = out["directoryPath"] # shown as the call's summary; not counted as a file
219
+ return out
220
+
221
+
222
+ def _result(step: dict) -> tuple[str, int | None]:
223
+ """A result step's text without its Created At / Completed At header, and how long the call took (ms)."""
224
+ text = step.get("content") if isinstance(step.get("content"), str) else str(step.get("error") or "")
225
+ m = _STAMPS_RE.match(text)
226
+ if not m:
227
+ return text, None
228
+ start, end = parse_ts(m.group(1)), parse_ts(m.group(2))
229
+ return text[m.end():], int((end - start).total_seconds() * 1000) if start and end else None
230
+
231
+
232
+ def _system_text(content: str) -> str:
233
+ m = _SYSTEM_RE.search(content)
234
+ return (m.group(1) if m else content).strip()
235
+
236
+
237
+ def parse_antigravity_conversation(conv: Path, *, title: str | None = None, db_meta: dict | None = None) -> ParsedSession | None:
238
+ """One brain/<id> folder as a session; None when it never got a prompt."""
239
+ steps, bad = load_steps(conv)
240
+ if not any(s.get("type") == "USER_INPUT" for s in steps):
241
+ return None
242
+ meta = db_meta or {}
243
+ ps = ParsedSession(id=conv.name)
244
+ ps.entrypoint = "antigravity"
245
+ ps.ai_title = title
246
+ ps.git_branch = meta.get("branch")
247
+ ps.git_remote = meta.get("remote")
248
+ ps.parse_errors = bad
249
+ b = _Builder(ps)
250
+ model = meta.get("model") # the database's model id; a model picked in the conversation takes over from there
251
+ pending: deque = deque() # tool calls waiting for their result steps
252
+ cwds: Counter = Counter()
253
+
254
+ def flush() -> None:
255
+ while pending:
256
+ ts, call_id, name, args = pending.popleft()
257
+ b.tool(ts, call_id, name, args)
258
+
259
+ for s in steps:
260
+ ts, kind, source = s.get("created_at"), s.get("type"), s.get("source")
261
+ content = s.get("content") if isinstance(s.get("content"), str) else ""
262
+ if kind == "USER_INPUT":
263
+ flush()
264
+ pick = _MODEL_PICK_RE.search(content)
265
+ if pick:
266
+ model = _model_from_label(pick.group(1)) or model
267
+ b.prompt(ts, _prompt_text(content))
268
+ elif kind == "PLANNER_RESPONSE":
269
+ flush()
270
+ if isinstance(s.get("thinking"), str) and s["thinking"].strip():
271
+ b.event(ts, "assistant", "thinking", s["thinking"])
272
+ if content.strip():
273
+ b.event(ts, "assistant", "text", content)
274
+ inp, cached, out = (int(s.get(k) or 0) for k in ("input_tokens", "cache_read_tokens", "output_tokens"))
275
+ if inp or cached or out:
276
+ m = normalize_model(model)
277
+ ps.api_calls.append(ApiCall("", f"step-{s['step_index']}", ts, m, input_tokens=inp, output_tokens=out,
278
+ cache_read_tokens=cached, cost_usd=estimate_cost(m, inp, cached, 0, out)))
279
+ for i, tc in enumerate(s.get("tool_calls") or []):
280
+ if isinstance(tc, dict):
281
+ args = _args(tc.get("args"))
282
+ if isinstance(args.get("cwd"), str) and args["cwd"]:
283
+ cwds[args["cwd"]] += 1
284
+ pending.append((ts, f"{s['step_index']}.{i}", tc.get("name") or "tool", args))
285
+ elif kind == "ERROR_MESSAGE": # the model's last tool call could not be run
286
+ text = one_line(s.get("error") or content, 300)
287
+ if pending:
288
+ call_ts, call_id, name, args = pending.popleft()
289
+ b.tool(call_ts, call_id, name, args, result=text, is_error=True)
290
+ else:
291
+ ps.n_api_errors += 1
292
+ b.event(ts, "system", "api_error", text)
293
+ elif source == "SYSTEM":
294
+ if kind == "SYSTEM_MESSAGE" and content.strip(): # e.g. a background command finished
295
+ b.event(ts, "system", "notification", _system_text(content))
296
+ elif source == "MODEL": # the result of the oldest call still waiting for one
297
+ text, ms = _result(s)
298
+ failed = s.get("status") == "ERROR" or bool(s.get("error"))
299
+ if pending:
300
+ call_ts, call_id, name, args = pending.popleft()
301
+ b.tool(call_ts, call_id, name, args, result=text, is_error=failed, duration_ms=ms)
302
+ elif text.strip():
303
+ b.event(ts, "user", "tool_result", text, is_error=failed, limit_key="tool_error" if failed else "tool_result")
304
+ flush()
305
+ ps.project_path = meta.get("workspace") or (cwds.most_common(1)[0][0] if cwds else None)
306
+ # keep files the agent read or changed, leaving out listed folders and its own task/plan/walkthrough artifacts
307
+ ps.files = {fp: st for fp, st in ps.files.items() if (st.reads or st.edits or st.writes) and not fp.startswith(f"{conv}/")}
308
+ ps = finish_session(ps, b.stamps)
309
+ if model and not ps.models: # older logs carry no token counts
310
+ ps.models[normalize_model(model)] += 1
311
+ return ps