ai-cli-toolkit 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.
- ai_cli/__init__.py +3 -0
- ai_cli/__main__.py +6 -0
- ai_cli/bin/ai-mux-linux-x86_64 +0 -0
- ai_cli/bin/remote-tty-wrapper +153 -0
- ai_cli/ca.py +175 -0
- ai_cli/completion_gen.py +680 -0
- ai_cli/config.py +185 -0
- ai_cli/credentials.py +341 -0
- ai_cli/detached_cleanup.py +135 -0
- ai_cli/housekeeping.py +50 -0
- ai_cli/instructions.py +308 -0
- ai_cli/log.py +53 -0
- ai_cli/main.py +1516 -0
- ai_cli/main_helpers.py +553 -0
- ai_cli/prompt_editor_launcher.py +324 -0
- ai_cli/proxy.py +627 -0
- ai_cli/remote.py +669 -0
- ai_cli/remote_package.py +1111 -0
- ai_cli/session.py +1344 -0
- ai_cli/session_store.py +236 -0
- ai_cli/traffic.py +1510 -0
- ai_cli/traffic_db.py +118 -0
- ai_cli/tui.py +525 -0
- ai_cli/update.py +200 -0
- ai_cli_toolkit-0.2.0.dist-info/METADATA +17 -0
- ai_cli_toolkit-0.2.0.dist-info/RECORD +30 -0
- ai_cli_toolkit-0.2.0.dist-info/WHEEL +5 -0
- ai_cli_toolkit-0.2.0.dist-info/entry_points.txt +2 -0
- ai_cli_toolkit-0.2.0.dist-info/licenses/LICENSE +21 -0
- ai_cli_toolkit-0.2.0.dist-info/top_level.txt +1 -0
ai_cli/session.py
ADDED
|
@@ -0,0 +1,1344 @@
|
|
|
1
|
+
"""Multi-agent session extractor for Claude, Codex, Copilot, and Gemini.
|
|
2
|
+
|
|
3
|
+
This module expands the Claude-only extractor in reference/extract_session.py into
|
|
4
|
+
unified discovery and parsing across multiple agent session stores.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
import hashlib
|
|
11
|
+
import json
|
|
12
|
+
import re
|
|
13
|
+
import signal
|
|
14
|
+
import sys
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from datetime import datetime
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any, Iterable, Optional
|
|
19
|
+
|
|
20
|
+
from ai_cli.session_store import (
|
|
21
|
+
StoreSession,
|
|
22
|
+
find_session_store_db as _find_session_store_db,
|
|
23
|
+
list_store_sessions as _list_store_sessions_sql,
|
|
24
|
+
query_store_checkpoints as _query_store_checkpoints_sql,
|
|
25
|
+
query_store_files as _query_store_files_sql,
|
|
26
|
+
query_store_turns as _query_store_turns_sql,
|
|
27
|
+
search_store as _search_store_sql,
|
|
28
|
+
)
|
|
29
|
+
from ai_cli.traffic_db import DEFAULT_DB_PATH as TRAFFIC_DB_PATH
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
AGENTS = ("claude", "codex", "copilot", "gemini")
|
|
33
|
+
|
|
34
|
+
try:
|
|
35
|
+
signal.signal(signal.SIGPIPE, signal.SIG_DFL)
|
|
36
|
+
except (AttributeError, ValueError):
|
|
37
|
+
pass
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# ---------------------------------------------------------------------------
|
|
41
|
+
# Session store (SQLite) querying — the "sql way"
|
|
42
|
+
# ---------------------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
def find_session_store_db(path: str = "") -> Optional[Path]:
|
|
45
|
+
return _find_session_store_db(path)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def list_store_sessions(
|
|
49
|
+
db_path: Path,
|
|
50
|
+
cwd: str = "",
|
|
51
|
+
branch: str = "",
|
|
52
|
+
limit: int = 50,
|
|
53
|
+
) -> list[StoreSession]:
|
|
54
|
+
return _list_store_sessions_sql(db_path=db_path, cwd=cwd, branch=branch, limit=limit)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def query_store_turns(
|
|
58
|
+
db_path: Path,
|
|
59
|
+
session_id: str = "",
|
|
60
|
+
grep: str = "",
|
|
61
|
+
limit: int = 200,
|
|
62
|
+
) -> list[dict[str, Any]]:
|
|
63
|
+
return _query_store_turns_sql(
|
|
64
|
+
db_path=db_path,
|
|
65
|
+
session_id=session_id,
|
|
66
|
+
grep=grep,
|
|
67
|
+
limit=limit,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def search_store(
|
|
72
|
+
db_path: Path,
|
|
73
|
+
query: str,
|
|
74
|
+
limit: int = 30,
|
|
75
|
+
) -> list[dict[str, Any]]:
|
|
76
|
+
return _search_store_sql(db_path=db_path, query=query, limit=limit)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def query_store_checkpoints(
|
|
80
|
+
db_path: Path,
|
|
81
|
+
session_id: str,
|
|
82
|
+
) -> list[dict[str, Any]]:
|
|
83
|
+
return _query_store_checkpoints_sql(db_path=db_path, session_id=session_id)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def query_store_files(
|
|
87
|
+
db_path: Path,
|
|
88
|
+
session_id: str = "",
|
|
89
|
+
file_pattern: str = "",
|
|
90
|
+
) -> list[dict[str, Any]]:
|
|
91
|
+
return _query_store_files_sql(
|
|
92
|
+
db_path=db_path,
|
|
93
|
+
session_id=session_id,
|
|
94
|
+
file_pattern=file_pattern,
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def parse_gemini_api_body(body_text: str, role_default: str = "user") -> list[str]:
|
|
99
|
+
"""Extract text from Gemini API generateContent request/response bodies."""
|
|
100
|
+
if not body_text:
|
|
101
|
+
return []
|
|
102
|
+
|
|
103
|
+
# Handle SSE (Server-Sent Events) format in responses
|
|
104
|
+
if body_text.startswith("data: "):
|
|
105
|
+
sse_out: list[str] = []
|
|
106
|
+
for line in body_text.splitlines():
|
|
107
|
+
if line.startswith("data: "):
|
|
108
|
+
try:
|
|
109
|
+
data = json.loads(line[6:])
|
|
110
|
+
sse_out.extend(parse_gemini_api_body(json.dumps(data), role_default="assistant"))
|
|
111
|
+
except json.JSONDecodeError:
|
|
112
|
+
pass
|
|
113
|
+
return sse_out
|
|
114
|
+
|
|
115
|
+
try:
|
|
116
|
+
body = json.loads(body_text)
|
|
117
|
+
except json.JSONDecodeError:
|
|
118
|
+
return []
|
|
119
|
+
|
|
120
|
+
# Internal envelope for Code Assist: body["request"] or body["response"]
|
|
121
|
+
if isinstance(body, dict):
|
|
122
|
+
if "request" in body and isinstance(body["request"], dict):
|
|
123
|
+
body = body["request"]
|
|
124
|
+
if "response" in body and isinstance(body["response"], (dict, list)):
|
|
125
|
+
# Could be a list of responses in stream mode.
|
|
126
|
+
resp = body["response"]
|
|
127
|
+
if isinstance(resp, list):
|
|
128
|
+
resp_out: list[str] = []
|
|
129
|
+
for r in resp:
|
|
130
|
+
resp_out.extend(parse_gemini_api_body(json.dumps(r), role_default="assistant"))
|
|
131
|
+
return resp_out
|
|
132
|
+
body = resp
|
|
133
|
+
|
|
134
|
+
out: list[str] = []
|
|
135
|
+
if isinstance(body, dict):
|
|
136
|
+
# Public API Request: contents[] -> parts[] -> text
|
|
137
|
+
if "contents" in body and isinstance(body["contents"], list):
|
|
138
|
+
for entry in body["contents"]:
|
|
139
|
+
parts = entry.get("parts", [])
|
|
140
|
+
if isinstance(parts, list):
|
|
141
|
+
for p in parts:
|
|
142
|
+
if isinstance(p, dict) and "text" in p:
|
|
143
|
+
out.append(str(p["text"]))
|
|
144
|
+
# Public API Response: candidates[] -> content -> parts[] -> text
|
|
145
|
+
if "candidates" in body and isinstance(body["candidates"], list):
|
|
146
|
+
for cand in body["candidates"]:
|
|
147
|
+
content = cand.get("content", {})
|
|
148
|
+
if isinstance(content, dict):
|
|
149
|
+
parts = content.get("parts", [])
|
|
150
|
+
if isinstance(parts, list):
|
|
151
|
+
for p in parts:
|
|
152
|
+
if isinstance(p, dict) and "text" in p:
|
|
153
|
+
out.append(str(p["text"]))
|
|
154
|
+
# Fallback for simple message/text fields
|
|
155
|
+
if not out:
|
|
156
|
+
for key in ("text", "content", "message"):
|
|
157
|
+
if key in body and isinstance(body[key], str):
|
|
158
|
+
out.append(body[key])
|
|
159
|
+
|
|
160
|
+
return out
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def query_traffic_turns(
|
|
164
|
+
db_path: Path = TRAFFIC_DB_PATH,
|
|
165
|
+
agent: str = "gemini",
|
|
166
|
+
limit: int = 50,
|
|
167
|
+
) -> list[dict[str, Any]]:
|
|
168
|
+
"""Extract conversation turns from the traffic log (SQLite)."""
|
|
169
|
+
if not db_path.is_file():
|
|
170
|
+
return []
|
|
171
|
+
|
|
172
|
+
import sqlite3
|
|
173
|
+
provider_map = {"gemini": "google", "claude": "anthropic", "openai": "openai"}
|
|
174
|
+
provider = provider_map.get(agent, agent)
|
|
175
|
+
|
|
176
|
+
turns: list[dict[str, Any]] = []
|
|
177
|
+
try:
|
|
178
|
+
conn = sqlite3.connect(str(db_path))
|
|
179
|
+
conn.row_factory = sqlite3.Row
|
|
180
|
+
# Query API rows with bodies.
|
|
181
|
+
rows = conn.execute(
|
|
182
|
+
"SELECT id, ts, method, path, req_body, resp_body FROM traffic "
|
|
183
|
+
"WHERE provider = ? AND is_api = 1 AND (req_body IS NOT NULL OR resp_body IS NOT NULL) "
|
|
184
|
+
"ORDER BY ts DESC LIMIT ?",
|
|
185
|
+
(provider, limit),
|
|
186
|
+
).fetchall()
|
|
187
|
+
conn.close()
|
|
188
|
+
|
|
189
|
+
for r in rows:
|
|
190
|
+
ts = r["ts"]
|
|
191
|
+
rid = r["id"]
|
|
192
|
+
|
|
193
|
+
# Request -> User
|
|
194
|
+
if r["req_body"]:
|
|
195
|
+
texts = parse_gemini_api_body(r["req_body"], role_default="user")
|
|
196
|
+
for t in texts:
|
|
197
|
+
turns.append({
|
|
198
|
+
"agent": agent,
|
|
199
|
+
"role": "user",
|
|
200
|
+
"type": "text",
|
|
201
|
+
"content": t,
|
|
202
|
+
"timestamp": ts,
|
|
203
|
+
"file": f"traffic.db:{rid}",
|
|
204
|
+
"line": rid,
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
# Response -> Assistant
|
|
208
|
+
if r["resp_body"]:
|
|
209
|
+
texts = parse_gemini_api_body(r["resp_body"], role_default="assistant")
|
|
210
|
+
for t in texts:
|
|
211
|
+
turns.append({
|
|
212
|
+
"agent": agent,
|
|
213
|
+
"role": "assistant",
|
|
214
|
+
"type": "text",
|
|
215
|
+
"content": t,
|
|
216
|
+
"timestamp": ts,
|
|
217
|
+
"file": f"traffic.db:{rid}",
|
|
218
|
+
"line": rid,
|
|
219
|
+
})
|
|
220
|
+
except Exception:
|
|
221
|
+
pass
|
|
222
|
+
|
|
223
|
+
return turns
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _list_store_sessions(db_path: Path, sessions: list[StoreSession]) -> int:
|
|
227
|
+
"""Print a table of session store sessions."""
|
|
228
|
+
if not sessions:
|
|
229
|
+
print("No sessions found in session store.", file=sys.stderr)
|
|
230
|
+
return 1
|
|
231
|
+
try:
|
|
232
|
+
print(f"Session store: {db_path}")
|
|
233
|
+
print(f"{'ID':<40} {'Branch':<12} {'Created':<20} Summary")
|
|
234
|
+
print("-" * 110)
|
|
235
|
+
for s in sessions:
|
|
236
|
+
created = s.created_at[:19] if s.created_at else "?"
|
|
237
|
+
summary = s.summary[:50] if s.summary else "(no summary)"
|
|
238
|
+
print(f"{s.id:<40} {s.branch:<12} {created:<20} {summary}")
|
|
239
|
+
except BrokenPipeError:
|
|
240
|
+
return 0
|
|
241
|
+
return 0
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
_CWD_PATTERNS = (
|
|
245
|
+
re.compile(r'"cwd"\s*:\s*"([^"]+)"'),
|
|
246
|
+
re.compile(r'"current_dir"\s*:\s*"([^"]+)"'),
|
|
247
|
+
re.compile(r'"working_dir"\s*:\s*"([^"]+)"'),
|
|
248
|
+
re.compile(r'"workingDirectory"\s*:\s*"([^"]+)"'),
|
|
249
|
+
re.compile(r'cwd=([^\r\n\s"]+)'),
|
|
250
|
+
re.compile(r'Workspace Directories:.*?\n\s*-\s*([^\r\n\s"]+)'),
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
@dataclass(frozen=True)
|
|
255
|
+
class SessionFile:
|
|
256
|
+
"""A discovered session JSONL file."""
|
|
257
|
+
|
|
258
|
+
agent: str
|
|
259
|
+
path: Path
|
|
260
|
+
|
|
261
|
+
@property
|
|
262
|
+
def mtime(self) -> float:
|
|
263
|
+
try:
|
|
264
|
+
return self.path.stat().st_mtime
|
|
265
|
+
except OSError:
|
|
266
|
+
return 0.0
|
|
267
|
+
|
|
268
|
+
@property
|
|
269
|
+
def size(self) -> int:
|
|
270
|
+
try:
|
|
271
|
+
return self.path.stat().st_size
|
|
272
|
+
except OSError:
|
|
273
|
+
return 0
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _project_slug(project_path: str) -> str:
|
|
277
|
+
return project_path.replace("/", "-")
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _format_size(num: int) -> str:
|
|
281
|
+
if num >= 1_000_000:
|
|
282
|
+
return f"{num / 1_000_000:.1f}MB"
|
|
283
|
+
if num >= 1_000:
|
|
284
|
+
return f"{num / 1_000:.1f}KB"
|
|
285
|
+
return f"{num}B"
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _remote_log_roots(remote_host: str) -> list[Path]:
|
|
289
|
+
host = remote_host.strip()
|
|
290
|
+
if not host:
|
|
291
|
+
return []
|
|
292
|
+
logs_root = Path.home() / ".ai-cli" / "logs"
|
|
293
|
+
safe_host = re.sub(r"[^A-Za-z0-9_-]", "-", host)
|
|
294
|
+
candidates = [logs_root / f"remote-{safe_host}"]
|
|
295
|
+
if safe_host != host:
|
|
296
|
+
candidates.append(logs_root / f"remote-{host}")
|
|
297
|
+
|
|
298
|
+
roots: list[Path] = []
|
|
299
|
+
seen: set[str] = set()
|
|
300
|
+
for candidate in candidates:
|
|
301
|
+
key = str(candidate)
|
|
302
|
+
if key in seen or not candidate.exists():
|
|
303
|
+
continue
|
|
304
|
+
seen.add(key)
|
|
305
|
+
roots.append(candidate)
|
|
306
|
+
return roots
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _candidate_roots(agent: str, remote_host: str = "") -> list[Path]:
|
|
310
|
+
home = Path.home()
|
|
311
|
+
if remote_host.strip():
|
|
312
|
+
roots: list[Path] = []
|
|
313
|
+
for base in _remote_log_roots(remote_host):
|
|
314
|
+
if agent == "claude":
|
|
315
|
+
roots.append(base / ".claude-projects")
|
|
316
|
+
elif agent == "codex":
|
|
317
|
+
roots.extend([base / ".codex-sessions", base / ".codex-projects"])
|
|
318
|
+
elif agent == "copilot":
|
|
319
|
+
roots.append(base / ".copilot-sessions")
|
|
320
|
+
elif agent == "gemini":
|
|
321
|
+
roots.append(base / ".gemini-sessions")
|
|
322
|
+
return roots
|
|
323
|
+
|
|
324
|
+
if agent == "claude":
|
|
325
|
+
return [home / ".claude" / "projects"]
|
|
326
|
+
if agent == "codex":
|
|
327
|
+
return [
|
|
328
|
+
home / ".codex" / "sessions",
|
|
329
|
+
home / ".codex" / "projects",
|
|
330
|
+
home / ".codex",
|
|
331
|
+
]
|
|
332
|
+
if agent == "copilot":
|
|
333
|
+
return [
|
|
334
|
+
home / ".copilot" / "sessions",
|
|
335
|
+
home / ".config" / "github-copilot" / "sessions",
|
|
336
|
+
home / ".config" / "github-copilot",
|
|
337
|
+
]
|
|
338
|
+
if agent == "gemini":
|
|
339
|
+
return [
|
|
340
|
+
home / ".gemini" / "tmp" / "ai-cli" / "chats",
|
|
341
|
+
home / ".gemini" / "sessions",
|
|
342
|
+
home / ".config" / "gemini" / "sessions",
|
|
343
|
+
home / ".gemini",
|
|
344
|
+
]
|
|
345
|
+
return []
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def _discover_agent_files(
|
|
349
|
+
agent: str,
|
|
350
|
+
project_path: str = "",
|
|
351
|
+
remote_host: str = "",
|
|
352
|
+
) -> list[SessionFile]:
|
|
353
|
+
"""Discover JSONL files for one agent.
|
|
354
|
+
|
|
355
|
+
For Claude, an optional project path narrows discovery to the matching slug.
|
|
356
|
+
For other agents, discovery scans common session roots recursively.
|
|
357
|
+
"""
|
|
358
|
+
discovered: list[SessionFile] = []
|
|
359
|
+
|
|
360
|
+
if agent == "claude":
|
|
361
|
+
roots = _candidate_roots("claude", remote_host=remote_host)
|
|
362
|
+
if not roots:
|
|
363
|
+
return discovered
|
|
364
|
+
root = roots[0]
|
|
365
|
+
if not root.is_dir():
|
|
366
|
+
return discovered
|
|
367
|
+
|
|
368
|
+
if project_path:
|
|
369
|
+
project = Path(project_path).expanduser()
|
|
370
|
+
if project.is_dir():
|
|
371
|
+
slug = _project_slug(str(project.resolve()))
|
|
372
|
+
exact = root / slug
|
|
373
|
+
candidates: list[Path] = []
|
|
374
|
+
if exact.is_dir():
|
|
375
|
+
candidates.append(exact)
|
|
376
|
+
else:
|
|
377
|
+
basename = project.name
|
|
378
|
+
for subdir in root.iterdir():
|
|
379
|
+
if subdir.is_dir() and subdir.name.endswith(basename):
|
|
380
|
+
candidates.append(subdir)
|
|
381
|
+
|
|
382
|
+
for candidate in candidates:
|
|
383
|
+
for jsonl in candidate.glob("*.jsonl"):
|
|
384
|
+
discovered.append(SessionFile(agent="claude", path=jsonl))
|
|
385
|
+
return discovered
|
|
386
|
+
|
|
387
|
+
for jsonl in root.glob("*/*.jsonl"):
|
|
388
|
+
discovered.append(SessionFile(agent="claude", path=jsonl))
|
|
389
|
+
return discovered
|
|
390
|
+
|
|
391
|
+
for base in _candidate_roots(agent, remote_host=remote_host):
|
|
392
|
+
if not base.exists():
|
|
393
|
+
continue
|
|
394
|
+
if base.is_file() and base.suffix == ".jsonl":
|
|
395
|
+
discovered.append(SessionFile(agent=agent, path=base))
|
|
396
|
+
continue
|
|
397
|
+
if base.is_dir():
|
|
398
|
+
for jsonl in base.rglob("*.jsonl"):
|
|
399
|
+
discovered.append(SessionFile(agent=agent, path=jsonl))
|
|
400
|
+
if agent == "gemini":
|
|
401
|
+
for json_file in base.rglob("*.json"):
|
|
402
|
+
if "session-" in json_file.name:
|
|
403
|
+
discovered.append(SessionFile(agent=agent, path=json_file))
|
|
404
|
+
|
|
405
|
+
return discovered
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def parse_gemini_chat_json(path: Path) -> list[dict[str, Any]]:
|
|
409
|
+
"""Parse Gemini chat JSON from ~/.gemini/tmp/ai-cli/chats/."""
|
|
410
|
+
messages: list[dict[str, Any]] = []
|
|
411
|
+
try:
|
|
412
|
+
with path.open(encoding="utf-8", errors="replace") as f:
|
|
413
|
+
data = json.load(f)
|
|
414
|
+
except (OSError, json.JSONDecodeError):
|
|
415
|
+
return []
|
|
416
|
+
|
|
417
|
+
# Format from ~/.gemini/tmp/ai-cli/chats/session-*.json
|
|
418
|
+
# { "messages": [ { "type": "user"|"gemini", "content": string|list, "displayContent": list, "timestamp": string } ] }
|
|
419
|
+
raw_messages = data.get("messages", [])
|
|
420
|
+
if not isinstance(raw_messages, list):
|
|
421
|
+
return []
|
|
422
|
+
|
|
423
|
+
for idx, msg in enumerate(raw_messages):
|
|
424
|
+
role_raw = str(msg.get("type", "")).lower()
|
|
425
|
+
role = "user" if role_raw == "user" else "assistant"
|
|
426
|
+
ts = _normalize_timestamp(msg.get("timestamp"))
|
|
427
|
+
|
|
428
|
+
# Prefer displayContent for user messages to show the original command.
|
|
429
|
+
content_parts = _extract_text(msg.get("displayContent") or msg.get("content"))
|
|
430
|
+
text = "\n".join(content_parts).strip()
|
|
431
|
+
if not text:
|
|
432
|
+
continue
|
|
433
|
+
|
|
434
|
+
messages.append({
|
|
435
|
+
"agent": "gemini",
|
|
436
|
+
"role": role,
|
|
437
|
+
"type": "text",
|
|
438
|
+
"content": text,
|
|
439
|
+
"line": idx,
|
|
440
|
+
"timestamp": ts,
|
|
441
|
+
"file": str(path),
|
|
442
|
+
})
|
|
443
|
+
|
|
444
|
+
return messages
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def infer_agent_from_path(path: Path) -> str:
|
|
448
|
+
"""Best-effort inference of agent type from a path."""
|
|
449
|
+
text = str(path)
|
|
450
|
+
if "/.claude/" in text:
|
|
451
|
+
return "claude"
|
|
452
|
+
if "/.codex/" in text:
|
|
453
|
+
return "codex"
|
|
454
|
+
if "copilot" in text:
|
|
455
|
+
return "copilot"
|
|
456
|
+
if "gemini" in text:
|
|
457
|
+
return "gemini"
|
|
458
|
+
return "claude"
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def discover_sessions(
|
|
462
|
+
target: str = "",
|
|
463
|
+
agent: str = "all",
|
|
464
|
+
remote_host: str = "",
|
|
465
|
+
) -> list[SessionFile]:
|
|
466
|
+
"""Discover session files based on optional target path and agent filter."""
|
|
467
|
+
target = target.strip()
|
|
468
|
+
if target:
|
|
469
|
+
path = Path(target).expanduser()
|
|
470
|
+
if path.is_file() and path.suffix == ".jsonl":
|
|
471
|
+
forced_agent = agent if agent in AGENTS else infer_agent_from_path(path)
|
|
472
|
+
return [SessionFile(agent=forced_agent, path=path)]
|
|
473
|
+
if path.is_dir() and any(path.glob("*.jsonl")):
|
|
474
|
+
forced_agent = agent if agent in AGENTS else infer_agent_from_path(path)
|
|
475
|
+
return [
|
|
476
|
+
SessionFile(agent=forced_agent, path=p)
|
|
477
|
+
for p in path.glob("*.jsonl")
|
|
478
|
+
]
|
|
479
|
+
|
|
480
|
+
agents = AGENTS if agent == "all" else (agent,)
|
|
481
|
+
files: list[SessionFile] = []
|
|
482
|
+
for name in agents:
|
|
483
|
+
files.extend(
|
|
484
|
+
_discover_agent_files(
|
|
485
|
+
name,
|
|
486
|
+
project_path=target,
|
|
487
|
+
remote_host=remote_host,
|
|
488
|
+
)
|
|
489
|
+
)
|
|
490
|
+
|
|
491
|
+
seen: set[str] = set()
|
|
492
|
+
deduped: list[SessionFile] = []
|
|
493
|
+
for item in sorted(files, key=lambda s: s.mtime, reverse=True):
|
|
494
|
+
key = str(item.path)
|
|
495
|
+
if key in seen:
|
|
496
|
+
continue
|
|
497
|
+
seen.add(key)
|
|
498
|
+
deduped.append(item)
|
|
499
|
+
return deduped
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def _decode_json_string(value: str) -> str:
|
|
503
|
+
try:
|
|
504
|
+
return json.loads(f'"{value}"')
|
|
505
|
+
except json.JSONDecodeError:
|
|
506
|
+
return value
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
def _normalize_cwd(value: str) -> str:
|
|
510
|
+
text = value.strip()
|
|
511
|
+
if not text:
|
|
512
|
+
return ""
|
|
513
|
+
try:
|
|
514
|
+
path = Path(text).expanduser()
|
|
515
|
+
if path.exists():
|
|
516
|
+
return str(path.resolve())
|
|
517
|
+
return str(path)
|
|
518
|
+
except OSError:
|
|
519
|
+
return text
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
def infer_session_cwd(path: Path, max_lines: int = 150) -> str:
|
|
523
|
+
"""Extract a declared working directory from a session file, if present."""
|
|
524
|
+
try:
|
|
525
|
+
with path.open(encoding="utf-8", errors="replace") as handle:
|
|
526
|
+
text_block = ""
|
|
527
|
+
for idx, raw in enumerate(handle):
|
|
528
|
+
if idx >= max_lines:
|
|
529
|
+
break
|
|
530
|
+
text_block += raw
|
|
531
|
+
for pattern in _CWD_PATTERNS:
|
|
532
|
+
match = pattern.search(raw)
|
|
533
|
+
if match:
|
|
534
|
+
val = match.group(1)
|
|
535
|
+
if val.startswith("/") or "~" in val:
|
|
536
|
+
return _normalize_cwd(_decode_json_string(val))
|
|
537
|
+
|
|
538
|
+
# Deeper scan if line-by-line failed
|
|
539
|
+
for pattern in _CWD_PATTERNS:
|
|
540
|
+
match = pattern.search(text_block)
|
|
541
|
+
if match:
|
|
542
|
+
val = match.group(1)
|
|
543
|
+
return _normalize_cwd(_decode_json_string(val))
|
|
544
|
+
except OSError:
|
|
545
|
+
return ""
|
|
546
|
+
return ""
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
def _cwd_matches(session_cwd: str, working_cwd: str) -> bool:
|
|
550
|
+
if not session_cwd or not working_cwd:
|
|
551
|
+
return False
|
|
552
|
+
session_norm = _normalize_cwd(session_cwd)
|
|
553
|
+
working_norm = _normalize_cwd(working_cwd)
|
|
554
|
+
if not session_norm or not working_norm:
|
|
555
|
+
return False
|
|
556
|
+
if session_norm == working_norm:
|
|
557
|
+
return True
|
|
558
|
+
return session_norm.startswith(working_norm + "/")
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
def sessions_for_working_dir(
|
|
562
|
+
working_cwd: str,
|
|
563
|
+
max_files: int = 20,
|
|
564
|
+
remote_host: str = "",
|
|
565
|
+
) -> list[SessionFile]:
|
|
566
|
+
"""Return recent session files whose recorded cwd matches *working_cwd*."""
|
|
567
|
+
working_norm = _normalize_cwd(working_cwd)
|
|
568
|
+
if not working_norm:
|
|
569
|
+
return []
|
|
570
|
+
|
|
571
|
+
slug = _project_slug(working_norm)
|
|
572
|
+
# Gemini uses a SHA256 of the project root as projectHash
|
|
573
|
+
gemini_hash = hashlib.sha256(working_norm.encode("utf-8")).hexdigest()
|
|
574
|
+
|
|
575
|
+
matched: list[SessionFile] = []
|
|
576
|
+
for session in discover_sessions(agent="all", remote_host=remote_host):
|
|
577
|
+
if len(matched) >= max_files:
|
|
578
|
+
break
|
|
579
|
+
|
|
580
|
+
# Claude sessions encode cwd in directory slug, so this is a fast path.
|
|
581
|
+
if session.agent == "claude" and slug in str(session.path.parent):
|
|
582
|
+
matched.append(session)
|
|
583
|
+
continue
|
|
584
|
+
|
|
585
|
+
# Gemini JSON sessions check projectHash
|
|
586
|
+
if session.agent == "gemini" and session.path.suffix == ".json":
|
|
587
|
+
try:
|
|
588
|
+
# Fast check for the hash in the first 500 characters
|
|
589
|
+
with session.path.open(encoding="utf-8", errors="replace") as f:
|
|
590
|
+
head = f.read(500)
|
|
591
|
+
if gemini_hash in head:
|
|
592
|
+
matched.append(session)
|
|
593
|
+
continue
|
|
594
|
+
except OSError:
|
|
595
|
+
pass
|
|
596
|
+
|
|
597
|
+
session_cwd = infer_session_cwd(session.path)
|
|
598
|
+
if _cwd_matches(session_cwd, working_norm):
|
|
599
|
+
matched.append(session)
|
|
600
|
+
|
|
601
|
+
return matched
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
def _compact_for_prompt(text: str, limit: int) -> str:
|
|
605
|
+
one_line = " ".join(text.split())
|
|
606
|
+
if len(one_line) <= limit:
|
|
607
|
+
return one_line
|
|
608
|
+
return one_line[: limit - 3] + "..."
|
|
609
|
+
|
|
610
|
+
|
|
611
|
+
def _is_context_candidate(text: str) -> bool:
|
|
612
|
+
cleaned = " ".join(text.split()).strip()
|
|
613
|
+
if len(cleaned) < 8:
|
|
614
|
+
return False
|
|
615
|
+
lowered = cleaned.lower()
|
|
616
|
+
if lowered.startswith("<task-notification>"):
|
|
617
|
+
return False
|
|
618
|
+
if "you're out of extra usage" in lowered:
|
|
619
|
+
return False
|
|
620
|
+
if lowered.startswith("<retrieval_status>"):
|
|
621
|
+
return False
|
|
622
|
+
if "agents.md instructions for" in lowered:
|
|
623
|
+
return False
|
|
624
|
+
if lowered.startswith("<permissions instructions>"):
|
|
625
|
+
return False
|
|
626
|
+
return True
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
def build_recent_context_for_cwd(
|
|
630
|
+
working_cwd: str,
|
|
631
|
+
max_messages: int = 8,
|
|
632
|
+
max_sessions: int = 6,
|
|
633
|
+
remote_host: str = "",
|
|
634
|
+
) -> str:
|
|
635
|
+
"""Build an agent-agnostic recent context block for prompt injection."""
|
|
636
|
+
|
|
637
|
+
# ── Session store summaries (SQL) ────────────────────────────────
|
|
638
|
+
store_summaries: list[str] = []
|
|
639
|
+
db_path = find_session_store_db() if not remote_host.strip() else None
|
|
640
|
+
if db_path:
|
|
641
|
+
try:
|
|
642
|
+
store_sessions = list_store_sessions(
|
|
643
|
+
db_path, cwd=_normalize_cwd(working_cwd), limit=max_sessions,
|
|
644
|
+
)
|
|
645
|
+
for ss in store_sessions:
|
|
646
|
+
if ss.summary:
|
|
647
|
+
ts = ss.created_at[:19] if ss.created_at else "?"
|
|
648
|
+
store_summaries.append(
|
|
649
|
+
f"- [copilot-store {ts}] {_compact_for_prompt(ss.summary, limit=160)}"
|
|
650
|
+
)
|
|
651
|
+
except Exception:
|
|
652
|
+
pass
|
|
653
|
+
|
|
654
|
+
# ── Legacy JSONL parsing ─────────────────────────────────────────
|
|
655
|
+
sessions = sessions_for_working_dir(
|
|
656
|
+
working_cwd,
|
|
657
|
+
max_files=max_sessions * 3,
|
|
658
|
+
remote_host=remote_host,
|
|
659
|
+
)
|
|
660
|
+
|
|
661
|
+
merged: list[dict[str, Any]] = []
|
|
662
|
+
if sessions:
|
|
663
|
+
selected = sorted(sessions, key=lambda s: s.mtime, reverse=True)[:max_sessions]
|
|
664
|
+
for session in selected:
|
|
665
|
+
parsed = parse_session_file(session, show_tools=False)
|
|
666
|
+
if not parsed:
|
|
667
|
+
continue
|
|
668
|
+
tail = parsed[-120:]
|
|
669
|
+
user_msgs = [m for m in tail if str(m.get("role", "")) == "user"]
|
|
670
|
+
assistant_msgs = [m for m in tail if str(m.get("role", "")) == "assistant"]
|
|
671
|
+
chosen = [*user_msgs[-3:], *assistant_msgs[-2:]]
|
|
672
|
+
for msg in chosen:
|
|
673
|
+
if not _is_context_candidate(str(msg.get("content", ""))):
|
|
674
|
+
continue
|
|
675
|
+
enriched = dict(msg)
|
|
676
|
+
enriched["_session_mtime"] = session.mtime
|
|
677
|
+
merged.append(enriched)
|
|
678
|
+
|
|
679
|
+
# ── Traffic log (Gemini) extraction ──────────────────────────────
|
|
680
|
+
if not remote_host.strip() and TRAFFIC_DB_PATH.is_file():
|
|
681
|
+
try:
|
|
682
|
+
traffic_turns = query_traffic_turns(TRAFFIC_DB_PATH, agent="gemini", limit=max_messages * 2)
|
|
683
|
+
# Since traffic log doesn't store CWD per row, we take recent ones.
|
|
684
|
+
# We filter for candidates.
|
|
685
|
+
for turn in traffic_turns:
|
|
686
|
+
if not _is_context_candidate(turn.get("content", "")):
|
|
687
|
+
continue
|
|
688
|
+
# Add a dummy mtime for sorting if missing.
|
|
689
|
+
turn["_session_mtime"] = _timestamp_for_sorting(turn.get("timestamp", ""))
|
|
690
|
+
merged.append(turn)
|
|
691
|
+
except Exception:
|
|
692
|
+
pass
|
|
693
|
+
|
|
694
|
+
if not merged and not store_summaries:
|
|
695
|
+
return ""
|
|
696
|
+
|
|
697
|
+
recent: list[dict[str, Any]] = []
|
|
698
|
+
if merged:
|
|
699
|
+
merged.sort(
|
|
700
|
+
key=lambda m: (
|
|
701
|
+
_timestamp_for_sorting(str(m.get("timestamp", ""))),
|
|
702
|
+
float(m.get("_session_mtime", 0.0)),
|
|
703
|
+
int(m.get("line", 0)),
|
|
704
|
+
)
|
|
705
|
+
)
|
|
706
|
+
recent = merged[-max_messages:]
|
|
707
|
+
|
|
708
|
+
lines = [
|
|
709
|
+
"RECENT WORKING-DIR CONTEXT (cross-agent):",
|
|
710
|
+
f"cwd={_normalize_cwd(working_cwd)}",
|
|
711
|
+
]
|
|
712
|
+
if store_summaries:
|
|
713
|
+
lines.append("Recent session summaries:")
|
|
714
|
+
lines.extend(store_summaries[:max_sessions])
|
|
715
|
+
|
|
716
|
+
seen_line: set[str] = set()
|
|
717
|
+
for msg in recent:
|
|
718
|
+
agent = str(msg.get("agent", "unknown"))
|
|
719
|
+
role = str(msg.get("role", "assistant"))
|
|
720
|
+
snippet = _compact_for_prompt(str(msg.get("content", "")), limit=220)
|
|
721
|
+
line = f"- [{agent}] {role}: {snippet}"
|
|
722
|
+
if line in seen_line:
|
|
723
|
+
continue
|
|
724
|
+
seen_line.add(line)
|
|
725
|
+
lines.append(line)
|
|
726
|
+
|
|
727
|
+
lines.append(
|
|
728
|
+
"Use this as continuity context only; prioritize current user instructions."
|
|
729
|
+
)
|
|
730
|
+
return "\n".join(lines)
|
|
731
|
+
|
|
732
|
+
|
|
733
|
+
def _extract_text(value: Any) -> list[str]:
|
|
734
|
+
"""Recursively extract text-like values from nested JSON structures."""
|
|
735
|
+
out: list[str] = []
|
|
736
|
+
if value is None:
|
|
737
|
+
return out
|
|
738
|
+
if isinstance(value, str):
|
|
739
|
+
text = value.strip()
|
|
740
|
+
if text:
|
|
741
|
+
out.append(text)
|
|
742
|
+
return out
|
|
743
|
+
if isinstance(value, list):
|
|
744
|
+
for item in value:
|
|
745
|
+
out.extend(_extract_text(item))
|
|
746
|
+
return out
|
|
747
|
+
if isinstance(value, dict):
|
|
748
|
+
if value.get("type") == "text" and isinstance(value.get("text"), str):
|
|
749
|
+
text = value["text"].strip()
|
|
750
|
+
if text:
|
|
751
|
+
out.append(text)
|
|
752
|
+
for key in (
|
|
753
|
+
"text",
|
|
754
|
+
"content",
|
|
755
|
+
"message",
|
|
756
|
+
"output_text",
|
|
757
|
+
"input_text",
|
|
758
|
+
"prompt",
|
|
759
|
+
"response",
|
|
760
|
+
"result",
|
|
761
|
+
):
|
|
762
|
+
if key in value:
|
|
763
|
+
out.extend(_extract_text(value.get(key)))
|
|
764
|
+
return out
|
|
765
|
+
out.append(str(value))
|
|
766
|
+
return out
|
|
767
|
+
|
|
768
|
+
|
|
769
|
+
def _normalize_timestamp(value: Any) -> str:
|
|
770
|
+
if value is None:
|
|
771
|
+
return ""
|
|
772
|
+
if isinstance(value, (int, float)):
|
|
773
|
+
try:
|
|
774
|
+
return datetime.fromtimestamp(float(value)).isoformat()
|
|
775
|
+
except (ValueError, OSError):
|
|
776
|
+
return ""
|
|
777
|
+
if isinstance(value, str):
|
|
778
|
+
return value
|
|
779
|
+
return ""
|
|
780
|
+
|
|
781
|
+
|
|
782
|
+
def _timestamp_for_sorting(value: str) -> float:
|
|
783
|
+
if not value:
|
|
784
|
+
return 0.0
|
|
785
|
+
text = value.strip()
|
|
786
|
+
if not text:
|
|
787
|
+
return 0.0
|
|
788
|
+
if text.endswith("Z"):
|
|
789
|
+
text = text[:-1] + "+00:00"
|
|
790
|
+
try:
|
|
791
|
+
return datetime.fromisoformat(text).timestamp()
|
|
792
|
+
except ValueError:
|
|
793
|
+
return 0.0
|
|
794
|
+
|
|
795
|
+
|
|
796
|
+
def parse_claude_jsonl(path: Path, show_tools: bool = False) -> list[dict[str, Any]]:
|
|
797
|
+
"""Parse Claude Code JSONL with support for tool_use/tool_result blocks."""
|
|
798
|
+
messages: list[dict[str, Any]] = []
|
|
799
|
+
|
|
800
|
+
with path.open(encoding="utf-8", errors="replace") as handle:
|
|
801
|
+
for lineno, raw in enumerate(handle, 1):
|
|
802
|
+
raw = raw.strip()
|
|
803
|
+
if not raw:
|
|
804
|
+
continue
|
|
805
|
+
try:
|
|
806
|
+
obj = json.loads(raw)
|
|
807
|
+
except json.JSONDecodeError:
|
|
808
|
+
continue
|
|
809
|
+
|
|
810
|
+
timestamp = _normalize_timestamp(
|
|
811
|
+
obj.get("timestamp")
|
|
812
|
+
or obj.get("created_at")
|
|
813
|
+
or obj.get("time")
|
|
814
|
+
)
|
|
815
|
+
|
|
816
|
+
entry_type = obj.get("type", "")
|
|
817
|
+
msg = obj.get("message", {})
|
|
818
|
+
if not msg and "data" in obj and isinstance(obj["data"], dict):
|
|
819
|
+
outer = obj["data"].get("message", {})
|
|
820
|
+
if isinstance(outer, dict):
|
|
821
|
+
msg = outer.get("message", outer)
|
|
822
|
+
if not isinstance(msg, dict):
|
|
823
|
+
continue
|
|
824
|
+
|
|
825
|
+
role = msg.get("role", entry_type)
|
|
826
|
+
if role not in ("user", "assistant"):
|
|
827
|
+
continue
|
|
828
|
+
|
|
829
|
+
content = msg.get("content", "")
|
|
830
|
+
if isinstance(content, str):
|
|
831
|
+
text = content.strip()
|
|
832
|
+
if text:
|
|
833
|
+
messages.append(
|
|
834
|
+
{
|
|
835
|
+
"agent": "claude",
|
|
836
|
+
"role": role,
|
|
837
|
+
"type": "text",
|
|
838
|
+
"content": text,
|
|
839
|
+
"line": lineno,
|
|
840
|
+
"timestamp": timestamp,
|
|
841
|
+
"file": str(path),
|
|
842
|
+
}
|
|
843
|
+
)
|
|
844
|
+
elif isinstance(content, list):
|
|
845
|
+
for block in content:
|
|
846
|
+
if not isinstance(block, dict):
|
|
847
|
+
continue
|
|
848
|
+
btype = block.get("type", "")
|
|
849
|
+
if btype == "text":
|
|
850
|
+
text = str(block.get("text", "")).strip()
|
|
851
|
+
if text:
|
|
852
|
+
messages.append(
|
|
853
|
+
{
|
|
854
|
+
"agent": "claude",
|
|
855
|
+
"role": role,
|
|
856
|
+
"type": "text",
|
|
857
|
+
"content": text,
|
|
858
|
+
"line": lineno,
|
|
859
|
+
"timestamp": timestamp,
|
|
860
|
+
"file": str(path),
|
|
861
|
+
}
|
|
862
|
+
)
|
|
863
|
+
elif btype == "tool_use" and show_tools:
|
|
864
|
+
name = str(block.get("name", "?"))
|
|
865
|
+
inp = block.get("input", {})
|
|
866
|
+
summary_parts: list[str] = []
|
|
867
|
+
if isinstance(inp, dict):
|
|
868
|
+
for key in (
|
|
869
|
+
"file_path",
|
|
870
|
+
"command",
|
|
871
|
+
"pattern",
|
|
872
|
+
"query",
|
|
873
|
+
"path",
|
|
874
|
+
"prompt",
|
|
875
|
+
):
|
|
876
|
+
if key in inp:
|
|
877
|
+
summary_parts.append(f"{key}={str(inp[key])[:120]}")
|
|
878
|
+
summary = (", ".join(summary_parts) if summary_parts else json.dumps(inp)[:200])
|
|
879
|
+
messages.append(
|
|
880
|
+
{
|
|
881
|
+
"agent": "claude",
|
|
882
|
+
"role": role,
|
|
883
|
+
"type": "tool_use",
|
|
884
|
+
"content": f"[TOOL: {name}] {summary}",
|
|
885
|
+
"line": lineno,
|
|
886
|
+
"timestamp": timestamp,
|
|
887
|
+
"file": str(path),
|
|
888
|
+
}
|
|
889
|
+
)
|
|
890
|
+
elif btype == "tool_result" and show_tools:
|
|
891
|
+
result = block.get("content", "")
|
|
892
|
+
parts = _extract_text(result)
|
|
893
|
+
text = " | ".join(parts)[:400]
|
|
894
|
+
if text:
|
|
895
|
+
messages.append(
|
|
896
|
+
{
|
|
897
|
+
"agent": "claude",
|
|
898
|
+
"role": role,
|
|
899
|
+
"type": "tool_result",
|
|
900
|
+
"content": f"[RESULT] {text}",
|
|
901
|
+
"line": lineno,
|
|
902
|
+
"timestamp": timestamp,
|
|
903
|
+
"file": str(path),
|
|
904
|
+
}
|
|
905
|
+
)
|
|
906
|
+
|
|
907
|
+
return messages
|
|
908
|
+
|
|
909
|
+
|
|
910
|
+
def parse_generic_jsonl(
|
|
911
|
+
path: Path,
|
|
912
|
+
agent: str,
|
|
913
|
+
show_tools: bool = False,
|
|
914
|
+
) -> list[dict[str, Any]]:
|
|
915
|
+
"""Best-effort parser for non-Claude session JSONL formats.
|
|
916
|
+
|
|
917
|
+
This handles common event records across Codex/Copilot/Gemini variants by
|
|
918
|
+
inspecting top-level type/role/message/content fields.
|
|
919
|
+
"""
|
|
920
|
+
messages: list[dict[str, Any]] = []
|
|
921
|
+
|
|
922
|
+
with path.open(encoding="utf-8", errors="replace") as handle:
|
|
923
|
+
for lineno, raw in enumerate(handle, 1):
|
|
924
|
+
raw = raw.strip()
|
|
925
|
+
if not raw:
|
|
926
|
+
continue
|
|
927
|
+
try:
|
|
928
|
+
obj = json.loads(raw)
|
|
929
|
+
except json.JSONDecodeError:
|
|
930
|
+
continue
|
|
931
|
+
|
|
932
|
+
timestamp = _normalize_timestamp(
|
|
933
|
+
obj.get("timestamp")
|
|
934
|
+
or obj.get("created_at")
|
|
935
|
+
or obj.get("time")
|
|
936
|
+
or obj.get("ts")
|
|
937
|
+
)
|
|
938
|
+
|
|
939
|
+
entry_type = str(obj.get("type", "")).lower()
|
|
940
|
+
role = str(obj.get("role", "")).lower()
|
|
941
|
+
payload = obj.get("payload")
|
|
942
|
+
|
|
943
|
+
if not role:
|
|
944
|
+
if entry_type in ("user", "assistant", "system"):
|
|
945
|
+
role = entry_type
|
|
946
|
+
elif entry_type.startswith("user"):
|
|
947
|
+
role = "user"
|
|
948
|
+
elif entry_type.startswith("assistant") or "agent" in entry_type:
|
|
949
|
+
role = "assistant"
|
|
950
|
+
elif entry_type.startswith("tool"):
|
|
951
|
+
role = "assistant"
|
|
952
|
+
|
|
953
|
+
if isinstance(payload, dict):
|
|
954
|
+
payload_role = payload.get("role")
|
|
955
|
+
if isinstance(payload_role, str):
|
|
956
|
+
role = payload_role.lower()
|
|
957
|
+
|
|
958
|
+
text_parts: list[str] = []
|
|
959
|
+
if isinstance(payload, dict):
|
|
960
|
+
# Common format in Codex sessions: payload.type=message with payload.content blocks.
|
|
961
|
+
if "content" in payload:
|
|
962
|
+
text_parts.extend(_extract_text(payload.get("content")))
|
|
963
|
+
elif "message" in payload:
|
|
964
|
+
text_parts.extend(_extract_text(payload.get("message")))
|
|
965
|
+
|
|
966
|
+
for key in (
|
|
967
|
+
"content",
|
|
968
|
+
"message",
|
|
969
|
+
"text",
|
|
970
|
+
"output",
|
|
971
|
+
"input",
|
|
972
|
+
"delta",
|
|
973
|
+
):
|
|
974
|
+
if key in obj:
|
|
975
|
+
text_parts.extend(_extract_text(obj.get(key)))
|
|
976
|
+
|
|
977
|
+
if not text_parts and isinstance(obj.get("data"), dict):
|
|
978
|
+
text_parts.extend(_extract_text(obj.get("data")))
|
|
979
|
+
|
|
980
|
+
text = "\n".join(part for part in text_parts if part).strip()
|
|
981
|
+
if not text:
|
|
982
|
+
continue
|
|
983
|
+
|
|
984
|
+
msg_type = "text"
|
|
985
|
+
if "tool" in entry_type:
|
|
986
|
+
if not show_tools:
|
|
987
|
+
continue
|
|
988
|
+
msg_type = "tool_use" if "result" not in entry_type else "tool_result"
|
|
989
|
+
|
|
990
|
+
messages.append(
|
|
991
|
+
{
|
|
992
|
+
"agent": agent,
|
|
993
|
+
"role": role if role in ("user", "assistant") else "assistant",
|
|
994
|
+
"type": msg_type,
|
|
995
|
+
"content": text[:2000],
|
|
996
|
+
"line": lineno,
|
|
997
|
+
"timestamp": timestamp,
|
|
998
|
+
"file": str(path),
|
|
999
|
+
}
|
|
1000
|
+
)
|
|
1001
|
+
|
|
1002
|
+
return messages
|
|
1003
|
+
|
|
1004
|
+
|
|
1005
|
+
def parse_session_file(session: SessionFile, show_tools: bool = False) -> list[dict[str, Any]]:
|
|
1006
|
+
if session.agent == "claude":
|
|
1007
|
+
return parse_claude_jsonl(session.path, show_tools=show_tools)
|
|
1008
|
+
if session.agent == "gemini" and session.path.suffix == ".json":
|
|
1009
|
+
return parse_gemini_chat_json(session.path)
|
|
1010
|
+
return parse_generic_jsonl(session.path, session.agent, show_tools=show_tools)
|
|
1011
|
+
|
|
1012
|
+
|
|
1013
|
+
def format_message(message: dict[str, Any], raw: bool = False) -> str:
|
|
1014
|
+
role = str(message.get("role", "assistant")).upper()
|
|
1015
|
+
mtype = str(message.get("type", "text"))
|
|
1016
|
+
agent = str(message.get("agent", "unknown")).upper()
|
|
1017
|
+
line = int(message.get("line", 0))
|
|
1018
|
+
content = str(message.get("content", ""))
|
|
1019
|
+
|
|
1020
|
+
if raw:
|
|
1021
|
+
return f"[{agent} L{line}] {role}: {content}"
|
|
1022
|
+
|
|
1023
|
+
if role == "USER":
|
|
1024
|
+
color = "\033[36m"
|
|
1025
|
+
elif mtype == "tool_use":
|
|
1026
|
+
color = "\033[33m"
|
|
1027
|
+
elif mtype == "tool_result":
|
|
1028
|
+
color = "\033[90m"
|
|
1029
|
+
else:
|
|
1030
|
+
color = "\033[32m"
|
|
1031
|
+
reset = "\033[0m"
|
|
1032
|
+
|
|
1033
|
+
label = f"[{agent} L{line}] {role}"
|
|
1034
|
+
if mtype != "text":
|
|
1035
|
+
label += f" ({mtype})"
|
|
1036
|
+
|
|
1037
|
+
if len(content) > 2000:
|
|
1038
|
+
content = content[:2000] + "\n... [truncated]"
|
|
1039
|
+
|
|
1040
|
+
return f"{color}{label}:{reset} {content}"
|
|
1041
|
+
|
|
1042
|
+
|
|
1043
|
+
def _list_sessions(sessions: Iterable[SessionFile]) -> int:
|
|
1044
|
+
rows = sorted(sessions, key=lambda s: s.mtime, reverse=True)
|
|
1045
|
+
if not rows:
|
|
1046
|
+
print("No session files found.", file=sys.stderr)
|
|
1047
|
+
return 1
|
|
1048
|
+
|
|
1049
|
+
try:
|
|
1050
|
+
print(f"{'Agent':<8} {'Modified':<19} {'Size':>10} File")
|
|
1051
|
+
print("-" * 90)
|
|
1052
|
+
for session in rows:
|
|
1053
|
+
mtime = datetime.fromtimestamp(session.mtime).strftime("%Y-%m-%d %H:%M:%S")
|
|
1054
|
+
print(
|
|
1055
|
+
f"{session.agent:<8} {mtime:<19} {_format_size(session.size):>10} {session.path}"
|
|
1056
|
+
)
|
|
1057
|
+
except BrokenPipeError:
|
|
1058
|
+
return 0
|
|
1059
|
+
return 0
|
|
1060
|
+
|
|
1061
|
+
|
|
1062
|
+
def main(argv: Optional[list[str]] = None) -> int:
|
|
1063
|
+
parser = argparse.ArgumentParser(
|
|
1064
|
+
description="Browse conversation history across Claude, Codex, Copilot, and Gemini.",
|
|
1065
|
+
)
|
|
1066
|
+
parser.add_argument(
|
|
1067
|
+
"target",
|
|
1068
|
+
nargs="?",
|
|
1069
|
+
default="",
|
|
1070
|
+
help="Optional project dir or direct .jsonl file path.",
|
|
1071
|
+
)
|
|
1072
|
+
parser.add_argument(
|
|
1073
|
+
"--agent",
|
|
1074
|
+
choices=["all", *AGENTS],
|
|
1075
|
+
default="all",
|
|
1076
|
+
help="Filter to one agent (default: all).",
|
|
1077
|
+
)
|
|
1078
|
+
parser.add_argument(
|
|
1079
|
+
"--all",
|
|
1080
|
+
action="store_true",
|
|
1081
|
+
help="Merge messages from all discovered sessions instead of only latest.",
|
|
1082
|
+
)
|
|
1083
|
+
parser.add_argument(
|
|
1084
|
+
"--list",
|
|
1085
|
+
"-l",
|
|
1086
|
+
action="store_true",
|
|
1087
|
+
help="List discovered session files and exit.",
|
|
1088
|
+
)
|
|
1089
|
+
parser.add_argument(
|
|
1090
|
+
"--grep",
|
|
1091
|
+
"-g",
|
|
1092
|
+
default="",
|
|
1093
|
+
help="Only show messages containing this substring (case-insensitive).",
|
|
1094
|
+
)
|
|
1095
|
+
parser.add_argument(
|
|
1096
|
+
"--tail",
|
|
1097
|
+
"-n",
|
|
1098
|
+
type=int,
|
|
1099
|
+
default=0,
|
|
1100
|
+
help="Only show the last N messages.",
|
|
1101
|
+
)
|
|
1102
|
+
parser.add_argument(
|
|
1103
|
+
"--tools",
|
|
1104
|
+
"-t",
|
|
1105
|
+
action="store_true",
|
|
1106
|
+
help="Include tool_use/tool_result messages where available.",
|
|
1107
|
+
)
|
|
1108
|
+
parser.add_argument(
|
|
1109
|
+
"--raw",
|
|
1110
|
+
action="store_true",
|
|
1111
|
+
help="Disable ANSI color output.",
|
|
1112
|
+
)
|
|
1113
|
+
|
|
1114
|
+
# Session store (SQL) flags
|
|
1115
|
+
sql_group = parser.add_argument_group(
|
|
1116
|
+
"session store (SQL)",
|
|
1117
|
+
"Query the Copilot CLI session store database instead of JSONL files.",
|
|
1118
|
+
)
|
|
1119
|
+
sql_group.add_argument(
|
|
1120
|
+
"--sql",
|
|
1121
|
+
action="store_true",
|
|
1122
|
+
help="Use the session store database (SQL) instead of JSONL file discovery.",
|
|
1123
|
+
)
|
|
1124
|
+
sql_group.add_argument(
|
|
1125
|
+
"--db",
|
|
1126
|
+
default="",
|
|
1127
|
+
help="Path to session store .db file (default: ~/.copilot/session-store.db).",
|
|
1128
|
+
)
|
|
1129
|
+
sql_group.add_argument(
|
|
1130
|
+
"--session-id",
|
|
1131
|
+
default="",
|
|
1132
|
+
help="Show turns for a specific session ID from the store.",
|
|
1133
|
+
)
|
|
1134
|
+
sql_group.add_argument(
|
|
1135
|
+
"--search",
|
|
1136
|
+
default="",
|
|
1137
|
+
help="Full-text search (FTS5) across the session store.",
|
|
1138
|
+
)
|
|
1139
|
+
sql_group.add_argument(
|
|
1140
|
+
"--files",
|
|
1141
|
+
default=None,
|
|
1142
|
+
help="List files touched in the store, optionally filtered by pattern.",
|
|
1143
|
+
metavar="PATTERN",
|
|
1144
|
+
nargs="?",
|
|
1145
|
+
const="*",
|
|
1146
|
+
)
|
|
1147
|
+
sql_group.add_argument(
|
|
1148
|
+
"--checkpoints",
|
|
1149
|
+
action="store_true",
|
|
1150
|
+
help="Show checkpoints for the given --session-id.",
|
|
1151
|
+
)
|
|
1152
|
+
args = parser.parse_args(argv)
|
|
1153
|
+
|
|
1154
|
+
# ── SQL mode ──────────────────────────────────────────────────────
|
|
1155
|
+
if args.sql or args.search or args.session_id or args.db or args.checkpoints or args.files is not None:
|
|
1156
|
+
db_path = find_session_store_db(args.db)
|
|
1157
|
+
if db_path is None:
|
|
1158
|
+
print("Session store database not found. Pass --db or ensure ~/.copilot/session-store.db exists.", file=sys.stderr)
|
|
1159
|
+
return 1
|
|
1160
|
+
|
|
1161
|
+
# FTS5 search
|
|
1162
|
+
if args.search:
|
|
1163
|
+
results = search_store(db_path, args.search, limit=args.tail or 30)
|
|
1164
|
+
if not results:
|
|
1165
|
+
print("No search results.", file=sys.stderr)
|
|
1166
|
+
return 0
|
|
1167
|
+
print(f"Session store: {db_path}", file=sys.stderr)
|
|
1168
|
+
print(f"{len(results)} search result(s) for: {args.search}", file=sys.stderr)
|
|
1169
|
+
print("---", file=sys.stderr)
|
|
1170
|
+
try:
|
|
1171
|
+
for msg in results:
|
|
1172
|
+
sid = msg.get("session_id", "?")
|
|
1173
|
+
stype = msg.get("source_type", "?")
|
|
1174
|
+
content = str(msg.get("content", ""))
|
|
1175
|
+
if args.tail and len(content) > 300:
|
|
1176
|
+
content = content[:300] + "..."
|
|
1177
|
+
if args.raw:
|
|
1178
|
+
print(f"[{sid[:8]} {stype}] {content}")
|
|
1179
|
+
else:
|
|
1180
|
+
print(f"\033[33m[{sid[:8]} {stype}]\033[0m {content}")
|
|
1181
|
+
print()
|
|
1182
|
+
except BrokenPipeError:
|
|
1183
|
+
pass
|
|
1184
|
+
return 0
|
|
1185
|
+
|
|
1186
|
+
# Checkpoints for a session
|
|
1187
|
+
if args.checkpoints:
|
|
1188
|
+
if not args.session_id:
|
|
1189
|
+
print("--checkpoints requires --session-id.", file=sys.stderr)
|
|
1190
|
+
return 1
|
|
1191
|
+
cps = query_store_checkpoints(db_path, args.session_id)
|
|
1192
|
+
if not cps:
|
|
1193
|
+
print("No checkpoints found.", file=sys.stderr)
|
|
1194
|
+
return 0
|
|
1195
|
+
print(f"Session store: {db_path}", file=sys.stderr)
|
|
1196
|
+
print(f"{len(cps)} checkpoint(s) for session {args.session_id[:12]}...", file=sys.stderr)
|
|
1197
|
+
print("---", file=sys.stderr)
|
|
1198
|
+
try:
|
|
1199
|
+
for cp in cps:
|
|
1200
|
+
num = cp.get("checkpoint_number", "?")
|
|
1201
|
+
title = cp.get("title", "(untitled)")
|
|
1202
|
+
overview = cp.get("overview", "")
|
|
1203
|
+
work = cp.get("work_done", "")
|
|
1204
|
+
nexts = cp.get("next_steps", "")
|
|
1205
|
+
print(f"── Checkpoint {num}: {title} ──")
|
|
1206
|
+
if overview:
|
|
1207
|
+
print(f" Overview: {overview[:500]}")
|
|
1208
|
+
if work:
|
|
1209
|
+
print(f" Work done: {work[:500]}")
|
|
1210
|
+
if nexts:
|
|
1211
|
+
print(f" Next steps: {nexts[:500]}")
|
|
1212
|
+
print()
|
|
1213
|
+
except BrokenPipeError:
|
|
1214
|
+
pass
|
|
1215
|
+
return 0
|
|
1216
|
+
|
|
1217
|
+
# Files touched
|
|
1218
|
+
if args.files is not None:
|
|
1219
|
+
pattern = args.files if args.files != "*" else ""
|
|
1220
|
+
files = query_store_files(db_path, session_id=args.session_id, file_pattern=pattern)
|
|
1221
|
+
if not files:
|
|
1222
|
+
print("No files found.", file=sys.stderr)
|
|
1223
|
+
return 0
|
|
1224
|
+
print(f"Session store: {db_path}", file=sys.stderr)
|
|
1225
|
+
print(f"{'Session':<40} {'Tool':<8} {'First Seen':<20} File", file=sys.stderr)
|
|
1226
|
+
print("-" * 110, file=sys.stderr)
|
|
1227
|
+
try:
|
|
1228
|
+
for f in files:
|
|
1229
|
+
sid = (f.get("session_id") or "?")[:36]
|
|
1230
|
+
tool = f.get("tool_name") or "?"
|
|
1231
|
+
seen = (f.get("first_seen_at") or "?")[:19]
|
|
1232
|
+
fp = f.get("file_path") or "?"
|
|
1233
|
+
print(f"{sid:<40} {tool:<8} {seen:<20} {fp}")
|
|
1234
|
+
except BrokenPipeError:
|
|
1235
|
+
pass
|
|
1236
|
+
return 0
|
|
1237
|
+
|
|
1238
|
+
# List sessions or show turns
|
|
1239
|
+
if args.list or (not args.session_id):
|
|
1240
|
+
cwd_filter = ""
|
|
1241
|
+
if args.target:
|
|
1242
|
+
cwd_filter = _normalize_cwd(args.target)
|
|
1243
|
+
sessions_list = list_store_sessions(
|
|
1244
|
+
db_path, cwd=cwd_filter, limit=args.tail or 50,
|
|
1245
|
+
)
|
|
1246
|
+
return _list_store_sessions(db_path, sessions_list)
|
|
1247
|
+
|
|
1248
|
+
# Show turns for a specific session
|
|
1249
|
+
messages = query_store_turns(
|
|
1250
|
+
db_path, session_id=args.session_id, grep=args.grep,
|
|
1251
|
+
limit=args.tail or 200,
|
|
1252
|
+
)
|
|
1253
|
+
if not messages:
|
|
1254
|
+
print("No turns found for this session.", file=sys.stderr)
|
|
1255
|
+
return 0
|
|
1256
|
+
|
|
1257
|
+
print(f"Session store: {db_path}", file=sys.stderr)
|
|
1258
|
+
print(f"Showing {len(messages)} message(s) for session {args.session_id[:12]}...", file=sys.stderr)
|
|
1259
|
+
print("---", file=sys.stderr)
|
|
1260
|
+
try:
|
|
1261
|
+
for message in messages:
|
|
1262
|
+
print(format_message(message, raw=args.raw))
|
|
1263
|
+
print()
|
|
1264
|
+
except BrokenPipeError:
|
|
1265
|
+
pass
|
|
1266
|
+
return 0
|
|
1267
|
+
|
|
1268
|
+
# ── Legacy mode (JSONL file discovery) ────────────────────────────
|
|
1269
|
+
sessions = discover_sessions(target=args.target, agent=args.agent)
|
|
1270
|
+
|
|
1271
|
+
if not sessions and args.agent not in ("all", "gemini"):
|
|
1272
|
+
print("No sessions found for the given filters.", file=sys.stderr)
|
|
1273
|
+
return 1
|
|
1274
|
+
|
|
1275
|
+
if args.list:
|
|
1276
|
+
if not sessions:
|
|
1277
|
+
# Check if we have traffic log entries to justify a "Gemini" presence
|
|
1278
|
+
traffic_turns = query_traffic_turns(TRAFFIC_DB_PATH, agent="gemini", limit=1)
|
|
1279
|
+
if traffic_turns:
|
|
1280
|
+
print(f"{'Agent':<8} {'Modified':<19} {'Size':>10} File")
|
|
1281
|
+
print("-" * 90)
|
|
1282
|
+
print(f"{'gemini':<8} {'(from traffic)':<19} {'-':>10} {TRAFFIC_DB_PATH}")
|
|
1283
|
+
return 0
|
|
1284
|
+
print("No sessions found for the given filters.", file=sys.stderr)
|
|
1285
|
+
return 1
|
|
1286
|
+
return _list_sessions(sessions)
|
|
1287
|
+
|
|
1288
|
+
chosen = sessions if args.all else ([max(sessions, key=lambda s: s.mtime)] if sessions else [])
|
|
1289
|
+
|
|
1290
|
+
parsed_messages: list[dict[str, Any]] = []
|
|
1291
|
+
for session in chosen:
|
|
1292
|
+
parsed_messages.extend(parse_session_file(session, show_tools=args.tools))
|
|
1293
|
+
|
|
1294
|
+
# ── Gemini traffic log integration ──────────────────────────────
|
|
1295
|
+
if args.agent in ("all", "gemini"):
|
|
1296
|
+
traffic_messages = query_traffic_turns(TRAFFIC_DB_PATH, agent="gemini", limit=args.tail or 50)
|
|
1297
|
+
parsed_messages.extend(traffic_messages)
|
|
1298
|
+
|
|
1299
|
+
if args.all or args.agent in ("all", "gemini"):
|
|
1300
|
+
parsed_messages.sort(
|
|
1301
|
+
key=lambda m: (
|
|
1302
|
+
_timestamp_for_sorting(str(m.get("timestamp", ""))),
|
|
1303
|
+
str(m.get("file", "")),
|
|
1304
|
+
int(m.get("line", 0)),
|
|
1305
|
+
)
|
|
1306
|
+
)
|
|
1307
|
+
|
|
1308
|
+
if args.grep:
|
|
1309
|
+
needle = args.grep.lower()
|
|
1310
|
+
parsed_messages = [
|
|
1311
|
+
m for m in parsed_messages if needle in str(m.get("content", "")).lower()
|
|
1312
|
+
]
|
|
1313
|
+
|
|
1314
|
+
if args.tail > 0:
|
|
1315
|
+
parsed_messages = parsed_messages[-args.tail :]
|
|
1316
|
+
|
|
1317
|
+
if not parsed_messages:
|
|
1318
|
+
print("No messages found matching your criteria.", file=sys.stderr)
|
|
1319
|
+
return 0
|
|
1320
|
+
|
|
1321
|
+
if chosen:
|
|
1322
|
+
latest = max(chosen, key=lambda s: s.mtime)
|
|
1323
|
+
print(f"Session file: {latest.path}", file=sys.stderr)
|
|
1324
|
+
elif args.agent in ("all", "gemini"):
|
|
1325
|
+
print(f"Session source: {TRAFFIC_DB_PATH} (Gemini traffic)", file=sys.stderr)
|
|
1326
|
+
|
|
1327
|
+
print(
|
|
1328
|
+
f"Showing {len(parsed_messages)} message(s) from {len(chosen)} session file(s).",
|
|
1329
|
+
file=sys.stderr,
|
|
1330
|
+
)
|
|
1331
|
+
print("---", file=sys.stderr)
|
|
1332
|
+
|
|
1333
|
+
try:
|
|
1334
|
+
for message in parsed_messages:
|
|
1335
|
+
print(format_message(message, raw=args.raw))
|
|
1336
|
+
print()
|
|
1337
|
+
except BrokenPipeError:
|
|
1338
|
+
return 0
|
|
1339
|
+
|
|
1340
|
+
return 0
|
|
1341
|
+
|
|
1342
|
+
|
|
1343
|
+
if __name__ == "__main__":
|
|
1344
|
+
raise SystemExit(main())
|