copilot-session-usage 0.1.0__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,242 @@
1
+ """VS Code Copilot session discovery logic.
2
+
3
+ Implements workspace storage layout parsing, state.vscdb queries, and
4
+ session directory resolution for the VS Code Copilot extension.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import os
11
+ import sqlite3
12
+ import sys
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ # ─── Workspace DB cache ─────────────────────────────────────────────────────
17
+
18
+ _WS_DB_CACHE: dict[str, list[dict]] = {}
19
+
20
+
21
+ # ─── Workspace storage path resolution (cross-platform) ─────────────────────
22
+
23
+
24
+ def default_workspace_storage_roots() -> list[Path]:
25
+ r"""Return all existing workspaceStorage directories for the current platform.
26
+
27
+ Checked locations:
28
+
29
+ - macOS: ~/Library/Application Support/Code{,-Insiders}/User/workspaceStorage
30
+ - Windows: %APPDATA%\\Code{,-Insiders}\\User\\workspaceStorage
31
+ - Linux: $XDG_CONFIG_HOME/Code{,-Insiders}/User/workspaceStorage
32
+ ~/.vscode-server{,-insiders}/data/User/workspaceStorage (WSL2 / remote)
33
+
34
+ Only directories that actually exist are returned.
35
+ """
36
+ home = Path.home()
37
+ candidates: list[Path] = []
38
+
39
+ if sys.platform == "darwin":
40
+ base = home / "Library" / "Application Support"
41
+ for variant in ("Code", "Code - Insiders"):
42
+ candidates.append(base / variant / "User" / "workspaceStorage")
43
+
44
+ elif sys.platform == "win32":
45
+ appdata = Path(os.environ.get("APPDATA") or (home / "AppData" / "Roaming"))
46
+ for variant in ("Code", "Code - Insiders"):
47
+ candidates.append(appdata / variant / "User" / "workspaceStorage")
48
+
49
+ else:
50
+ xdg = Path(os.environ.get("XDG_CONFIG_HOME") or (home / ".config"))
51
+ for variant in ("Code", "Code - Insiders"):
52
+ candidates.append(xdg / variant / "User" / "workspaceStorage")
53
+ for variant in (".vscode-server", ".vscode-server-insiders"):
54
+ candidates.append(home / variant / "data" / "User" / "workspaceStorage")
55
+
56
+ return [p for p in candidates if p.is_dir()]
57
+
58
+
59
+ def _get_workspace_folder(ws_dir: Path) -> str:
60
+ """Return the actual folder path for a workspace storage directory."""
61
+ ws_json = ws_dir / "workspace.json"
62
+ if ws_json.exists():
63
+ try:
64
+ data: dict[str, Any] = json.loads(ws_json.read_text(encoding="utf-8"))
65
+ folder = data.get("folder") or data.get("workspace", "")
66
+ if isinstance(folder, str):
67
+ return folder.removeprefix("file://") if folder.startswith("file://") else folder
68
+ except (json.JSONDecodeError, OSError):
69
+ pass
70
+ return ""
71
+
72
+
73
+ def get_sessions_from_workspace(ws_dir: Path, use_cache: bool = True) -> list[dict]:
74
+ """Return session metadata from a workspace's state.vscdb.
75
+
76
+ When ``use_cache`` is True (the default), results are cached per
77
+ workspace directory so batch operations only read each DB once.
78
+ """
79
+ cache_key = str(ws_dir.resolve())
80
+ if use_cache and cache_key in _WS_DB_CACHE:
81
+ return _WS_DB_CACHE[cache_key]
82
+
83
+ db_path = ws_dir / "state.vscdb"
84
+ if not db_path.exists():
85
+ return []
86
+ try:
87
+ conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)
88
+ cur = conn.execute("SELECT value FROM ItemTable WHERE key = 'chat.ChatSessionStore.index'")
89
+ row = cur.fetchone()
90
+ conn.close()
91
+ except (sqlite3.OperationalError, sqlite3.DatabaseError):
92
+ return []
93
+ if not row:
94
+ return []
95
+ try:
96
+ data = json.loads(row[0])
97
+ except json.JSONDecodeError:
98
+ return []
99
+
100
+ workspace_folder = _get_workspace_folder(ws_dir)
101
+ sessions = []
102
+ for v in data.get("entries", {}).values():
103
+ session_id = v.get("sessionId", "")
104
+ if not session_id:
105
+ continue
106
+ debug_log_dir = ws_dir / "GitHub.copilot-chat/debug-logs" / session_id
107
+ sessions.append(
108
+ {
109
+ "session_id": session_id,
110
+ "title": v.get("title", ""),
111
+ "workspace_folder": workspace_folder,
112
+ "workspace_hash": ws_dir.name,
113
+ "created_ms": v.get("timing", {}).get("created"),
114
+ "last_message_ms": v.get("lastMessageDate"),
115
+ "has_debug_logs": debug_log_dir.exists(),
116
+ "debug_log_dir": str(debug_log_dir),
117
+ }
118
+ )
119
+
120
+ if use_cache:
121
+ _WS_DB_CACHE[cache_key] = sessions
122
+ return sessions
123
+
124
+
125
+ def find_session_dir_by_id(session_id: str, ws_roots: list[Path]) -> Path | None:
126
+ """Search ws_roots for a debug log directory matching session_id."""
127
+ for ws_dir in ws_roots:
128
+ if not ws_dir.is_dir():
129
+ continue
130
+ for candidate in ws_dir.iterdir():
131
+ if not candidate.is_dir():
132
+ continue
133
+ log_dir = candidate / "GitHub.copilot-chat" / "debug-logs" / session_id
134
+ if log_dir.exists():
135
+ return log_dir
136
+ return None
137
+
138
+
139
+ def find_sessions_by_title(title: str, ws_roots: list[Path]) -> list[dict]:
140
+ """Search ws_roots for sessions whose title contains the given string."""
141
+ lower = title.lower()
142
+ matches: list[dict] = []
143
+ for ws_dir in ws_roots:
144
+ if not ws_dir.is_dir():
145
+ continue
146
+ for ws_sub in ws_dir.iterdir():
147
+ if not ws_sub.is_dir():
148
+ continue
149
+ for session in get_sessions_from_workspace(ws_sub, use_cache=True):
150
+ if lower in session.get("title", "").lower():
151
+ matches.append(session)
152
+ return sorted(matches, key=lambda s: s.get("created_ms") or 0, reverse=True)
153
+
154
+
155
+ def find_latest_session_dir(
156
+ ws_roots: list[Path], workspace_filter: str | None = None
157
+ ) -> Path | None:
158
+ """Return the most recently modified debug log directory across ws_roots."""
159
+ latest: Path | None = None
160
+ latest_mtime = 0.0
161
+ for ws_dir in ws_roots:
162
+ if not ws_dir.is_dir():
163
+ continue
164
+ for ws_sub in ws_dir.iterdir():
165
+ if not ws_sub.is_dir():
166
+ continue
167
+ if workspace_filter:
168
+ folder = _get_workspace_folder(ws_sub)
169
+ if workspace_filter not in folder:
170
+ continue
171
+ debug_logs_base = ws_sub / "GitHub.copilot-chat" / "debug-logs"
172
+ if not debug_logs_base.exists():
173
+ continue
174
+ for session_dir in debug_logs_base.iterdir():
175
+ if not session_dir.is_dir():
176
+ continue
177
+ mtime = session_dir.stat().st_mtime
178
+ if mtime > latest_mtime:
179
+ latest_mtime = mtime
180
+ latest = session_dir
181
+ return latest
182
+
183
+
184
+ def list_recent_sessions(
185
+ ws_roots: list[Path],
186
+ limit: int = 20,
187
+ since_ms: int | None = None,
188
+ workspace_filter: str | None = None,
189
+ require_logs: bool = False,
190
+ ) -> list[dict]:
191
+ """List sessions from ws_roots, sorted most-recent first.
192
+
193
+ When ``require_logs`` is True, only sessions with existing debug logs
194
+ are returned (useful for batch analysis).
195
+ """
196
+ from copilot_session_usage._internal import core
197
+
198
+ all_sessions: list[dict] = []
199
+ for ws_dir in ws_roots:
200
+ if not ws_dir.is_dir():
201
+ continue
202
+ for ws_sub in ws_dir.iterdir():
203
+ if not ws_sub.is_dir():
204
+ continue
205
+ for session in get_sessions_from_workspace(ws_sub, use_cache=True):
206
+ if since_ms and (session.get("created_ms") or 0) < since_ms:
207
+ continue
208
+ if workspace_filter and workspace_filter not in session.get("workspace_folder", ""):
209
+ continue
210
+ if require_logs and not session.get("has_debug_logs"):
211
+ continue
212
+ session["created_at"] = core.ts_to_iso(session.get("created_ms"))
213
+ session["last_activity_at"] = core.ts_to_iso(session.get("last_message_ms"))
214
+ all_sessions.append(session)
215
+ all_sessions.sort(key=lambda s: s.get("created_ms") or 0, reverse=True)
216
+ return all_sessions[:limit]
217
+
218
+
219
+ def resolve_ws_roots(workspace_storage: str | None) -> list[Path]:
220
+ """Resolve workspaceStorage roots from an explicit override or auto-detection."""
221
+ if workspace_storage:
222
+ root = Path(workspace_storage)
223
+ if not root.is_dir():
224
+ msg = f"--workspace-storage path not found: {root}"
225
+ raise click.ClickException(msg)
226
+ return [root]
227
+ roots = default_workspace_storage_roots()
228
+ if not roots:
229
+ msg = (
230
+ "No workspaceStorage directory found for this platform.\n"
231
+ "Pass --workspace-storage PATH to specify the location manually.\n"
232
+ "Common paths:\n"
233
+ " macOS: ~/Library/Application Support/Code/User/workspaceStorage\n"
234
+ " Linux: ~/.config/Code/User/workspaceStorage\n"
235
+ " Windows: %APPDATA%\\Code\\User\\workspaceStorage\n"
236
+ " WSL2: /mnt/c/Users/<you>/AppData/Roaming/Code/User/workspaceStorage"
237
+ )
238
+ raise click.ClickException(msg)
239
+ return roots
240
+
241
+
242
+ import click # noqa: E402
@@ -0,0 +1,211 @@
1
+ """Public Python API for copilot-session-usage.
2
+
3
+ All functions accept an optional ``agent`` parameter for future routing
4
+ between VS Code and Copilot-CLI providers.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from pathlib import Path
10
+
11
+ from copilot_session_usage._internal import core, vscode
12
+
13
+
14
+ def analyze_session(path: Path, detail: str = "compact", agent: str = "vscode") -> dict:
15
+ """Analyze one session by its debug-log directory path.
16
+
17
+ Args:
18
+ path: Path to the session's debug-log directory.
19
+ detail: ``minimal``, ``compact`` (default), or ``full``.
20
+ agent: Provider to use (``vscode`` or ``cli``).
21
+
22
+ Returns:
23
+ Session analysis dict shaped to the requested detail level.
24
+
25
+ Raises:
26
+ NotImplementedError: If ``agent`` is ``"cli"``.
27
+ """
28
+ if agent == "cli":
29
+ raise NotImplementedError("Copilot-CLI support is not yet implemented.")
30
+ pricing = core.load_pricing()
31
+ result = core.analyze_session(Path(path), pricing)
32
+ return core.shape_session(result, detail)
33
+
34
+
35
+ def list_sessions(
36
+ workspace_roots: list[Path] | None = None,
37
+ limit: int = 20,
38
+ since: str | None = None,
39
+ workspace_filter: str | None = None,
40
+ agent: str = "vscode",
41
+ ) -> list[dict]:
42
+ """List recent sessions (metadata only, no JSONL reads).
43
+
44
+ Args:
45
+ workspace_roots: Override workspaceStorage directories.
46
+ Auto-detected if None.
47
+ limit: Maximum sessions to return.
48
+ since: Only sessions created after this date (YYYY-MM-DD or ISO 8601).
49
+ workspace_filter: Only sessions from this workspace folder.
50
+ agent: Provider to use (``vscode`` or ``cli``).
51
+
52
+ Returns:
53
+ List of session metadata dicts, most-recent first.
54
+ """
55
+ if agent == "cli":
56
+ raise NotImplementedError("Copilot-CLI support is not yet implemented.")
57
+
58
+ if workspace_roots is None:
59
+ workspace_roots = vscode.default_workspace_storage_roots()
60
+
61
+ since_ms = core.parse_since_to_ms(since) if since else None
62
+ return vscode.list_recent_sessions(
63
+ workspace_roots,
64
+ limit=limit,
65
+ since_ms=since_ms,
66
+ workspace_filter=workspace_filter,
67
+ )
68
+
69
+
70
+ def find_sessions_by_title(
71
+ title: str,
72
+ workspace_roots: list[Path] | None = None,
73
+ agent: str = "vscode",
74
+ ) -> list[dict]:
75
+ """Fuzzy-match sessions by title substring.
76
+
77
+ Args:
78
+ title: Substring to search for (case-insensitive).
79
+ workspace_roots: Override workspaceStorage directories.
80
+ agent: Provider to use (``vscode`` or ``cli``).
81
+
82
+ Returns:
83
+ Matching session metadata dicts, most-recent first.
84
+ """
85
+ if agent == "cli":
86
+ raise NotImplementedError("Copilot-CLI support is not yet implemented.")
87
+
88
+ if workspace_roots is None:
89
+ workspace_roots = vscode.default_workspace_storage_roots()
90
+
91
+ return vscode.find_sessions_by_title(title, workspace_roots)
92
+
93
+
94
+ def find_session_by_id(
95
+ session_id: str, workspace_roots: list[Path] | None = None, agent: str = "vscode"
96
+ ) -> dict | None:
97
+ """Analyze a session by its exact UUID.
98
+
99
+ Args:
100
+ session_id: The session UUID.
101
+ workspace_roots: Override workspaceStorage directories.
102
+ agent: Provider to use (``vscode`` or ``cli``).
103
+
104
+ Returns:
105
+ Session analysis dict, or None if not found.
106
+ """
107
+ if agent == "cli":
108
+ raise NotImplementedError("Copilot-CLI support is not yet implemented.")
109
+
110
+ if workspace_roots is None:
111
+ workspace_roots = vscode.default_workspace_storage_roots()
112
+
113
+ session_dir = vscode.find_session_dir_by_id(session_id, workspace_roots)
114
+ if session_dir is None:
115
+ return None
116
+ pricing = core.load_pricing()
117
+ return core.analyze_session(session_dir, pricing)
118
+
119
+
120
+ def analyze_latest(
121
+ workspace_roots: list[Path] | None = None,
122
+ detail: str = "compact",
123
+ workspace_filter: str | None = None,
124
+ agent: str = "vscode",
125
+ ) -> dict:
126
+ """Analyze the most recently modified session.
127
+
128
+ Args:
129
+ workspace_roots: Override workspaceStorage directories.
130
+ detail: ``minimal``, ``compact`` (default), or ``full``.
131
+ workspace_filter: Only sessions from this workspace folder.
132
+ agent: Provider to use (``vscode`` or ``cli``).
133
+
134
+ Returns:
135
+ Session analysis dict shaped to the requested detail level.
136
+
137
+ Raises:
138
+ ValueError: If no sessions are found.
139
+ """
140
+ if agent == "cli":
141
+ raise NotImplementedError("Copilot-CLI support is not yet implemented.")
142
+
143
+ if workspace_roots is None:
144
+ workspace_roots = vscode.default_workspace_storage_roots()
145
+
146
+ session_dir = vscode.find_latest_session_dir(workspace_roots, workspace_filter=workspace_filter)
147
+ if session_dir is None:
148
+ raise ValueError("No session debug logs found in workspace storage.")
149
+ pricing = core.load_pricing()
150
+ result = core.analyze_session(session_dir, pricing)
151
+ return core.shape_session(result, detail)
152
+
153
+
154
+ def batch_analyze(
155
+ n: int,
156
+ workspace_roots: list[Path] | None = None,
157
+ detail: str = "compact",
158
+ since: str | None = None,
159
+ workspace_filter: str | None = None,
160
+ agent: str = "vscode",
161
+ ) -> dict:
162
+ """Analyze the N most recent sessions.
163
+
164
+ Args:
165
+ n: Number of sessions to analyze.
166
+ workspace_roots: Override workspaceStorage directories.
167
+ detail: ``minimal``, ``compact`` (default), or ``full``.
168
+ since: Only sessions created after this date.
169
+ workspace_filter: Only sessions from this workspace folder.
170
+ agent: Provider to use (``vscode`` or ``cli``).
171
+
172
+ Returns:
173
+ Dict with ``summary`` (aggregate) and ``sessions`` (per-session list).
174
+ """
175
+ if agent == "cli":
176
+ raise NotImplementedError("Copilot-CLI support is not yet implemented.")
177
+
178
+ if workspace_roots is None:
179
+ workspace_roots = vscode.default_workspace_storage_roots()
180
+
181
+ since_ms = core.parse_since_to_ms(since) if since else None
182
+ sessions = vscode.list_recent_sessions(
183
+ workspace_roots,
184
+ limit=n,
185
+ since_ms=since_ms,
186
+ workspace_filter=workspace_filter,
187
+ require_logs=True,
188
+ )
189
+ pricing = core.load_pricing()
190
+ results: list[dict] = []
191
+ for session in sessions:
192
+ session_dir = Path(session["debug_log_dir"])
193
+ if not session_dir.exists():
194
+ continue
195
+ result = core.analyze_session(session_dir, pricing)
196
+ result["title"] = session.get("title") or result.get("title")
197
+ results.append(result)
198
+ return core.shape_batch(results, detail)
199
+
200
+
201
+ def load_pricing(ref_dir: Path | None = None) -> dict:
202
+ """Load pricing data.
203
+
204
+ Args:
205
+ ref_dir: Directory containing pricing YAML files. If None, uses
206
+ the bundled data directory shipped with the package.
207
+
208
+ Returns:
209
+ Pricing dict with model rates.
210
+ """
211
+ return core.load_pricing(ref_dir)