Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
225781d
Added vscode-insiders support
smaglio-ucsb May 26, 2026
38f179d
fix: remove unused VSCODE_STORAGE_ROOT import and out-of-root readme …
smaglio81 May 26, 2026
bbf573e
fix: prefer current VS Code host storage root and add deprecated stor…
smaglio81 May 26, 2026
281e297
fix: propagate storageRoots to all refresh/discovery paths and normal…
smaglio81 May 26, 2026
0d2e827
test: add unit tests for getWorkspaceStorageRoots Insiders/stable ord…
smaglio81 May 26, 2026
a17d372
fix: tighten test path assertions to 'Code - Insiders' and pass stora…
smaglio81 May 26, 2026
14bfc71
fix: remove premature constructor refresh in StatusBarManager; log fa…
smaglio81 May 26, 2026
352a330
fix: replace hard-coded storage root names with dynamic VSCODE_STORAG…
smaglio81 May 26, 2026
841bbf4
fix: use urlparse+url2pathname for file:// URIs; make discover_all_se…
smaglio81 May 26, 2026
547da55
fix: pass storage_roots as keyword arg to discover_all_session_files
smaglio81 May 26, 2026
eadf256
fix: (dashboard) Default button sets joined VSCODE_STORAGE_ROOTS; (di…
smaglio81 May 26, 2026
59564e5
fix: default storageRoots to getWorkspaceStorageRoots(appName) when u…
smaglio81 May 27, 2026
e58ebf4
fix: initialize storageRoots on-demand in loadData() and unquote file…
smaglio81 May 27, 2026
404fd83
fix: de-duplicate discoverWorkspaces results by workspaceId across st…
smaglio81 May 27, 2026
0bed45e
test: cover workspaceId merge across stable and insiders roots
smaglio81 May 27, 2026
67a0191
fix: preserve compatibility in multi-root discovery helpers
smaglio81 May 27, 2026
9e6b30e
fix: copy README.md into apps/cli for sdist/wheel compatibility; upda…
smaglio81 May 27, 2026
75d38af
fix: handle UNC file URIs and restore package readme metadata
smaglio81 May 27, 2026
6c44f07
fix: restore positional compatibility for discovery helpers
smaglio81 May 28, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -66,4 +66,6 @@ badges/

.github/copilot-instructions.md

reference/
reference/
# BlamePrompt staging (auto-generated)
.blameprompt/
32 changes: 32 additions & 0 deletions apps/cli/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# Copilot Usage Analytics

`copilot-usage` is a local-first analytics tool for GitHub Copilot session data. It scans VS Code chat session files, stores structured results in DuckDB, and exposes both a CLI workflow and a local dashboard for exploring token usage, premium estimates, and workspace-level activity.

## Highlights

- Incremental scanning of Copilot chat session files
- Local DuckDB storage with no external data upload
- Workspace and model-level usage breakdowns
- Premium request estimation based on model multipliers
- Browser dashboard plus terminal-friendly workflows

## Install

```bash
pip install copilot-usage
```

## Run

```bash
copilot-usage
copilot-usage analyze
copilot-usage dashboard
copilot-usage tui
```

## Project

Source, documentation, and release notes live in the main repository:

- https://github.com/SachiHarshitha/copilot-usage
6 changes: 4 additions & 2 deletions apps/cli/src/copilot_usage/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -231,7 +231,7 @@ def _interactive():
def _settings_menu(console):
"""Interactive settings sub-menu."""
from InquirerPy import inquirer
from copilot_usage.config import APP_DATA_DIR, DB_PATH, VSCODE_STORAGE_ROOT
from copilot_usage.config import APP_DATA_DIR, DB_PATH, VSCODE_STORAGE_ROOTS
from copilot_usage.logging import LOG_DIR
from copilot_usage import __version__
from rich.table import Table
Expand All @@ -244,7 +244,9 @@ def _settings_menu(console):
info.add_row("Database", str(DB_PATH))
info.add_row("App Data", str(APP_DATA_DIR))
info.add_row("Log Dir", str(LOG_DIR))
info.add_row("VS Code Storage", str(VSCODE_STORAGE_ROOT))
for i, p in enumerate(VSCODE_STORAGE_ROOTS):
label = "VS Code Storage" + (f" ({i + 1})" if len(VSCODE_STORAGE_ROOTS) > 1 else "")
info.add_row(label, str(p))
console.print(info)
console.print()

Expand Down
19 changes: 13 additions & 6 deletions apps/cli/src/copilot_usage/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,18 +9,25 @@
# VS Code workspace storage root
# ---------------------------------------------------------------------------

def _default_vscode_storage() -> pathlib.Path:
sys = platform.system()
if sys == "Windows":
def _default_vscode_storage_roots() -> list[pathlib.Path]:
sys_name = platform.system()
if sys_name == "Windows":
base = pathlib.Path(os.environ.get("APPDATA", "") or str(pathlib.Path.home() / "AppData" / "Roaming"))
elif sys == "Darwin":
elif sys_name == "Darwin":
base = pathlib.Path.home() / "Library" / "Application Support"
else: # Linux / other
base = pathlib.Path(os.environ.get("XDG_CONFIG_HOME", "") or str(pathlib.Path.home() / ".config"))
return base / "Code" / "User" / "workspaceStorage"
candidates = [
base / "Code" / "User" / "workspaceStorage",
base / "Code - Insiders" / "User" / "workspaceStorage",
]
found = [p for p in candidates if p.exists()]
return found if found else [candidates[0]]
Comment on lines +20 to +25


VSCODE_STORAGE_ROOT = _default_vscode_storage()
VSCODE_STORAGE_ROOTS: list[pathlib.Path] = _default_vscode_storage_roots()
# Backward-compat alias: first discovered root (stable Code path when neither exists yet)
VSCODE_STORAGE_ROOT: pathlib.Path = VSCODE_STORAGE_ROOTS[0]

# ---------------------------------------------------------------------------
# App data directory (writable; holds DuckDB, layout JSON, badge exports)
Expand Down
28 changes: 19 additions & 9 deletions apps/cli/src/copilot_usage/dashboard/pages/pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
import dash_bootstrap_components as dbc
from dash import Input, Output, State, callback, ctx, dcc, html

from copilot_usage.config import VSCODE_STORAGE_ROOT
from copilot_usage.config import VSCODE_STORAGE_ROOTS

dash.register_page(__name__, path="/pipeline", name="Pipeline", order=2)

Expand Down Expand Up @@ -52,10 +52,17 @@ def _run_pipeline_thread(storage_path: str):
from copilot_usage.pipeline import run_scan

try:
_append_log(f"Storage path: {storage_path}", 0)
storage_root = Path(storage_path) if storage_path else None
if storage_path and storage_path.strip():
storage_roots = [Path(p.strip()) for p in storage_path.split(";") if p.strip()] or None
if storage_roots:
_append_log(f"Storage paths: {'; '.join(str(p) for p in storage_roots)}", 0)
else:
_append_log(f"No valid paths parsed; using auto-detected roots: {'; '.join(str(p) for p in VSCODE_STORAGE_ROOTS)}", 0)
else:
storage_roots = None # use all auto-detected roots
_append_log(f"Storage paths: {'; '.join(str(p) for p in VSCODE_STORAGE_ROOTS)}", 0)
con = get_connection()
stats = run_scan(con, storage_root=storage_root, on_progress=_append_log)
stats = run_scan(con, storage_roots=storage_roots, on_progress=_append_log)
con.close()
# Invalidate dashboard query cache so fresh data appears immediately
from copilot_usage.dashboard.queries import invalidate_cache
Expand Down Expand Up @@ -93,8 +100,8 @@ def _run_pipeline_thread(storage_path: str):
dbc.Input(
id="pl-storage-path",
type="text",
value=str(VSCODE_STORAGE_ROOT),
placeholder="Auto-detected path…",
value="",
placeholder=f"Auto-detect ({'; '.join(str(p) for p in VSCODE_STORAGE_ROOTS)})",
className="theme-input",
),
dbc.Button(
Expand All @@ -106,8 +113,9 @@ def _run_pipeline_thread(storage_path: str):
),
]),
html.Small(
"Path to VS Code workspaceStorage directory. "
"Uses auto-detected default if left unchanged.",
"One or more workspaceStorage paths separated by semicolons. "
f"Leave blank to scan all auto-detected locations "
f"({'; '.join(str(p) for p in VSCODE_STORAGE_ROOTS)}).",
className="text-muted",
),
], md=9),
Expand Down Expand Up @@ -176,7 +184,9 @@ def _run_pipeline_thread(storage_path: str):
prevent_initial_call=True,
)
def _reset_path(_):
return str(VSCODE_STORAGE_ROOT)
# Set to joined VSCODE_STORAGE_ROOTS for user visibility
from copilot_usage.config import VSCODE_STORAGE_ROOTS
return ";".join(str(p) for p in VSCODE_STORAGE_ROOTS)


