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.
- copilot_session_tools/__init__.py +72 -0
- copilot_session_tools/cli.py +993 -0
- copilot_session_tools/database.py +1848 -0
- copilot_session_tools/html_exporter.py +270 -0
- copilot_session_tools/markdown_exporter.py +426 -0
- copilot_session_tools/py.typed +0 -0
- copilot_session_tools/scanner/__init__.py +73 -0
- copilot_session_tools/scanner/cli.py +606 -0
- copilot_session_tools/scanner/content.py +313 -0
- copilot_session_tools/scanner/diff.py +297 -0
- copilot_session_tools/scanner/discovery.py +365 -0
- copilot_session_tools/scanner/git.py +114 -0
- copilot_session_tools/scanner/models.py +122 -0
- copilot_session_tools/scanner/vscode.py +693 -0
- copilot_session_tools/web/__init__.py +107 -0
- copilot_session_tools/web/templates/error.html +61 -0
- copilot_session_tools/web/templates/index.html +1072 -0
- copilot_session_tools/web/templates/session.html +1592 -0
- copilot_session_tools/web/webapp.py +610 -0
- copilot_session_tools-0.1.1.dist-info/METADATA +375 -0
- copilot_session_tools-0.1.1.dist-info/RECORD +24 -0
- copilot_session_tools-0.1.1.dist-info/WHEEL +4 -0
- copilot_session_tools-0.1.1.dist-info/entry_points.txt +3 -0
- copilot_session_tools-0.1.1.dist-info/licenses/LICENSE +21 -0
|
@@ -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
|