fast-resume 1.12.8__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,216 @@
1
+ """Codex CLI session adapter."""
2
+
3
+ import orjson
4
+ from datetime import datetime
5
+ from pathlib import Path
6
+
7
+ from ..config import AGENTS, CODEX_DIR
8
+ from ..logging_config import log_parse_error
9
+ from .base import BaseSessionAdapter, ErrorCallback, ParseError, Session, truncate_title
10
+
11
+
12
+ class CodexAdapter(BaseSessionAdapter):
13
+ """Adapter for Codex CLI sessions."""
14
+
15
+ name = "codex"
16
+ color = AGENTS["codex"]["color"]
17
+ badge = AGENTS["codex"]["badge"]
18
+ supports_yolo = True
19
+
20
+ def __init__(self, sessions_dir: Path | None = None) -> None:
21
+ self._sessions_dir = sessions_dir if sessions_dir is not None else CODEX_DIR
22
+
23
+ def find_sessions(self) -> list[Session]:
24
+ """Find all Codex CLI sessions."""
25
+ if not self.is_available():
26
+ return []
27
+
28
+ sessions = []
29
+ # Codex stores sessions in YYYY/MM/DD subdirectories
30
+ for session_file in self._sessions_dir.rglob("*.jsonl"):
31
+ session = self._parse_session_file(session_file)
32
+ if session:
33
+ sessions.append(session)
34
+
35
+ return sessions
36
+
37
+ def _parse_session_file(
38
+ self, session_file: Path, on_error: ErrorCallback = None
39
+ ) -> Session | None:
40
+ """Parse a Codex CLI session file."""
41
+ try:
42
+ session_id = ""
43
+ directory = ""
44
+ timestamp = datetime.fromtimestamp(session_file.stat().st_mtime)
45
+ messages: list[str] = []
46
+ user_prompts: list[str] = [] # Actual human inputs for title
47
+ turn_count = 0 # Count user + assistant turns
48
+ yolo = False # Track if session was started in yolo mode
49
+
50
+ with open(session_file, "rb") as f:
51
+ for line in f:
52
+ if not line.strip():
53
+ continue
54
+ try:
55
+ data = orjson.loads(line)
56
+ except orjson.JSONDecodeError:
57
+ # Skip malformed lines within the file
58
+ continue
59
+
60
+ msg_type = data.get("type", "")
61
+ payload = data.get("payload", {})
62
+
63
+ # Get session metadata
64
+ if msg_type == "session_meta":
65
+ session_id = payload.get("id", "")
66
+ directory = payload.get("cwd", "")
67
+
68
+ # Check turn_context for yolo mode
69
+ if msg_type == "turn_context":
70
+ approval_policy = payload.get("approval_policy", "")
71
+ sandbox_policy = payload.get("sandbox_policy", {})
72
+ sandbox_mode = (
73
+ sandbox_policy.get("mode", "")
74
+ if isinstance(sandbox_policy, dict)
75
+ else ""
76
+ )
77
+ if (
78
+ approval_policy == "never"
79
+ or sandbox_mode == "danger-full-access"
80
+ ):
81
+ yolo = True
82
+
83
+ # Extract response items for preview content
84
+ if msg_type == "response_item":
85
+ role = payload.get("role", "")
86
+ content = payload.get("content", [])
87
+ if role in ("user", "assistant"):
88
+ role_prefix = "» " if role == "user" else " "
89
+ has_text = False
90
+ for part in content:
91
+ if isinstance(part, dict):
92
+ text = part.get("text", "") or part.get(
93
+ "input_text", ""
94
+ )
95
+ if text:
96
+ # Skip system context for content
97
+ if not text.strip().startswith(
98
+ "<environment_context>"
99
+ ):
100
+ messages.append(f"{role_prefix}{text}")
101
+ has_text = True
102
+ if has_text:
103
+ turn_count += 1
104
+
105
+ # Extract event messages (user prompts) - actual human inputs
106
+ if msg_type == "event_msg":
107
+ event_type = payload.get("type", "")
108
+ if event_type == "user_message":
109
+ msg = payload.get("message", "")
110
+ if msg:
111
+ messages.append(f"» {msg}")
112
+ user_prompts.append(msg)
113
+ elif event_type == "agent_reasoning":
114
+ text = payload.get("text", "")
115
+ if text:
116
+ messages.append(f" {text}")
117
+
118
+ if not session_id:
119
+ # Extract from filename: rollout-2025-12-17T18-24-27-019b2d57-...
120
+ session_id = (
121
+ session_file.stem.split("-", 1)[-1]
122
+ if "-" in session_file.stem
123
+ else session_file.stem
124
+ )
125
+
126
+ # Skip sessions with no actual user prompt
127
+ if not user_prompts:
128
+ return None
129
+
130
+ # Generate title from first actual user prompt (80-char hard truncate)
131
+ title = truncate_title(user_prompts[0], max_length=80, word_break=False)
132
+
133
+ full_content = "\n\n".join(messages)
134
+
135
+ return Session(
136
+ id=session_id,
137
+ agent=self.name,
138
+ title=title,
139
+ directory=directory,
140
+ timestamp=timestamp,
141
+ content=full_content,
142
+ message_count=turn_count,
143
+ yolo=yolo,
144
+ )
145
+ except OSError as e:
146
+ error = ParseError(
147
+ agent=self.name,
148
+ file_path=str(session_file),
149
+ error_type="OSError",
150
+ message=str(e),
151
+ )
152
+ log_parse_error(
153
+ error.agent, error.file_path, error.error_type, error.message
154
+ )
155
+ if on_error:
156
+ on_error(error)
157
+ return None
158
+ except (KeyError, TypeError, AttributeError) as e:
159
+ error = ParseError(
160
+ agent=self.name,
161
+ file_path=str(session_file),
162
+ error_type=type(e).__name__,
163
+ message=str(e),
164
+ )
165
+ log_parse_error(
166
+ error.agent, error.file_path, error.error_type, error.message
167
+ )
168
+ if on_error:
169
+ on_error(error)
170
+ return None
171
+
172
+ def _get_session_id_from_file(self, session_file: Path) -> str:
173
+ """Extract session ID from file content or filename."""
174
+ # Try to get ID from session_meta in file content first
175
+ try:
176
+ with open(session_file, "rb") as f:
177
+ for line in f:
178
+ if not line.strip():
179
+ continue
180
+ try:
181
+ data = orjson.loads(line)
182
+ if data.get("type") == "session_meta":
183
+ session_id = data.get("payload", {}).get("id", "")
184
+ if session_id:
185
+ return session_id
186
+ break
187
+ except orjson.JSONDecodeError:
188
+ continue
189
+ except Exception:
190
+ pass
191
+
192
+ # Fallback to filename extraction
193
+ return (
194
+ session_file.stem.split("-", 1)[-1]
195
+ if "-" in session_file.stem
196
+ else session_file.stem
197
+ )
198
+
199
+ def _scan_session_files(self) -> dict[str, tuple[Path, float]]:
200
+ """Scan all Codex CLI session files."""
201
+ current_files: dict[str, tuple[Path, float]] = {}
202
+
203
+ for session_file in self._sessions_dir.rglob("*.jsonl"):
204
+ session_id = self._get_session_id_from_file(session_file)
205
+ mtime = session_file.stat().st_mtime
206
+ current_files[session_id] = (session_file, mtime)
207
+
208
+ return current_files
209
+
210
+ def get_resume_command(self, session: Session, yolo: bool = False) -> list[str]:
211
+ """Get command to resume a Codex CLI session."""
212
+ cmd = ["codex"]
213
+ if yolo:
214
+ cmd.append("--dangerously-bypass-approvals-and-sandbox")
215
+ cmd.extend(["resume", session.id])
216
+ return cmd
@@ -0,0 +1,176 @@
1
+ """GitHub Copilot CLI session adapter."""
2
+
3
+ import orjson
4
+ import re
5
+ from datetime import datetime
6
+ from pathlib import Path
7
+
8
+ from ..config import AGENTS, COPILOT_DIR
9
+ from ..logging_config import log_parse_error
10
+ from .base import BaseSessionAdapter, ErrorCallback, ParseError, Session, truncate_title
11
+
12
+
13
+ class CopilotAdapter(BaseSessionAdapter):
14
+ """Adapter for GitHub Copilot CLI sessions."""
15
+
16
+ name = "copilot-cli"
17
+ color = AGENTS["copilot-cli"]["color"]
18
+ badge = AGENTS["copilot-cli"]["badge"]
19
+ supports_yolo = True
20
+
21
+ def __init__(self, sessions_dir: Path | None = None) -> None:
22
+ self._sessions_dir = sessions_dir if sessions_dir is not None else COPILOT_DIR
23
+
24
+ def find_sessions(self) -> list[Session]:
25
+ """Find all Copilot CLI sessions."""
26
+ if not self.is_available():
27
+ return []
28
+
29
+ sessions = []
30
+ for session_file in self._sessions_dir.glob("*.jsonl"):
31
+ session = self._parse_session_file(session_file)
32
+ if session:
33
+ sessions.append(session)
34
+
35
+ return sessions
36
+
37
+ def _parse_session_file(
38
+ self, session_file: Path, on_error: ErrorCallback = None
39
+ ) -> Session | None:
40
+ """Parse a Copilot CLI session file."""
41
+ try:
42
+ session_id = session_file.stem
43
+ first_user_message = ""
44
+ directory = ""
45
+ timestamp = datetime.fromtimestamp(session_file.stat().st_mtime)
46
+ messages: list[str] = []
47
+ turn_count = 0
48
+
49
+ with open(session_file, "rb") as f:
50
+ for line in f:
51
+ if not line.strip():
52
+ continue
53
+ try:
54
+ entry = orjson.loads(line)
55
+ except orjson.JSONDecodeError:
56
+ # Skip malformed lines within the file
57
+ continue
58
+
59
+ msg_type = entry.get("type", "")
60
+ data = entry.get("data", {})
61
+
62
+ # Get session ID from session.start
63
+ if msg_type == "session.start":
64
+ session_id = data.get("sessionId", session_id)
65
+
66
+ # Get directory from folder_trust info
67
+ if msg_type == "session.info" and not directory:
68
+ if data.get("infoType") == "folder_trust":
69
+ # Extract path from message like "Folder /path/to/dir has been added..."
70
+ message = data.get("message", "")
71
+ match = re.search(r"Folder (/[^\s]+)", message)
72
+ if match:
73
+ directory = match.group(1)
74
+
75
+ # Process user messages
76
+ if msg_type == "user.message":
77
+ content = data.get("content", "")
78
+ if content:
79
+ messages.append(f"» {content}")
80
+ turn_count += 1
81
+ if not first_user_message and len(content) > 10:
82
+ first_user_message = content
83
+
84
+ # Process assistant messages
85
+ if msg_type == "assistant.message":
86
+ content = data.get("content", "")
87
+ if content:
88
+ messages.append(f" {content}")
89
+ turn_count += 1
90
+
91
+ # Skip sessions with no actual user message
92
+ if not first_user_message:
93
+ return None
94
+
95
+ # Use first user message as title
96
+ title = truncate_title(first_user_message)
97
+
98
+ # Skip sessions with no actual conversation content
99
+ if not messages:
100
+ return None
101
+
102
+ full_content = "\n\n".join(messages)
103
+
104
+ return Session(
105
+ id=session_id,
106
+ agent=self.name,
107
+ title=title,
108
+ directory=directory,
109
+ timestamp=timestamp,
110
+ content=full_content,
111
+ message_count=turn_count,
112
+ )
113
+ except OSError as e:
114
+ error = ParseError(
115
+ agent=self.name,
116
+ file_path=str(session_file),
117
+ error_type="OSError",
118
+ message=str(e),
119
+ )
120
+ log_parse_error(
121
+ error.agent, error.file_path, error.error_type, error.message
122
+ )
123
+ if on_error:
124
+ on_error(error)
125
+ return None
126
+ except (KeyError, TypeError, AttributeError) as e:
127
+ error = ParseError(
128
+ agent=self.name,
129
+ file_path=str(session_file),
130
+ error_type=type(e).__name__,
131
+ message=str(e),
132
+ )
133
+ log_parse_error(
134
+ error.agent, error.file_path, error.error_type, error.message
135
+ )
136
+ if on_error:
137
+ on_error(error)
138
+ return None
139
+
140
+ def _get_session_id_from_file(self, session_file: Path) -> str:
141
+ """Extract session ID from file content or filename."""
142
+ try:
143
+ with open(session_file, "rb") as f:
144
+ for line in f:
145
+ if not line.strip():
146
+ continue
147
+ try:
148
+ entry = orjson.loads(line)
149
+ if entry.get("type") == "session.start":
150
+ session_id = entry.get("data", {}).get("sessionId", "")
151
+ if session_id:
152
+ return session_id
153
+ except orjson.JSONDecodeError:
154
+ continue
155
+ except Exception:
156
+ pass
157
+ return session_file.stem
158
+
159
+ def _scan_session_files(self) -> dict[str, tuple[Path, float]]:
160
+ """Scan all Copilot CLI session files."""
161
+ current_files: dict[str, tuple[Path, float]] = {}
162
+
163
+ for session_file in self._sessions_dir.glob("*.jsonl"):
164
+ session_id = self._get_session_id_from_file(session_file)
165
+ mtime = session_file.stat().st_mtime
166
+ current_files[session_id] = (session_file, mtime)
167
+
168
+ return current_files
169
+
170
+ def get_resume_command(self, session: Session, yolo: bool = False) -> list[str]:
171
+ """Get command to resume a Copilot CLI session."""
172
+ cmd = ["copilot"]
173
+ if yolo:
174
+ cmd.extend(["--allow-all-tools", "--allow-all-paths"])
175
+ cmd.extend(["--resume", session.id])
176
+ return cmd