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/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())