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,5 @@
1
+ """fast-resume: Fuzzy finder for coding agent session history."""
2
+
3
+ from importlib.metadata import version
4
+
5
+ __version__ = version("fast-resume")
@@ -0,0 +1,25 @@
1
+ """Agent adapters for different coding tools."""
2
+
3
+ from .base import AgentAdapter, ErrorCallback, ParseError, RawAdapterStats, Session
4
+ from .claude import ClaudeAdapter
5
+ from .codex import CodexAdapter
6
+ from .copilot import CopilotAdapter
7
+ from .copilot_vscode import CopilotVSCodeAdapter
8
+ from .crush import CrushAdapter
9
+ from .opencode import OpenCodeAdapter
10
+ from .vibe import VibeAdapter
11
+
12
+ __all__ = [
13
+ "AgentAdapter",
14
+ "ErrorCallback",
15
+ "ParseError",
16
+ "RawAdapterStats",
17
+ "Session",
18
+ "ClaudeAdapter",
19
+ "CodexAdapter",
20
+ "CopilotAdapter",
21
+ "CopilotVSCodeAdapter",
22
+ "CrushAdapter",
23
+ "OpenCodeAdapter",
24
+ "VibeAdapter",
25
+ ]
@@ -0,0 +1,263 @@
1
+ """Base protocol and abstract class for agent adapters."""
2
+
3
+ from abc import ABC, abstractmethod
4
+ from collections.abc import Callable
5
+ from dataclasses import dataclass
6
+ from datetime import datetime
7
+ import logging
8
+ from pathlib import Path
9
+ from typing import Protocol
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+ # 1ms tolerance for mtime comparison due to datetime precision loss
14
+ MTIME_TOLERANCE = 0.001
15
+
16
+
17
+ def truncate_title(text: str, max_length: int = 100, word_break: bool = True) -> str:
18
+ """Truncate title text with optional word-break.
19
+
20
+ Args:
21
+ text: The text to truncate
22
+ max_length: Maximum length before truncation (default 100)
23
+ word_break: If True, break at last word boundary; if False, hard truncate
24
+
25
+ Returns:
26
+ Truncated title with "..." suffix if text exceeded max_length
27
+ """
28
+ text = text.strip()
29
+ if len(text) <= max_length:
30
+ return text
31
+
32
+ truncated = text[:max_length]
33
+ if word_break:
34
+ # Break at last word boundary
35
+ truncated = truncated.rsplit(" ", 1)[0]
36
+ return truncated + "..."
37
+
38
+
39
+ @dataclass
40
+ class Session:
41
+ """Represents a coding agent session."""
42
+
43
+ id: str
44
+ agent: str # "claude", "codex", "crush", "opencode", "vibe"
45
+ title: str
46
+ directory: str
47
+ timestamp: datetime
48
+ content: str # Full searchable content
49
+ message_count: int = 0 # Number of user + assistant messages
50
+ mtime: float = 0.0 # File modification time for incremental updates
51
+ yolo: bool = False # Session was started with auto-approve/skip-permissions
52
+
53
+
54
+ @dataclass
55
+ class RawAdapterStats:
56
+ """Raw statistics from an adapter's data folder."""
57
+
58
+ agent: str
59
+ data_dir: str # Path to the data directory
60
+ available: bool # Whether the data directory exists
61
+ file_count: int # Number of session files
62
+ total_bytes: int # Total size in bytes
63
+
64
+
65
+ @dataclass
66
+ class ParseError:
67
+ """Represents a session parsing error."""
68
+
69
+ agent: str # Which adapter encountered the error
70
+ file_path: str # Path to the problematic file
71
+ error_type: str # e.g., "JSONDecodeError", "KeyError", "OSError"
72
+ message: str # Human-readable error message
73
+
74
+
75
+ # Type alias for error callback
76
+ ErrorCallback = Callable[["ParseError"], None] | None
77
+
78
+
79
+ class AgentAdapter(Protocol):
80
+ """Protocol for agent-specific session adapters."""
81
+
82
+ name: str
83
+ color: str
84
+ badge: str
85
+
86
+ def find_sessions(self) -> list[Session]:
87
+ """Find all sessions for this agent."""
88
+ ...
89
+
90
+ def find_sessions_incremental(
91
+ self,
92
+ known: dict[str, tuple[float, str]],
93
+ on_error: ErrorCallback = None,
94
+ ) -> tuple[list[Session], list[str]]:
95
+ """Find sessions incrementally, comparing against known sessions.
96
+
97
+ Args:
98
+ known: Dict mapping session_id to (mtime, agent_name) tuple
99
+ on_error: Optional callback for parse errors
100
+
101
+ Returns:
102
+ Tuple of (new_or_modified sessions, deleted session IDs)
103
+ """
104
+ ...
105
+
106
+ def get_resume_command(self, session: "Session", yolo: bool = False) -> list[str]:
107
+ """Get the command to resume a session.
108
+
109
+ Args:
110
+ session: The session to resume
111
+ yolo: If True, add auto-approve/skip-permissions flags
112
+ """
113
+ ...
114
+
115
+ def is_available(self) -> bool:
116
+ """Check if this agent's data directory exists."""
117
+ ...
118
+
119
+ def get_raw_stats(self) -> RawAdapterStats:
120
+ """Get raw statistics from the adapter's data folder."""
121
+ ...
122
+
123
+ @property
124
+ def supports_yolo(self) -> bool:
125
+ """Whether this adapter supports yolo mode in resume command."""
126
+ ...
127
+
128
+
129
+ class BaseSessionAdapter(ABC):
130
+ """Base class for file-based session adapters.
131
+
132
+ Provides a template method for find_sessions_incremental().
133
+ Used by Claude, Copilot, Codex, and Vibe adapters.
134
+
135
+ Subclasses implement:
136
+ - _scan_session_files(): Return dict of session_id -> (path, mtime)
137
+ - _parse_session_file(): Parse a single file into a Session
138
+ - find_sessions(): Find all sessions
139
+ - get_resume_command(): Command to resume a session
140
+ """
141
+
142
+ name: str
143
+ color: str
144
+ badge: str
145
+ _sessions_dir: Path
146
+
147
+ def is_available(self) -> bool:
148
+ """Check if data directory exists."""
149
+ return self._sessions_dir.exists()
150
+
151
+ @property
152
+ def supports_yolo(self) -> bool:
153
+ """Whether this adapter supports yolo mode in resume command.
154
+
155
+ Override in subclasses that support yolo flags.
156
+ """
157
+ return False
158
+
159
+ @abstractmethod
160
+ def _scan_session_files(self) -> dict[str, tuple[Path, float]]:
161
+ """Scan session files and return current state.
162
+
163
+ Returns:
164
+ Dict mapping session_id to (file_path, mtime) tuple
165
+ """
166
+ ...
167
+
168
+ @abstractmethod
169
+ def _parse_session_file(
170
+ self, session_file: Path, on_error: ErrorCallback = None
171
+ ) -> Session | None:
172
+ """Parse a single session file.
173
+
174
+ Args:
175
+ session_file: Path to the session file
176
+ on_error: Optional callback for parse errors
177
+
178
+ Returns:
179
+ Session object or None if parsing failed
180
+ """
181
+ ...
182
+
183
+ @abstractmethod
184
+ def find_sessions(self) -> list[Session]:
185
+ """Find all sessions for this agent."""
186
+ ...
187
+
188
+ @abstractmethod
189
+ def get_resume_command(self, session: Session, yolo: bool = False) -> list[str]:
190
+ """Get command to resume a session."""
191
+ ...
192
+
193
+ def find_sessions_incremental(
194
+ self,
195
+ known: dict[str, tuple[float, str]],
196
+ on_error: ErrorCallback = None,
197
+ ) -> tuple[list[Session], list[str]]:
198
+ """Find sessions incrementally using template method pattern.
199
+
200
+ Args:
201
+ known: Dict mapping session_id to (mtime, agent_name) tuple
202
+ on_error: Optional callback for parse errors
203
+
204
+ Returns:
205
+ Tuple of (new_or_modified sessions, deleted session IDs)
206
+ """
207
+ if not self.is_available():
208
+ # All known sessions from this agent are deleted
209
+ deleted_ids = [
210
+ sid for sid, (_, agent) in known.items() if agent == self.name
211
+ ]
212
+ return [], deleted_ids
213
+
214
+ # Scan current session files
215
+ current_files = self._scan_session_files()
216
+
217
+ # Find new and modified sessions
218
+ new_or_modified = []
219
+ for session_id, (path, mtime) in current_files.items():
220
+ known_entry = known.get(session_id)
221
+ if known_entry is None or mtime > known_entry[0] + MTIME_TOLERANCE:
222
+ session = self._parse_session_file(path, on_error=on_error)
223
+ if session:
224
+ session.mtime = mtime
225
+ new_or_modified.append(session)
226
+
227
+ # Find deleted sessions (in known but not in current, for this agent only)
228
+ current_ids = set(current_files.keys())
229
+ deleted_ids = [
230
+ sid
231
+ for sid, (_, agent) in known.items()
232
+ if agent == self.name and sid not in current_ids
233
+ ]
234
+
235
+ return new_or_modified, deleted_ids
236
+
237
+ def get_raw_stats(self) -> RawAdapterStats:
238
+ """Get raw statistics from the adapter's data folder."""
239
+ if not self.is_available():
240
+ return RawAdapterStats(
241
+ agent=self.name,
242
+ data_dir=str(self._sessions_dir),
243
+ available=False,
244
+ file_count=0,
245
+ total_bytes=0,
246
+ )
247
+
248
+ # Use _scan_session_files to get file info
249
+ files = self._scan_session_files()
250
+ total_bytes = 0
251
+ for path, _ in files.values():
252
+ try:
253
+ total_bytes += path.stat().st_size
254
+ except OSError:
255
+ pass
256
+
257
+ return RawAdapterStats(
258
+ agent=self.name,
259
+ data_dir=str(self._sessions_dir),
260
+ available=True,
261
+ file_count=len(files),
262
+ total_bytes=total_bytes,
263
+ )
@@ -0,0 +1,209 @@
1
+ """Claude Code session adapter."""
2
+
3
+ import orjson
4
+ from datetime import datetime
5
+ from pathlib import Path
6
+
7
+ from ..config import AGENTS, CLAUDE_DIR
8
+ from ..logging_config import log_parse_error
9
+ from .base import BaseSessionAdapter, ErrorCallback, ParseError, Session, truncate_title
10
+
11
+
12
+ class ClaudeAdapter(BaseSessionAdapter):
13
+ """Adapter for Claude Code sessions."""
14
+
15
+ name = "claude"
16
+ color = AGENTS["claude"]["color"]
17
+ badge = AGENTS["claude"]["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 CLAUDE_DIR
22
+
23
+ def find_sessions(self) -> list[Session]:
24
+ """Find all Claude Code sessions."""
25
+ if not self.is_available():
26
+ return []
27
+
28
+ sessions = []
29
+ for project_dir in self._sessions_dir.iterdir():
30
+ if not project_dir.is_dir():
31
+ continue
32
+
33
+ for session_file in project_dir.glob("*.jsonl"):
34
+ # Skip agent subprocesses
35
+ if session_file.name.startswith("agent-"):
36
+ continue
37
+
38
+ session = self._parse_session_file(session_file)
39
+ if session:
40
+ sessions.append(session)
41
+
42
+ return sessions
43
+
44
+ def _parse_session_file(
45
+ self, session_file: Path, on_error: ErrorCallback = None
46
+ ) -> Session | None:
47
+ """Parse a Claude Code session file."""
48
+ try:
49
+ first_user_message = ""
50
+ directory = ""
51
+ timestamp = datetime.fromtimestamp(session_file.stat().st_mtime)
52
+ messages: list[str] = []
53
+ # Count conversation turns (user + assistant, not tool results)
54
+ turn_count = 0
55
+
56
+ with open(session_file, "rb") as f:
57
+ for line in f:
58
+ if not line.strip():
59
+ continue
60
+ try:
61
+ data = orjson.loads(line)
62
+ except orjson.JSONDecodeError:
63
+ # Skip malformed lines within the file
64
+ continue
65
+
66
+ msg_type = data.get("type", "")
67
+
68
+ # Get directory from user message
69
+ if msg_type == "user" and not directory:
70
+ directory = data.get("cwd", "")
71
+
72
+ # Process user messages
73
+ if msg_type == "user":
74
+ msg = data.get("message", {})
75
+ content = msg.get("content", "")
76
+
77
+ # Check if this is a real human input or automatic tool result
78
+ is_human_input = False
79
+ if isinstance(content, str):
80
+ is_human_input = True
81
+ if not data.get("isMeta") and not content.startswith(
82
+ ("<command", "<local-command")
83
+ ):
84
+ messages.append(f"» {content}")
85
+ if not first_user_message and len(content) > 10:
86
+ first_user_message = content
87
+ elif isinstance(content, list):
88
+ # Check first part - if it's text (not tool_result), it's human
89
+ first_part = content[0] if content else {}
90
+ if isinstance(first_part, dict):
91
+ part_type = first_part.get("type", "")
92
+ if part_type == "text":
93
+ is_human_input = True
94
+ # tool_result means automatic response, not human input
95
+
96
+ for part in content:
97
+ if (
98
+ isinstance(part, dict)
99
+ and part.get("type") == "text"
100
+ ):
101
+ text = part.get("text", "")
102
+ messages.append(f"» {text}")
103
+ if not first_user_message:
104
+ first_user_message = text
105
+ elif isinstance(part, str):
106
+ messages.append(f"» {part}")
107
+
108
+ if is_human_input:
109
+ turn_count += 1
110
+
111
+ # Extract assistant content
112
+ if msg_type == "assistant":
113
+ msg = data.get("message", {})
114
+ content = msg.get("content", "")
115
+ has_text = False
116
+ if isinstance(content, str) and content:
117
+ messages.append(f" {content}")
118
+ has_text = True
119
+ elif isinstance(content, list):
120
+ for part in content:
121
+ if (
122
+ isinstance(part, dict)
123
+ and part.get("type") == "text"
124
+ ):
125
+ text = part.get("text", "")
126
+ if text:
127
+ messages.append(f" {text}")
128
+ has_text = True
129
+ elif isinstance(part, str):
130
+ messages.append(f" {part}")
131
+ has_text = True
132
+ if has_text:
133
+ turn_count += 1
134
+
135
+ # Skip sessions with no actual user message
136
+ if not first_user_message:
137
+ return None
138
+
139
+ # Always use first user message as title (matches Claude Code's Resume Session UI)
140
+ # The summary field is not a good title - it's often stale after session resume
141
+ title = truncate_title(first_user_message)
142
+
143
+ # Skip sessions with no actual conversation content
144
+ if not messages:
145
+ return None
146
+
147
+ full_content = "\n\n".join(messages)
148
+
149
+ return Session(
150
+ id=session_file.stem,
151
+ agent=self.name,
152
+ title=title,
153
+ directory=directory,
154
+ timestamp=timestamp,
155
+ content=full_content,
156
+ message_count=turn_count,
157
+ )
158
+ except OSError as e:
159
+ error = ParseError(
160
+ agent=self.name,
161
+ file_path=str(session_file),
162
+ error_type="OSError",
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
+ except (KeyError, TypeError, AttributeError) as e:
172
+ error = ParseError(
173
+ agent=self.name,
174
+ file_path=str(session_file),
175
+ error_type=type(e).__name__,
176
+ message=str(e),
177
+ )
178
+ log_parse_error(
179
+ error.agent, error.file_path, error.error_type, error.message
180
+ )
181
+ if on_error:
182
+ on_error(error)
183
+ return None
184
+
185
+ def _scan_session_files(self) -> dict[str, tuple[Path, float]]:
186
+ """Scan all Claude Code session files."""
187
+ current_files: dict[str, tuple[Path, float]] = {}
188
+
189
+ for project_dir in self._sessions_dir.iterdir():
190
+ if not project_dir.is_dir():
191
+ continue
192
+
193
+ for session_file in project_dir.glob("*.jsonl"):
194
+ if session_file.name.startswith("agent-"):
195
+ continue
196
+
197
+ session_id = session_file.stem
198
+ mtime = session_file.stat().st_mtime
199
+ current_files[session_id] = (session_file, mtime)
200
+
201
+ return current_files
202
+
203
+ def get_resume_command(self, session: Session, yolo: bool = False) -> list[str]:
204
+ """Get command to resume a Claude Code session."""
205
+ cmd = ["claude"]
206
+ if yolo:
207
+ cmd.append("--dangerously-skip-permissions")
208
+ cmd.extend(["--resume", session.id])
209
+ return cmd