diff --git a/.github/agents/refactor.agent.md b/.github/agents/refactor.agent.md index efb1eab..33175ea 100644 --- a/.github/agents/refactor.agent.md +++ b/.github/agents/refactor.agent.md @@ -1,7 +1,7 @@ --- description: "Improve code quality, apply security best practices, and enhance design whilst maintaining green tests." name: "Code Refactor - Improve Quality & Security" -tools: ["execute/runTests", "execute/getTerminalOutput", "execute/runInTerminal", "read/terminalLastCommand", "read/terminalSelection", "search/codebase", "read/problems", "execute/testFailure"] +tools: ["execute/getTerminalOutput", "execute/runInTerminal", "read/terminalLastCommand", "read/terminalSelection", "search/codebase", "read/problems", "execute/testFailure"] --- # Code Refactor - Improve Quality & Security diff --git a/.github/workflows/cli-build.yml b/.github/workflows/cli-build.yml new file mode 100644 index 0000000..4fbe752 --- /dev/null +++ b/.github/workflows/cli-build.yml @@ -0,0 +1,96 @@ +name: CLI - Build & Validate + +on: + push: + branches: [main] + paths: + - 'cli/**' + - 'src/sessionDiscovery.ts' + - 'src/sessionParser.ts' + - 'src/tokenEstimation.ts' + - 'src/maturityScoring.ts' + - 'src/usageAnalysis.ts' + - 'src/opencode.ts' + - 'src/types.ts' + - 'src/tokenEstimators.json' + - 'src/modelPricing.json' + - 'src/toolNames.json' + pull_request: + branches: [main] + paths: + - 'cli/**' + - 'src/sessionDiscovery.ts' + - 'src/sessionParser.ts' + - 'src/tokenEstimation.ts' + - 'src/maturityScoring.ts' + - 'src/usageAnalysis.ts' + - 'src/opencode.ts' + - 'src/types.ts' + - 'src/tokenEstimators.json' + - 'src/modelPricing.json' + - 'src/toolNames.json' + +permissions: + contents: read + +jobs: + build-and-validate: + runs-on: ubuntu-latest + strategy: + matrix: + node-version: [18, 20, 22] + steps: + - name: Harden Runner + uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0 + with: + egress-policy: audit + + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: Setup Node.js ${{ matrix.node-version }} + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: ${{ matrix.node-version }} + + - name: Install extension dependencies + run: npm ci + + - name: Install CLI dependencies + working-directory: cli + run: npm ci + + - name: Build CLI + working-directory: cli + run: npm run build + + - name: Validate CLI --help + working-directory: cli + run: node dist/cli.js --help + + - name: Validate CLI --version + working-directory: cli + run: node dist/cli.js --version + + - name: Validate stats command + working-directory: cli + run: node dist/cli.js stats --verbose + + - name: Validate usage command + working-directory: cli + run: node dist/cli.js usage + + - name: Validate environmental command + working-directory: cli + run: node dist/cli.js environmental + + - name: Validate fluency command + working-directory: cli + run: node dist/cli.js fluency --tips + + - name: Build production bundle + working-directory: cli + run: npm run build:production + + - name: Verify production bundle runs + working-directory: cli + run: node dist/cli.js --help diff --git a/.github/workflows/cli-publish.yml b/.github/workflows/cli-publish.yml new file mode 100644 index 0000000..d349d95 --- /dev/null +++ b/.github/workflows/cli-publish.yml @@ -0,0 +1,98 @@ +name: CLI - Publish to npm + +on: + workflow_dispatch: + inputs: + version_bump: + description: 'Version bump type' + required: true + default: 'patch' + type: choice + options: + - patch + - minor + - major + dry_run: + description: 'Dry run (do not actually publish)' + required: false + default: false + type: boolean + +permissions: + contents: write + +jobs: + publish: + runs-on: ubuntu-latest + defaults: + run: + working-directory: cli + steps: + - name: Harden Runner + uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0 + with: + egress-policy: audit + + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Setup Node.js + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: 20 + registry-url: https://registry.npmjs.org + + - name: Install extension dependencies + run: npm ci + working-directory: . + + - name: Install CLI dependencies + run: npm ci + + - name: Build production bundle + run: npm run build:production + + - name: Validate CLI works + run: node dist/cli.js --help + + - name: Bump version + run: npm version ${{ inputs.version_bump }} --no-git-tag-version + + - name: Get new version + id: version + run: echo "version=$(node -p 'require("./package.json").version')" >> "$GITHUB_OUTPUT" + + - name: Publish to npm + if: ${{ !inputs.dry_run }} + run: npm publish --access public + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + + - name: Dry run publish + if: ${{ inputs.dry_run }} + run: npm publish --access public --dry-run + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + + - name: Commit version bump + if: ${{ !inputs.dry_run }} + run: | + cd .. + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add cli/package.json cli/package-lock.json + git commit -m "chore(cli): bump version to v${{ steps.version.outputs.version }}" + git push + + - name: Summary + run: | + echo "## CLI Package Published πŸ“¦" >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + echo "- **Version:** v${{ steps.version.outputs.version }}" >> "$GITHUB_STEP_SUMMARY" + echo "- **Bump:** ${{ inputs.version_bump }}" >> "$GITHUB_STEP_SUMMARY" + echo "- **Dry run:** ${{ inputs.dry_run }}" >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + if [ "${{ inputs.dry_run }}" = "false" ]; then + echo "Install with: \`npx copilot-token-tracker-cli\`" >> "$GITHUB_STEP_SUMMARY" + fi diff --git a/README.md b/README.md index dd1e071..d40435c 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,22 @@ # GitHub Copilot Token Tracker -A VS Code extension that shows your daily and monthly GitHub Copilot estimated token usage in the status bar. It reads GitHub Copilot Chat session logs and computes local aggregates. +A VS Code extension that shows your daily and monthly GitHub Copilot estimated token usage and AI Fluency. It reads the local session logs and computes local aggregates. -Optionally, you can enable an **opt-in Azure Storage backend** to sync aggregates from all your VS Code instances (across machines, profiles, and windows) into **your own Azure Storage account** for cross-device reporting. +## Supported AI engineering tools: -You can also use a **shared Azure Storage account** (a β€œshared storage server” for the team) so that multiple developers sync into the same dataset and a team lead can view aggregated usage across the team (with explicit per-user consent). +- VS Code + GitHub Copilot +- VS Code Insiders + GitHub Copilot +- GitHub Copilot CLI +- OpenCode + GitHub Copilot (not tested with other AI tooling) + +### CLI + +We also added a CLI that you can run as an npx package: +```bash +npx copilot-token-tracker usage +``` + +For screenshots and examples of the CLI output, see the [CLI README](cli/README.md). ## Features diff --git a/cli/.npmignore b/cli/.npmignore new file mode 100644 index 0000000..58429cf --- /dev/null +++ b/cli/.npmignore @@ -0,0 +1,6 @@ +src/ +tsconfig.json +esbuild.js +node_modules/ +*.ts +*.map diff --git a/cli/README.md b/cli/README.md new file mode 100644 index 0000000..67a34f8 --- /dev/null +++ b/cli/README.md @@ -0,0 +1,99 @@ +# Copilot Token Tracker CLI + +Command-line interface for analyzing GitHub Copilot token usage from local session files. Works anywhere Copilot Chat stores its session data. + +## Quick Start + +```bash +# Run directly with npx (no install required) +npx copilot-token-tracker-cli stats + +# Or install globally +npm install -g copilot-token-tracker-cli +copilot-token-tracker stats +``` + +## Commands + +### `stats` - Session Overview + +Show discovered session files, sessions, chat turns, and token counts. + +```bash +copilot-token-tracker stats +copilot-token-tracker stats --verbose # Show per-folder breakdown +``` + +![Terminal Statistics](../docs/images/Terminal%20Statistics.png) + +### `usage` - Token Usage Report + +Show token usage broken down by time period. + +```bash +copilot-token-tracker usage +copilot-token-tracker usage --models # Show per-model breakdown +copilot-token-tracker usage --cost # Show estimated cost +``` + +![Terminal Usage](../docs/images/Terminal%20Usage.png) + +### `environmental` - Environmental Impact + +Show environmental impact of your Copilot usage (COβ‚‚ emissions, water usage, tree equivalents). + +```bash +copilot-token-tracker environmental +copilot-token-tracker env # Short alias +``` + +### `fluency` - Fluency Score + +Show your Copilot Fluency Score across multiple categories (Prompt Engineering, Context Engineering, Agentic, Tool Usage, Customization, Team Collaboration). + +```bash +copilot-token-tracker fluency +copilot-token-tracker fluency --tips # Show improvement tips, if there are any +``` + +### `diagnostics` - Search Locations & Stats + +Show all locations searched for session files, whether each path exists, and per-location stats (files, sessions, chat turns, tokens). + +```bash +copilot-token-tracker diagnostics +``` + +![Terminal Diagnostics](../docs/images/Terminal%20Diagnostitcs.png) + +## Data Sources + +The CLI scans the same session files that the [Copilot Token Tracker VS Code extension](https://marketplace.visualstudio.com/items?itemName=RobBos.copilot-token-tracker) uses: + +- **VS Code** (Stable, Insiders, Exploration) workspace and global storage +- **VSCodium** and **Cursor** editor sessions +- **VS Code Remote** / Codespaces sessions +- **Copilot CLI** agent mode sessions +- **OpenCode** sessions (JSON and SQLite) + +## Development + +```bash +# From the repository root +npm run cli:build # Build the CLI +npm run cli:stats # Run stats command +npm run cli:usage # Run usage command +npm run cli:environmental # Run environmental command +npm run cli:fluency # Run fluency command +npm run cli:diagnostics # Run diagnostics command +npm run cli -- --help # Run any CLI command +``` + +## Requirements + +- Node.js 18 or later +- GitHub Copilot Chat session files on the local machine + +## License + +MIT - See [LICENSE](../LICENSE) for details. diff --git a/cli/esbuild.js b/cli/esbuild.js new file mode 100644 index 0000000..6dd4f9f --- /dev/null +++ b/cli/esbuild.js @@ -0,0 +1,82 @@ +const esbuild = require("esbuild"); +const fs = require("fs"); +const path = require("path"); + +const production = process.argv.includes("--production"); + +async function main() { + // Copy JSON data files from src/ to a temp location for bundling + const dataFiles = [ + "tokenEstimators.json", + "modelPricing.json", + "toolNames.json", + ]; + + for (const file of dataFiles) { + const srcPath = path.join(__dirname, "..", "src", file); + const destPath = path.join(__dirname, "src", file); + if (fs.existsSync(srcPath) && !fs.existsSync(destPath)) { + fs.copyFileSync(srcPath, destPath); + } + } + + // Copy sql-wasm.wasm to dist/ + const distDir = path.join(__dirname, "dist"); + if (!fs.existsSync(distDir)) { + fs.mkdirSync(distDir, { recursive: true }); + } + + const wasmSrc = path.join( + __dirname, + "node_modules", + "sql.js", + "dist", + "sql-wasm.wasm" + ); + const wasmDest = path.join(distDir, "sql-wasm.wasm"); + if (fs.existsSync(wasmSrc)) { + fs.copyFileSync(wasmSrc, wasmDest); + console.log("Copied sql-wasm.wasm to dist/"); + } + + const buildOptions = { + entryPoints: ["src/cli.ts"], + bundle: true, + outfile: "dist/cli.js", + format: "cjs", + platform: "node", + target: "node18", + sourcemap: !production, + minify: production, + banner: { + js: "#!/usr/bin/env node", + }, + external: ["vscode"], + // Resolve the parent src/ directory modules + alias: { + vscode: path.join(__dirname, "src", "vscode-stub.ts"), + }, + loader: { + ".json": "json", + }, + logLevel: "info", + }; + + await esbuild.build(buildOptions); + console.log( + `CLI built successfully (${production ? "production" : "development"})` + ); + + // Clean up copied JSON files + for (const file of dataFiles) { + const destPath = path.join(__dirname, "src", file); + if (fs.existsSync(destPath)) { + fs.unlinkSync(destPath); + } + } +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/cli/package-lock.json b/cli/package-lock.json new file mode 100644 index 0000000..f156533 --- /dev/null +++ b/cli/package-lock.json @@ -0,0 +1,635 @@ +{ + "name": "copilot-token-tracker-cli", + "version": "0.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "copilot-token-tracker-cli", + "version": "0.0.1", + "license": "MIT", + "bin": { + "copilot-token-tracker": "dist/cli.js" + }, + "devDependencies": { + "@types/node": "^22.12.0", + "chalk": "^4.1.2", + "commander": "^13.1.0", + "esbuild": "^0.25.0", + "sql.js": "^1.12.0", + "typescript": "^5.7.3" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@types/node": { + "version": "22.19.15", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz", + "integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/commander": { + "version": "13.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-13.1.0.tgz", + "integrity": "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/sql.js": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/sql.js/-/sql.js-1.14.1.tgz", + "integrity": "sha512-gcj8zBWU5cFsi9WUP+4bFNXAyF1iRpA3LLyS/DP5xlrNzGmPIizUeBggKa8DbDwdqaKwUcTEnChtd2grWo/x/A==", + "dev": true, + "license": "MIT" + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/cli/package.json b/cli/package.json new file mode 100644 index 0000000..cb63e86 --- /dev/null +++ b/cli/package.json @@ -0,0 +1,43 @@ +{ + "name": "@rajbos/ai-engineering-fluency", + "version": "0.0.1", + "description": "AI Engineering Fluency - CLI tool to analyze GitHub Copilot token usage from local session files", + "license": "MIT", + "author": "RobBos", + "repository": { + "type": "git", + "url": "https://github.com/rajbos/github-copilot-token-usage", + "directory": "cli" + }, + "keywords": [ + "github-copilot", + "token-usage", + "copilot", + "cli", + "token-tracker" + ], + "bin": { + "copilot-token-tracker": "./dist/cli.js" + }, + "files": [ + "dist/**/*" + ], + "scripts": { + "build": "node esbuild.js", + "build:production": "node esbuild.js --production", + "lint": "eslint src", + "check-types": "tsc --noEmit", + "test": "node dist/cli.js --help && node dist/cli.js --version" + }, + "devDependencies": { + "commander": "^13.1.0", + "chalk": "^4.1.2", + "esbuild": "^0.25.0", + "typescript": "^5.7.3", + "@types/node": "^22.12.0", + "sql.js": "^1.12.0" + }, + "engines": { + "node": ">=18.0.0" + } +} diff --git a/cli/src/cli.ts b/cli/src/cli.ts new file mode 100644 index 0000000..a947dbc --- /dev/null +++ b/cli/src/cli.ts @@ -0,0 +1,30 @@ +/** + * Copilot Token Tracker CLI + * + * Command-line interface for analyzing GitHub Copilot token usage + * from local session files. Can be run via `npx copilot-token-tracker-cli`. + */ +import { Command } from 'commander'; +import { statsCommand } from './commands/stats'; +import { usageCommand } from './commands/usage'; +import { environmentalCommand } from './commands/environmental'; +import { fluencyCommand } from './commands/fluency'; +import { diagnosticsCommand } from './commands/diagnostics'; + +// eslint-disable-next-line @typescript-eslint/no-require-imports +const packageJson = require('../package.json'); + +const program = new Command(); + +program + .name('copilot-token-tracker') + .description('Analyze GitHub Copilot token usage from local session files') + .version(packageJson.version); + +program.addCommand(statsCommand); +program.addCommand(usageCommand); +program.addCommand(environmentalCommand); +program.addCommand(fluencyCommand); +program.addCommand(diagnosticsCommand); + +program.parse(); diff --git a/cli/src/commands/diagnostics.ts b/cli/src/commands/diagnostics.ts new file mode 100644 index 0000000..98ff964 --- /dev/null +++ b/cli/src/commands/diagnostics.ts @@ -0,0 +1,215 @@ +/** + * `diagnostics` command - Show where session files are searched, and stats per location. + */ +import { Command } from 'commander'; +import chalk from 'chalk'; +import * as path from 'path'; +import { discoverSessionFiles, getDiagnosticPaths, processSessionFile, fmt, formatTokens } from '../helpers'; + +interface LocationStats { + label: string; + source: string; + files: number; + sessions: number; + interactions: number; + tokens: number; +} + +/** + * Map a session file path to the candidate search path it lives under. + * Returns the matching candidate path, or 'unknown' if none match. + */ +function matchToCandidatePath( + filePath: string, + candidates: { path: string; exists: boolean; source: string }[] +): { path: string; source: string } | null { + const normalized = filePath.replace(/\\/g, '/'); + // For OpenCode DB virtual paths, resolve to the db file path + const effectivePath = normalized.includes('opencode.db#') ? normalized.split('#')[0] : normalized; + + // Try to find the deepest matching candidate + let best: { path: string; source: string } | null = null; + let bestLen = 0; + for (const cand of candidates) { + const candNorm = cand.path.replace(/\\/g, '/'); + if (effectivePath.startsWith(candNorm) && candNorm.length > bestLen) { + best = { path: cand.path, source: cand.source }; + bestLen = candNorm.length; + } + } + return best; +} + +/** Truncate a path for display, keeping the last N segments */ +function truncatePath(p: string, maxLen = 55): string { + if (p.length <= maxLen) { return p; } + const parts = p.replace(/\\/g, '/').split('/'); + let result = p; + for (let keep = parts.length - 1; keep > 1; keep--) { + result = '…/' + parts.slice(-keep).join('/'); + if (result.length <= maxLen) { break; } + } + return result; +} + +/** Print a simple aligned table to stdout */ +function printTable(headers: string[], rows: string[][], colWidths: number[]): void { + const sep = chalk.dim('─'.repeat(colWidths.reduce((a, b) => a + b + 3, -1))); + + // Header + const headerLine = headers.map((h, i) => chalk.bold(h.padEnd(colWidths[i]))).join(' β”‚ '); + console.log(' ' + headerLine); + console.log(' ' + sep); + + // Rows + for (const row of rows) { + const line = row.map((cell, i) => { + const padded = cell.padEnd(colWidths[i]); + return i === 0 ? chalk.dim(padded) : padded; + }).join(chalk.dim(' β”‚ ')); + console.log(' ' + line); + } + console.log(' ' + sep); +} + +export const diagnosticsCommand = new Command('diagnostics') + .description('Show session file search locations and per-location usage stats') + .action(async () => { + console.log(chalk.bold.cyan('\nπŸ”¬ Copilot Token Tracker - Diagnostics\n')); + + // --- Search path candidates --- + const candidates = getDiagnosticPaths(); + const existing = candidates.filter(c => c.exists); + const missing = candidates.filter(c => !c.exists); + + console.log(chalk.bold(`πŸ“‚ Search Locations (${existing.length} found / ${candidates.length} total)`)); + console.log(chalk.dim('─'.repeat(65))); + + const pathHeaders = ['Source', 'Exists', 'Path']; + const pathColWidths = [18, 6, 55]; + const pathRows = candidates.map(c => [ + c.source, + c.exists ? chalk.green('yes') : chalk.dim('no'), + truncatePath(c.path, 55), + ]); + printTable(pathHeaders, pathRows, pathColWidths); + console.log(); + + if (existing.length === 0) { + console.log(chalk.yellow('⚠️ No search paths exist on this machine.')); + console.log(chalk.dim('Have you used GitHub Copilot Chat in VS Code yet?')); + return; + } + + // --- Discover and process files --- + process.stdout.write(chalk.dim('Scanning for session files...')); + const files = await discoverSessionFiles(); + process.stdout.write('\r' + ' '.repeat(60) + '\r'); + + if (files.length === 0) { + console.log(chalk.yellow('⚠️ No session files found in any search path.')); + return; + } + + // Accumulate stats per candidate path + const locationMap = new Map(); + + // Seed all existing candidates so they appear even if no files matched + for (const cand of existing) { + locationMap.set(cand.path, { + label: truncatePath(cand.path, 45), + source: cand.source, + files: 0, + sessions: 0, + interactions: 0, + tokens: 0, + }); + } + + let totalFiles = 0; + let totalSessions = 0; + let totalInteractions = 0; + let totalTokens = 0; + + for (let i = 0; i < files.length; i++) { + // Progress + if ((i + 1) % 25 === 0 || i === files.length - 1) { + process.stdout.write(`\r${chalk.dim(`Processing: ${i + 1}/${files.length} files...`)}`); + } + + const match = matchToCandidatePath(files[i], candidates); + const key = match ? match.path : '__unknown__'; + + if (!locationMap.has(key)) { + locationMap.set(key, { + label: match ? truncatePath(match.path, 45) : '(unknown)', + source: match ? match.source : 'unknown', + files: 0, + sessions: 0, + interactions: 0, + tokens: 0, + }); + } + + const loc = locationMap.get(key)!; + loc.files++; + totalFiles++; + + const data = await processSessionFile(files[i]); + if (data && data.tokens > 0) { + loc.sessions++; + loc.interactions += data.interactions; + loc.tokens += data.tokens; + totalSessions++; + totalInteractions += data.interactions; + totalTokens += data.tokens; + } + } + + process.stdout.write('\r' + ' '.repeat(60) + '\r'); + + // --- Per-location table --- + console.log(chalk.bold(`πŸ“Š Stats per Search Location`)); + console.log(chalk.dim('─'.repeat(65))); + + const statsHeaders = ['Source', 'Files', 'Sessions', 'Turns', 'Tokens']; + const statsColWidths = [18, 6, 9, 8, 14]; + const statsRows: string[][] = []; + + // Sort by tokens desc, then by source name + const sorted = [...locationMap.values()] + .filter(l => l.files > 0) + .sort((a, b) => b.tokens - a.tokens || a.source.localeCompare(b.source)); + + for (const loc of sorted) { + statsRows.push([ + loc.source, + fmt(loc.files), + fmt(loc.sessions), + fmt(loc.interactions), + formatTokens(loc.tokens), + ]); + } + + if (statsRows.length === 0) { + console.log(chalk.dim(' No session data found in any location.')); + } else { + printTable(statsHeaders, statsRows, statsColWidths); + } + + // --- Totals --- + console.log(); + console.log(chalk.bold('πŸ“ˆ Totals (all time)')); + console.log(chalk.dim('─'.repeat(65))); + console.log(` Total files found: ${chalk.bold(fmt(totalFiles))}`); + console.log(` Files with data: ${chalk.bold(fmt(totalSessions))}`); + console.log(` Total chat turns: ${chalk.bold(fmt(totalInteractions))}`); + console.log(` Total tokens (est.): ${chalk.bold.yellow(formatTokens(totalTokens))}`); + + if (missing.length > 0) { + console.log(); + console.log(chalk.dim(`ℹ️ ${missing.length} search path(s) not present on this machine β€” this is normal if you don't use all VS Code variants.`)); + } + + console.log(); + }); diff --git a/cli/src/commands/environmental.ts b/cli/src/commands/environmental.ts new file mode 100644 index 0000000..c2c3340 --- /dev/null +++ b/cli/src/commands/environmental.ts @@ -0,0 +1,100 @@ +/** + * `environmental` command - Show environmental impact of Copilot usage. + */ +import { Command } from 'commander'; +import chalk from 'chalk'; +import { discoverSessionFiles, calculateDetailedStats, fmt, formatTokens, ENVIRONMENTAL } from '../helpers'; +import type { PeriodStats } from '../../../src/types'; + +export const environmentalCommand = new Command('environmental') + .alias('env') + .description('Show environmental impact of your Copilot usage (CO2, water, trees)') + .action(async () => { + console.log(chalk.bold.cyan('\n🌍 Copilot Token Tracker - Environmental Impact\n')); + + process.stdout.write(chalk.dim('Scanning for session files...')); + const files = await discoverSessionFiles(); + process.stdout.write('\r' + ' '.repeat(50) + '\r'); + + if (files.length === 0) { + console.log(chalk.yellow('⚠️ No session files found.')); + return; + } + + process.stdout.write(chalk.dim('Calculating usage...')); + const stats = await calculateDetailedStats(files, (completed, total) => { + process.stdout.write(`\r${chalk.dim(`Processing: ${completed}/${total} files`)}`); + }); + process.stdout.write('\r' + ' '.repeat(50) + '\r'); + + const periods: { label: string; emoji: string; stats: PeriodStats }[] = [ + { label: 'Today', emoji: 'πŸ“…', stats: stats.today }, + { label: 'This Month', emoji: 'πŸ“†', stats: stats.month }, + { label: 'Last Month', emoji: 'πŸ—“οΈ', stats: stats.lastMonth }, + { label: 'Last 30 Days', emoji: 'πŸ“ˆ', stats: stats.last30Days }, + ]; + + // Environmental impact methodology + console.log(chalk.dim('Methodology: Estimates based on industry averages for AI inference')); + console.log(chalk.dim(` COβ‚‚: ${ENVIRONMENTAL.CO2_PER_1K_TOKENS} gCOβ‚‚e per 1K tokens`)); + console.log(chalk.dim(` Water: ${ENVIRONMENTAL.WATER_USAGE_PER_1K_TOKENS} L per 1K tokens`)); + console.log(chalk.dim(` Tree absorption: ${fmt(ENVIRONMENTAL.CO2_ABSORPTION_PER_TREE_PER_YEAR)} g COβ‚‚/year\n`)); + + for (const period of periods) { + printEnvironmentalStats(period.label, period.emoji, period.stats); + } + + // Context comparisons for last 30 days + const last30 = stats.last30Days; + if (last30.tokens > 0) { + console.log(chalk.bold('πŸ”„ Context Comparisons (Last 30 Days)')); + console.log(chalk.dim('─'.repeat(55))); + + const co2 = last30.co2; + const water = last30.waterUsage; + + // Driving comparison + const drivingKm = co2 / ENVIRONMENTAL.CO2_PER_KM_DRIVING; + console.log(` πŸš— Equivalent to driving: ${drivingKm.toFixed(3)} km`); + + // Smartphone charges + const phoneCharges = co2 / ENVIRONMENTAL.CO2_PER_PHONE_CHARGE; + console.log(` πŸ“± Smartphone charges: ${phoneCharges.toFixed(1)}`); + + // Cups of coffee water + const coffeeCups = water / ENVIRONMENTAL.WATER_PER_COFFEE_CUP; + console.log(` β˜• Cups of coffee (water): ${coffeeCups.toFixed(4)}`); + + // LED bulb hours + const ledHours = co2 / ENVIRONMENTAL.CO2_PER_LED_HOUR; + console.log(` πŸ’‘ LED bulb hours: ${ledHours.toFixed(2)}`); + + console.log(); + } + + console.log(chalk.dim(`Last updated: ${stats.lastUpdated.toLocaleString()}\n`)); + }); + +function printEnvironmentalStats(label: string, emoji: string, stats: PeriodStats): void { + console.log(chalk.bold(`${emoji} ${label}`)); + console.log(chalk.dim('─'.repeat(55))); + + if (stats.sessions === 0) { + console.log(chalk.dim(' No activity in this period')); + console.log(); + return; + } + + console.log(` Tokens used: ${chalk.bold.yellow(formatTokens(stats.tokens))}`); + console.log(` COβ‚‚ emissions: ${chalk.bold(stats.co2.toFixed(3))} gCOβ‚‚e`); + console.log(` Water usage: ${chalk.bold(stats.waterUsage.toFixed(3))} liters`); + + if (stats.treesEquivalent > 0) { + const treeStr = stats.treesEquivalent < 0.001 + ? stats.treesEquivalent.toExponential(2) + : stats.treesEquivalent.toFixed(6); + console.log(` Trees to offset: ${chalk.green(treeStr)} trees/year`); + } + + console.log(); +} diff --git a/cli/src/commands/fluency.ts b/cli/src/commands/fluency.ts new file mode 100644 index 0000000..02ea871 --- /dev/null +++ b/cli/src/commands/fluency.ts @@ -0,0 +1,125 @@ +/** + * `fluency` command - Show Copilot Fluency Score based on usage patterns. + */ +import { Command } from 'commander'; +import chalk from 'chalk'; +import { discoverSessionFiles, calculateUsageAnalysisStats, fmt } from '../helpers'; +import { calculateMaturityScores } from '../../../src/maturityScoring'; + +export const fluencyCommand = new Command('fluency') + .description('Show your Copilot Fluency Score and improvement tips') + .option('-t, --tips', 'Show improvement tips for each category') + .action(async (options) => { + console.log(chalk.bold.cyan('\n🎯 Copilot Token Tracker - Fluency Score\n')); + + process.stdout.write(chalk.dim('Scanning for session files...')); + const files = await discoverSessionFiles(); + process.stdout.write('\r' + ' '.repeat(50) + '\r'); + + if (files.length === 0) { + console.log(chalk.yellow('⚠️ No session files found.')); + return; + } + + process.stdout.write(chalk.dim('Analyzing usage patterns...')); + + // Calculate usage analysis stats + const usageStats = await calculateUsageAnalysisStats(files); + process.stdout.write('\r' + ' '.repeat(50) + '\r'); + + // Calculate maturity scores + const scores = await calculateMaturityScores( + undefined, + async () => usageStats, + false + ); + + // Overall score + const stageColors: Record = { + 1: chalk.red, + 2: chalk.yellow, + 3: chalk.blue, + 4: chalk.green, + }; + + const stageBar = (stage: number): string => { + const filled = 'β–ˆ'.repeat(stage); + const empty = 'β–‘'.repeat(4 - stage); + return filled + empty; + }; + + console.log(chalk.bold('Overall Fluency Score')); + console.log(chalk.dim('─'.repeat(55))); + + const colorFn = stageColors[scores.overallStage] || chalk.white; + console.log(` ${colorFn(stageBar(scores.overallStage))} ${chalk.bold(scores.overallLabel)}`); + console.log(); + + // Category breakdown + console.log(chalk.bold('Category Breakdown')); + console.log(chalk.dim('─'.repeat(55))); + + for (const cat of scores.categories) { + const catColor = stageColors[cat.stage] || chalk.white; + console.log(` ${cat.icon} ${chalk.bold(cat.category)}`); + console.log(` ${catColor(stageBar(cat.stage))} Stage ${cat.stage}/4`); + + // Evidence + if (cat.evidence.length > 0) { + const evidenceToShow = cat.evidence.slice(0, 3); + for (const ev of evidenceToShow) { + console.log(chalk.dim(` βœ“ ${ev}`)); + } + if (cat.evidence.length > 3) { + console.log(chalk.dim(` ... and ${cat.evidence.length - 3} more`)); + } + } + + // Tips + if (cat.tips.length > 0 && cat.stage < 4) { + console.log(chalk.yellow(` πŸ’‘ Tips:`)); + for (const tip of cat.tips.slice(0, 2)) { + // Strip markdown links for cleaner CLI output + const cleanTip = tip.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1').replace(/\[β–Ά [^\]]+\]\([^)]+\)/g, '').trim(); + console.log(chalk.yellow(` β†’ ${cleanTip}`)); + } + } + + console.log(); + } + + // Summary stats + const p = scores.period; + console.log(chalk.bold('πŸ“Š Analysis Period (Last 30 Days)')); + console.log(chalk.dim('─'.repeat(55))); + console.log(` Sessions analyzed: ${chalk.bold(fmt(p.sessions))}`); + + const totalInteractions = p.modeUsage.ask + p.modeUsage.edit + p.modeUsage.agent; + console.log(` Total interactions: ${chalk.bold(fmt(totalInteractions))}`); + + if (p.modeUsage.ask > 0) { + console.log(` Ask mode: ${fmt(p.modeUsage.ask)}`); + } + if (p.modeUsage.edit > 0) { + console.log(` Edit mode: ${fmt(p.modeUsage.edit)}`); + } + if (p.modeUsage.agent > 0) { + console.log(` Agent mode: ${fmt(p.modeUsage.agent)}`); + } + if (p.toolCalls.total > 0) { + console.log(` Tool calls: ${fmt(p.toolCalls.total)}`); + } + if (p.mcpTools.total > 0) { + console.log(` MCP tool calls: ${fmt(p.mcpTools.total)}`); + } + + const totalContextRefs = p.contextReferences.file + p.contextReferences.selection + + p.contextReferences.codebase + p.contextReferences.workspace + + p.contextReferences.terminal + p.contextReferences.vscode; + if (totalContextRefs > 0) { + console.log(` Context references: ${fmt(totalContextRefs)}`); + } + + console.log(); + console.log(chalk.dim(`Last updated: ${scores.lastUpdated}\n`)); + }); diff --git a/cli/src/commands/stats.ts b/cli/src/commands/stats.ts new file mode 100644 index 0000000..cde6984 --- /dev/null +++ b/cli/src/commands/stats.ts @@ -0,0 +1,149 @@ +/** + * `stats` command - Show overview of discovered session files, sessions, and token counts. + */ +import { Command } from 'commander'; +import chalk from 'chalk'; +import { discoverSessionFiles, processSessionFile, getDiagnosticPaths, fmt, formatTokens } from '../helpers'; + +export const statsCommand = new Command('stats') + .description('Show overview of discovered session files, sessions, chat turns, and tokens') + .option('-v, --verbose', 'Show detailed per-folder breakdown') + .action(async (options) => { + console.log(chalk.bold.cyan('\nπŸ” Copilot Token Tracker - Session Statistics\n')); + + // Show search paths if verbose + if (options.verbose) { + const paths = getDiagnosticPaths(); + console.log(chalk.dim('Search paths:')); + for (const p of paths) { + const status = p.exists ? chalk.green('βœ…') : chalk.dim('❌'); + console.log(` ${status} ${chalk.dim(p.source)}: ${p.path}`); + } + console.log(); + } + + // Discover session files + process.stdout.write(chalk.dim('Scanning for session files...')); + const files = await discoverSessionFiles(); + process.stdout.write('\r' + ' '.repeat(50) + '\r'); // Clear line + + if (files.length === 0) { + console.log(chalk.yellow('⚠️ No session files found.')); + console.log(chalk.dim('Have you used GitHub Copilot Chat in VS Code yet?')); + return; + } + + console.log(chalk.green(`πŸ“‚ Found ${chalk.bold(fmt(files.length))} session file(s)\n`)); + + // Process files and gather stats + let totalTokens = 0; + let totalThinkingTokens = 0; + let totalInteractions = 0; + let processedCount = 0; + let emptyCount = 0; + const editorCounts: { [editor: string]: { files: number; tokens: number; interactions: number } } = {}; + const folderCounts: { [folder: string]: { files: number; tokens: number } } = {}; + + for (let i = 0; i < files.length; i++) { + const data = await processSessionFile(files[i]); + + if (!data || data.tokens === 0) { + emptyCount++; + continue; + } + + processedCount++; + totalTokens += data.tokens; + totalThinkingTokens += data.thinkingTokens; + totalInteractions += data.interactions; + + // Track by editor + if (!editorCounts[data.editorSource]) { + editorCounts[data.editorSource] = { files: 0, tokens: 0, interactions: 0 }; + } + editorCounts[data.editorSource].files++; + editorCounts[data.editorSource].tokens += data.tokens; + editorCounts[data.editorSource].interactions += data.interactions; + + // Track by parent folder + if (options.verbose) { + const folder = getDisplayFolder(files[i]); + if (!folderCounts[folder]) { + folderCounts[folder] = { files: 0, tokens: 0 }; + } + folderCounts[folder].files++; + folderCounts[folder].tokens += data.tokens; + } + + // Progress indicator + if ((i + 1) % 50 === 0 || i === files.length - 1) { + process.stdout.write(`\r${chalk.dim(`Processing: ${i + 1}/${files.length}`)}`); + } + } + process.stdout.write('\r' + ' '.repeat(50) + '\r'); // Clear progress line + + // Summary table + console.log(chalk.bold('πŸ“Š Summary')); + console.log(chalk.dim('─'.repeat(50))); + console.log(` Session files (with data): ${chalk.bold(fmt(processedCount))}`); + if (emptyCount > 0) { + console.log(` Empty/skipped files: ${chalk.dim(fmt(emptyCount))}`); + } + console.log(` Total chat turns: ${chalk.bold(fmt(totalInteractions))}`); + console.log(` Total estimated tokens: ${chalk.bold.yellow(formatTokens(totalTokens))}`); + if (totalThinkingTokens > 0) { + console.log(` Thinking tokens (included): ${chalk.dim(formatTokens(totalThinkingTokens))}`); + } + console.log(); + + // Editor breakdown + const editors = Object.entries(editorCounts).sort((a, b) => b[1].tokens - a[1].tokens); + if (editors.length > 0) { + console.log(chalk.bold('πŸ–₯️ By Editor')); + console.log(chalk.dim('─'.repeat(50))); + for (const [editor, counts] of editors) { + const editorName = getEditorDisplayName(editor); + console.log(` ${editorName.padEnd(25)} ${fmt(counts.files).padStart(5)} files ${formatTokens(counts.tokens).padStart(8)} tokens ${fmt(counts.interactions).padStart(6)} turns`); + } + console.log(); + } + + // Verbose: per-folder breakdown + if (options.verbose && Object.keys(folderCounts).length > 0) { + const folders = Object.entries(folderCounts).sort((a, b) => b[1].tokens - a[1].tokens); + console.log(chalk.bold('πŸ“ By Folder')); + console.log(chalk.dim('─'.repeat(70))); + for (const [folder, counts] of folders.slice(0, 20)) { + console.log(` ${folder.substring(0, 50).padEnd(50)} ${fmt(counts.files).padStart(5)} files ${formatTokens(counts.tokens).padStart(8)} tokens`); + } + if (folders.length > 20) { + console.log(chalk.dim(` ... and ${folders.length - 20} more folders`)); + } + console.log(); + } + }); + +function getEditorDisplayName(source: string): string { + const names: Record = { + 'vscode': 'VS Code', + 'vscode-insiders': 'VS Code Insiders', + 'vscode-exploration': 'VS Code Exploration', + 'vscode-remote': 'VS Code Remote', + 'vscodium': 'VSCodium', + 'cursor': 'Cursor', + 'copilot-cli': 'Copilot CLI', + 'opencode': 'OpenCode', + }; + return names[source] || source; +} + +function getDisplayFolder(filePath: string): string { + const parts = filePath.split(/[/\\]/); + // Find the meaningful folder (e.g., workspaceStorage/ or chatSessions) + const chatIdx = parts.indexOf('chatSessions'); + if (chatIdx >= 2) { + return parts.slice(chatIdx - 1, chatIdx + 1).join('/'); + } + // Fall back to parent directory + return parts.slice(-3, -1).join('/'); +} diff --git a/cli/src/commands/usage.ts b/cli/src/commands/usage.ts new file mode 100644 index 0000000..726ce1d --- /dev/null +++ b/cli/src/commands/usage.ts @@ -0,0 +1,105 @@ +/** + * `usage` command - Show token usage for today, current month, and last 30 days. + */ +import { Command } from 'commander'; +import chalk from 'chalk'; +import { discoverSessionFiles, calculateDetailedStats, fmt, formatTokens, modelPricing } from '../helpers'; +import type { PeriodStats, ModelUsage } from '../../../src/types'; +import { getModelTier } from '../../../src/tokenEstimation'; + +export const usageCommand = new Command('usage') + .description('Show token usage for today, current month, last month, and last 30 days') + .option('-m, --models', 'Show per-model token breakdown') + .option('-c, --cost', 'Show estimated cost breakdown') + .action(async (options) => { + console.log(chalk.bold.cyan('\nπŸ“Š Copilot Token Tracker - Usage Report\n')); + + process.stdout.write(chalk.dim('Scanning for session files...')); + const files = await discoverSessionFiles(); + process.stdout.write('\r' + ' '.repeat(50) + '\r'); + + if (files.length === 0) { + console.log(chalk.yellow('⚠️ No session files found.')); + return; + } + + process.stdout.write(chalk.dim('Calculating token usage...')); + const stats = await calculateDetailedStats(files, (completed, total) => { + process.stdout.write(`\r${chalk.dim(`Processing: ${completed}/${total} files`)}`); + }); + process.stdout.write('\r' + ' '.repeat(50) + '\r'); + + // Display each period + const periods: { label: string; emoji: string; stats: PeriodStats }[] = [ + { label: 'Today', emoji: 'πŸ“…', stats: stats.today }, + { label: 'This Month', emoji: 'πŸ“†', stats: stats.month }, + { label: 'Last Month', emoji: 'πŸ—“οΈ', stats: stats.lastMonth }, + { label: 'Last 30 Days', emoji: 'πŸ“ˆ', stats: stats.last30Days }, + ]; + + for (const period of periods) { + printPeriodStats(period.label, period.emoji, period.stats, options); + } + + console.log(chalk.dim(`Last updated: ${stats.lastUpdated.toLocaleString()}\n`)); + }); + +function printPeriodStats( + label: string, + emoji: string, + stats: PeriodStats, + options: { models?: boolean; cost?: boolean } +): void { + console.log(chalk.bold(`${emoji} ${label}`)); + console.log(chalk.dim('─'.repeat(55))); + + if (stats.sessions === 0) { + console.log(chalk.dim(' No activity in this period')); + console.log(); + return; + } + + console.log(` Sessions: ${chalk.bold(fmt(stats.sessions))}`); + console.log(` Avg interactions/sess: ${chalk.bold(stats.avgInteractionsPerSession.toFixed(1))}`); + console.log(` Total tokens: ${chalk.bold.yellow(formatTokens(stats.tokens))}`); + if (stats.thinkingTokens > 0) { + console.log(` Thinking tokens: ${chalk.dim(formatTokens(stats.thinkingTokens))} (included in total)`); + } + console.log(` Avg tokens/session: ${chalk.bold(formatTokens(stats.avgTokensPerSession))}`); + + if (options.cost && stats.estimatedCost > 0) { + console.log(` Estimated cost: ${chalk.green('$' + stats.estimatedCost.toFixed(4))}`); + } + + // Model breakdown + if (options.models && Object.keys(stats.modelUsage).length > 0) { + console.log(); + console.log(chalk.dim(' Model Breakdown:')); + const models = Object.entries(stats.modelUsage) + .sort((a, b) => (b[1].inputTokens + b[1].outputTokens) - (a[1].inputTokens + a[1].outputTokens)); + + for (const [model, usage] of models) { + const total = usage.inputTokens + usage.outputTokens; + const tier = getModelTier(model, modelPricing); + const tierBadge = tier === 'premium' + ? chalk.yellow(' ⭐') + : tier === 'standard' + ? chalk.dim(' β—‹') + : ''; + console.log(` ${(model + tierBadge).padEnd(35)} ${formatTokens(usage.inputTokens).padStart(8)} in ${formatTokens(usage.outputTokens).padStart(8)} out ${formatTokens(total).padStart(8)} total`); + } + } + + // Editor breakdown + if (Object.keys(stats.editorUsage).length > 1) { + console.log(); + console.log(chalk.dim(' Editor Breakdown:')); + const editors = Object.entries(stats.editorUsage) + .sort((a, b) => b[1].tokens - a[1].tokens); + for (const [editor, usage] of editors) { + console.log(` ${editor.padEnd(25)} ${fmt(usage.sessions).padStart(5)} sessions ${formatTokens(usage.tokens).padStart(8)} tokens`); + } + } + + console.log(); +} diff --git a/cli/src/helpers.ts b/cli/src/helpers.ts new file mode 100644 index 0000000..11900a8 --- /dev/null +++ b/cli/src/helpers.ts @@ -0,0 +1,451 @@ +/** + * Shared helper functions for CLI commands. + * Handles session file discovery, parsing, and stats aggregation. + */ +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import chalk from 'chalk'; +import { SessionDiscovery } from '../../src/sessionDiscovery'; +import { OpenCodeDataAccess } from '../../src/opencode'; +import { parseSessionFileContent } from '../../src/sessionParser'; +import { estimateTokensFromText, getModelFromRequest, isJsonlContent, estimateTokensFromJsonlSession, calculateEstimatedCost, getModelTier } from '../../src/tokenEstimation'; +import type { DetailedStats, PeriodStats, ModelUsage, EditorUsage, SessionFileCache, UsageAnalysisStats, UsageAnalysisPeriod } from '../../src/types'; +import { analyzeSessionUsage, mergeUsageAnalysis, calculateModelSwitching, trackEnhancedMetrics } from '../../src/usageAnalysis'; +import { createEmptyContextRefs } from '../../src/tokenEstimation'; +import * as vscodeStub from './vscode-stub'; + +// Import JSON data files +import tokenEstimatorsData from '../../src/tokenEstimators.json'; +import modelPricingData from '../../src/modelPricing.json'; +import toolNamesData from '../../src/toolNames.json'; + +// Environmental impact constants (from extension.ts) +const CO2_PER_1K_TOKENS = 0.2; // gCO2e per 1000 tokens +const CO2_ABSORPTION_PER_TREE_PER_YEAR = 21000; // grams CO2 per tree/year +const WATER_USAGE_PER_1K_TOKENS = 0.3; // liters per 1000 tokens + +const tokenEstimators: { [key: string]: number } = tokenEstimatorsData.estimators; +const modelPricing = modelPricingData.pricing as { [key: string]: any }; +const toolNameMap = toolNamesData as { [key: string]: string }; + +/** Logging functions for the CLI context */ +const log = (msg: string) => { /* quiet by default */ }; +const warn = (msg: string) => { /* quiet by default */ }; +const error = (msg: string, err?: any) => console.error(chalk.red(msg), err || ''); + +/** Create OpenCode data access instance for CLI */ +function createOpenCode(): OpenCodeDataAccess { + const fakeUri = vscodeStub.Uri.file(__dirname); + return new OpenCodeDataAccess(fakeUri as any); +} + +/** Create session discovery instance for CLI */ +function createSessionDiscovery(): SessionDiscovery { + const openCode = createOpenCode(); + return new SessionDiscovery({ log, warn, error, openCode }); +} + +/** Discover all session files on this machine */ +export async function discoverSessionFiles(): Promise { + const discovery = createSessionDiscovery(); + return discovery.getCopilotSessionFiles(); +} + +/** Get diagnostic candidate paths info */ +export function getDiagnosticPaths(): { path: string; exists: boolean; source: string }[] { + const discovery = createSessionDiscovery(); + return discovery.getDiagnosticCandidatePaths(); +} + +/** + * Token estimation wrapper that uses the shared tokenEstimators data. + */ +function estimateTokens(text: string, model?: string): number { + return estimateTokensFromText(text, model || 'gpt-4', tokenEstimators); +} + +/** + * Model resolver wrapper. + */ +function resolveModel(request: any): string { + return getModelFromRequest(request, modelPricing); +} + +/** + * Check if a session file path is an OpenCode DB virtual path. + */ +function isOpenCodeDbSession(filePath: string): boolean { + return filePath.includes('opencode.db#ses_'); +} + +/** + * Stat a session file, handling OpenCode DB virtual paths. + * Virtual DB paths (opencode.db#ses_) are resolved to the actual DB file. + */ +async function statSessionFile(filePath: string): Promise { + if (isOpenCodeDbSession(filePath)) { + const dbPath = filePath.split('#')[0]; + return fs.promises.stat(dbPath); + } + return fs.promises.stat(filePath); +} + +/** Determine editor source from file path */ +function getEditorSourceFromPath(filePath: string): string { + const normalized = filePath.toLowerCase().replace(/\\/g, '/'); + if (normalized.includes('/cursor/')) { return 'cursor'; } + if (normalized.includes('/code - insiders/')) { return 'vscode-insiders'; } + if (normalized.includes('/code - exploration/')) { return 'vscode-exploration'; } + if (normalized.includes('/vscodium/')) { return 'vscodium'; } + if (normalized.includes('/.copilot/')) { return 'copilot-cli'; } + if (normalized.includes('/opencode/')) { return 'opencode'; } + if (normalized.includes('.vscode-server')) { return 'vscode-remote'; } + return 'vscode'; +} + +export interface SessionData { + file: string; + tokens: number; + thinkingTokens: number; + interactions: number; + modelUsage: ModelUsage; + lastModified: Date; + editorSource: string; +} + +/** + * Process a single session file and extract its data. + */ +export async function processSessionFile(filePath: string): Promise { + try { + const stats = await statSessionFile(filePath); + const content = isOpenCodeDbSession(filePath) + ? '' // OpenCode DB sessions are handled by the opencode module + : await fs.promises.readFile(filePath, 'utf-8'); + + if (!content.trim()) { + return null; + } + + const isJsonl = filePath.endsWith('.jsonl') || isJsonlContent(content); + + let tokens = 0; + let thinkingTokens = 0; + let interactions = 0; + let fileModelUsage: ModelUsage = {}; + + if (isJsonl) { + const result = estimateTokensFromJsonlSession(content); + tokens = result.tokens; + thinkingTokens = result.thinkingTokens; + + // Count interactions from JSONL + const lines = content.trim().split('\n'); + for (const line of lines) { + try { + const event = JSON.parse(line); + if (event.type === 'user.message' || (event.kind === 2 && event.k?.[0] === 'requests')) { + interactions++; + } + } catch { + // skip + } + } + } else { + const result = parseSessionFileContent( + filePath, + content, + estimateTokens, + resolveModel + ); + tokens = result.tokens; + thinkingTokens = result.thinkingTokens; + interactions = result.interactions; + fileModelUsage = result.modelUsage as ModelUsage; + } + + return { + file: filePath, + tokens, + thinkingTokens, + interactions, + modelUsage: fileModelUsage, + lastModified: stats.mtime, + editorSource: getEditorSourceFromPath(filePath), + }; + } catch { + return null; + } +} + +/** + * Calculate detailed statistics across all time periods. + */ +export async function calculateDetailedStats( + sessionFiles: string[], + progressCallback?: (completed: number, total: number) => void +): Promise { + const now = new Date(); + const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + const monthStart = new Date(now.getFullYear(), now.getMonth(), 1); + const lastMonthStart = new Date(now.getFullYear(), now.getMonth() - 1, 1); + const lastMonthEnd = new Date(now.getFullYear(), now.getMonth(), 0, 23, 59, 59); + const last30DaysStart = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000); + + const periods: { + today: PeriodStats; + month: PeriodStats; + lastMonth: PeriodStats; + last30Days: PeriodStats; + } = { + today: createEmptyPeriodStats(), + month: createEmptyPeriodStats(), + lastMonth: createEmptyPeriodStats(), + last30Days: createEmptyPeriodStats(), + }; + + let processed = 0; + for (const file of sessionFiles) { + const data = await processSessionFile(file); + processed++; + if (progressCallback) { + progressCallback(processed, sessionFiles.length); + } + + if (!data || data.tokens === 0) { + continue; + } + + const modified = data.lastModified; + + // Skip files older than the last month's start + if (modified < lastMonthStart) { + continue; + } + + // Aggregate into appropriate periods + if (modified >= todayStart) { + aggregateIntoPeriod(periods.today, data); + } + if (modified >= monthStart) { + aggregateIntoPeriod(periods.month, data); + } + if (modified >= lastMonthStart && modified <= lastMonthEnd) { + aggregateIntoPeriod(periods.lastMonth, data); + } + if (modified >= last30DaysStart) { + aggregateIntoPeriod(periods.last30Days, data); + } + } + + // Compute derived stats + for (const period of Object.values(periods)) { + if (period.sessions > 0) { + period.avgTokensPerSession = Math.round(period.tokens / period.sessions); + } + period.co2 = (period.tokens / 1000) * CO2_PER_1K_TOKENS; + period.treesEquivalent = period.co2 / CO2_ABSORPTION_PER_TREE_PER_YEAR; + period.waterUsage = (period.tokens / 1000) * WATER_USAGE_PER_1K_TOKENS; + period.estimatedCost = calculateEstimatedCost(period.modelUsage, modelPricing); + } + + return { + ...periods, + lastUpdated: now, + }; +} + +function createEmptyPeriodStats(): PeriodStats { + return { + tokens: 0, + thinkingTokens: 0, + estimatedTokens: 0, + actualTokens: 0, + sessions: 0, + avgInteractionsPerSession: 0, + avgTokensPerSession: 0, + modelUsage: {}, + editorUsage: {}, + co2: 0, + treesEquivalent: 0, + waterUsage: 0, + estimatedCost: 0, + }; +} + +function aggregateIntoPeriod(period: PeriodStats, data: SessionData): void { + period.tokens += data.tokens; + period.thinkingTokens += data.thinkingTokens; + period.estimatedTokens += data.tokens; + period.sessions++; + + // Merge model usage + for (const [model, usage] of Object.entries(data.modelUsage)) { + if (!period.modelUsage[model]) { + period.modelUsage[model] = { inputTokens: 0, outputTokens: 0 }; + } + period.modelUsage[model].inputTokens += usage.inputTokens; + period.modelUsage[model].outputTokens += usage.outputTokens; + } + + // Track interactions + const totalInteractions = period.avgInteractionsPerSession * (period.sessions - 1) + data.interactions; + period.avgInteractionsPerSession = period.sessions > 0 ? totalInteractions / period.sessions : 0; + + // Editor usage + if (!period.editorUsage[data.editorSource]) { + period.editorUsage[data.editorSource] = { tokens: 0, sessions: 0 }; + } + period.editorUsage[data.editorSource].tokens += data.tokens; + period.editorUsage[data.editorSource].sessions++; +} + +/** + * Calculate usage analysis stats for fluency scoring. + * This is a simplified version that uses the shared usageAnalysis module. + */ +export async function calculateUsageAnalysisStats(sessionFiles: string[]): Promise { + const openCode = createOpenCode(); + + const deps = { + warn, + openCode, + tokenEstimators, + modelPricing, + toolNameMap, + }; + + const now = new Date(); + const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + const last30DaysStart = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000); + const monthStart = new Date(now.getFullYear(), now.getMonth(), 1); + + const todayPeriod = createEmptyUsageAnalysisPeriod(); + const last30DaysPeriod = createEmptyUsageAnalysisPeriod(); + const monthPeriod = createEmptyUsageAnalysisPeriod(); + + for (const file of sessionFiles) { + try { + const stats = await statSessionFile(file); + const modified = stats.mtime; + + if (modified < last30DaysStart) { + continue; + } + + const analysis = await analyzeSessionUsage(deps, file); + + if (modified >= last30DaysStart) { + mergeUsageAnalysis(last30DaysPeriod, analysis); + last30DaysPeriod.sessions++; + } + if (modified >= monthStart) { + mergeUsageAnalysis(monthPeriod, analysis); + monthPeriod.sessions++; + } + if (modified >= todayStart) { + mergeUsageAnalysis(todayPeriod, analysis); + todayPeriod.sessions++; + } + } catch { + // Skip files that can't be processed + } + } + + return { + today: todayPeriod, + last30Days: last30DaysPeriod, + month: monthPeriod, + lastUpdated: now, + }; +} + +function createEmptyUsageAnalysisPeriod(): UsageAnalysisPeriod { + return { + sessions: 0, + toolCalls: { total: 0, byTool: {} }, + modeUsage: { ask: 0, edit: 0, agent: 0, plan: 0, customAgent: 0 }, + contextReferences: createEmptyContextRefs(), + mcpTools: { total: 0, byServer: {}, byTool: {} }, + modelSwitching: { + modelsPerSession: [], + totalSessions: 0, + averageModelsPerSession: 0, + maxModelsPerSession: 0, + minModelsPerSession: 0, + switchingFrequency: 0, + standardModels: [], + premiumModels: [], + unknownModels: [], + mixedTierSessions: 0, + standardRequests: 0, + premiumRequests: 0, + unknownRequests: 0, + totalRequests: 0, + }, + repositories: [], + repositoriesWithCustomization: [], + editScope: { + singleFileEdits: 0, + multiFileEdits: 0, + totalEditedFiles: 0, + avgFilesPerSession: 0, + }, + applyUsage: { + totalApplies: 0, + totalCodeBlocks: 0, + applyRate: 0, + }, + sessionDuration: { + totalDurationMs: 0, + avgDurationMs: 0, + avgFirstProgressMs: 0, + avgTotalElapsedMs: 0, + avgWaitTimeMs: 0, + }, + conversationPatterns: { + multiTurnSessions: 0, + singleTurnSessions: 0, + avgTurnsPerSession: 0, + maxTurnsInSession: 0, + }, + agentTypes: { + editsAgent: 0, + defaultAgent: 0, + workspaceAgent: 0, + other: 0, + }, + }; +} + +/** Format a number with thousand separators */ +export function fmt(n: number): string { + return n.toLocaleString('en-US'); +} + +/** Format token counts for display */ +export function formatTokens(tokens: number): string { + if (tokens >= 1_000_000) { + return `${(tokens / 1_000_000).toFixed(1)}M`; + } + if (tokens >= 1_000) { + return `${(tokens / 1_000).toFixed(1)}K`; + } + return tokens.toString(); +} + +/** Environmental impact constants export for use in commands */ +export const ENVIRONMENTAL = { + CO2_PER_1K_TOKENS, + CO2_ABSORPTION_PER_TREE_PER_YEAR, + WATER_USAGE_PER_1K_TOKENS, + // Context comparison constants + CO2_PER_KM_DRIVING: 120, // grams CO2 per km for average car + CO2_PER_PHONE_CHARGE: 8.22, // grams CO2 per smartphone full charge + WATER_PER_COFFEE_CUP: 140, // liters of water per cup of coffee + CO2_PER_LED_HOUR: 20, // grams CO2 per hour for 10W LED bulb +}; + +/** Model pricing data export */ +export { modelPricing, tokenEstimators, toolNameMap }; diff --git a/cli/src/vscode-stub.ts b/cli/src/vscode-stub.ts new file mode 100644 index 0000000..dbc197e --- /dev/null +++ b/cli/src/vscode-stub.ts @@ -0,0 +1,56 @@ +/** + * Minimal VS Code API stub for CLI usage. + * Only provides the bare minimum needed by sessionDiscovery.ts and opencode.ts + * when running outside VS Code. + */ + +export const workspace = { + getConfiguration: () => ({ + get: () => undefined, + }), +}; + +export const extensions = { + getExtension: () => undefined, +}; + +export class Uri { + readonly fsPath: string; + readonly scheme: string; + readonly path: string; + + private constructor(fsPath: string) { + this.fsPath = fsPath; + this.scheme = 'file'; + this.path = fsPath; + } + + static file(path: string): Uri { + return new Uri(path); + } + + static joinPath(base: Uri, ...pathSegments: string[]): Uri { + const joined = [base.fsPath, ...pathSegments].join('/'); + return new Uri(joined); + } + + toString(): string { + return this.fsPath; + } +} + +export const window = { + createOutputChannel: () => ({ + appendLine: () => { /* noop */ }, + show: () => { /* noop */ }, + clear: () => { /* noop */ }, + dispose: () => { /* noop */ }, + }), +}; + +export default { + workspace, + extensions, + Uri, + window, +}; diff --git a/cli/tsconfig.json b/cli/tsconfig.json new file mode 100644 index 0000000..33774d1 --- /dev/null +++ b/cli/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "module": "Node16", + "target": "ES2022", + "lib": ["ES2022"], + "sourceMap": true, + "rootDir": "src", + "outDir": "dist", + "strict": true, + "resolveJsonModule": true, + "esModuleInterop": true, + "declaration": false, + "paths": { + "../src/*": ["../src/*"] + } + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/docs/images/Terminal Diagnostitcs.png b/docs/images/Terminal Diagnostitcs.png new file mode 100644 index 0000000..f11a8d8 Binary files /dev/null and b/docs/images/Terminal Diagnostitcs.png differ diff --git a/docs/images/Terminal Statistics.png b/docs/images/Terminal Statistics.png new file mode 100644 index 0000000..596c796 Binary files /dev/null and b/docs/images/Terminal Statistics.png differ diff --git a/docs/images/Terminal Usage.png b/docs/images/Terminal Usage.png new file mode 100644 index 0000000..68be140 Binary files /dev/null and b/docs/images/Terminal Usage.png differ diff --git a/package.json b/package.json index ab32895..71ae9e3 100644 --- a/package.json +++ b/package.json @@ -288,7 +288,14 @@ "pre-release": "node scripts/pre-release.js", "capture-screenshots": "pwsh -File scripts/capture-screenshots.ps1", "sync-changelog": "node scripts/sync-changelog.js", - "sync-changelog:test": "node scripts/sync-changelog.js --test" + "sync-changelog:test": "node scripts/sync-changelog.js --test", + "cli:build": "cd cli && npm install && npm run build", + "cli:build:production": "cd cli && npm install && npm run build:production", + "cli:stats": "cd cli && node dist/cli.js stats", + "cli:usage": "cd cli && node dist/cli.js usage", + "cli:environmental": "cd cli && node dist/cli.js environmental", + "cli:fluency": "cd cli && node dist/cli.js fluency", + "cli": "cd cli && node dist/cli.js" }, "devDependencies": { "@github/copilot": "^1.0.5", diff --git a/tsconfig.json b/tsconfig.json index 00e90c9..9a8cee5 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -17,6 +17,7 @@ // "noUnusedParameters": true, /* Report errors on unused parameters. */ }, "exclude": [ - "test" + "test", + "cli" ] }