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.
- agent_sessions/__init__.py +3 -0
- agent_sessions/__main__.py +3 -0
- agent_sessions/adapters/__init__.py +136 -0
- agent_sessions/adapters/base.py +108 -0
- agent_sessions/adapters/claude_code.py +300 -0
- agent_sessions/adapters/codex.py +360 -0
- agent_sessions/adapters/gemini.py +176 -0
- agent_sessions/adapters/opencode.py +231 -0
- agent_sessions/cli.py +858 -0
- agent_sessions/core.py +748 -0
- agent_sessions/model.py +119 -0
- agent_sessions/templates/gitattributes +2 -0
- agent_sessions/templates/sessions-README.md +31 -0
- agent_sessions/templates/summary.md +31 -0
- agent_sessions_cli-0.2.0.dist-info/METADATA +141 -0
- agent_sessions_cli-0.2.0.dist-info/RECORD +19 -0
- agent_sessions_cli-0.2.0.dist-info/WHEEL +4 -0
- agent_sessions_cli-0.2.0.dist-info/entry_points.txt +2 -0
- agent_sessions_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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
|
+
)
|