@callback(
Expand Down
10 changes: 8 additions & 2 deletions apps/cli/src/copilot_usage/dashboard/pages/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
from dash_bootstrap_templates import ThemeChangerAIO

from copilot_usage import __version__
from copilot_usage.config import APP_DATA_DIR, DB_PATH, VSCODE_STORAGE_ROOT
from copilot_usage.config import APP_DATA_DIR, DB_PATH, VSCODE_STORAGE_ROOTS
from copilot_usage.dashboard.app import THEME_OPTIONS
from copilot_usage.logging import LOG_DIR

Expand Down Expand Up @@ -78,7 +78,13 @@ def _about_row(label: str, value: str):
_about_row("Database", str(DB_PATH)),
_about_row("App Data", str(APP_DATA_DIR)),
_about_row("Log Directory", str(LOG_DIR)),
_about_row("VS Code Storage", str(VSCODE_STORAGE_ROOT)),
*[
_about_row(
"VS Code Storage" + (f" ({i + 1})" if len(VSCODE_STORAGE_ROOTS) > 1 else ""),
str(p),
)
for i, p in enumerate(VSCODE_STORAGE_ROOTS)
],
])
], className="table table-dark table-sm mb-0",
style={"--bs-table-bg": "transparent"}),
Expand Down
86 changes: 56 additions & 30 deletions apps/cli/src/copilot_usage/discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,34 +3,41 @@

