From 225781d6b6859752a46f70b376c36ddf9346d264 Mon Sep 17 00:00:00 2001 From: Steven Maglio Date: Tue, 26 May 2026 08:59:19 -0700 Subject: [PATCH 01/19] Added vscode-insiders support --- .gitignore | 4 +- apps/cli/pyproject.toml | 2 +- apps/cli/src/copilot_usage/__main__.py | 6 +- apps/cli/src/copilot_usage/config.py | 19 ++- .../copilot_usage/dashboard/pages/pipeline.py | 23 +-- .../copilot_usage/dashboard/pages/settings.py | 10 +- apps/cli/src/copilot_usage/discovery.py | 56 +++---- apps/cli/src/copilot_usage/pipeline.py | 4 +- apps/vscode-extension/package-lock.json | 4 +- apps/vscode-extension/src/core/discovery.ts | 151 ++++++++++-------- apps/vscode-extension/src/extension.ts | 45 +++--- .../src/test/discovery.test.ts | 12 +- 12 files changed, 187 insertions(+), 149 deletions(-) diff --git a/.gitignore b/.gitignore index 41f26ff..dff4e8f 100644 --- a/.gitignore +++ b/.gitignore @@ -66,4 +66,6 @@ badges/ .github/copilot-instructions.md -reference/ \ No newline at end of file +reference/ +# BlamePrompt staging (auto-generated) +.blameprompt/ diff --git a/apps/cli/pyproject.toml b/apps/cli/pyproject.toml index b9ec8dd..7e26f89 100644 --- a/apps/cli/pyproject.toml +++ b/apps/cli/pyproject.toml @@ -16,7 +16,7 @@ authors = [ maintainers = [ { name = "Sachith Liyanagama", email = "liyanagama@outlook.de" }, ] -readme = "README.md" +readme = "../../README.md" license = "Apache-2.0" keywords = ["copilot", "github", "vs-code" ,"usage", "tokens", "analytics", "dashboard", "observability", "local-first", "privacy-focused"] classifiers = [ diff --git a/apps/cli/src/copilot_usage/__main__.py b/apps/cli/src/copilot_usage/__main__.py index 5ec9d09..5c7829b 100644 --- a/apps/cli/src/copilot_usage/__main__.py +++ b/apps/cli/src/copilot_usage/__main__.py @@ -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 @@ -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() diff --git a/apps/cli/src/copilot_usage/config.py b/apps/cli/src/copilot_usage/config.py index 2906992..fea9b73 100644 --- a/apps/cli/src/copilot_usage/config.py +++ b/apps/cli/src/copilot_usage/config.py @@ -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]] -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) diff --git a/apps/cli/src/copilot_usage/dashboard/pages/pipeline.py b/apps/cli/src/copilot_usage/dashboard/pages/pipeline.py index 1cf38ab..1a8f22f 100644 --- a/apps/cli/src/copilot_usage/dashboard/pages/pipeline.py +++ b/apps/cli/src/copilot_usage/dashboard/pages/pipeline.py @@ -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_ROOT, VSCODE_STORAGE_ROOTS dash.register_page(__name__, path="/pipeline", name="Pipeline", order=2) @@ -52,10 +52,14 @@ 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()] + _append_log(f"Storage paths: {'; '.join(str(p) for p in 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 @@ -93,8 +97,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( @@ -106,8 +110,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. " + "Leave blank to scan all auto-detected locations " + "(Code and Code - Insiders).", className="text-muted", ), ], md=9), @@ -176,7 +181,7 @@ def _run_pipeline_thread(storage_path: str): prevent_initial_call=True, ) def _reset_path(_): - return str(VSCODE_STORAGE_ROOT) + return "" @callback( diff --git a/apps/cli/src/copilot_usage/dashboard/pages/settings.py b/apps/cli/src/copilot_usage/dashboard/pages/settings.py index 38dfc18..d72e964 100644 --- a/apps/cli/src/copilot_usage/dashboard/pages/settings.py +++ b/apps/cli/src/copilot_usage/dashboard/pages/settings.py @@ -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 @@ -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"}), diff --git a/apps/cli/src/copilot_usage/discovery.py b/apps/cli/src/copilot_usage/discovery.py index 5799ce2..994ecc6 100644 --- a/apps/cli/src/copilot_usage/discovery.py +++ b/apps/cli/src/copilot_usage/discovery.py @@ -8,10 +8,10 @@ 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: @@ -25,12 +25,13 @@ def _uri_to_path(uri: str) -> str: 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. @@ -48,7 +49,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. @@ -75,34 +76,35 @@ def resolve_workspace(workspace_dir: Path) -> tuple[str, str]: def discover_all_session_files( - storage_root: Path | None = None, + storage_roots: list[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 + 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(): + for root in roots: + if not root.exists(): + log.warning("VS Code storage root not found: {}", root) continue - sessions_dir = workspace_dir / "chatSessions" - if not sessions_dir.is_dir(): - 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", @@ -114,24 +116,24 @@ def discover_all_session_files( def discover_jsonl_files( - storage_root: Path | None = None, + storage_roots: list[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) + jsonl, _ = discover_all_session_files(storage_roots) return jsonl def discover_legacy_json_files( - storage_root: Path | None = None, + storage_roots: list[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) + _, legacy = discover_all_session_files(storage_roots) return legacy diff --git a/apps/cli/src/copilot_usage/pipeline.py b/apps/cli/src/copilot_usage/pipeline.py index 10e53e6..2891df3 100644 --- a/apps/cli/src/copilot_usage/pipeline.py +++ b/apps/cli/src/copilot_usage/pipeline.py @@ -24,7 +24,7 @@ def run_scan( con: duckdb.DuckDBPyConnection, *, - storage_root=None, + storage_roots=None, on_progress: ProgressCallback | None = None, ) -> dict: """Execute a full incremental scan pipeline. Returns stats dict.""" @@ -43,7 +43,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) _emit(f" Found {len(all_jsonl)} JSONL + {len(all_legacy)} legacy JSON files", 15) all_files = all_jsonl + all_legacy diff --git a/apps/vscode-extension/package-lock.json b/apps/vscode-extension/package-lock.json index 9bda1b1..e5d1ea4 100644 --- a/apps/vscode-extension/package-lock.json +++ b/apps/vscode-extension/package-lock.json @@ -1,12 +1,12 @@ { "name": "copilot-usage", - "version": "0.0.1", + "version": "0.0.10", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "copilot-usage", - "version": "0.0.1", + "version": "0.0.10", "devDependencies": { "@types/mocha": "^10.0.10", "@types/node": "22.x", diff --git a/apps/vscode-extension/src/core/discovery.ts b/apps/vscode-extension/src/core/discovery.ts index 6c98a05..269e66a 100644 --- a/apps/vscode-extension/src/core/discovery.ts +++ b/apps/vscode-extension/src/core/discovery.ts @@ -5,18 +5,27 @@ import * as path from 'path'; import * as os from 'os'; import { WorkspaceInfo } from './types'; -/** Return platform-specific VS Code workspace storage root. */ -export function getWorkspaceStorageRoot(): string { +/** Return platform-specific VS Code workspace storage roots (stable + Insiders). */ +export function getWorkspaceStorageRoots(): string[] { switch (process.platform) { case 'win32': { const appdata = process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'); - return path.join(appdata, 'Code', 'User', 'workspaceStorage'); + return [ + path.join(appdata, 'Code', 'User', 'workspaceStorage'), + path.join(appdata, 'Code - Insiders', 'User', 'workspaceStorage'), + ]; } case 'darwin': - return path.join(os.homedir(), 'Library', 'Application Support', 'Code', 'User', 'workspaceStorage'); + return [ + path.join(os.homedir(), 'Library', 'Application Support', 'Code', 'User', 'workspaceStorage'), + path.join(os.homedir(), 'Library', 'Application Support', 'Code - Insiders', 'User', 'workspaceStorage'), + ]; default: { const config = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config'); - return path.join(config, 'Code', 'User', 'workspaceStorage'); + return [ + path.join(config, 'Code', 'User', 'workspaceStorage'), + path.join(config, 'Code - Insiders', 'User', 'workspaceStorage'), + ]; } } } @@ -68,48 +77,50 @@ async function resolveWorkspace(workspaceDir: string): Promise<{ id: string; pat /** Discover all workspaces that have chatSessions with JSONL or JSON files. */ export async function discoverWorkspaces( - storageRoot?: string, + storageRoots?: string[], ): Promise { - const root = storageRoot || getWorkspaceStorageRoot(); + const roots = storageRoots || getWorkspaceStorageRoots(); const results: WorkspaceInfo[] = []; - let dirs: string[]; - try { - dirs = await fs.readdir(root); - } catch { - return results; - } - - for (const dirName of dirs) { - const wsDir = path.join(root, dirName); - const sessionsDir = path.join(wsDir, 'chatSessions'); + for (const root of roots) { + let dirs: string[]; try { - const stat = await fs.stat(sessionsDir); - if (!stat.isDirectory()) { continue; } + dirs = await fs.readdir(root); } catch { - continue; + continue; // root doesn't exist or is unreadable — skip it } - const ws = await resolveWorkspace(wsDir); - const files: string[] = []; - try { - for (const f of await fs.readdir(sessionsDir)) { - const ext = path.extname(f).toLowerCase(); - if (ext === '.jsonl' || ext === '.json') { - files.push(path.join(sessionsDir, f)); + for (const dirName of dirs) { + const wsDir = path.join(root, dirName); + const sessionsDir = path.join(wsDir, 'chatSessions'); + try { + const stat = await fs.stat(sessionsDir); + if (!stat.isDirectory()) { continue; } + } catch { + continue; + } + + const ws = await resolveWorkspace(wsDir); + const files: string[] = []; + try { + for (const f of await fs.readdir(sessionsDir)) { + const ext = path.extname(f).toLowerCase(); + if (ext === '.jsonl' || ext === '.json') { + files.push(path.join(sessionsDir, f)); + } } + } catch { + continue; } - } catch { - continue; - } - if (files.length > 0) { - results.push({ - workspaceId: ws.id, - workspacePath: ws.path, - referencedFolders: ws.referencedFolders, - sessionFiles: files, - }); + if (files.length > 0) { + results.push({ + workspaceId: ws.id, + workspacePath: ws.path, + referencedFolders: ws.referencedFolders, + sessionFiles: files, + }); + } } } @@ -123,44 +134,46 @@ export async function discoverWorkspaces( * changes when the number of tracked files changes, file sizes change, or * newest modification time changes. */ -export async function computeChatSessionsSignature(storageRoot?: string): Promise { - const root = storageRoot || getWorkspaceStorageRoot(); - - let dirs: string[]; - try { - dirs = await fs.readdir(root); - } catch { - return '0:0:0'; - } +export async function computeChatSessionsSignature(storageRoots?: string[]): Promise { + const roots = storageRoots || getWorkspaceStorageRoots(); let fileCount = 0; let totalBytes = 0; let newestMtimeMs = 0; - for (const dirName of dirs) { - const sessionsDir = path.join(root, dirName, 'chatSessions'); - - let files: string[]; + for (const root of roots) { + let dirs: string[]; try { - files = await fs.readdir(sessionsDir); + dirs = await fs.readdir(root); } catch { - continue; + continue; // root doesn't exist — skip } - for (const fileName of files) { - const ext = path.extname(fileName).toLowerCase(); - if (ext !== '.jsonl' && ext !== '.json') { continue; } + for (const dirName of dirs) { + const sessionsDir = path.join(root, dirName, 'chatSessions'); + let files: string[]; try { - const stat = await fs.stat(path.join(sessionsDir, fileName)); - if (!stat.isFile()) { continue; } - - fileCount++; - totalBytes += stat.size; - const mtimeMs = Number(stat.mtimeMs) || 0; - if (mtimeMs > newestMtimeMs) { newestMtimeMs = mtimeMs; } + files = await fs.readdir(sessionsDir); } catch { - // File may disappear during scan; safe to ignore. + continue; + } + + for (const fileName of files) { + const ext = path.extname(fileName).toLowerCase(); + if (ext !== '.jsonl' && ext !== '.json') { continue; } + + try { + const stat = await fs.stat(path.join(sessionsDir, fileName)); + if (!stat.isFile()) { continue; } + + fileCount++; + totalBytes += stat.size; + const mtimeMs = Number(stat.mtimeMs) || 0; + if (mtimeMs > newestMtimeMs) { newestMtimeMs = mtimeMs; } + } catch { + // File may disappear during scan; safe to ignore. + } } } } @@ -171,9 +184,9 @@ export async function computeChatSessionsSignature(storageRoot?: string): Promis /** Find workspace info for a specific workspace folder path. */ export async function findWorkspaceByPath( folderPath: string, - storageRoot?: string, + storageRoots?: string[], ): Promise { - const workspaces = await discoverWorkspaces(storageRoot); + const workspaces = await discoverWorkspaces(storageRoots); const normTarget = normalizePath(folderPath); return workspaces.find(ws => normalizePath(ws.workspacePath) === normTarget); } @@ -185,9 +198,9 @@ export async function findWorkspaceByPath( */ export async function findWorkspaceByFile( workspaceFilePath: string, - storageRoot?: string, + storageRoots?: string[], ): Promise { - const workspaces = await discoverWorkspaces(storageRoot); + const workspaces = await discoverWorkspaces(storageRoots); const normTarget = normalizePath(workspaceFilePath); return workspaces.find(ws => normalizePath(ws.workspacePath) === normTarget); } @@ -201,9 +214,9 @@ export async function findWorkspaceByFile( export async function findCurrentWorkspace( workspaceFileUri: string | undefined, folderPaths: string[], - storageRoot?: string, + storageRoots?: string[], ): Promise { - const workspaces = await discoverWorkspaces(storageRoot); + const workspaces = await discoverWorkspaces(storageRoots); // Strategy 1: match by .code-workspace file path if (workspaceFileUri) { diff --git a/apps/vscode-extension/src/extension.ts b/apps/vscode-extension/src/extension.ts index 879f949..f1d0121 100644 --- a/apps/vscode-extension/src/extension.ts +++ b/apps/vscode-extension/src/extension.ts @@ -1,7 +1,7 @@ import * as vscode from 'vscode'; import { WorkspacePanel, DashboardPanel } from './views/panels'; import { StatusBarManager } from './views/statusBar'; -import { getWorkspaceStorageRoot, computeChatSessionsSignature } from './core/discovery'; +import { getWorkspaceStorageRoots, computeChatSessionsSignature } from './core/discovery'; import { openCopilotDebugLogSettings } from './core/copilotDebugLog'; import { CostEstimatorPanel, enableCostEstimator } from './features/costEstimator'; @@ -24,13 +24,13 @@ export function activate(context: vscode.ExtensionContext) { await Promise.all(tasks); }; - const storageRoot = getWorkspaceStorageRoot(); + const storageRoots = getWorkspaceStorageRoots(); let lastSignature: string | undefined; let refreshTimer: ReturnType | undefined; const updateSignature = async () => { try { - lastSignature = await computeChatSessionsSignature(storageRoot); + lastSignature = await computeChatSessionsSignature(storageRoots); } catch { // Ignore transient IO errors; next scan will retry. } @@ -59,33 +59,34 @@ export function activate(context: vscode.ExtensionContext) { } })); - // Watch the actual VS Code workspaceStorage directory for chat session changes. + // Watch each VS Code workspaceStorage directory (stable + Insiders) for chat session changes. // createFileSystemWatcher with RelativePattern(absolute path) works outside // the current workspace — this is the correct way to watch AppData files. - const watcherJsonl = vscode.workspace.createFileSystemWatcher( - new vscode.RelativePattern(vscode.Uri.file(storageRoot), '**/chatSessions/*.jsonl'), - ); - const watcherJson = vscode.workspace.createFileSystemWatcher( - new vscode.RelativePattern(vscode.Uri.file(storageRoot), '**/chatSessions/*.json'), - ); const onSessionFilesChanged = () => scheduleRefresh(); - - context.subscriptions.push( - watcherJsonl, - watcherJson, - watcherJsonl.onDidCreate(onSessionFilesChanged), - watcherJsonl.onDidChange(onSessionFilesChanged), - watcherJsonl.onDidDelete(onSessionFilesChanged), - watcherJson.onDidCreate(onSessionFilesChanged), - watcherJson.onDidChange(onSessionFilesChanged), - watcherJson.onDidDelete(onSessionFilesChanged), - ); + for (const storageRoot of storageRoots) { + const watcherJsonl = vscode.workspace.createFileSystemWatcher( + new vscode.RelativePattern(vscode.Uri.file(storageRoot), '**/chatSessions/*.jsonl'), + ); + const watcherJson = vscode.workspace.createFileSystemWatcher( + new vscode.RelativePattern(vscode.Uri.file(storageRoot), '**/chatSessions/*.json'), + ); + context.subscriptions.push( + watcherJsonl, + watcherJson, + watcherJsonl.onDidCreate(onSessionFilesChanged), + watcherJsonl.onDidChange(onSessionFilesChanged), + watcherJsonl.onDidDelete(onSessionFilesChanged), + watcherJson.onDidCreate(onSessionFilesChanged), + watcherJson.onDidChange(onSessionFilesChanged), + watcherJson.onDidDelete(onSessionFilesChanged), + ); + } // Fallback polling closes gaps on platforms/setups where external watcher // notifications are delayed or dropped. const pollForMissedChanges = async () => { try { - const nextSignature = await computeChatSessionsSignature(storageRoot); + const nextSignature = await computeChatSessionsSignature(storageRoots); if (lastSignature === undefined) { lastSignature = nextSignature; return; diff --git a/apps/vscode-extension/src/test/discovery.test.ts b/apps/vscode-extension/src/test/discovery.test.ts index 08ebff6..9fafbd4 100644 --- a/apps/vscode-extension/src/test/discovery.test.ts +++ b/apps/vscode-extension/src/test/discovery.test.ts @@ -58,7 +58,7 @@ async function withTempStorage(run: (storageRoot: string, folderA: string, folde suite('Discovery: current workspace matching', () => { test('prefers multi-root storage entry by referenced folders when workspaceFileUri is unavailable', async () => { await withTempStorage(async (storageRoot, folderA, folderB) => { - const match = await findCurrentWorkspace(undefined, [folderA, folderB], storageRoot); + const match = await findCurrentWorkspace(undefined, [folderA, folderB], [storageRoot]); assert.ok(match); assert.strictEqual(match!.workspaceId, 'multi-root-workspace-id'); }); @@ -66,7 +66,7 @@ suite('Discovery: current workspace matching', () => { test('still matches single-folder entry when only one folder is open', async () => { await withTempStorage(async (storageRoot, folderA) => { - const match = await findCurrentWorkspace(undefined, [folderA], storageRoot); + const match = await findCurrentWorkspace(undefined, [folderA], [storageRoot]); assert.ok(match); assert.strictEqual(match!.workspaceId, 'single-folder-workspace-id'); }); @@ -74,7 +74,7 @@ suite('Discovery: current workspace matching', () => { test('matches multi-root entry when workspaceFileUri is available', async () => { await withTempStorage(async (storageRoot, folderA, folderB, wsFilePath) => { - const match = await findCurrentWorkspace(pathToFileURL(wsFilePath).toString(), [folderA, folderB], storageRoot); + const match = await findCurrentWorkspace(pathToFileURL(wsFilePath).toString(), [folderA, folderB], [storageRoot]); assert.ok(match); assert.strictEqual(match!.workspaceId, 'multi-root-workspace-id'); }); @@ -82,16 +82,16 @@ suite('Discovery: current workspace matching', () => { test('chat session signature changes when tracked files change', async () => { await withTempStorage(async (storageRoot) => { - const before = await computeChatSessionsSignature(storageRoot); + const before = await computeChatSessionsSignature([storageRoot]); const jsonlPath = path.join(storageRoot, 'single-folder-workspace-id', 'chatSessions', 'single.jsonl'); await fs.appendFile(jsonlPath, '{"kind":1,"k":["requests",0,"completionTokens"],"v":42}\n', 'utf-8'); - const afterJsonlAppend = await computeChatSessionsSignature(storageRoot); + const afterJsonlAppend = await computeChatSessionsSignature([storageRoot]); assert.notStrictEqual(afterJsonlAppend, before); const jsonPath = path.join(storageRoot, 'single-folder-workspace-id', 'chatSessions', 'legacy.json'); await fs.writeFile(jsonPath, '{"requests":[]}', 'utf-8'); - const afterJsonCreate = await computeChatSessionsSignature(storageRoot); + const afterJsonCreate = await computeChatSessionsSignature([storageRoot]); assert.notStrictEqual(afterJsonCreate, afterJsonlAppend); }); }); From 38f179de0e8d620890b1771073edeba0dfcb9d28 Mon Sep 17 00:00:00 2001 From: Steven Maglio Date: Tue, 26 May 2026 11:18:54 -0700 Subject: [PATCH 02/19] fix: remove unused VSCODE_STORAGE_ROOT import and out-of-root readme path --- apps/cli/pyproject.toml | 1 - apps/cli/src/copilot_usage/dashboard/pages/pipeline.py | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/cli/pyproject.toml b/apps/cli/pyproject.toml index 7e26f89..52e33ce 100644 --- a/apps/cli/pyproject.toml +++ b/apps/cli/pyproject.toml @@ -16,7 +16,6 @@ authors = [ maintainers = [ { name = "Sachith Liyanagama", email = "liyanagama@outlook.de" }, ] -readme = "../../README.md" license = "Apache-2.0" keywords = ["copilot", "github", "vs-code" ,"usage", "tokens", "analytics", "dashboard", "observability", "local-first", "privacy-focused"] classifiers = [ diff --git a/apps/cli/src/copilot_usage/dashboard/pages/pipeline.py b/apps/cli/src/copilot_usage/dashboard/pages/pipeline.py index 1a8f22f..f01ac77 100644 --- a/apps/cli/src/copilot_usage/dashboard/pages/pipeline.py +++ b/apps/cli/src/copilot_usage/dashboard/pages/pipeline.py @@ -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, VSCODE_STORAGE_ROOTS +from copilot_usage.config import VSCODE_STORAGE_ROOTS dash.register_page(__name__, path="/pipeline", name="Pipeline", order=2) From bbf573e6cd54dd72a1c12f4ac0f29721db201db0 Mon Sep 17 00:00:00 2001 From: Steven Maglio Date: Tue, 26 May 2026 11:48:20 -0700 Subject: [PATCH 03/19] fix: prefer current VS Code host storage root and add deprecated storage_root aliases --- apps/cli/src/copilot_usage/discovery.py | 3 ++ apps/cli/src/copilot_usage/pipeline.py | 3 ++ apps/vscode-extension/src/core/discovery.ts | 35 ++++++++++++--------- apps/vscode-extension/src/extension.ts | 2 +- 4 files changed, 27 insertions(+), 16 deletions(-) diff --git a/apps/cli/src/copilot_usage/discovery.py b/apps/cli/src/copilot_usage/discovery.py index 994ecc6..49ea50f 100644 --- a/apps/cli/src/copilot_usage/discovery.py +++ b/apps/cli/src/copilot_usage/discovery.py @@ -77,12 +77,15 @@ def resolve_workspace(workspace_dir: Path, storage_root: Path | None = None) -> def discover_all_session_files( storage_roots: list[Path] | None = None, + storage_root: Path | None = None, # deprecated: use storage_roots ) -> tuple[list[tuple[str, str, Path]], list[tuple[str, str, Path]]]: """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). """ + 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]] = [] diff --git a/apps/cli/src/copilot_usage/pipeline.py b/apps/cli/src/copilot_usage/pipeline.py index 2891df3..7439ad8 100644 --- a/apps/cli/src/copilot_usage/pipeline.py +++ b/apps/cli/src/copilot_usage/pipeline.py @@ -25,9 +25,12 @@ def run_scan( con: duckdb.DuckDBPyConnection, *, 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): diff --git a/apps/vscode-extension/src/core/discovery.ts b/apps/vscode-extension/src/core/discovery.ts index 269e66a..08772d2 100644 --- a/apps/vscode-extension/src/core/discovery.ts +++ b/apps/vscode-extension/src/core/discovery.ts @@ -5,27 +5,32 @@ import * as path from 'path'; import * as os from 'os'; import { WorkspaceInfo } from './types'; -/** Return platform-specific VS Code workspace storage roots (stable + Insiders). */ -export function getWorkspaceStorageRoots(): string[] { +/** + * Return platform-specific VS Code workspace storage roots. + * + * When `appName` contains "Insiders" (e.g. `vscode.env.appName`), the Insiders + * root is returned first so that workspace lookups prefer the current host's + * storage over the stable installation's storage. + */ +export function getWorkspaceStorageRoots(appName?: string): string[] { + const isInsiders = appName?.toLowerCase().includes('insiders') ?? false; switch (process.platform) { case 'win32': { const appdata = process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'); - return [ - path.join(appdata, 'Code', 'User', 'workspaceStorage'), - path.join(appdata, 'Code - Insiders', 'User', 'workspaceStorage'), - ]; + const stable = path.join(appdata, 'Code', 'User', 'workspaceStorage'); + const insiders = path.join(appdata, 'Code - Insiders', 'User', 'workspaceStorage'); + return isInsiders ? [insiders, stable] : [stable, insiders]; + } + case 'darwin': { + const stable = path.join(os.homedir(), 'Library', 'Application Support', 'Code', 'User', 'workspaceStorage'); + const insiders = path.join(os.homedir(), 'Library', 'Application Support', 'Code - Insiders', 'User', 'workspaceStorage'); + return isInsiders ? [insiders, stable] : [stable, insiders]; } - case 'darwin': - return [ - path.join(os.homedir(), 'Library', 'Application Support', 'Code', 'User', 'workspaceStorage'), - path.join(os.homedir(), 'Library', 'Application Support', 'Code - Insiders', 'User', 'workspaceStorage'), - ]; default: { const config = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config'); - return [ - path.join(config, 'Code', 'User', 'workspaceStorage'), - path.join(config, 'Code - Insiders', 'User', 'workspaceStorage'), - ]; + const stable = path.join(config, 'Code', 'User', 'workspaceStorage'); + const insiders = path.join(config, 'Code - Insiders', 'User', 'workspaceStorage'); + return isInsiders ? [insiders, stable] : [stable, insiders]; } } } diff --git a/apps/vscode-extension/src/extension.ts b/apps/vscode-extension/src/extension.ts index f1d0121..15b51a9 100644 --- a/apps/vscode-extension/src/extension.ts +++ b/apps/vscode-extension/src/extension.ts @@ -24,7 +24,7 @@ export function activate(context: vscode.ExtensionContext) { await Promise.all(tasks); }; - const storageRoots = getWorkspaceStorageRoots(); + const storageRoots = getWorkspaceStorageRoots(vscode.env.appName); let lastSignature: string | undefined; let refreshTimer: ReturnType | undefined; From 281e297786d43f6459224eac076b1e64dde8c99b Mon Sep 17 00:00:00 2001 From: Steven Maglio Date: Tue, 26 May 2026 12:29:31 -0700 Subject: [PATCH 04/19] fix: propagate storageRoots to all refresh/discovery paths and normalize empty roots to None --- .../src/copilot_usage/dashboard/pages/pipeline.py | 5 +++-- apps/vscode-extension/src/extension.ts | 6 +++--- apps/vscode-extension/src/views/panels.ts | 12 ++++++++---- apps/vscode-extension/src/views/statusBar.ts | 6 ++++-- 4 files changed, 18 insertions(+), 11 deletions(-) diff --git a/apps/cli/src/copilot_usage/dashboard/pages/pipeline.py b/apps/cli/src/copilot_usage/dashboard/pages/pipeline.py index f01ac77..5a751fd 100644 --- a/apps/cli/src/copilot_usage/dashboard/pages/pipeline.py +++ b/apps/cli/src/copilot_usage/dashboard/pages/pipeline.py @@ -53,8 +53,9 @@ def _run_pipeline_thread(storage_path: str): try: if storage_path and storage_path.strip(): - storage_roots = [Path(p.strip()) for p in storage_path.split(";") if p.strip()] - _append_log(f"Storage paths: {'; '.join(str(p) for p in storage_roots)}", 0) + 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: storage_roots = None # use all auto-detected roots _append_log(f"Storage paths: {'; '.join(str(p) for p in VSCODE_STORAGE_ROOTS)}", 0) diff --git a/apps/vscode-extension/src/extension.ts b/apps/vscode-extension/src/extension.ts index 15b51a9..c96a990 100644 --- a/apps/vscode-extension/src/extension.ts +++ b/apps/vscode-extension/src/extension.ts @@ -14,9 +14,9 @@ export function activate(context: vscode.ExtensionContext) { /** Refresh status bar + any open panels. */ const refreshAll = async () => { const tasks: Promise[] = [ - statusBar.refresh(), - WorkspacePanel.refresh(), - DashboardPanel.refresh(), + statusBar.refresh(storageRoots), + WorkspacePanel.refresh(storageRoots), + DashboardPanel.refresh(storageRoots), ]; if (enableCostEstimator) { tasks.push(CostEstimatorPanel.refresh()); diff --git a/apps/vscode-extension/src/views/panels.ts b/apps/vscode-extension/src/views/panels.ts index 10b0491..c68590f 100644 --- a/apps/vscode-extension/src/views/panels.ts +++ b/apps/vscode-extension/src/views/panels.ts @@ -13,6 +13,7 @@ import { export class WorkspacePanel { public static currentPanel: WorkspacePanel | undefined; + private static storageRoots?: string[]; private readonly panel: vscode.WebviewPanel; private disposables: vscode.Disposable[] = []; private disposed = false; @@ -46,7 +47,8 @@ export class WorkspacePanel { ); } - public static async refresh(): Promise { + public static async refresh(storageRoots?: string[]): Promise { + WorkspacePanel.storageRoots = storageRoots ?? WorkspacePanel.storageRoots; if (WorkspacePanel.currentPanel) { await WorkspacePanel.currentPanel.loadData(); } @@ -95,7 +97,7 @@ export class WorkspacePanel { const wsFileUri = vscode.workspace.workspaceFile?.toString(); const folderPaths = folders.map(f => f.uri.fsPath); - const ws = await findCurrentWorkspace(wsFileUri, folderPaths); + const ws = await findCurrentWorkspace(wsFileUri, folderPaths, WorkspacePanel.storageRoots); if (!ws) { const searched = wsFileUri ? `workspace file: ${vscode.workspace.workspaceFile!.fsPath}` @@ -129,6 +131,7 @@ export class WorkspacePanel { /** Global dashboard webview panel. */ export class DashboardPanel { public static currentPanel: DashboardPanel | undefined; + private static storageRoots?: string[]; private readonly panel: vscode.WebviewPanel; private disposables: vscode.Disposable[] = []; private disposed = false; @@ -162,7 +165,8 @@ export class DashboardPanel { ); } - public static async refresh(): Promise { + public static async refresh(storageRoots?: string[]): Promise { + DashboardPanel.storageRoots = storageRoots ?? DashboardPanel.storageRoots; if (DashboardPanel.currentPanel) { await DashboardPanel.currentPanel.loadData(); } @@ -203,7 +207,7 @@ export class DashboardPanel { const autoRefreshSeconds = cfg.get('dashboard.autoRefreshSeconds', 0); const showDebugLogBanner = !isCopilotDebugLogEnabled(); - const workspaces = await discoverWorkspaces(); + const workspaces = await discoverWorkspaces(DashboardPanel.storageRoots); if (workspaces.length === 0) { this.setHtml(getDashboardHtml(undefined, undefined, undefined, undefined, 'No Copilot session data found.', autoRefreshSeconds, 0, showDebugLogBanner)); return; diff --git a/apps/vscode-extension/src/views/statusBar.ts b/apps/vscode-extension/src/views/statusBar.ts index e82f170..7c39b25 100644 --- a/apps/vscode-extension/src/views/statusBar.ts +++ b/apps/vscode-extension/src/views/statusBar.ts @@ -12,6 +12,7 @@ export class StatusBarManager implements vscode.Disposable { private item: vscode.StatusBarItem; private debugLogItem: vscode.StatusBarItem; private disposables: vscode.Disposable[] = []; + private storageRoots?: string[]; constructor() { this.item = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, 50); @@ -34,7 +35,8 @@ export class StatusBarManager implements vscode.Disposable { this.refresh(); } - async refresh(): Promise { + async refresh(storageRoots?: string[]): Promise { + this.storageRoots = storageRoots ?? this.storageRoots; if (isCopilotDebugLogEnabled()) { this.debugLogItem.hide(); } else { @@ -51,7 +53,7 @@ export class StatusBarManager implements vscode.Disposable { try { const wsFileUri = vscode.workspace.workspaceFile?.toString(); const folderPaths = folders.map(f => f.uri.fsPath); - const ws = await findCurrentWorkspace(wsFileUri, folderPaths); + const ws = await findCurrentWorkspace(wsFileUri, folderPaths, this.storageRoots); if (!ws) { this.item.text = '$(copilot) No data'; this.item.tooltip = 'Copilot Usage — No session data found for this workspace'; From 0d2e8274512fc9e5b7fa58ffbc4f56e1a114db60 Mon Sep 17 00:00:00 2001 From: Steven Maglio Date: Tue, 26 May 2026 12:45:20 -0700 Subject: [PATCH 05/19] test: add unit tests for getWorkspaceStorageRoots Insiders/stable ordering --- .../src/test/discovery.test.ts | 23 ++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/apps/vscode-extension/src/test/discovery.test.ts b/apps/vscode-extension/src/test/discovery.test.ts index 9fafbd4..ae160c6 100644 --- a/apps/vscode-extension/src/test/discovery.test.ts +++ b/apps/vscode-extension/src/test/discovery.test.ts @@ -3,7 +3,7 @@ import * as fs from 'fs/promises'; import * as os from 'os'; import * as path from 'path'; import { pathToFileURL } from 'url'; -import { findCurrentWorkspace, computeChatSessionsSignature } from '../core/discovery'; +import { getWorkspaceStorageRoots, findCurrentWorkspace, computeChatSessionsSignature } from '../core/discovery'; async function withTempStorage(run: (storageRoot: string, folderA: string, folderB: string, wsFilePath: string) => Promise): Promise { const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'copilot-usage-discovery-')); @@ -96,3 +96,24 @@ suite('Discovery: current workspace matching', () => { }); }); }); + +suite('Discovery: getWorkspaceStorageRoots ordering', () => { + test('returns stable root first when appName is undefined', () => { + const roots = getWorkspaceStorageRoots(); + assert.strictEqual(roots.length, 2); + assert.ok(!roots[0].toLowerCase().includes('insiders'), `Expected stable root first, got: ${roots[0]}`); + assert.ok(roots[1].toLowerCase().includes('insiders'), `Expected Insiders root second, got: ${roots[1]}`); + }); + + test('returns stable root first when appName is "Visual Studio Code"', () => { + const roots = getWorkspaceStorageRoots('Visual Studio Code'); + assert.ok(!roots[0].toLowerCase().includes('insiders'), `Expected stable root first, got: ${roots[0]}`); + assert.ok(roots[1].toLowerCase().includes('insiders'), `Expected Insiders root second, got: ${roots[1]}`); + }); + + test('returns Insiders root first when appName contains "Insiders"', () => { + const roots = getWorkspaceStorageRoots('Visual Studio Code - Insiders'); + assert.ok(roots[0].toLowerCase().includes('insiders'), `Expected Insiders root first, got: ${roots[0]}`); + assert.ok(!roots[1].toLowerCase().includes('insiders'), `Expected stable root second, got: ${roots[1]}`); + }); +}); From a17d3724de41b510553678cb97c70c7cb51c712d Mon Sep 17 00:00:00 2001 From: Steven Maglio Date: Tue, 26 May 2026 13:22:29 -0700 Subject: [PATCH 06/19] fix: tighten test path assertions to 'Code - Insiders' and pass storage_root to nested folder URI resolution --- apps/cli/src/copilot_usage/discovery.py | 2 +- apps/vscode-extension/src/test/discovery.test.ts | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/apps/cli/src/copilot_usage/discovery.py b/apps/cli/src/copilot_usage/discovery.py index 49ea50f..4b8f7e3 100644 --- a/apps/cli/src/copilot_usage/discovery.py +++ b/apps/cli/src/copilot_usage/discovery.py @@ -58,7 +58,7 @@ def resolve_workspace(workspace_dir: Path, storage_root: Path | None = None) -> 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) ] diff --git a/apps/vscode-extension/src/test/discovery.test.ts b/apps/vscode-extension/src/test/discovery.test.ts index ae160c6..1625568 100644 --- a/apps/vscode-extension/src/test/discovery.test.ts +++ b/apps/vscode-extension/src/test/discovery.test.ts @@ -101,19 +101,19 @@ suite('Discovery: getWorkspaceStorageRoots ordering', () => { test('returns stable root first when appName is undefined', () => { const roots = getWorkspaceStorageRoots(); assert.strictEqual(roots.length, 2); - assert.ok(!roots[0].toLowerCase().includes('insiders'), `Expected stable root first, got: ${roots[0]}`); - assert.ok(roots[1].toLowerCase().includes('insiders'), `Expected Insiders root second, got: ${roots[1]}`); + assert.ok(!roots[0].toLowerCase().includes('code - insiders'), `Expected stable root first, got: ${roots[0]}`); + assert.ok(roots[1].toLowerCase().includes('code - insiders'), `Expected Insiders root second, got: ${roots[1]}`); }); test('returns stable root first when appName is "Visual Studio Code"', () => { const roots = getWorkspaceStorageRoots('Visual Studio Code'); - assert.ok(!roots[0].toLowerCase().includes('insiders'), `Expected stable root first, got: ${roots[0]}`); - assert.ok(roots[1].toLowerCase().includes('insiders'), `Expected Insiders root second, got: ${roots[1]}`); + assert.ok(!roots[0].toLowerCase().includes('code - insiders'), `Expected stable root first, got: ${roots[0]}`); + assert.ok(roots[1].toLowerCase().includes('code - insiders'), `Expected Insiders root second, got: ${roots[1]}`); }); test('returns Insiders root first when appName contains "Insiders"', () => { const roots = getWorkspaceStorageRoots('Visual Studio Code - Insiders'); - assert.ok(roots[0].toLowerCase().includes('insiders'), `Expected Insiders root first, got: ${roots[0]}`); - assert.ok(!roots[1].toLowerCase().includes('insiders'), `Expected stable root second, got: ${roots[1]}`); + assert.ok(roots[0].toLowerCase().includes('code - insiders'), `Expected Insiders root first, got: ${roots[0]}`); + assert.ok(!roots[1].toLowerCase().includes('code - insiders'), `Expected stable root second, got: ${roots[1]}`); }); }); From 14bfc71afed6f66245e01f5bc9dd9c87ff4f920a Mon Sep 17 00:00:00 2001 From: Steven Maglio Date: Tue, 26 May 2026 14:44:21 -0700 Subject: [PATCH 07/19] fix: remove premature constructor refresh in StatusBarManager; log fallback roots when input parses empty --- apps/cli/src/copilot_usage/dashboard/pages/pipeline.py | 2 ++ apps/vscode-extension/src/views/statusBar.ts | 2 -- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/cli/src/copilot_usage/dashboard/pages/pipeline.py b/apps/cli/src/copilot_usage/dashboard/pages/pipeline.py index 5a751fd..1dc5491 100644 --- a/apps/cli/src/copilot_usage/dashboard/pages/pipeline.py +++ b/apps/cli/src/copilot_usage/dashboard/pages/pipeline.py @@ -56,6 +56,8 @@ def _run_pipeline_thread(storage_path: str): 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) diff --git a/apps/vscode-extension/src/views/statusBar.ts b/apps/vscode-extension/src/views/statusBar.ts index 7c39b25..7ba13ab 100644 --- a/apps/vscode-extension/src/views/statusBar.ts +++ b/apps/vscode-extension/src/views/statusBar.ts @@ -31,8 +31,6 @@ export class StatusBarManager implements vscode.Disposable { if (e.affectsConfiguration('copilot-usage') || didAffectCopilotDebugLogSetting(e)) { this.refresh(); } }), ); - - this.refresh(); } async refresh(storageRoots?: string[]): Promise { From 352a330d323a3406234c06bbc8251aefc7f1f67a Mon Sep 17 00:00:00 2001 From: Steven Maglio Date: Tue, 26 May 2026 16:08:49 -0700 Subject: [PATCH 08/19] fix: replace hard-coded storage root names with dynamic VSCODE_STORAGE_ROOTS in helper text --- apps/cli/src/copilot_usage/dashboard/pages/pipeline.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/cli/src/copilot_usage/dashboard/pages/pipeline.py b/apps/cli/src/copilot_usage/dashboard/pages/pipeline.py index 1dc5491..e6060c3 100644 --- a/apps/cli/src/copilot_usage/dashboard/pages/pipeline.py +++ b/apps/cli/src/copilot_usage/dashboard/pages/pipeline.py @@ -114,8 +114,8 @@ def _run_pipeline_thread(storage_path: str): ]), html.Small( "One or more workspaceStorage paths separated by semicolons. " - "Leave blank to scan all auto-detected locations " - "(Code and Code - Insiders).", + f"Leave blank to scan all auto-detected locations " + f"({'; '.join(str(p) for p in VSCODE_STORAGE_ROOTS)}).", className="text-muted", ), ], md=9), From 841bbf4942359a048f4878b291819a1ab1f932df Mon Sep 17 00:00:00 2001 From: Steven Maglio Date: Tue, 26 May 2026 16:28:38 -0700 Subject: [PATCH 09/19] fix: use urlparse+url2pathname for file:// URIs; make discover_all_session_files keyword-only --- apps/cli/src/copilot_usage/discovery.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/apps/cli/src/copilot_usage/discovery.py b/apps/cli/src/copilot_usage/discovery.py index 4b8f7e3..5813830 100644 --- a/apps/cli/src/copilot_usage/discovery.py +++ b/apps/cli/src/copilot_usage/discovery.py @@ -3,7 +3,8 @@ 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 @@ -19,8 +20,8 @@ def _uri_to_path(uri: str, storage_root: Path | None = None) -> str: - ``vscode-userdata:///Code/...`` → ``{APPDATA}/Code/...`` - bare string → decoded as-is """ - if uri.startswith("file:///"): - return unquote(uri[len("file:///"):]) + if uri.startswith("file://"): + return url2pathname(urlparse(uri).path) 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), @@ -76,6 +77,7 @@ def resolve_workspace(workspace_dir: Path, storage_root: Path | None = None) -> def discover_all_session_files( + *, storage_roots: list[Path] | None = None, storage_root: Path | None = None, # deprecated: use storage_roots ) -> tuple[list[tuple[str, str, Path]], list[tuple[str, str, Path]]]: From 547da559235fd737a1fcd78791fb4d2f6940640a Mon Sep 17 00:00:00 2001 From: Steven Maglio Date: Tue, 26 May 2026 16:35:40 -0700 Subject: [PATCH 10/19] fix: pass storage_roots as keyword arg to discover_all_session_files --- apps/cli/src/copilot_usage/discovery.py | 4 ++-- apps/cli/src/copilot_usage/pipeline.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/cli/src/copilot_usage/discovery.py b/apps/cli/src/copilot_usage/discovery.py index 5813830..33c716b 100644 --- a/apps/cli/src/copilot_usage/discovery.py +++ b/apps/cli/src/copilot_usage/discovery.py @@ -127,7 +127,7 @@ def discover_jsonl_files( Returns list of (workspace_id, workspace_path, jsonl_path). """ - jsonl, _ = discover_all_session_files(storage_roots) + jsonl, _ = discover_all_session_files(storage_roots=storage_roots) return jsonl @@ -138,7 +138,7 @@ def discover_legacy_json_files( Returns list of (workspace_id, workspace_path, json_path). """ - _, legacy = discover_all_session_files(storage_roots) + _, legacy = discover_all_session_files(storage_roots=storage_roots) return legacy diff --git a/apps/cli/src/copilot_usage/pipeline.py b/apps/cli/src/copilot_usage/pipeline.py index 7439ad8..366ec29 100644 --- a/apps/cli/src/copilot_usage/pipeline.py +++ b/apps/cli/src/copilot_usage/pipeline.py @@ -46,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_roots) + 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 From eadf2566e3fe65189eb4e9ce631865f257dd60f3 Mon Sep 17 00:00:00 2001 From: Steven Maglio Date: Tue, 26 May 2026 16:47:18 -0700 Subject: [PATCH 11/19] fix: (dashboard) Default button sets joined VSCODE_STORAGE_ROOTS; (discovery) update _uri_to_path docstring for platform-agnostic output --- apps/cli/src/copilot_usage/dashboard/pages/pipeline.py | 4 +++- apps/cli/src/copilot_usage/discovery.py | 3 ++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/apps/cli/src/copilot_usage/dashboard/pages/pipeline.py b/apps/cli/src/copilot_usage/dashboard/pages/pipeline.py index e6060c3..937c72f 100644 --- a/apps/cli/src/copilot_usage/dashboard/pages/pipeline.py +++ b/apps/cli/src/copilot_usage/dashboard/pages/pipeline.py @@ -184,7 +184,9 @@ def _run_pipeline_thread(storage_path: str): prevent_initial_call=True, ) def _reset_path(_): - return "" + # 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( diff --git a/apps/cli/src/copilot_usage/discovery.py b/apps/cli/src/copilot_usage/discovery.py index 33c716b..42ab7ee 100644 --- a/apps/cli/src/copilot_usage/discovery.py +++ b/apps/cli/src/copilot_usage/discovery.py @@ -16,9 +16,10 @@ 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 url2pathname(urlparse(uri).path) From 59564e54270613b4f4d627c79dce68506817b869 Mon Sep 17 00:00:00 2001 From: Steven Maglio Date: Wed, 27 May 2026 07:37:36 -0700 Subject: [PATCH 12/19] fix: default storageRoots to getWorkspaceStorageRoots(appName) when unset in panels and statusBar --- apps/vscode-extension/src/views/panels.ts | 6 +++--- apps/vscode-extension/src/views/statusBar.ts | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/vscode-extension/src/views/panels.ts b/apps/vscode-extension/src/views/panels.ts index c68590f..9418548 100644 --- a/apps/vscode-extension/src/views/panels.ts +++ b/apps/vscode-extension/src/views/panels.ts @@ -1,7 +1,7 @@ /** Workspace-scoped analysis webview panel. */ import * as vscode from 'vscode'; -import { findCurrentWorkspace, discoverWorkspaces } from '../core/discovery'; +import { findCurrentWorkspace, discoverWorkspaces, getWorkspaceStorageRoots } from '../core/discovery'; import { parseAllFiles, flattenEvents, computeKpis, computeModelStats, computeDailyStats, computeWorkspaceStats } from '../core/aggregator'; import { computeRepoAttributionStats, discoverRepoDescriptors, RepoAttributionStats } from '../core/repoAttribution'; import { enableCostEstimator } from '../features/costEstimator/flags'; @@ -48,7 +48,7 @@ export class WorkspacePanel { } public static async refresh(storageRoots?: string[]): Promise { - WorkspacePanel.storageRoots = storageRoots ?? WorkspacePanel.storageRoots; + WorkspacePanel.storageRoots = storageRoots ?? WorkspacePanel.storageRoots ?? getWorkspaceStorageRoots(vscode.env.appName); if (WorkspacePanel.currentPanel) { await WorkspacePanel.currentPanel.loadData(); } @@ -166,7 +166,7 @@ export class DashboardPanel { } public static async refresh(storageRoots?: string[]): Promise { - DashboardPanel.storageRoots = storageRoots ?? DashboardPanel.storageRoots; + DashboardPanel.storageRoots = storageRoots ?? DashboardPanel.storageRoots ?? getWorkspaceStorageRoots(vscode.env.appName); if (DashboardPanel.currentPanel) { await DashboardPanel.currentPanel.loadData(); } diff --git a/apps/vscode-extension/src/views/statusBar.ts b/apps/vscode-extension/src/views/statusBar.ts index 7ba13ab..99150dc 100644 --- a/apps/vscode-extension/src/views/statusBar.ts +++ b/apps/vscode-extension/src/views/statusBar.ts @@ -1,7 +1,7 @@ /** Status bar item showing workspace token count, split by input/output. */ import * as vscode from 'vscode'; -import { findCurrentWorkspace } from '../core/discovery'; +import { findCurrentWorkspace, getWorkspaceStorageRoots } from '../core/discovery'; import { parseAllFiles, flattenEvents } from '../core/aggregator'; import { RequestEvent } from '../core/types'; import { didAffectCopilotDebugLogSetting, isCopilotDebugLogEnabled } from '../core/copilotDebugLog'; @@ -34,7 +34,7 @@ export class StatusBarManager implements vscode.Disposable { } async refresh(storageRoots?: string[]): Promise { - this.storageRoots = storageRoots ?? this.storageRoots; + this.storageRoots = storageRoots ?? this.storageRoots ?? getWorkspaceStorageRoots(vscode.env.appName); if (isCopilotDebugLogEnabled()) { this.debugLogItem.hide(); } else { From e58ebf45ca1dc6b1c74f0f386b206cf793c82254 Mon Sep 17 00:00:00 2001 From: Steven Maglio Date: Wed, 27 May 2026 08:38:04 -0700 Subject: [PATCH 13/19] fix: initialize storageRoots on-demand in loadData() and unquote file URIs in _uri_to_path --- apps/cli/src/copilot_usage/discovery.py | 2 +- apps/vscode-extension/src/views/panels.ts | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/cli/src/copilot_usage/discovery.py b/apps/cli/src/copilot_usage/discovery.py index 42ab7ee..72d5430 100644 --- a/apps/cli/src/copilot_usage/discovery.py +++ b/apps/cli/src/copilot_usage/discovery.py @@ -22,7 +22,7 @@ def _uri_to_path(uri: str, storage_root: Path | None = None) -> str: Output is platform-dependent (Windows paths use backslashes, POSIX uses forward slashes). """ if uri.startswith("file://"): - return url2pathname(urlparse(uri).path) + return url2pathname(unquote(urlparse(uri).path)) 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), diff --git a/apps/vscode-extension/src/views/panels.ts b/apps/vscode-extension/src/views/panels.ts index 9418548..555d356 100644 --- a/apps/vscode-extension/src/views/panels.ts +++ b/apps/vscode-extension/src/views/panels.ts @@ -97,6 +97,7 @@ export class WorkspacePanel { const wsFileUri = vscode.workspace.workspaceFile?.toString(); const folderPaths = folders.map(f => f.uri.fsPath); + WorkspacePanel.storageRoots ??= getWorkspaceStorageRoots(vscode.env.appName); const ws = await findCurrentWorkspace(wsFileUri, folderPaths, WorkspacePanel.storageRoots); if (!ws) { const searched = wsFileUri @@ -207,6 +208,7 @@ export class DashboardPanel { const autoRefreshSeconds = cfg.get('dashboard.autoRefreshSeconds', 0); const showDebugLogBanner = !isCopilotDebugLogEnabled(); + DashboardPanel.storageRoots ??= getWorkspaceStorageRoots(vscode.env.appName); const workspaces = await discoverWorkspaces(DashboardPanel.storageRoots); if (workspaces.length === 0) { this.setHtml(getDashboardHtml(undefined, undefined, undefined, undefined, 'No Copilot session data found.', autoRefreshSeconds, 0, showDebugLogBanner)); From 404fd83f428fe504cc4a1ab2ef0e20d90ee716a8 Mon Sep 17 00:00:00 2001 From: Steven Maglio Date: Wed, 27 May 2026 08:58:30 -0700 Subject: [PATCH 14/19] fix: de-duplicate discoverWorkspaces results by workspaceId across stable and Insiders roots --- apps/vscode-extension/src/core/discovery.ts | 23 ++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/apps/vscode-extension/src/core/discovery.ts b/apps/vscode-extension/src/core/discovery.ts index 08772d2..af628b3 100644 --- a/apps/vscode-extension/src/core/discovery.ts +++ b/apps/vscode-extension/src/core/discovery.ts @@ -129,7 +129,28 @@ export async function discoverWorkspaces( } } - return results; + // De-duplicate by workspaceId: when both stable and Insiders roots contain + // entries for the same workspace, merge their session files and referenced + // folders so downstream sees a unified view across all installations. + const merged = new Map(); + for (const ws of results) { + const existing = merged.get(ws.workspaceId); + if (existing) { + existing.sessionFiles = [...existing.sessionFiles, ...ws.sessionFiles]; + if (!existing.workspacePath && ws.workspacePath) { + existing.workspacePath = ws.workspacePath; + } + if (ws.referencedFolders) { + existing.referencedFolders = [ + ...(existing.referencedFolders ?? []), + ...ws.referencedFolders, + ]; + } + } else { + merged.set(ws.workspaceId, { ...ws }); + } + } + return [...merged.values()]; } /** From 0bed45ea382626c9be815d570ea9ab1a4cb9e7ae Mon Sep 17 00:00:00 2001 From: Steven Maglio Date: Wed, 27 May 2026 11:06:59 -0700 Subject: [PATCH 15/19] test: cover workspaceId merge across stable and insiders roots --- .../src/test/discovery.test.ts | 54 ++++++++++++++++++- 1 file changed, 53 insertions(+), 1 deletion(-) diff --git a/apps/vscode-extension/src/test/discovery.test.ts b/apps/vscode-extension/src/test/discovery.test.ts index 1625568..4e70963 100644 --- a/apps/vscode-extension/src/test/discovery.test.ts +++ b/apps/vscode-extension/src/test/discovery.test.ts @@ -3,7 +3,7 @@ import * as fs from 'fs/promises'; import * as os from 'os'; import * as path from 'path'; import { pathToFileURL } from 'url'; -import { getWorkspaceStorageRoots, findCurrentWorkspace, computeChatSessionsSignature } from '../core/discovery'; +import { getWorkspaceStorageRoots, findCurrentWorkspace, computeChatSessionsSignature, discoverWorkspaces } from '../core/discovery'; async function withTempStorage(run: (storageRoot: string, folderA: string, folderB: string, wsFilePath: string) => Promise): Promise { const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'copilot-usage-discovery-')); @@ -117,3 +117,55 @@ suite('Discovery: getWorkspaceStorageRoots ordering', () => { assert.ok(!roots[1].toLowerCase().includes('code - insiders'), `Expected stable root second, got: ${roots[1]}`); }); }); + +suite('Discovery: merged workspace entries across roots', () => { + test('merges same workspaceId from multiple storage roots and preserves matching', async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'copilot-usage-discovery-merge-')); + try { + const storageRootStable = path.join(tempRoot, 'stable', 'workspaceStorage'); + const storageRootInsiders = path.join(tempRoot, 'insiders', 'workspaceStorage'); + const workspaceId = 'shared-workspace-id'; + + const sharedFolder = path.join(tempRoot, 'repo-shared'); + await fs.mkdir(sharedFolder, { recursive: true }); + + const stableWsDir = path.join(storageRootStable, workspaceId); + await fs.mkdir(path.join(stableWsDir, 'chatSessions'), { recursive: true }); + await fs.writeFile( + path.join(stableWsDir, 'workspace.json'), + JSON.stringify({ folder: pathToFileURL(sharedFolder).toString() }), + 'utf-8', + ); + await fs.writeFile(path.join(stableWsDir, 'chatSessions', 'stable.jsonl'), '{"kind":0}\n', 'utf-8'); + + const insidersWsDir = path.join(storageRootInsiders, workspaceId); + await fs.mkdir(path.join(insidersWsDir, 'chatSessions'), { recursive: true }); + await fs.writeFile( + path.join(insidersWsDir, 'workspace.json'), + JSON.stringify({ folder: pathToFileURL(sharedFolder).toString() }), + 'utf-8', + ); + await fs.writeFile(path.join(insidersWsDir, 'chatSessions', 'insiders.jsonl'), '{"kind":0}\n', 'utf-8'); + + const workspaces = await discoverWorkspaces([storageRootStable, storageRootInsiders]); + assert.strictEqual(workspaces.length, 1, `Expected one merged workspace entry, got ${workspaces.length}`); + assert.strictEqual(workspaces[0].workspaceId, workspaceId); + assert.strictEqual(workspaces[0].sessionFiles.length, 2, 'Expected merged session files from both roots'); + assert.ok( + workspaces[0].sessionFiles.some((f) => f.endsWith(path.join('chatSessions', 'stable.jsonl'))), + 'Expected stable root session file to be preserved', + ); + assert.ok( + workspaces[0].sessionFiles.some((f) => f.endsWith(path.join('chatSessions', 'insiders.jsonl'))), + 'Expected Insiders root session file to be preserved', + ); + + const matched = await findCurrentWorkspace(undefined, [sharedFolder], [storageRootStable, storageRootInsiders]); + assert.ok(matched); + assert.strictEqual(matched!.workspaceId, workspaceId); + assert.strictEqual(matched!.sessionFiles.length, 2); + } finally { + await fs.rm(tempRoot, { recursive: true, force: true }); + } + }); +}); From 67a0191e6b1156c1942b4f98167c4266ea59b48b Mon Sep 17 00:00:00 2001 From: Steven Maglio Date: Wed, 27 May 2026 11:19:08 -0700 Subject: [PATCH 16/19] fix: preserve compatibility in multi-root discovery helpers --- apps/cli/src/copilot_usage/discovery.py | 6 ++++ apps/vscode-extension/src/core/discovery.ts | 8 +++-- .../src/test/discovery.test.ts | 30 +++++++++++++++---- 3 files changed, 36 insertions(+), 8 deletions(-) diff --git a/apps/cli/src/copilot_usage/discovery.py b/apps/cli/src/copilot_usage/discovery.py index 72d5430..0dc33f3 100644 --- a/apps/cli/src/copilot_usage/discovery.py +++ b/apps/cli/src/copilot_usage/discovery.py @@ -123,22 +123,28 @@ def discover_all_session_files( def discover_jsonl_files( storage_roots: list[Path] | None = None, + storage_root: Path | None = None, ) -> list[tuple[str, str, Path]]: """Find all chatSessions/*.jsonl files. Returns list of (workspace_id, workspace_path, jsonl_path). """ + 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_roots: list[Path] | None = None, + storage_root: 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). """ + 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 diff --git a/apps/vscode-extension/src/core/discovery.ts b/apps/vscode-extension/src/core/discovery.ts index af628b3..f587e80 100644 --- a/apps/vscode-extension/src/core/discovery.ts +++ b/apps/vscode-extension/src/core/discovery.ts @@ -136,14 +136,16 @@ export async function discoverWorkspaces( for (const ws of results) { const existing = merged.get(ws.workspaceId); if (existing) { - existing.sessionFiles = [...existing.sessionFiles, ...ws.sessionFiles]; + existing.sessionFiles = [...new Set([...existing.sessionFiles, ...ws.sessionFiles])]; if (!existing.workspacePath && ws.workspacePath) { existing.workspacePath = ws.workspacePath; } if (ws.referencedFolders) { existing.referencedFolders = [ - ...(existing.referencedFolders ?? []), - ...ws.referencedFolders, + ...new Set([ + ...(existing.referencedFolders ?? []), + ...ws.referencedFolders, + ]), ]; } } else { diff --git a/apps/vscode-extension/src/test/discovery.test.ts b/apps/vscode-extension/src/test/discovery.test.ts index 4e70963..0e83fc6 100644 --- a/apps/vscode-extension/src/test/discovery.test.ts +++ b/apps/vscode-extension/src/test/discovery.test.ts @@ -126,14 +126,28 @@ suite('Discovery: merged workspace entries across roots', () => { const storageRootInsiders = path.join(tempRoot, 'insiders', 'workspaceStorage'); const workspaceId = 'shared-workspace-id'; - const sharedFolder = path.join(tempRoot, 'repo-shared'); - await fs.mkdir(sharedFolder, { recursive: true }); + const sharedFolderA = path.join(tempRoot, 'repo-shared-a'); + const sharedFolderB = path.join(tempRoot, 'repo-shared-b'); + await fs.mkdir(sharedFolderA, { recursive: true }); + await fs.mkdir(sharedFolderB, { recursive: true }); + + const wsFilePath = path.join(tempRoot, 'shared.code-workspace'); + await fs.writeFile( + wsFilePath, + JSON.stringify({ + folders: [ + { path: sharedFolderA }, + { path: sharedFolderB }, + ], + }), + 'utf-8', + ); const stableWsDir = path.join(storageRootStable, workspaceId); await fs.mkdir(path.join(stableWsDir, 'chatSessions'), { recursive: true }); await fs.writeFile( path.join(stableWsDir, 'workspace.json'), - JSON.stringify({ folder: pathToFileURL(sharedFolder).toString() }), + JSON.stringify({ workspace: pathToFileURL(wsFilePath).toString() }), 'utf-8', ); await fs.writeFile(path.join(stableWsDir, 'chatSessions', 'stable.jsonl'), '{"kind":0}\n', 'utf-8'); @@ -142,7 +156,7 @@ suite('Discovery: merged workspace entries across roots', () => { await fs.mkdir(path.join(insidersWsDir, 'chatSessions'), { recursive: true }); await fs.writeFile( path.join(insidersWsDir, 'workspace.json'), - JSON.stringify({ folder: pathToFileURL(sharedFolder).toString() }), + JSON.stringify({ workspace: pathToFileURL(wsFilePath).toString() }), 'utf-8', ); await fs.writeFile(path.join(insidersWsDir, 'chatSessions', 'insiders.jsonl'), '{"kind":0}\n', 'utf-8'); @@ -151,6 +165,11 @@ suite('Discovery: merged workspace entries across roots', () => { assert.strictEqual(workspaces.length, 1, `Expected one merged workspace entry, got ${workspaces.length}`); assert.strictEqual(workspaces[0].workspaceId, workspaceId); assert.strictEqual(workspaces[0].sessionFiles.length, 2, 'Expected merged session files from both roots'); + assert.deepStrictEqual( + workspaces[0].referencedFolders, + [sharedFolderA, sharedFolderB], + 'Expected referenced folders to be de-duplicated while preserving order', + ); assert.ok( workspaces[0].sessionFiles.some((f) => f.endsWith(path.join('chatSessions', 'stable.jsonl'))), 'Expected stable root session file to be preserved', @@ -160,10 +179,11 @@ suite('Discovery: merged workspace entries across roots', () => { 'Expected Insiders root session file to be preserved', ); - const matched = await findCurrentWorkspace(undefined, [sharedFolder], [storageRootStable, storageRootInsiders]); + const matched = await findCurrentWorkspace(undefined, [sharedFolderA, sharedFolderB], [storageRootStable, storageRootInsiders]); assert.ok(matched); assert.strictEqual(matched!.workspaceId, workspaceId); assert.strictEqual(matched!.sessionFiles.length, 2); + assert.deepStrictEqual(matched!.referencedFolders, [sharedFolderA, sharedFolderB]); } finally { await fs.rm(tempRoot, { recursive: true, force: true }); } From 9e6b30e635c0cb01ae751e1966e6355cb513b264 Mon Sep 17 00:00:00 2001 From: Steven Maglio Date: Wed, 27 May 2026 11:24:34 -0700 Subject: [PATCH 17/19] fix: copy README.md into apps/cli for sdist/wheel compatibility; update pyproject.toml to use local README.md --- apps/cli/README.md | 1 + apps/cli/pyproject.toml | 1 + 2 files changed, 2 insertions(+) create mode 100644 apps/cli/README.md diff --git a/apps/cli/README.md b/apps/cli/README.md new file mode 100644 index 0000000..73cea98 --- /dev/null +++ b/apps/cli/README.md @@ -0,0 +1 @@ +// Copied from repo root for sdist compatibility. See pyproject.toml for rationale. diff --git a/apps/cli/pyproject.toml b/apps/cli/pyproject.toml index 52e33ce..b9ec8dd 100644 --- a/apps/cli/pyproject.toml +++ b/apps/cli/pyproject.toml @@ -16,6 +16,7 @@ authors = [ maintainers = [ { name = "Sachith Liyanagama", email = "liyanagama@outlook.de" }, ] +readme = "README.md" license = "Apache-2.0" keywords = ["copilot", "github", "vs-code" ,"usage", "tokens", "analytics", "dashboard", "observability", "local-first", "privacy-focused"] classifiers = [ From 75d38af44b6e7b039d744ee114afc06ac8d02d60 Mon Sep 17 00:00:00 2001 From: Steven Maglio Date: Wed, 27 May 2026 11:58:06 -0700 Subject: [PATCH 18/19] fix: handle UNC file URIs and restore package readme metadata --- apps/cli/README.md | 33 ++++++++++++++++++++++++- apps/cli/src/copilot_usage/discovery.py | 6 ++++- 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/apps/cli/README.md b/apps/cli/README.md index 73cea98..8894164 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -1 +1,32 @@ -// Copied from repo root for sdist compatibility. See pyproject.toml for rationale. +# 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 diff --git a/apps/cli/src/copilot_usage/discovery.py b/apps/cli/src/copilot_usage/discovery.py index 0dc33f3..8124e82 100644 --- a/apps/cli/src/copilot_usage/discovery.py +++ b/apps/cli/src/copilot_usage/discovery.py @@ -22,7 +22,11 @@ def _uri_to_path(uri: str, storage_root: Path | None = None) -> str: Output is platform-dependent (Windows paths use backslashes, POSIX uses forward slashes). """ if uri.startswith("file://"): - return url2pathname(unquote(urlparse(uri).path)) + parsed = urlparse(uri) + parsed_path = parsed.path + if parsed.netloc: + parsed_path = f"//{parsed.netloc}{parsed_path}" + return url2pathname(unquote(parsed_path)) 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), From 6c44f079f860d730b846d31992bfdc7400a1bdc9 Mon Sep 17 00:00:00 2001 From: Steven Maglio Date: Thu, 28 May 2026 06:40:14 -0700 Subject: [PATCH 19/19] fix: restore positional compatibility for discovery helpers --- apps/cli/src/copilot_usage/discovery.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/apps/cli/src/copilot_usage/discovery.py b/apps/cli/src/copilot_usage/discovery.py index 8124e82..0f87c8c 100644 --- a/apps/cli/src/copilot_usage/discovery.py +++ b/apps/cli/src/copilot_usage/discovery.py @@ -82,15 +82,17 @@ def resolve_workspace(workspace_dir: Path, storage_root: Path | None = None) -> def discover_all_session_files( - *, - storage_roots: list[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 across all roots. Returns (jsonl_files, legacy_json_files) where each item is (workspace_id, workspace_path, file_path). """ + 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 @@ -126,13 +128,16 @@ def discover_all_session_files( def discover_jsonl_files( - storage_roots: list[Path] | None = None, 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). """ + 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) @@ -140,13 +145,16 @@ def discover_jsonl_files( def discover_legacy_json_files( - storage_roots: list[Path] | None = None, 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). """ + 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)