agent-sessions-cli 0.2.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.
@@ -0,0 +1,360 @@
1
+ """OpenAI Codex CLI: $CODEX_HOME/sessions/YYYY/MM/DD/rollout-<ts>-<uuid>.jsonl."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import datetime as dt
6
+ import glob
7
+ import json
8
+ import os
9
+ import re
10
+ from typing import Dict, List, Optional, Tuple
11
+
12
+ from ..core import first_json_line, mtime, parse_ts, read_jsonl, rel_to_root, under
13
+ from ..model import Event, PUSH_MARKER_RE, Session, ToolCall
14
+ from .base import Adapter, SessionRef, generic_label, strip_wrappers
15
+
16
+ WRAPPER_TAGS = ("environment_context", "recommended_plugins", "user_instructions", "permissions instructions",
17
+ "permissions_instructions", "turn_aborted", "send_user_message_question_reply", "INSTRUCTIONS")
18
+ DROP_PREFIXES = ("# AGENTS.md instructions", "<INSTRUCTIONS>", "<environment_context>", "<recommended_plugins>")
19
+ SHELL_NAMES = {"exec", "shell", "exec_command", "container.exec", "local_shell", "shell_command"}
20
+ READ_NAMES = {"read_file", "view_image", "read_files"}
21
+ WEB_NAMES = {"web_search", "web.run", "browser_search", "web_fetch"}
22
+ JS_STR = r"""(?:"((?:[^"\\]|\\.)*)"|'((?:[^'\\]|\\.)*)'|`((?:[^`\\]|\\.)*)`)"""
23
+ CMD_RE = re.compile(r"exec_command\(\s*\{[^}]*?\bcmd\s*:\s*" + JS_STR, re.S)
24
+ PATCH_CALL_RE = re.compile(r"apply_patch\(\s*" + JS_STR, re.S)
25
+ PATCH_HEADER_RE = re.compile(r"^\*\*\* (?:Update|Add|Delete) File: (.+)$|^\*\*\* Move to: (.+)$", re.M)
26
+ LEAD_RE = re.compile(r"^(?:Script completed\s*\n(?:Wall time [^\n]*\n)?)?(?:Output:\s*\n?)?", re.S)
27
+ TRAIL_RE = re.compile(r"\n?Script completed\s*(?:\nWall time [^\n]*)?\s*$", re.S)
28
+ ROLLOUT_ID_RE = re.compile(r"rollout-.*-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$")
29
+ AGENT_NAMESPACES = {"collaboration", "agents"}
30
+
31
+
32
+ def _unescape(s: str) -> str:
33
+ """Body of a JS string literal (escapes intact) -> the string it denotes."""
34
+ try:
35
+ return json.loads('"' + s + '"')
36
+ except ValueError:
37
+ return s.replace("\\n", "\n").replace("\\t", "\t").replace('\\"', '"').replace("\\'", "'")
38
+
39
+
40
+ def _js_strings(regex: "re.Pattern[str]", snippet: str) -> List[str]:
41
+ out = []
42
+ for m in regex.finditer(snippet):
43
+ raw = next((g for g in m.groups() if g is not None), "")
44
+ out.append(_unescape(raw))
45
+ return out
46
+
47
+
48
+ def extract_commands(snippet: str) -> List[str]:
49
+ return _js_strings(CMD_RE, snippet)
50
+
51
+
52
+ def extract_patches(snippet: str) -> List[str]:
53
+ return _js_strings(PATCH_CALL_RE, snippet)
54
+
55
+
56
+ def output_text(payload_output: object) -> str:
57
+ """Codex outputs are strings or [{type: input_text, text}] with a 'Script completed' preamble and JSON body."""
58
+ if isinstance(payload_output, list):
59
+ text = "\n".join(str(b.get("text", "")) for b in payload_output if isinstance(b, dict))
60
+ else:
61
+ text = str(payload_output or "")
62
+ text = TRAIL_RE.sub("", LEAD_RE.sub("", text.strip(), count=1)).strip()
63
+ body = text
64
+ if not (body.startswith("{") or body.startswith("[")):
65
+ return text
66
+ # one JSON document, or one JSON object per line for batched exec_command calls
67
+ docs: List[object] = []
68
+ try:
69
+ docs.append(json.loads(body))
70
+ except ValueError:
71
+ for line in body.split("\n"):
72
+ line = line.strip()
73
+ if not line:
74
+ continue
75
+ try:
76
+ docs.append(json.loads(line))
77
+ except ValueError:
78
+ return text
79
+ outs: List[str] = []
80
+
81
+ def walk(v: object) -> None:
82
+ if isinstance(v, dict):
83
+ if isinstance(v.get("output"), str):
84
+ outs.append(v["output"])
85
+ for k, x in v.items():
86
+ if k != "output":
87
+ walk(x)
88
+ elif isinstance(v, list):
89
+ for x in v:
90
+ walk(x)
91
+
92
+ walk(docs)
93
+ return "\n".join(outs) if outs else text
94
+
95
+
96
+ def patch_paths(patch: str) -> List[str]:
97
+ out = []
98
+ for m in PATCH_HEADER_RE.finditer(patch):
99
+ p = m.group(1) or m.group(2)
100
+ if p:
101
+ out.append(p.strip())
102
+ return out
103
+
104
+
105
+ class CodexAdapter(Adapter):
106
+ name = "codex"
107
+ label = "Codex CLI"
108
+ env_ids = ("CODEX_THREAD_ID", "CODEX_SESSION_ID")
109
+ tested_version = (0, 148, 0)
110
+
111
+ def home(self) -> str:
112
+ return os.path.abspath(os.path.expanduser(os.environ.get("CODEX_HOME") or "~/.codex"))
113
+
114
+ def data_dir(self) -> str:
115
+ return os.path.join(self.home(), "sessions")
116
+
117
+ def short_id(self, session_id: str) -> str:
118
+ return session_id.replace("-", "")[-8:]
119
+
120
+ # -- discovery
121
+
122
+ def _rollout_paths(self, cap: int = 400) -> List[str]:
123
+ base = self.data_dir()
124
+ today = dt.date.today()
125
+ ordered: List[str] = []
126
+ seen = set()
127
+ for day in (today, today - dt.timedelta(days=1)):
128
+ for p in sorted(glob.glob(os.path.join(base, day.strftime("%Y"), day.strftime("%m"), day.strftime("%d"), "rollout-*.jsonl")),
129
+ key=os.path.getmtime, reverse=True):
130
+ ordered.append(p)
131
+ seen.add(p)
132
+ rest = [p for p in glob.glob(os.path.join(base, "*", "*", "*", "rollout-*.jsonl")) if p not in seen]
133
+ rest += glob.glob(os.path.join(self.home(), "archived_sessions", "**", "rollout-*.jsonl"), recursive=True)
134
+ rest.sort(key=os.path.getmtime, reverse=True)
135
+ return (ordered + rest)[:cap]
136
+
137
+ def _titles(self) -> Dict[str, str]:
138
+ titles: Dict[str, str] = {}
139
+ idx = os.path.join(self.home(), "session_index.jsonl")
140
+ if os.path.exists(idx):
141
+ recs, _ = read_jsonl(idx)
142
+ for r in recs:
143
+ if isinstance(r.get("id"), str) and isinstance(r.get("thread_name"), str):
144
+ titles[r["id"]] = r["thread_name"]
145
+ return titles
146
+
147
+ def _ref(self, path: str, titles: Optional[Dict[str, str]] = None) -> SessionRef:
148
+ first = first_json_line(path) or {}
149
+ payload = first.get("payload") if first.get("type") == "session_meta" and isinstance(first.get("payload"), dict) else {}
150
+ sid = payload.get("id") or payload.get("session_id")
151
+ if not isinstance(sid, str):
152
+ m = ROLLOUT_ID_RE.search(os.path.basename(path))
153
+ sid = m.group(1) if m else os.path.basename(path)[:-6]
154
+ cwd = payload.get("cwd") if isinstance(payload.get("cwd"), str) else None
155
+ titles = titles if titles is not None else self._titles()
156
+ return SessionRef(self.name, sid, self.short_id(sid), path, cwd=cwd, updated=mtime(path), title=titles.get(sid),
157
+ extra={"parent_id": payload.get("parent_thread_id"), "cli_version": payload.get("cli_version"),
158
+ "originator": payload.get("originator")})
159
+
160
+ def locate_by_id(self, session_id: str) -> Optional[SessionRef]:
161
+ hits = glob.glob(os.path.join(self.data_dir(), "*", "*", "*", f"rollout-*-{session_id}.jsonl"))
162
+ hits += glob.glob(os.path.join(self.home(), "archived_sessions", "**", f"rollout-*-{session_id}.jsonl"), recursive=True)
163
+ if not hits:
164
+ return None
165
+ hits.sort(key=os.path.getmtime, reverse=True)
166
+ return self._ref(hits[0])
167
+
168
+ def candidates(self, repo_root: str, limit: int = 5) -> List[SessionRef]:
169
+ titles = self._titles()
170
+ out: List[SessionRef] = []
171
+ for p in self._rollout_paths():
172
+ ref = self._ref(p, titles)
173
+ if under(ref.cwd, repo_root) and _has_content(p):
174
+ out.append(ref)
175
+ if len(out) >= limit:
176
+ break
177
+ return out
178
+
179
+ def ref_from_source(self, source: str) -> Optional[SessionRef]:
180
+ if os.path.basename(source).startswith("rollout-") and source.endswith(".jsonl") and os.path.isfile(source):
181
+ return self._ref(os.path.abspath(source))
182
+ return None
183
+
184
+ # -- loading
185
+
186
+ def load(self, ref: SessionRef) -> Session:
187
+ records, bad = read_jsonl(ref.source)
188
+ root_hint = ref.extra.get("repo_root") or ref.cwd
189
+ outputs: Dict[str, str] = {}
190
+ for rec in records:
191
+ if rec.get("type") != "response_item":
192
+ continue
193
+ p = rec.get("payload") or {}
194
+ if p.get("type") in ("custom_tool_call_output", "function_call_output") and isinstance(p.get("call_id"), str):
195
+ outputs[p["call_id"]] = output_text(p.get("output"))
196
+
197
+ events: List[Event] = []
198
+ tokens: Optional[dict] = None
199
+ stamps: List[dt.datetime] = []
200
+ version = ref.extra.get("cli_version")
201
+ originator = ref.extra.get("originator")
202
+ parent_id = ref.extra.get("parent_id")
203
+ for rec in records:
204
+ rtype = rec.get("type")
205
+ ts = parse_ts(rec.get("timestamp"))
206
+ p = rec.get("payload") if isinstance(rec.get("payload"), dict) else {}
207
+ if rtype == "session_meta":
208
+ version = p.get("cli_version") or version
209
+ originator = p.get("originator") or originator
210
+ parent_id = p.get("parent_thread_id") or parent_id
211
+ continue
212
+ if rtype == "compacted":
213
+ summary = _history_summary(p.get("replacement_history"))
214
+ events.append(Event("compaction", text=summary, ts=ts))
215
+ continue
216
+ if rtype == "event_msg" and p.get("type") == "token_count":
217
+ info = p.get("info") if isinstance(p.get("info"), dict) else p
218
+ usage = info.get("total_token_usage") or info.get("last_token_usage") or info
219
+ if isinstance(usage, dict):
220
+ tokens = {k: usage.get(k) for k in ("input_tokens", "output_tokens", "cached_input_tokens", "reasoning_output_tokens", "total_tokens") if k in usage}
221
+ continue
222
+ if rtype != "response_item":
223
+ continue
224
+ if ts:
225
+ stamps.append(ts)
226
+ ptype = p.get("type")
227
+ if ptype == "message":
228
+ role = p.get("role")
229
+ if role == "developer":
230
+ continue
231
+ texts = [str(c.get("text", "")) for c in p.get("content", []) if isinstance(c, dict) and c.get("type") in ("input_text", "output_text", "text")]
232
+ if role == "user":
233
+ kept = []
234
+ for t in texts:
235
+ if any(t.lstrip().startswith(pref) for pref in DROP_PREFIXES):
236
+ t = strip_wrappers(t, WRAPPER_TAGS)
237
+ if t.startswith("# AGENTS.md") or t.startswith("<INSTRUCTIONS>"):
238
+ continue
239
+ t = strip_wrappers(t, WRAPPER_TAGS)
240
+ if t:
241
+ kept.append(t)
242
+ text = "\n\n".join(kept).strip()
243
+ if not text:
244
+ continue
245
+ if PUSH_MARKER_RE.search(text):
246
+ events.append(Event("user", text="", ts=ts, is_push_invocation=True))
247
+ else:
248
+ events.append(Event("user", text=text, ts=ts))
249
+ elif role == "assistant":
250
+ text = "\n\n".join(t for t in texts if t.strip())
251
+ if text:
252
+ events.append(Event("assistant", text=text, ts=ts))
253
+ elif ptype == "agent_message":
254
+ text = str(p.get("text") or p.get("message") or "")
255
+ if text:
256
+ events.append(Event("assistant", text=text, ts=ts, meta={"agent_name": str(p.get("agent") or p.get("from") or "agent")}))
257
+ elif ptype == "reasoning":
258
+ parts = [str(s.get("text", "")) for s in p.get("summary", []) if isinstance(s, dict)]
259
+ if any(parts):
260
+ events.append(Event("thinking", text="\n".join(x for x in parts if x), ts=ts))
261
+ elif ptype == "custom_tool_call":
262
+ name = str(p.get("name") or "tool")
263
+ raw = str(p.get("input") or "")
264
+ call_id = p.get("call_id") if isinstance(p.get("call_id"), str) else None
265
+ cmds = extract_commands(raw)
266
+ patches = extract_patches(raw) if "apply_patch" in raw else []
267
+ paths: List[str] = []
268
+ if patches:
269
+ # Codex Desktop edits files through tools.apply_patch(...) inside the exec snippet
270
+ for patch in patches:
271
+ paths.extend(rel_to_root(x, root_hint) if os.path.isabs(x) else x for x in patch_paths(patch))
272
+ inp: object = {"patch": "\n\n".join(patches)}
273
+ if cmds:
274
+ inp["command"] = "\n".join(cmds) # type: ignore[index]
275
+ kind = "edit"
276
+ label = f"apply_patch: {paths[0] if paths else ''}"
277
+ elif name in SHELL_NAMES or cmds:
278
+ inp = {"command": "\n".join(cmds)} if cmds else raw
279
+ kind = "shell"
280
+ label = generic_label(name, kind, inp, [])
281
+ else:
282
+ inp, kind = raw, "other"
283
+ label = generic_label(name, kind, inp, [])
284
+ out = outputs.get(call_id, "") if call_id else ""
285
+ pending = bool(call_id) and call_id not in outputs
286
+ events.append(Event("tool", ts=ts, tool=ToolCall(kind, name, label, input=inp, output=out, paths=paths, pending=pending)))
287
+ elif ptype == "function_call":
288
+ name = str(p.get("name") or "tool")
289
+ ns = str(p.get("namespace") or "")
290
+ call_id = p.get("call_id") if isinstance(p.get("call_id"), str) else None
291
+ try:
292
+ args = json.loads(p.get("arguments") or "{}")
293
+ except ValueError:
294
+ args = {"arguments": p.get("arguments")}
295
+ if not isinstance(args, dict):
296
+ args = {"arguments": args}
297
+ paths: List[str] = []
298
+ if ns.startswith("mcp__") or name.startswith("mcp__"):
299
+ kind = "mcp"
300
+ elif name in SHELL_NAMES:
301
+ kind = "shell"
302
+ elif name == "apply_patch":
303
+ kind = "edit"
304
+ patch = str(args.get("patch") or args.get("input") or "")
305
+ args = {"patch": patch}
306
+ paths = [rel_to_root(x, root_hint) if os.path.isabs(x) else x for x in patch_paths(patch)]
307
+ elif name in READ_NAMES:
308
+ kind = "read"
309
+ elif name in WEB_NAMES:
310
+ kind = "web"
311
+ elif name.startswith("request_user_input"):
312
+ kind = "ask"
313
+ elif ns in AGENT_NAMESPACES or name in ("spawn_agent", "send_message", "wait_for_agent", "wait_agent", "followup_task"):
314
+ kind = "agent"
315
+ else:
316
+ kind = "other"
317
+ label = generic_label(f"{ns + '.' if ns else ''}{name}", kind, args, paths)
318
+ out = outputs.get(call_id, "") if call_id else ""
319
+ pending = bool(call_id) and call_id not in outputs
320
+ events.append(Event("tool", ts=ts, tool=ToolCall(kind, name, label, input=args, output=out, paths=paths, pending=pending)))
321
+ elif ptype == "compaction":
322
+ events.append(Event("compaction", ts=ts))
323
+ return Session(
324
+ agent=self.name, session_id=ref.session_id, short_id=ref.short_id, title=ref.title or self._titles().get(ref.session_id),
325
+ cwd=ref.cwd, started=min(stamps) if stamps else None, ended=max(stamps) if stamps else None, events=events,
326
+ source=ref.source, agent_version=version if isinstance(version, str) else None, originator=originator if isinstance(originator, str) else None,
327
+ parent_id=parent_id if isinstance(parent_id, str) else None, tokens=tokens, tested_version=self.tested_version,
328
+ record_count=len(records), bad_lines=bad,
329
+ )
330
+
331
+
332
+ def _has_content(path: str, lines: int = 40) -> bool:
333
+ """Codex writes 3-record stubs when it forks or compacts; only rollouts with turns are candidates."""
334
+ try:
335
+ with open(path, "r", encoding="utf-8", errors="replace") as fh:
336
+ for _ in range(lines):
337
+ line = fh.readline()
338
+ if not line:
339
+ return False
340
+ if '"type":"response_item"' in line or '"type": "response_item"' in line:
341
+ return True
342
+ except OSError:
343
+ return False
344
+ return False
345
+
346
+
347
+ def _history_summary(history: object) -> str:
348
+ """The messages Codex substitutes for everything before a compaction, as plain text."""
349
+ if not isinstance(history, list):
350
+ return ""
351
+ parts = []
352
+ for item in history:
353
+ if not isinstance(item, dict) or item.get("type") != "message":
354
+ continue
355
+ role = item.get("role", "?")
356
+ texts = [str(c.get("text", "")) for c in item.get("content", []) if isinstance(c, dict)]
357
+ text = strip_wrappers("\n".join(texts), WRAPPER_TAGS)
358
+ if text:
359
+ parts.append(f"**{role}:** {text}")
360
+ return "\n\n".join(parts)
@@ -0,0 +1,176 @@
1
+ """Gemini CLI (experimental): ~/.gemini/tmp/<project_hash>/chats/session-<ts>-<id8>.jsonl.
2
+
3
+ Built from the chatRecordingService source, not from a real sample. The first line is
4
+ session metadata; later lines are message records or update records ($set, $rewindTo).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import datetime as dt
10
+ import glob
11
+ import os
12
+ from typing import List, Optional
13
+
14
+ from ..core import first_json_line, mtime, parse_ts, read_jsonl, rel_to_root, under
15
+ from ..model import Event, PUSH_MARKER_RE, Session, ToolCall
16
+ from .base import Adapter, SessionRef, generic_label
17
+
18
+ KIND_BY_TOOL = {
19
+ "run_shell_command": "shell", "shell": "shell",
20
+ "read_file": "read", "read_many_files": "read",
21
+ "write_file": "write", "replace": "edit", "edit": "edit",
22
+ "glob": "search", "grep_search": "search", "search_file_content": "search", "list_directory": "search",
23
+ "web_fetch": "web", "google_web_search": "web",
24
+ "activate_skill": "other", "save_memory": "other",
25
+ }
26
+
27
+
28
+ def _parts_text(content: object) -> str:
29
+ if isinstance(content, str):
30
+ return content
31
+ if isinstance(content, list):
32
+ return "\n".join(str(p.get("text", "")) for p in content if isinstance(p, dict) and isinstance(p.get("text"), str))
33
+ if isinstance(content, dict) and isinstance(content.get("text"), str):
34
+ return content["text"]
35
+ return ""
36
+
37
+
38
+ class GeminiAdapter(Adapter):
39
+ name = "gemini-cli"
40
+ label = "Gemini CLI"
41
+ tested_version = None
42
+
43
+ def home(self) -> str:
44
+ return os.path.abspath(os.path.expanduser(os.environ.get("GEMINI_CLI_HOME") or "~/.gemini"))
45
+
46
+ def data_dir(self) -> str:
47
+ return os.path.join(self.home(), "tmp")
48
+
49
+ def installed(self) -> bool:
50
+ return bool(glob.glob(os.path.join(self.data_dir(), "*", "chats")))
51
+
52
+ def short_id(self, session_id: str) -> str:
53
+ return session_id.replace("-", "")[:8]
54
+
55
+ def _ref(self, path: str) -> SessionRef:
56
+ meta = first_json_line(path) or {}
57
+ sid = meta.get("sessionId") if isinstance(meta.get("sessionId"), str) else os.path.basename(path)[:-6]
58
+ dirs = meta.get("directories") if isinstance(meta.get("directories"), list) else []
59
+ cwd = next((d for d in dirs if isinstance(d, str)), None)
60
+ return SessionRef(self.name, sid, self.short_id(sid), path, cwd=cwd, updated=mtime(path),
61
+ title=meta.get("summary") if isinstance(meta.get("summary"), str) else None, extra={"directories": dirs})
62
+
63
+ def _paths(self) -> List[str]:
64
+ return sorted(glob.glob(os.path.join(self.data_dir(), "*", "chats", "session-*.jsonl")), key=os.path.getmtime, reverse=True)
65
+
66
+ def locate_by_id(self, session_id: str) -> Optional[SessionRef]:
67
+ for p in glob.glob(os.path.join(self.data_dir(), "*", "chats", f"session-*-{session_id[:8]}.jsonl")):
68
+ ref = self._ref(p)
69
+ if ref.session_id == session_id:
70
+ return ref
71
+ return None
72
+
73
+ def candidates(self, repo_root: str, limit: int = 5) -> List[SessionRef]:
74
+ out: List[SessionRef] = []
75
+ for p in self._paths()[:200]:
76
+ ref = self._ref(p)
77
+ if any(under(d, repo_root) for d in ref.extra.get("directories", [])) or under(ref.cwd, repo_root):
78
+ out.append(ref)
79
+ if len(out) >= limit:
80
+ break
81
+ return out
82
+
83
+ def ref_from_source(self, source: str) -> Optional[SessionRef]:
84
+ if os.path.basename(source).startswith("session-") and source.endswith(".jsonl") and os.path.isfile(source):
85
+ return self._ref(os.path.abspath(source))
86
+ return None
87
+
88
+ def load(self, ref: SessionRef) -> Session:
89
+ records, bad = read_jsonl(ref.source)
90
+ root_hint = ref.extra.get("repo_root") or ref.cwd
91
+ meta: dict = {}
92
+ messages: List[dict] = []
93
+ for rec in records:
94
+ if "sessionId" in rec and "messages" in rec or ("sessionId" in rec and not meta):
95
+ meta = dict(rec)
96
+ if isinstance(rec.get("messages"), list):
97
+ messages.extend(m for m in rec["messages"] if isinstance(m, dict))
98
+ continue
99
+ if "$set" in rec and isinstance(rec["$set"], dict):
100
+ meta.update(rec["$set"])
101
+ continue
102
+ if "$rewindTo" in rec:
103
+ target = rec["$rewindTo"]
104
+ idx = next((i for i, m in enumerate(messages) if m.get("id") == target), None)
105
+ if idx is not None:
106
+ messages = messages[: idx + 1]
107
+ continue
108
+ if rec.get("type") in ("user", "gemini", "info", "error", "warning"):
109
+ messages.append(rec)
110
+
111
+ events: List[Event] = []
112
+ stamps: List[dt.datetime] = []
113
+ tokens: Optional[dict] = None
114
+ for m in messages:
115
+ ts = parse_ts(m.get("timestamp"))
116
+ if ts:
117
+ stamps.append(ts)
118
+ mtype = m.get("type")
119
+ if mtype == "user":
120
+ text = _parts_text(m.get("displayContent") or m.get("content")).strip()
121
+ if not text:
122
+ continue
123
+ if PUSH_MARKER_RE.search(text):
124
+ events.append(Event("user", text="", ts=ts, is_push_invocation=True))
125
+ else:
126
+ events.append(Event("user", text=text, ts=ts))
127
+ elif mtype == "gemini":
128
+ for th in m.get("thoughts") or []:
129
+ if isinstance(th, dict):
130
+ t = " ".join(str(th.get(k, "")) for k in ("subject", "description") if th.get(k))
131
+ if t.strip():
132
+ events.append(Event("thinking", text=t, ts=ts))
133
+ text = _parts_text(m.get("content")).strip()
134
+ if text:
135
+ events.append(Event("assistant", text=text, ts=ts))
136
+ for tc in m.get("toolCalls") or []:
137
+ if not isinstance(tc, dict):
138
+ continue
139
+ name = str(tc.get("name") or "tool")
140
+ args = tc.get("args") if isinstance(tc.get("args"), dict) else {}
141
+ kind = KIND_BY_TOOL.get(name, "mcp" if "__" in name else "other")
142
+ paths: List[str] = []
143
+ if kind in ("edit", "write"):
144
+ p = args.get("file_path") or args.get("path")
145
+ if isinstance(p, str) and p:
146
+ paths.append(rel_to_root(p, root_hint))
147
+ result = tc.get("result")
148
+ output = _parts_text(result) if not isinstance(result, str) else result
149
+ if not output and isinstance(result, dict):
150
+ output = str(result.get("output") or result.get("llmContent") or "")
151
+ status = str(tc.get("status") or "")
152
+ events.append(Event("tool", ts=ts, tool=ToolCall(kind, name, generic_label(name, kind, args, paths), input=args, output=output,
153
+ is_error=status.lower() in ("error", "failed"), paths=paths,
154
+ pending=status.lower() in ("executing", "scheduled", "running"))))
155
+ tk = m.get("tokens")
156
+ if isinstance(tk, dict):
157
+ tokens = tokens or {}
158
+ for k in ("input", "output", "cached", "thoughts", "tool", "total"):
159
+ if isinstance(tk.get(k), (int, float)):
160
+ tokens[k] = tokens.get(k, 0) + tk[k]
161
+ elif mtype in ("info", "error", "warning"):
162
+ text = _parts_text(m.get("content")).strip()
163
+ if text:
164
+ events.append(Event("note", text=f"{mtype}: {text}", ts=ts))
165
+ started = parse_ts(meta.get("startTime")) or (min(stamps) if stamps else None)
166
+ ended = parse_ts(meta.get("lastUpdated")) or (max(stamps) if stamps else None)
167
+ sub = 0
168
+ parent_dir = os.path.join(os.path.dirname(ref.source), ref.session_id.replace("/", "_"))
169
+ if os.path.isdir(parent_dir):
170
+ sub = len(glob.glob(os.path.join(parent_dir, "*.jsonl")))
171
+ return Session(
172
+ agent=self.name, session_id=ref.session_id, short_id=ref.short_id, title=ref.title or (meta.get("summary") if isinstance(meta.get("summary"), str) else None),
173
+ cwd=ref.cwd, started=started, ended=ended, events=events, source=ref.source, subagent_files=sub, tokens=tokens,
174
+ tested_version=None, warnings=["Gemini CLI adapter is experimental: built from the source schema without a real sample"],
175
+ record_count=len(records), bad_lines=bad,
176
+ )