import json
from pathlib import Path
from urllib.parse import unquote
from urllib.parse import unquote, urlparse
from urllib.request import url2pathname

import duckdb
from loguru import logger as log

from copilot_usage.config import VSCODE_STORAGE_ROOT
from copilot_usage.config import VSCODE_STORAGE_ROOT, VSCODE_STORAGE_ROOTS


def _uri_to_path(uri: str) -> str:
def _uri_to_path(uri: str, storage_root: Path | None = None) -> str:
"""Strip a VS Code URI scheme and decode percent-encoding → plain filesystem path.

Handles:
- ``file:///c%3A/path`` → ``c:/path``
- ``file:///c%3A/path`` → platform-native path (e.g. ``C:/path`` or ``C:\\path``)
- ``vscode-userdata:///Code/...`` → ``{APPDATA}/Code/...``
- bare string → decoded as-is
Output is platform-dependent (Windows paths use backslashes, POSIX uses forward slashes).
"""
if uri.startswith("file:///"):
return unquote(uri[len("file:///"):])
if uri.startswith("file://"):
parsed = urlparse(uri)
parsed_path = parsed.path
if parsed.netloc:
parsed_path = f"//{parsed.netloc}{parsed_path}"
return url2pathname(unquote(parsed_path))
Comment on lines +24 to +29
if uri.startswith("vscode-userdata:///"):
rel = unquote(uri[len("vscode-userdata:///"):])
# vscode-userdata:/// is rooted at the VS Code user-data base (e.g. %APPDATA% on Windows),
# which is three levels above workspaceStorage: .../Code/User/workspaceStorage
userdata_base = VSCODE_STORAGE_ROOT.parents[2]
root = storage_root or VSCODE_STORAGE_ROOT
userdata_base = root.parents[2]
return str(userdata_base / rel)
return unquote(uri)


def resolve_workspace(workspace_dir: Path) -> tuple[str, str]:
def resolve_workspace(workspace_dir: Path, storage_root: Path | None = None) -> tuple[str, str]:
"""Return (workspace_id, workspace_path) from a workspaceStorage subfolder.

For single-folder workspaces the path is the decoded project folder.
Expand All @@ -48,7 +55,7 @@ def resolve_workspace(workspace_dir: Path) -> tuple[str, str]:
workspace_uri = data.get("workspace", "")
raw = folder_uri or workspace_uri
if raw:
resolved = _uri_to_path(raw)
resolved = _uri_to_path(raw, storage_root)
if workspace_uri:
# Multi-root workspace: try to read the referenced workspace file
# and extract the actual folder paths for a readable workspace_path.
Expand All @@ -57,7 +64,7 @@ def resolve_workspace(workspace_dir: Path) -> tuple[str, str]:
try:
ws_data = json.loads(ws_file.read_text(encoding="utf-8"))
folder_paths = [
_uri_to_path(f.get("uri", "") or f.get("path", ""))
_uri_to_path(f.get("uri", "") or f.get("path", ""), storage_root)
for f in ws_data.get("folders", [])
if isinstance(f, dict)
]
Expand All @@ -75,34 +82,41 @@ def resolve_workspace(workspace_dir: Path) -> tuple[str, str]:


def discover_all_session_files(
storage_root: Path | None = None,
storage_root: Path | None = None, # deprecated: use storage_roots
*,
storage_roots: list[Path] | Path | None = None,
) -> tuple[list[tuple[str, str, Path]], list[tuple[str, str, Path]]]:
"""Single-pass discovery of both JSONL and legacy JSON session files.
"""Single-pass discovery of both JSONL and legacy JSON session files across all roots.

Returns (jsonl_files, legacy_json_files) where each item is
(workspace_id, workspace_path, file_path).
"""
root = storage_root or VSCODE_STORAGE_ROOT
if isinstance(storage_roots, Path):
storage_roots = [storage_roots]
if storage_root is not None and storage_roots is None:
storage_roots = [storage_root]
roots = storage_roots or VSCODE_STORAGE_ROOTS
jsonl_results: list[tuple[str, str, Path]] = []
legacy_results: list[tuple[str, str, Path]] = []
if not root.exists():
log.warning("VS Code storage root not found: {}", root)
return jsonl_results, legacy_results

