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.
- fast_resume/__init__.py +5 -0
- fast_resume/adapters/__init__.py +25 -0
- fast_resume/adapters/base.py +263 -0
- fast_resume/adapters/claude.py +209 -0
- fast_resume/adapters/codex.py +216 -0
- fast_resume/adapters/copilot.py +176 -0
- fast_resume/adapters/copilot_vscode.py +326 -0
- fast_resume/adapters/crush.py +341 -0
- fast_resume/adapters/opencode.py +333 -0
- fast_resume/adapters/vibe.py +188 -0
- fast_resume/assets/claude.png +0 -0
- fast_resume/assets/codex.png +0 -0
- fast_resume/assets/copilot-cli.png +0 -0
- fast_resume/assets/copilot-vscode.png +0 -0
- fast_resume/assets/crush.png +0 -0
- fast_resume/assets/opencode.png +0 -0
- fast_resume/assets/vibe.png +0 -0
- fast_resume/cli.py +327 -0
- fast_resume/config.py +30 -0
- fast_resume/index.py +758 -0
- fast_resume/logging_config.py +57 -0
- fast_resume/query.py +264 -0
- fast_resume/search.py +281 -0
- fast_resume/tui/__init__.py +58 -0
- fast_resume/tui/app.py +629 -0
- fast_resume/tui/filter_bar.py +128 -0
- fast_resume/tui/modal.py +73 -0
- fast_resume/tui/preview.py +396 -0
- fast_resume/tui/query.py +86 -0
- fast_resume/tui/results_table.py +178 -0
- fast_resume/tui/search_input.py +117 -0
- fast_resume/tui/styles.py +302 -0
- fast_resume/tui/utils.py +160 -0
- fast_resume-1.12.8.dist-info/METADATA +545 -0
- fast_resume-1.12.8.dist-info/RECORD +38 -0
- fast_resume-1.12.8.dist-info/WHEEL +4 -0
- fast_resume-1.12.8.dist-info/entry_points.txt +3 -0
- fast_resume-1.12.8.dist-info/licenses/LICENSE +21 -0
|
@@ -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
|