copilot-session-tools 0.1.1__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,365 @@
1
+ """Session file discovery, scanning, and parsing dispatch."""
2
+
3
+ import os
4
+ import platform
5
+ from collections.abc import Iterator
6
+ from pathlib import Path
7
+ from urllib.parse import unquote
8
+
9
+ import orjson
10
+
11
+ from .cli import _parse_cli_jsonl_file
12
+ from .models import ChatSession, SessionFileInfo
13
+ from .vscode import _parse_chat_session_file, _parse_vscdb_file, _parse_vscode_jsonl_file
14
+
15
+
16
+ def get_vscode_storage_paths() -> list[tuple[str, str]]:
17
+ """Get the paths to VS Code workspace storage directories.
18
+
19
+ Returns a list of tuples: (path, edition) where edition is 'stable' or 'insider'.
20
+ """
21
+ system = platform.system()
22
+
23
+ paths = []
24
+
25
+ if system == "Windows":
26
+ appdata = os.environ.get("APPDATA", "")
27
+ if appdata:
28
+ # VS Code Stable
29
+ stable_path = Path(appdata) / "Code" / "User" / "workspaceStorage"
30
+ paths.append((str(stable_path), "stable"))
31
+ # VS Code Insiders
32
+ insider_path = Path(appdata) / "Code - Insiders" / "User" / "workspaceStorage"
33
+ paths.append((str(insider_path), "insider"))
34
+ elif system == "Darwin": # macOS
35
+ home = Path.home()
36
+ # VS Code Stable
37
+ stable_path = home / "Library" / "Application Support" / "Code" / "User" / "workspaceStorage"
38
+ paths.append((str(stable_path), "stable"))
39
+ # VS Code Insiders
40
+ insider_path = home / "Library" / "Application Support" / "Code - Insiders" / "User" / "workspaceStorage"
41
+ paths.append((str(insider_path), "insider"))
42
+ else: # Linux and others
43
+ home = Path.home()
44
+ # VS Code Stable
45
+ stable_path = home / ".config" / "Code" / "User" / "workspaceStorage"
46
+ paths.append((str(stable_path), "stable"))
47
+ # VS Code Insiders
48
+ insider_path = home / ".config" / "Code - Insiders" / "User" / "workspaceStorage"
49
+ paths.append((str(insider_path), "insider"))
50
+
51
+ return paths
52
+
53
+
54
+ def find_copilot_chat_dirs(
55
+ storage_paths: list[tuple[str, str]] | None = None,
56
+ ) -> Iterator[tuple[Path, str, str]]:
57
+ """Find directories containing Copilot chat sessions.
58
+
59
+ Args:
60
+ storage_paths: Optional list of (path, edition) tuples to search.
61
+ If None, uses default VS Code storage paths.
62
+
63
+ Yields:
64
+ Tuples of (chat_dir_path, workspace_id, edition)
65
+ """
66
+ if storage_paths is None:
67
+ storage_paths = get_vscode_storage_paths()
68
+
69
+ for storage_path, edition in storage_paths:
70
+ storage_dir = Path(storage_path)
71
+ if not storage_dir.exists():
72
+ continue
73
+
74
+ # Each subdirectory is a workspace
75
+ for workspace_dir in storage_dir.iterdir():
76
+ if not workspace_dir.is_dir():
77
+ continue
78
+
79
+ workspace_id = workspace_dir.name
80
+
81
+ # Look for Copilot chat sessions - they may be in different locations
82
+ # depending on the VS Code and Copilot extension versions
83
+
84
+ # Check for github.copilot-chat extension storage
85
+ copilot_chat_dir = workspace_dir / "state.vscdb.backup" # Some versions use this
86
+ if copilot_chat_dir.exists():
87
+ yield copilot_chat_dir.parent, workspace_id, edition
88
+
89
+ # Check for chatSessions directory (newer format)
90
+ chat_sessions_dir = workspace_dir / "chatSessions"
91
+ if chat_sessions_dir.exists() and chat_sessions_dir.is_dir():
92
+ yield chat_sessions_dir, workspace_id, edition
93
+
94
+ # Check for workspaceState file
95
+ workspace_state = workspace_dir / "workspace.json"
96
+ if workspace_state.exists():
97
+ yield workspace_dir, workspace_id, edition
98
+
99
+
100
+ def _parse_workspace_json(workspace_dir: Path) -> tuple[str | None, str | None]:
101
+ """Parse workspace.json to get workspace name and path."""
102
+ workspace_json = workspace_dir / "workspace.json"
103
+ if workspace_json.exists():
104
+ try:
105
+ with workspace_json.open("rb") as f:
106
+ data = orjson.loads(f.read())
107
+ folder = data.get("folder", "")
108
+ # folder is often a URI like file:///path/to/workspace
109
+ if folder.startswith("file://"):
110
+ folder = folder[7:]
111
+ if platform.system() == "Windows" and folder.startswith("/"):
112
+ # Windows paths like /C:/path
113
+ folder = folder[1:]
114
+ # URL decode the path (e.g., %3A -> :, %20 -> space)
115
+ folder = unquote(folder) if folder else ""
116
+ workspace_name = Path(folder).name if folder else None
117
+ return workspace_name, folder if folder else None
118
+ except (orjson.JSONDecodeError, OSError):
119
+ pass
120
+ return None, None
121
+
122
+
123
+ def get_cli_storage_paths() -> list[Path]:
124
+ """Get the paths to GitHub Copilot CLI session storage directories.
125
+
126
+ Returns a list of Path objects for CLI session directories.
127
+ """
128
+ home = Path.home()
129
+ copilot_dir = home / ".copilot"
130
+
131
+ paths = []
132
+
133
+ # Current format (v0.0.342+)
134
+ session_state_dir = copilot_dir / "session-state"
135
+ if session_state_dir.exists():
136
+ paths.append(session_state_dir)
137
+
138
+ # Legacy format (pre-v0.0.342)
139
+ history_state_dir = copilot_dir / "history-session-state"
140
+ if history_state_dir.exists():
141
+ paths.append(history_state_dir)
142
+
143
+ return paths
144
+
145
+
146
+ def scan_chat_sessions(
147
+ storage_paths: list[tuple[str, str]] | None = None,
148
+ include_cli: bool = True,
149
+ ) -> Iterator[ChatSession]:
150
+ """Scan for and parse all Copilot chat sessions.
151
+
152
+ Args:
153
+ storage_paths: Optional list of (path, edition) tuples to search for VS Code sessions.
154
+ include_cli: Whether to also scan for CLI sessions (default: True).
155
+
156
+ Yields:
157
+ ChatSession objects for each found session.
158
+ """
159
+ # Scan VS Code chat sessions
160
+ for chat_dir, _workspace_id, edition in find_copilot_chat_dirs(storage_paths):
161
+ # Get workspace info
162
+ workspace_name, workspace_path = _parse_workspace_json(chat_dir.parent)
163
+
164
+ # Process JSON files in the directory
165
+ for item in chat_dir.iterdir():
166
+ if item.is_file():
167
+ if item.suffix == ".json":
168
+ session = _parse_chat_session_file(item, workspace_name, workspace_path, edition)
169
+ if session:
170
+ yield session
171
+ elif item.suffix == ".jsonl":
172
+ session = _parse_vscode_jsonl_file(item, workspace_name, workspace_path, edition)
173
+ if session:
174
+ yield session
175
+ elif item.suffix == ".vscdb":
176
+ # Parse SQLite database files
177
+ sessions = _parse_vscdb_file(item, workspace_name, workspace_path, edition)
178
+ for session in sessions:
179
+ yield session
180
+
181
+ # Also check for state.vscdb in parent directory
182
+ state_db = chat_dir.parent / "state.vscdb"
183
+ if state_db.exists():
184
+ sessions = _parse_vscdb_file(state_db, workspace_name, workspace_path, edition)
185
+ for session in sessions:
186
+ yield session
187
+
188
+ # Scan CLI chat sessions
189
+ if include_cli:
190
+ for cli_dir in get_cli_storage_paths():
191
+ if not cli_dir.exists() or not cli_dir.is_dir():
192
+ continue
193
+
194
+ # Process CLI storage directory - supports two formats:
195
+ # 1. Old format: {session-id}.jsonl files directly in the directory
196
+ # 2. New format: {session-id}/events.jsonl subdirectories
197
+ for item in cli_dir.iterdir():
198
+ if item.is_file() and item.suffix == ".jsonl":
199
+ # Old format: flat JSONL files
200
+ session = _parse_cli_jsonl_file(item)
201
+ if session:
202
+ yield session
203
+ elif item.is_dir():
204
+ # New format: subdirectory with events.jsonl
205
+ events_file = item / "events.jsonl"
206
+ if events_file.exists():
207
+ session = _parse_cli_jsonl_file(events_file)
208
+ if session:
209
+ yield session
210
+
211
+
212
+ def scan_session_files(
213
+ storage_paths: list[tuple[str, str]] | None = None,
214
+ include_cli: bool = True,
215
+ ) -> Iterator[SessionFileInfo]:
216
+ """Scan for session files and yield metadata without parsing content.
217
+
218
+ This allows callers to check mtime/size before expensive parsing.
219
+ Use parse_session_file() to parse a specific file.
220
+
221
+ Args:
222
+ storage_paths: Optional list of (path, edition) tuples to search for VS Code sessions.
223
+ include_cli: Whether to also scan for CLI sessions (default: True).
224
+
225
+ Yields:
226
+ SessionFileInfo objects with file metadata.
227
+ """
228
+ # Scan VS Code chat session files
229
+ for chat_dir, _workspace_id, edition in find_copilot_chat_dirs(storage_paths):
230
+ workspace_name, workspace_path = _parse_workspace_json(chat_dir.parent)
231
+
232
+ # Process files in the chat directory
233
+ for item in chat_dir.iterdir():
234
+ if item.is_file():
235
+ try:
236
+ stat = item.stat()
237
+ if item.suffix == ".json":
238
+ yield SessionFileInfo(
239
+ file_path=item,
240
+ file_type="json",
241
+ session_type="vscode",
242
+ vscode_edition=edition,
243
+ mtime=stat.st_mtime,
244
+ size=stat.st_size,
245
+ workspace_name=workspace_name,
246
+ workspace_path=workspace_path,
247
+ )
248
+ elif item.suffix == ".jsonl":
249
+ yield SessionFileInfo(
250
+ file_path=item,
251
+ file_type="jsonl",
252
+ session_type="vscode",
253
+ vscode_edition=edition,
254
+ mtime=stat.st_mtime,
255
+ size=stat.st_size,
256
+ workspace_name=workspace_name,
257
+ workspace_path=workspace_path,
258
+ )
259
+ elif item.suffix == ".vscdb":
260
+ yield SessionFileInfo(
261
+ file_path=item,
262
+ file_type="vscdb",
263
+ session_type="vscode",
264
+ vscode_edition=edition,
265
+ mtime=stat.st_mtime,
266
+ size=stat.st_size,
267
+ workspace_name=workspace_name,
268
+ workspace_path=workspace_path,
269
+ )
270
+ except OSError:
271
+ continue
272
+
273
+ # Check for state.vscdb in parent directory
274
+ state_db = chat_dir.parent / "state.vscdb"
275
+ if state_db.exists():
276
+ try:
277
+ stat = state_db.stat()
278
+ yield SessionFileInfo(
279
+ file_path=state_db,
280
+ file_type="vscdb",
281
+ session_type="vscode",
282
+ vscode_edition=edition,
283
+ mtime=stat.st_mtime,
284
+ size=stat.st_size,
285
+ workspace_name=workspace_name,
286
+ workspace_path=workspace_path,
287
+ )
288
+ except OSError:
289
+ pass
290
+
291
+ # Scan CLI session files
292
+ if include_cli:
293
+ for cli_dir in get_cli_storage_paths():
294
+ if not cli_dir.exists() or not cli_dir.is_dir():
295
+ continue
296
+
297
+ for item in cli_dir.iterdir():
298
+ try:
299
+ if item.is_file() and item.suffix == ".jsonl":
300
+ stat = item.stat()
301
+ yield SessionFileInfo(
302
+ file_path=item,
303
+ file_type="jsonl",
304
+ session_type="cli",
305
+ vscode_edition="cli",
306
+ mtime=stat.st_mtime,
307
+ size=stat.st_size,
308
+ )
309
+ elif item.is_dir():
310
+ events_file = item / "events.jsonl"
311
+ if events_file.exists():
312
+ stat = events_file.stat()
313
+ yield SessionFileInfo(
314
+ file_path=events_file,
315
+ file_type="jsonl",
316
+ session_type="cli",
317
+ vscode_edition="cli",
318
+ mtime=stat.st_mtime,
319
+ size=stat.st_size,
320
+ )
321
+ except OSError:
322
+ continue
323
+
324
+
325
+ def parse_session_file(file_info: SessionFileInfo) -> list[ChatSession]:
326
+ """Parse a session file and return ChatSession objects.
327
+
328
+ Args:
329
+ file_info: SessionFileInfo from scan_session_files().
330
+
331
+ Returns:
332
+ List of ChatSession objects (may be multiple for vscdb files).
333
+ """
334
+ if file_info.file_type == "json":
335
+ session = _parse_chat_session_file(
336
+ file_info.file_path,
337
+ file_info.workspace_name,
338
+ file_info.workspace_path,
339
+ file_info.vscode_edition,
340
+ )
341
+ return [session] if session else []
342
+
343
+ elif file_info.file_type == "vscdb":
344
+ return list(
345
+ _parse_vscdb_file(
346
+ file_info.file_path,
347
+ file_info.workspace_name,
348
+ file_info.workspace_path,
349
+ file_info.vscode_edition,
350
+ )
351
+ )
352
+
353
+ elif file_info.file_type == "jsonl":
354
+ if file_info.session_type == "vscode":
355
+ session = _parse_vscode_jsonl_file(
356
+ file_info.file_path,
357
+ file_info.workspace_name,
358
+ file_info.workspace_path,
359
+ file_info.vscode_edition,
360
+ )
361
+ else:
362
+ session = _parse_cli_jsonl_file(file_info.file_path)
363
+ return [session] if session else []
364
+
365
+ return []
@@ -0,0 +1,114 @@
1
+ """Git repository URL detection and normalization."""
2
+
3
+ import re
4
+ import subprocess
5
+ from pathlib import Path
6
+
7
+ # Cache for detect_repository_url to avoid repeated subprocess calls
8
+ _repository_url_cache: dict[str, str | None] = {}
9
+
10
+
11
+ def _clear_repository_url_cache() -> None:
12
+ """Clear the repository URL cache (for testing)."""
13
+ _repository_url_cache.clear()
14
+
15
+
16
+ def detect_repository_url(workspace_path: str | None) -> str | None:
17
+ """Detect the git repository URL from a workspace path.
18
+
19
+ Results are cached to avoid repeated subprocess calls for the same workspace.
20
+
21
+ This function looks for a .git directory and reads the remote origin URL.
22
+ This enables repository-scoped memories that work across multiple worktrees
23
+ of the same repository.
24
+
25
+ Args:
26
+ workspace_path: Path to the workspace directory.
27
+
28
+ Returns:
29
+ The normalized repository URL (e.g., "github.com/owner/repo"), or None
30
+ if no git repository is found or no remote is configured.
31
+ """
32
+ if not workspace_path:
33
+ return None
34
+
35
+ # Check cache first
36
+ if workspace_path in _repository_url_cache:
37
+ return _repository_url_cache[workspace_path]
38
+
39
+ workspace = Path(workspace_path)
40
+ result_url: str | None = None
41
+
42
+ # Check if this is a git repository (handles both regular repos and worktrees)
43
+ try:
44
+ # Use git rev-parse to find the actual git directory
45
+ # This works for both regular repos and worktrees
46
+ result = subprocess.run(
47
+ ["git", "rev-parse", "--git-dir"], # noqa: S607 - trusted git command
48
+ cwd=str(workspace),
49
+ capture_output=True,
50
+ text=True,
51
+ timeout=5,
52
+ check=False,
53
+ )
54
+ if result.returncode == 0:
55
+ # Get the remote origin URL
56
+ result = subprocess.run(
57
+ ["git", "config", "--get", "remote.origin.url"], # noqa: S607 - trusted git command
58
+ cwd=str(workspace),
59
+ capture_output=True,
60
+ text=True,
61
+ timeout=5,
62
+ check=False,
63
+ )
64
+ if result.returncode == 0:
65
+ remote_url = result.stdout.strip()
66
+ if remote_url:
67
+ result_url = _normalize_git_url(remote_url)
68
+
69
+ except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
70
+ pass
71
+
72
+ # Cache the result (including None)
73
+ _repository_url_cache[workspace_path] = result_url
74
+ return result_url
75
+
76
+
77
+ def _normalize_git_url(url: str) -> str:
78
+ """Normalize a git remote URL to a consistent format.
79
+
80
+ Converts various URL formats to a normalized form:
81
+ - https://github.com/owner/repo.git -> github.com/owner/repo
82
+ - git@github.com:owner/repo.git -> github.com/owner/repo
83
+ - ssh://git@github.com/owner/repo.git -> github.com/owner/repo
84
+
85
+ Args:
86
+ url: The raw git remote URL.
87
+
88
+ Returns:
89
+ Normalized URL string in format "host/owner/repo".
90
+ """
91
+ # Remove trailing .git
92
+ url = url.rstrip("/")
93
+ url = url.removesuffix(".git")
94
+
95
+ # Handle SSH format: git@github.com:owner/repo
96
+ ssh_match = re.match(r"^git@([^:]+):(.+)$", url)
97
+ if ssh_match:
98
+ host, path = ssh_match.groups()
99
+ return f"{host}/{path}"
100
+
101
+ # Handle SSH URL format: ssh://git@github.com/owner/repo
102
+ ssh_url_match = re.match(r"^ssh://(?:git@)?([^/]+)/(.+)$", url)
103
+ if ssh_url_match:
104
+ host, path = ssh_url_match.groups()
105
+ return f"{host}/{path}"
106
+
107
+ # Handle HTTPS format: https://github.com/owner/repo
108
+ https_match = re.match(r"^https?://([^/]+)/(.+)$", url)
109
+ if https_match:
110
+ host, path = https_match.groups()
111
+ return f"{host}/{path}"
112
+
113
+ # Return as-is if we can't parse it
114
+ return url
@@ -0,0 +1,122 @@
1
+ """Data models for Copilot chat session scanning."""
2
+
3
+ from dataclasses import dataclass, field
4
+ from pathlib import Path
5
+
6
+
7
+ @dataclass
8
+ class ToolInvocation:
9
+ """Represents a tool invocation in a chat response.
10
+
11
+ Based on ChatToolInvocation from Arbuzov/copilot-chat-history.
12
+ """
13
+
14
+ name: str
15
+ input: str | None = None
16
+ result: str | None = None
17
+ status: str | None = None
18
+ start_time: int | None = None
19
+ end_time: int | None = None
20
+ source_type: str | None = None # 'mcp' or 'internal'
21
+ invocation_message: str | None = None # Pretty display message (e.g., "Reading file.txt, lines 1 to 100")
22
+
23
+
24
+ @dataclass
25
+ class FileChange:
26
+ """Represents a file change in a chat response.
27
+
28
+ Based on ChatFileChange from Arbuzov/copilot-chat-history.
29
+ """
30
+
31
+ path: str
32
+ diff: str | None = None
33
+ content: str | None = None
34
+ explanation: str | None = None
35
+ language_id: str | None = None
36
+
37
+
38
+ @dataclass
39
+ class CommandRun:
40
+ """Represents a command execution in a chat response.
41
+
42
+ Based on ChatCommandRun from Arbuzov/copilot-chat-history.
43
+ """
44
+
45
+ command: str
46
+ title: str | None = None
47
+ result: str | None = None
48
+ status: str | None = None
49
+ output: str | None = None
50
+ timestamp: int | None = None
51
+
52
+
53
+ @dataclass
54
+ class ContentBlock:
55
+ """Represents a content block in an assistant response.
56
+
57
+ Each block has a kind (e.g., 'text', 'thinking', 'tool') and content.
58
+ This allows differentiation between thinking/reasoning and regular output.
59
+ """
60
+
61
+ kind: str # 'text', 'thinking', 'tool', 'promptFile', etc.
62
+ content: str
63
+ description: str | None = None # Optional description (e.g., generatedTitle for thinking blocks)
64
+
65
+
66
+ @dataclass
67
+ class ChatMessage:
68
+ """Represents a single message in a chat session.
69
+
70
+ Enhanced based on ChatMessage/ChatResponseItem from Arbuzov/copilot-chat-history.
71
+ """
72
+
73
+ role: str # 'user' or 'assistant'
74
+ content: str
75
+ timestamp: str | None = None
76
+ tool_invocations: list[ToolInvocation] = field(default_factory=list)
77
+ file_changes: list[FileChange] = field(default_factory=list)
78
+ command_runs: list[CommandRun] = field(default_factory=list)
79
+ content_blocks: list[ContentBlock] = field(default_factory=list) # Structured content with kind
80
+ cached_markdown: str | None = None # Pre-computed markdown for this message
81
+
82
+
83
+ @dataclass
84
+ class ChatSession:
85
+ """Represents a Copilot chat session.
86
+
87
+ Based on ChatSession/ChatSessionData from Arbuzov/copilot-chat-history.
88
+ """
89
+
90
+ session_id: str
91
+ workspace_name: str | None
92
+ workspace_path: str | None
93
+ messages: list[ChatMessage]
94
+ created_at: str | None = None
95
+ updated_at: str | None = None
96
+ source_file: str | None = None
97
+ vscode_edition: str = "stable" # 'stable' or 'insider'
98
+ custom_title: str | None = None
99
+ requester_username: str | None = None
100
+ responder_username: str | None = None
101
+ source_file_mtime: float | None = None # File modification time for incremental refresh
102
+ source_file_size: int | None = None # File size in bytes for incremental refresh
103
+ type: str = "vscode" # 'vscode' or 'cli'
104
+ raw_json: bytes | None = None # Original raw JSON bytes from source file
105
+ repository_url: str | None = None # Git remote URL for repository-scoped memories
106
+
107
+
108
+ @dataclass
109
+ class SessionFileInfo:
110
+ """Lightweight metadata about a session file for incremental scanning.
111
+
112
+ This allows checking mtime/size before expensive parsing.
113
+ """
114
+
115
+ file_path: Path
116
+ file_type: str # 'json', 'vscdb', 'jsonl'
117
+ session_type: str # 'vscode' or 'cli'
118
+ vscode_edition: str # 'stable', 'insider', or 'cli'
119
+ mtime: float
120
+ size: int
121
+ workspace_name: str | None = None
122
+ workspace_path: str | None = None