for workspace_dir in root.iterdir():
if not workspace_dir.is_dir():
continue
sessions_dir = workspace_dir / "chatSessions"
if not sessions_dir.is_dir():
for root in roots:
if not root.exists():
log.warning("VS Code storage root not found: {}", root)
continue
workspace_id, workspace_path = resolve_workspace(workspace_dir)
for f in sessions_dir.iterdir():
if not f.is_file():
for workspace_dir in root.iterdir():
if not workspace_dir.is_dir():
continue
sessions_dir = workspace_dir / "chatSessions"
if not sessions_dir.is_dir():
continue
if f.suffix == ".jsonl":
jsonl_results.append((workspace_id, workspace_path, f))
elif f.suffix == ".json":
legacy_results.append((workspace_id, workspace_path, f))
workspace_id, workspace_path = resolve_workspace(workspace_dir, root)
for f in sessions_dir.iterdir():
if not f.is_file():
continue
if f.suffix == ".jsonl":
jsonl_results.append((workspace_id, workspace_path, f))
elif f.suffix == ".json":
legacy_results.append((workspace_id, workspace_path, f))

log.info(
"Discovered {} JSONL + {} legacy JSON files across {} workspaces",
Expand All @@ -115,23 +129,35 @@ def discover_all_session_files(

def discover_jsonl_files(
storage_root: Path | None = None,
*,
storage_roots: list[Path] | Path | None = None,
) -> list[tuple[str, str, Path]]:
"""Find all chatSessions/*.jsonl files.

Returns list of (workspace_id, workspace_path, jsonl_path).
"""
jsonl, _ = discover_all_session_files(storage_root)
if isinstance(storage_roots, Path):
storage_roots = [storage_roots]
if storage_root is not None and storage_roots is None:
storage_roots = [storage_root]
jsonl, _ = discover_all_session_files(storage_roots=storage_roots)
return jsonl


def discover_legacy_json_files(
storage_root: Path | None = None,
*,
storage_roots: list[Path] | Path | None = None,
) -> list[tuple[str, str, Path]]:
"""Find all chatSessions/*.json files (legacy, pre-Feb 2026).

Returns list of (workspace_id, workspace_path, json_path).
"""
_, legacy = discover_all_session_files(storage_root)
if isinstance(storage_roots, Path):
storage_roots = [storage_roots]
if storage_root is not None and storage_roots is None:
storage_roots = [storage_root]
_, legacy = discover_all_session_files(storage_roots=storage_roots)
return legacy


Expand Down
7 changes: 5 additions & 2 deletions apps/cli/src/copilot_usage/pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,13 @@
def run_scan(
con: duckdb.DuckDBPyConnection,
*,
storage_root=None,
storage_roots=None,
storage_root=None, # deprecated: use storage_roots
on_progress: ProgressCallback | None = None,
) -> dict:
"""Execute a full incremental scan pipeline. Returns stats dict."""
if storage_root is not None and storage_roots is None:
storage_roots = [storage_root]
t0 = time.perf_counter()

def _emit(msg: str, pct: float | None = None):
Expand All @@ -43,7 +46,7 @@ def _emit(msg: str, pct: float | None = None):

# 2. Discover all session files (single directory walk)
_emit("Discovering session files…", 5)
all_jsonl, all_legacy = discover_all_session_files(storage_root)
all_jsonl, all_legacy = discover_all_session_files(storage_roots=storage_roots)
_emit(f" Found {len(all_jsonl)} JSONL + {len(all_legacy)} legacy JSON files", 15)
all_files = all_jsonl + all_legacy

Expand Down
4 changes: 2 additions & 2 deletions apps/vscode-extension/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading