diff --git a/.github/plugin/marketplace.json b/.github/plugin/marketplace.json index cd19cc914..fd0f0e800 100644 --- a/.github/plugin/marketplace.json +++ b/.github/plugin/marketplace.json @@ -1832,6 +1832,12 @@ "sha": "f8abab778cfb5b57a3c745d540631b7aab15d5b3" } }, + { + "name": "usage-insights", + "source": "plugins/usage-insights", + "description": "Inspect current and historical GitHub Copilot token and AI-credit usage with per-agent rollups and in-canvas session drill-down.", + "version": "1.0.0" + }, { "name": "vercel-plugin", "description": "Build and deploy web apps and agents. Comprehensive Vercel ecosystem plugin — relational knowledge graph, skills for every major product, specialized agents, and Vercel conventions. Turns any AI agent into a Vercel expert.", diff --git a/docs/README.agents.md b/docs/README.agents.md index eac43b3ef..608834918 100644 --- a/docs/README.agents.md +++ b/docs/README.agents.md @@ -90,7 +90,7 @@ See [CONTRIBUTING.md](../CONTRIBUTING.md#adding-agents) for guidelines on how to | [Doublecheck](../agents/doublecheck.agent.md)
[![Install in VS Code](https://img.shields.io/badge/VS_Code-Install-0098FF?style=flat-square&logo=visualstudiocode&logoColor=white)](https://aka.ms/awesome-copilot/install/agent?url=vscode%3Achat-agent%2Finstall%3Furl%3Dhttps%3A%2F%2Fraw.githubusercontent.com%2Fgithub%2Fawesome-copilot%2Fmain%2Fagents%2Fdoublecheck.agent.md)
[![Install in VS Code Insiders](https://img.shields.io/badge/VS_Code_Insiders-Install-24bfa5?style=flat-square&logo=visualstudiocode&logoColor=white)](https://aka.ms/awesome-copilot/install/agent?url=vscode-insiders%3Achat-agent%2Finstall%3Furl%3Dhttps%3A%2F%2Fraw.githubusercontent.com%2Fgithub%2Fawesome-copilot%2Fmain%2Fagents%2Fdoublecheck.agent.md) | Interactive verification agent for AI-generated output. Runs a three-layer pipeline (self-audit, source verification, adversarial review) and produces structured reports with source links for human review. | | | [Droid](../agents/droid.agent.md)
[![Install in VS Code](https://img.shields.io/badge/VS_Code-Install-0098FF?style=flat-square&logo=visualstudiocode&logoColor=white)](https://aka.ms/awesome-copilot/install/agent?url=vscode%3Achat-agent%2Finstall%3Furl%3Dhttps%3A%2F%2Fraw.githubusercontent.com%2Fgithub%2Fawesome-copilot%2Fmain%2Fagents%2Fdroid.agent.md)
[![Install in VS Code Insiders](https://img.shields.io/badge/VS_Code_Insiders-Install-24bfa5?style=flat-square&logo=visualstudiocode&logoColor=white)](https://aka.ms/awesome-copilot/install/agent?url=vscode-insiders%3Achat-agent%2Finstall%3Furl%3Dhttps%3A%2F%2Fraw.githubusercontent.com%2Fgithub%2Fawesome-copilot%2Fmain%2Fagents%2Fdroid.agent.md) | Provides installation guidance, usage examples, and automation patterns for the Droid CLI, with emphasis on droid exec for CI/CD and non-interactive automation | | | [Drupal Expert](../agents/drupal-expert.agent.md)
[![Install in VS Code](https://img.shields.io/badge/VS_Code-Install-0098FF?style=flat-square&logo=visualstudiocode&logoColor=white)](https://aka.ms/awesome-copilot/install/agent?url=vscode%3Achat-agent%2Finstall%3Furl%3Dhttps%3A%2F%2Fraw.githubusercontent.com%2Fgithub%2Fawesome-copilot%2Fmain%2Fagents%2Fdrupal-expert.agent.md)
[![Install in VS Code Insiders](https://img.shields.io/badge/VS_Code_Insiders-Install-24bfa5?style=flat-square&logo=visualstudiocode&logoColor=white)](https://aka.ms/awesome-copilot/install/agent?url=vscode-insiders%3Achat-agent%2Finstall%3Furl%3Dhttps%3A%2F%2Fraw.githubusercontent.com%2Fgithub%2Fawesome-copilot%2Fmain%2Fagents%2Fdrupal-expert.agent.md) | Expert assistant for Drupal development, architecture, and best practices using PHP 8.3+ and modern Drupal patterns | | -| [Dynatrace Expert](../agents/dynatrace-expert.agent.md)
[![Install in VS Code](https://img.shields.io/badge/VS_Code-Install-0098FF?style=flat-square&logo=visualstudiocode&logoColor=white)](https://aka.ms/awesome-copilot/install/agent?url=vscode%3Achat-agent%2Finstall%3Furl%3Dhttps%3A%2F%2Fraw.githubusercontent.com%2Fgithub%2Fawesome-copilot%2Fmain%2Fagents%2Fdynatrace-expert.agent.md)
[![Install in VS Code Insiders](https://img.shields.io/badge/VS_Code_Insiders-Install-24bfa5?style=flat-square&logo=visualstudiocode&logoColor=white)](https://aka.ms/awesome-copilot/install/agent?url=vscode-insiders%3Achat-agent%2Finstall%3Furl%3Dhttps%3A%2F%2Fraw.githubusercontent.com%2Fgithub%2Fawesome-copilot%2Fmain%2Fagents%2Fdynatrace-expert.agent.md) | The Dynatrace Expert Agent integrates observability and security capabilities directly into GitHub workflows, enabling development teams to investigate incidents, validate deployments, triage errors, detect performance regressions, validate releases, and manage security vulnerabilities by autonomously analysing traces, logs, and Dynatrace findings. This enables targeted and precise remediation of identified issues directly within the repository. | [dynatrace](https://github.com/mcp/io.github.dynatrace-oss/Dynatrace-mcp)
[![Install MCP](https://img.shields.io/badge/Install-VS_Code-0098FF?style=flat-square)](https://aka.ms/awesome-copilot/install/mcp-vscode?name=dynatrace&config=%7B%22url%22%3A%22https%3A%2F%2Fpia1134d.dev.apps.dynatracelabs.com%2Fplatform-reserved%2Fmcp-gateway%2Fv0.1%2Fservers%2Fdynatrace-mcp%2Fmcp%22%2C%22headers%22%3A%7B%22Authorization%22%3A%22Bearer%20%24COPILOT_MCP_DT_API_TOKEN%22%7D%7D)
[![Install MCP](https://img.shields.io/badge/Install-VS_Code_Insiders-24bfa5?style=flat-square)](https://aka.ms/awesome-copilot/install/mcp-vscodeinsiders?name=dynatrace&config=%7B%22url%22%3A%22https%3A%2F%2Fpia1134d.dev.apps.dynatracelabs.com%2Fplatform-reserved%2Fmcp-gateway%2Fv0.1%2Fservers%2Fdynatrace-mcp%2Fmcp%22%2C%22headers%22%3A%7B%22Authorization%22%3A%22Bearer%20%24COPILOT_MCP_DT_API_TOKEN%22%7D%7D)
[![Install MCP](https://img.shields.io/badge/Install-Visual_Studio-C16FDE?style=flat-square)](https://aka.ms/awesome-copilot/install/mcp-visualstudio/mcp-install?%7B%22url%22%3A%22https%3A%2F%2Fpia1134d.dev.apps.dynatracelabs.com%2Fplatform-reserved%2Fmcp-gateway%2Fv0.1%2Fservers%2Fdynatrace-mcp%2Fmcp%22%2C%22headers%22%3A%7B%22Authorization%22%3A%22Bearer%20%24COPILOT_MCP_DT_API_TOKEN%22%7D%7D) | +| [Dynatrace Expert](../agents/dynatrace-expert.agent.md)
[![Install in VS Code](https://img.shields.io/badge/VS_Code-Install-0098FF?style=flat-square&logo=visualstudiocode&logoColor=white)](https://aka.ms/awesome-copilot/install/agent?url=vscode%3Achat-agent%2Finstall%3Furl%3Dhttps%3A%2F%2Fraw.githubusercontent.com%2Fgithub%2Fawesome-copilot%2Fmain%2Fagents%2Fdynatrace-expert.agent.md)
[![Install in VS Code Insiders](https://img.shields.io/badge/VS_Code_Insiders-Install-24bfa5?style=flat-square&logo=visualstudiocode&logoColor=white)](https://aka.ms/awesome-copilot/install/agent?url=vscode-insiders%3Achat-agent%2Finstall%3Furl%3Dhttps%3A%2F%2Fraw.githubusercontent.com%2Fgithub%2Fawesome-copilot%2Fmain%2Fagents%2Fdynatrace-expert.agent.md) | The Dynatrace Expert Agent integrates observability and security capabilities directly into GitHub workflows, enabling development teams to investigate incidents, validate deployments, triage errors, detect performance regressions, validate releases, and manage security vulnerabilities by autonomously analysing traces, logs, and Dynatrace findings. This enables targeted and precise remediation of identified issues directly within the repository. | [dynatrace](https://github.com/mcp/io.github.Dynatrace/dynatrace-for-ai)
[![Install MCP](https://img.shields.io/badge/Install-VS_Code-0098FF?style=flat-square)](https://aka.ms/awesome-copilot/install/mcp-vscode?name=dynatrace&config=%7B%22url%22%3A%22https%3A%2F%2Fpia1134d.dev.apps.dynatracelabs.com%2Fplatform-reserved%2Fmcp-gateway%2Fv0.1%2Fservers%2Fdynatrace-mcp%2Fmcp%22%2C%22headers%22%3A%7B%22Authorization%22%3A%22Bearer%20%24COPILOT_MCP_DT_API_TOKEN%22%7D%7D)
[![Install MCP](https://img.shields.io/badge/Install-VS_Code_Insiders-24bfa5?style=flat-square)](https://aka.ms/awesome-copilot/install/mcp-vscodeinsiders?name=dynatrace&config=%7B%22url%22%3A%22https%3A%2F%2Fpia1134d.dev.apps.dynatracelabs.com%2Fplatform-reserved%2Fmcp-gateway%2Fv0.1%2Fservers%2Fdynatrace-mcp%2Fmcp%22%2C%22headers%22%3A%7B%22Authorization%22%3A%22Bearer%20%24COPILOT_MCP_DT_API_TOKEN%22%7D%7D)
[![Install MCP](https://img.shields.io/badge/Install-Visual_Studio-C16FDE?style=flat-square)](https://aka.ms/awesome-copilot/install/mcp-visualstudio/mcp-install?%7B%22url%22%3A%22https%3A%2F%2Fpia1134d.dev.apps.dynatracelabs.com%2Fplatform-reserved%2Fmcp-gateway%2Fv0.1%2Fservers%2Fdynatrace-mcp%2Fmcp%22%2C%22headers%22%3A%7B%22Authorization%22%3A%22Bearer%20%24COPILOT_MCP_DT_API_TOKEN%22%7D%7D) | | [Elasticsearch Agent](../agents/elasticsearch-observability.agent.md)
[![Install in VS Code](https://img.shields.io/badge/VS_Code-Install-0098FF?style=flat-square&logo=visualstudiocode&logoColor=white)](https://aka.ms/awesome-copilot/install/agent?url=vscode%3Achat-agent%2Finstall%3Furl%3Dhttps%3A%2F%2Fraw.githubusercontent.com%2Fgithub%2Fawesome-copilot%2Fmain%2Fagents%2Felasticsearch-observability.agent.md)
[![Install in VS Code Insiders](https://img.shields.io/badge/VS_Code_Insiders-Install-24bfa5?style=flat-square&logo=visualstudiocode&logoColor=white)](https://aka.ms/awesome-copilot/install/agent?url=vscode-insiders%3Achat-agent%2Finstall%3Furl%3Dhttps%3A%2F%2Fraw.githubusercontent.com%2Fgithub%2Fawesome-copilot%2Fmain%2Fagents%2Felasticsearch-observability.agent.md) | Our expert AI assistant for debugging code (O11y), optimizing vector search (RAG), and remediating security threats using live Elastic data. | elastic-mcp
[![Install MCP](https://img.shields.io/badge/Install-VS_Code-0098FF?style=flat-square)](https://aka.ms/awesome-copilot/install/mcp-vscode?name=elastic-mcp&config=%7B%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22mcp-remote%22%2C%22https%253A%252F%252F%257BKIBANA_URL%257D%252Fapi%252Fagent_builder%252Fmcp%22%2C%22--header%22%2C%22Authorization%253A%2524%257BAUTH_HEADER%257D%22%5D%2C%22env%22%3A%7B%7D%7D)
[![Install MCP](https://img.shields.io/badge/Install-VS_Code_Insiders-24bfa5?style=flat-square)](https://aka.ms/awesome-copilot/install/mcp-vscodeinsiders?name=elastic-mcp&config=%7B%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22mcp-remote%22%2C%22https%253A%252F%252F%257BKIBANA_URL%257D%252Fapi%252Fagent_builder%252Fmcp%22%2C%22--header%22%2C%22Authorization%253A%2524%257BAUTH_HEADER%257D%22%5D%2C%22env%22%3A%7B%7D%7D)
[![Install MCP](https://img.shields.io/badge/Install-Visual_Studio-C16FDE?style=flat-square)](https://aka.ms/awesome-copilot/install/mcp-visualstudio/mcp-install?%7B%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22mcp-remote%22%2C%22https%253A%252F%252F%257BKIBANA_URL%257D%252Fapi%252Fagent_builder%252Fmcp%22%2C%22--header%22%2C%22Authorization%253A%2524%257BAUTH_HEADER%257D%22%5D%2C%22env%22%3A%7B%7D%7D) | | [Electron Code Review Mode Instructions](../agents/electron-angular-native.agent.md)
[![Install in VS Code](https://img.shields.io/badge/VS_Code-Install-0098FF?style=flat-square&logo=visualstudiocode&logoColor=white)](https://aka.ms/awesome-copilot/install/agent?url=vscode%3Achat-agent%2Finstall%3Furl%3Dhttps%3A%2F%2Fraw.githubusercontent.com%2Fgithub%2Fawesome-copilot%2Fmain%2Fagents%2Felectron-angular-native.agent.md)
[![Install in VS Code Insiders](https://img.shields.io/badge/VS_Code_Insiders-Install-24bfa5?style=flat-square&logo=visualstudiocode&logoColor=white)](https://aka.ms/awesome-copilot/install/agent?url=vscode-insiders%3Achat-agent%2Finstall%3Furl%3Dhttps%3A%2F%2Fraw.githubusercontent.com%2Fgithub%2Fawesome-copilot%2Fmain%2Fagents%2Felectron-angular-native.agent.md) | Code Review Mode tailored for Electron app with Node.js backend (main), Angular frontend (render), and native integration layer (e.g., AppleScript, shell, or native tooling). Services in other repos are not reviewed here. | | | [Ember](../agents/ember.agent.md)
[![Install in VS Code](https://img.shields.io/badge/VS_Code-Install-0098FF?style=flat-square&logo=visualstudiocode&logoColor=white)](https://aka.ms/awesome-copilot/install/agent?url=vscode%3Achat-agent%2Finstall%3Furl%3Dhttps%3A%2F%2Fraw.githubusercontent.com%2Fgithub%2Fawesome-copilot%2Fmain%2Fagents%2Fember.agent.md)
[![Install in VS Code Insiders](https://img.shields.io/badge/VS_Code_Insiders-Install-24bfa5?style=flat-square&logo=visualstudiocode&logoColor=white)](https://aka.ms/awesome-copilot/install/agent?url=vscode-insiders%3Achat-agent%2Finstall%3Furl%3Dhttps%3A%2F%2Fraw.githubusercontent.com%2Fgithub%2Fawesome-copilot%2Fmain%2Fagents%2Fember.agent.md) | An AI partner, not an assistant. Ember carries fire from person to person — helping humans discover that AI partnership isn't something you learn, it's something you find. | | diff --git a/docs/README.plugins.md b/docs/README.plugins.md index 274783eff..296ab2406 100644 --- a/docs/README.plugins.md +++ b/docs/README.plugins.md @@ -122,6 +122,7 @@ See [CONTRIBUTING.md](../CONTRIBUTING.md#adding-plugins) for guidelines on how t | [typescript-mcp-development](../plugins/typescript-mcp-development/README.md) | Complete toolkit for building Model Context Protocol (MCP) servers in TypeScript/Node.js using the official SDK. Includes instructions for best practices, a prompt for generating servers, and an expert chat mode for guidance. | 2 items | typescript, mcp, model-context-protocol, nodejs, server-development | | [typespec-m365-copilot](../plugins/typespec-m365-copilot/README.md) | Comprehensive collection of prompts, instructions, and resources for building declarative agents and API plugins using TypeSpec for Microsoft 365 Copilot extensibility. | 3 items | typespec, m365-copilot, declarative-agents, api-plugins, agent-development, microsoft-365 | | [uizze](../plugins/uizze/README.md) | Build product-specific interfaces with a focused UIZZE workflow, optional real-screen evidence, and a practical finish check. | 1 items | ui, design, frontend, ios, web, design-review, quality-gate | +| [usage-insights](../plugins/usage-insights/README.md) | Inspect current and historical GitHub Copilot token and AI-credit usage with per-agent rollups and in-canvas session drill-down. | 1 items | ai-credits, canvas, copilot-extension, session-analytics, token-usage, usage-insights | | [visual-pr](../plugins/visual-pr/README.md) | Capture, annotate, and embed screenshots and animated GIF demos in pull request descriptions. Includes Playwright-based UI capture, PIL image annotations, PR embedding workflows for GitHub and Azure DevOps, and screen recording with variable timing. | 4 items | screenshots, pull-request, before-after, annotations, playwright, gif, screen-recording, visual | | [where-was-i](../plugins/where-was-i/README.md) | Reconstruct your dev context (branch, commits, uncommitted work, PR clues) and trigger a resume prompt to continue quickly. | 1 items | branch-state, developer-context, git-history, interrupt-recovery, pull-request-context, resume-work | | [windows-app-storage-inspector-cleanup](../plugins/windows-app-storage-inspector-cleanup/README.md) | Inspect Windows application storage, understand local disk usage, and safely move approved cleanup items to the Recycle Bin. | 1 items | app-storage, canvas, cleanup, storage, windows | diff --git a/extensions/usage-insights/README.md b/extensions/usage-insights/README.md new file mode 100644 index 000000000..accbb374e --- /dev/null +++ b/extensions/usage-insights/README.md @@ -0,0 +1,85 @@ +# Usage Insights + +A GitHub Copilot canvas extension for inspecting local session usage: + +- current and historical AI-credit totals +- input, output, reasoning, cache-read, and cache-write tokens +- root-agent and per-sub-agent rollups +- model-call counts and durations +- recent and all-session aggregates +- highest-cost local sessions with in-canvas drill-down + +The interface follows GitHub Copilot's native visual language and refreshes while +the canvas is open. + +## Privacy + +All metrics are read locally from Copilot's session data. The extension: + +- binds its renderer to `127.0.0.1` +- requires a per-instance capability token on every local HTTP and SSE route +- opens local session databases read-only +- does not send usage data to an external service +- keeps historical session drill-down inside the canvas + +The published preview uses entirely synthetic sample data. + +## Installation + +```bash +copilot plugin install usage-insights@awesome-copilot +``` + +Reload extensions, then ask Copilot to open the **Usage Insights** canvas. + +## Canvas actions + +### `refresh` + +Returns current metrics for an optional history range or selected session. + +```json +{ + "range": "7d", + "sessionId": "optional-session-id" +} +``` + +Supported ranges are `24h`, `7d`, `30d`, and `all`. + +### `inspect_session` + +Returns overall and per-agent metrics for one local session. + +```json +{ + "sessionId": "required-session-id", + "range": "7d" +} +``` + +## Structure + +- `extension.mjs` - canvas declaration, loopback server, actions, and refresh events +- `stats.mjs` - read-only SQLite aggregation and sub-agent metadata resolution +- `renderer.mjs` - responsive, theme-aware HTML renderer + +The extension intentionally has no `package.json`: `@github/copilot-sdk` is +resolved by the Copilot extension runtime, and SQLite uses Node.js's built-in +`node:sqlite` module. + +## Requirements + +- GitHub Copilot with extension canvas support +- a runtime version that provides `node:sqlite` +- local Copilot session data containing `assistant_usage_events` + +## Source + +Contributed from +[`davidkaya/github-copilot-usage-insights`](https://github.com/davidkaya/github-copilot-usage-insights) +at commit `206d53faab1d8a3331de96e203d0206ad21845aa`. + +## License + +MIT diff --git a/extensions/usage-insights/assets/preview.png b/extensions/usage-insights/assets/preview.png new file mode 100644 index 000000000..954fef320 Binary files /dev/null and b/extensions/usage-insights/assets/preview.png differ diff --git a/extensions/usage-insights/copilot-extension.json b/extensions/usage-insights/copilot-extension.json new file mode 100644 index 000000000..be5833b6b --- /dev/null +++ b/extensions/usage-insights/copilot-extension.json @@ -0,0 +1,4 @@ +{ + "name": "usage-insights", + "version": 1 +} diff --git a/extensions/usage-insights/extension.mjs b/extensions/usage-insights/extension.mjs new file mode 100644 index 000000000..2ab78ff3d --- /dev/null +++ b/extensions/usage-insights/extension.mjs @@ -0,0 +1,228 @@ +import { randomBytes, timingSafeEqual } from "node:crypto"; +import { createServer } from "node:http"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { createCanvas, CanvasError, joinSession } from "@github/copilot-sdk/extension"; +import { AgentMetadataStore, UsageInsightsStore } from "./stats.mjs"; +import { renderDashboardHtml } from "./renderer.mjs"; + +const RANGE_VALUES = ["24h", "7d", "30d", "all"]; +const servers = new Map(); +const copilotHome = process.env.COPILOT_HOME || join(homedir(), ".copilot"); +const statsStore = new UsageInsightsStore(copilotHome); +const agentMetadata = new AgentMetadataStore(copilotHome); +let session; + +function normalizeRange(value) { + return RANGE_VALUES.includes(value) ? value : "7d"; +} + +function json(res, status, value) { + res.writeHead(status, { + "Cache-Control": "no-store", + "Content-Type": "application/json; charset=utf-8", + }); + res.end(JSON.stringify(value)); +} + +function hasCapabilityToken(url, capabilityToken) { + const supplied = Buffer.from(url.searchParams.get("token") || ""); + const expected = Buffer.from(capabilityToken); + return supplied.length === expected.length && timingSafeEqual(supplied, expected); +} + +function notifyCanvases() { + for (const entry of servers.values()) { + for (const client of entry.clients) { + client.write("event: refresh\ndata: {}\n\n"); + } + } +} + +async function buildDashboardData({ range = "7d", sessionId } = {}) { + if (!session) { + throw new CanvasError("session_not_ready", "The session metrics provider is still starting."); + } + + const selectedSessionId = sessionId || session.sessionId; + const currentRuntime = + selectedSessionId === session.sessionId + ? await session.rpc.usage.getMetrics() + : undefined; + const metadata = await agentMetadata.get(selectedSessionId); + + return statsStore.buildDashboard({ + currentSessionId: session.sessionId, + currentRuntime, + range: normalizeRange(range), + selectedSessionId, + agentMetadata: metadata, + }); +} + +async function startServer(instanceId, defaults) { + const capabilityToken = randomBytes(32).toString("base64url"); + const clients = new Set(); + const server = createServer(async (req, res) => { + const url = new URL(req.url || "/", "http://127.0.0.1"); + if (!hasCapabilityToken(url, capabilityToken)) { + json(res, 403, { error: "Forbidden" }); + return; + } + + if (req.method === "GET" && url.pathname === "/") { + try { + const initialData = await buildDashboardData(defaults); + res.writeHead(200, { + "Cache-Control": "no-store", + "Content-Type": "text/html; charset=utf-8", + }); + res.end( + renderDashboardHtml({ + instanceId, + defaults: { ...defaults, capabilityToken }, + initialData, + }), + ); + } catch (error) { + res.writeHead(500, { + "Cache-Control": "no-store", + "Content-Type": "text/plain; charset=utf-8", + }); + res.end(error instanceof Error ? error.message : "Unable to load session metrics."); + } + return; + } + + if (req.method === "GET" && url.pathname === "/api/stats") { + try { + const data = await buildDashboardData({ + range: url.searchParams.get("range") || defaults.range, + sessionId: url.searchParams.get("sessionId") || defaults.sessionId, + }); + json(res, 200, data); + } catch (error) { + json(res, 500, { + error: error instanceof Error ? error.message : "Unable to load session metrics.", + }); + } + return; + } + + if (req.method === "GET" && url.pathname === "/events") { + res.writeHead(200, { + "Cache-Control": "no-cache", + Connection: "keep-alive", + "Content-Type": "text/event-stream", + }); + res.write("event: ready\ndata: {}\n\n"); + clients.add(res); + req.on("close", () => clients.delete(res)); + return; + } + + json(res, 404, { error: "Not found" }); + }); + + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + const address = server.address(); + const port = typeof address === "object" && address ? address.port : 0; + return { + clients, + server, + url: `http://127.0.0.1:${port}/?token=${encodeURIComponent(capabilityToken)}`, + }; +} + +const usageInsightsCanvas = createCanvas({ + id: "usage-insights", + displayName: "Usage Insights", + description: "Inspect live token and AI-credit usage for the current session, its agents, and recent history.", + inputSchema: { + type: "object", + additionalProperties: false, + properties: { + range: { type: "string", enum: RANGE_VALUES }, + sessionId: { type: "string", minLength: 1 }, + }, + }, + actions: [ + { + name: "refresh", + description: "Return fresh metrics for the current or selected session and time range.", + inputSchema: { + type: "object", + additionalProperties: false, + properties: { + range: { type: "string", enum: RANGE_VALUES }, + sessionId: { type: "string", minLength: 1 }, + }, + }, + handler: async (ctx) => buildDashboardData(ctx.input), + }, + { + name: "inspect_session", + description: "Return the overall and per-agent metrics for one local session.", + inputSchema: { + type: "object", + additionalProperties: false, + required: ["sessionId"], + properties: { + sessionId: { type: "string", minLength: 1 }, + range: { type: "string", enum: RANGE_VALUES }, + }, + }, + handler: async (ctx) => buildDashboardData(ctx.input), + }, + ], + open: async (ctx) => { + let entry = servers.get(ctx.instanceId); + if (!entry) { + const defaults = { + range: normalizeRange(ctx.input?.range), + sessionId: ctx.input?.sessionId || "", + }; + entry = await startServer(ctx.instanceId, defaults); + servers.set(ctx.instanceId, entry); + } + return { + title: "Usage Insights", + status: "Live", + url: entry.url, + }; + }, + onClose: async (ctx) => { + const entry = servers.get(ctx.instanceId); + if (!entry) { + return; + } + servers.delete(ctx.instanceId); + for (const client of entry.clients) { + client.end(); + } + await new Promise((resolve) => entry.server.close(resolve)); + }, +}); + +session = await joinSession({ canvases: [usageInsightsCanvas] }); + +const initialEvents = await session.getEvents(); +agentMetadata.seed(session.sessionId, initialEvents); + +session.on("assistant.usage", notifyCanvases); +session.on("session.usage_checkpoint", notifyCanvases); +session.on("subagent.started", (event) => { + agentMetadata.update(session.sessionId, event); + notifyCanvases(); +}); +session.on("subagent.completed", (event) => { + agentMetadata.update(session.sessionId, event); + notifyCanvases(); +}); +session.on("subagent.failed", (event) => { + agentMetadata.update(session.sessionId, event); + notifyCanvases(); +}); diff --git a/extensions/usage-insights/renderer.mjs b/extensions/usage-insights/renderer.mjs new file mode 100644 index 000000000..373918fb8 --- /dev/null +++ b/extensions/usage-insights/renderer.mjs @@ -0,0 +1,931 @@ +function safeJson(value) { + return JSON.stringify(value).replaceAll("<", "\\u003c"); +} + +export function renderDashboardHtml({ instanceId, defaults, initialData }) { + const initial = safeJson({ ...defaults, instanceId }); + const bootstrap = safeJson(initialData || null); + return ` + + + + + Usage Insights + + + + +
+
+
+

Session usage: AI credits · calls

+ Loading session... +
+
+ + Live +
+
+ +
+
+

+ +

+
+
+
+
+ + + Latest model call + + +
+
+
+ +
+
+

Usage by agent

+
+ + +
+
+
+
+ +
+
+

Token breakdown

+

— total tokens

+
+
+
+ +
+
+
+

Session history

+

Recent locally recorded usage

+
+
+
+
+
AI credits
+
Sessions
+
Root agents
+
Sub-agents
+
+
+

Highest-cost sessions

+

Select one to inspect its agent breakdown

+
+
+
+ + + + +
+ + + +`; +} diff --git a/extensions/usage-insights/stats.mjs b/extensions/usage-insights/stats.mjs new file mode 100644 index 000000000..b490dd043 --- /dev/null +++ b/extensions/usage-insights/stats.mjs @@ -0,0 +1,515 @@ +import { createReadStream, existsSync } from "node:fs"; +import { isAbsolute, join, relative, resolve, sep } from "node:path"; +import { createInterface } from "node:readline"; +import { DatabaseSync } from "node:sqlite"; + +const BILLION = 1_000_000_000; +const RANGE_LABELS = { + "24h": "Last 24 hours", + "7d": "Last 7 days", + "30d": "Last 30 days", + all: "All recorded usage", +}; + +function number(value) { + return Number(value || 0); +} + +function credits(nanoAiu) { + return number(nanoAiu) / BILLION; +} + +function timestampMs(value) { + if (!value) { + return Number.NaN; + } + const timestamp = String(value); + const normalized = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(?:\.\d+)?$/.test(timestamp) + ? `${timestamp.replace(" ", "T")}Z` + : timestamp; + return Date.parse(normalized); +} + +function normalizeTimestamp(value) { + const milliseconds = timestampMs(value); + return Number.isFinite(milliseconds) ? new Date(milliseconds).toISOString() : value; +} + +function normalizeAggregate(row = {}) { + return { + aiCredits: credits(row.total_nano_aiu), + apiDurationMs: number(row.duration_ms), + cacheReadTokens: number(row.cache_read_tokens), + cacheWriteTokens: number(row.cache_write_tokens), + calls: number(row.calls), + inputTokens: number(row.input_tokens), + outputTokens: number(row.output_tokens), + reasoningTokens: number(row.reasoning_tokens), + sessions: number(row.sessions), + subagents: number(row.subagents), + totalNanoAiu: number(row.total_nano_aiu), + }; +} + +function runtimeAggregate(metrics) { + if (!metrics) { + return undefined; + } + + const totalNanoAiu = + number(metrics.totalNanoAiu) || number(metrics.totalPremiumRequestCost) * BILLION; + const aggregate = { + aiCredits: credits(totalNanoAiu), + apiDurationMs: number(metrics.totalApiDurationMs), + cacheReadTokens: 0, + cacheWriteTokens: 0, + calls: 0, + inputTokens: 0, + outputTokens: 0, + reasoningTokens: 0, + sessions: 1, + subagents: 0, + totalNanoAiu, + }; + + for (const metric of Object.values(metrics.modelMetrics || {})) { + if (!metric) { + continue; + } + aggregate.calls += number(metric.requests?.count); + aggregate.inputTokens += number(metric.usage?.inputTokens); + aggregate.outputTokens += number(metric.usage?.outputTokens); + aggregate.reasoningTokens += number(metric.usage?.reasoningTokens); + aggregate.cacheReadTokens += number(metric.usage?.cacheReadTokens); + aggregate.cacheWriteTokens += number(metric.usage?.cacheWriteTokens); + } + return aggregate; +} + +function cutoffFor(range) { + const duration = { + "24h": 24 * 60 * 60 * 1000, + "7d": 7 * 24 * 60 * 60 * 1000, + "30d": 30 * 24 * 60 * 60 * 1000, + }[range]; + return duration ? new Date(Date.now() - duration).toISOString() : undefined; +} + +function statusFor(event) { + if (event.type === "subagent.failed") { + return "failed"; + } + if (event.type === "subagent.completed") { + return event.data.cancelled ? "cancelled" : "completed"; + } + return "running"; +} + +export class AgentMetadataStore { + constructor(copilotHome) { + this.copilotHome = copilotHome; + this.cache = new Map(); + } + + seed(sessionId, events) { + const map = this.cache.get(sessionId) || new Map(); + for (const event of events) { + this.apply(map, event); + } + this.cache.set(sessionId, map); + } + + update(sessionId, event) { + const map = this.cache.get(sessionId) || new Map(); + this.apply(map, event); + this.cache.set(sessionId, map); + } + + apply(map, event) { + if ( + !event?.agentId || + !["subagent.started", "subagent.completed", "subagent.failed"].includes(event.type) + ) { + return; + } + const existing = map.get(event.agentId) || {}; + map.set(event.agentId, { + ...existing, + agentId: event.agentId, + displayName: event.data?.agentDisplayName || existing.displayName || "Sub-agent", + internalName: event.data?.agentName || existing.internalName, + model: event.data?.model || existing.model, + status: statusFor(event), + }); + } + + async get(sessionId) { + if (this.cache.has(sessionId)) { + return this.cache.get(sessionId); + } + + const map = new Map(); + const sessionStateRoot = resolve(this.copilotHome, "session-state"); + const eventPath = resolve(sessionStateRoot, sessionId, "events.jsonl"); + const relativeEventPath = relative(sessionStateRoot, eventPath); + if ( + isAbsolute(relativeEventPath) || + relativeEventPath === ".." || + relativeEventPath.startsWith(`..${sep}`) + ) { + throw new Error("Invalid session ID."); + } + if (existsSync(eventPath)) { + const lines = createInterface({ + crlfDelay: Infinity, + input: createReadStream(eventPath, { encoding: "utf8" }), + }); + for await (const line of lines) { + if (!line.includes('"type":"subagent.')) { + continue; + } + try { + this.apply(map, JSON.parse(line)); + } catch (error) { + if (!(error instanceof SyntaxError)) { + throw error; + } + // Ignore an incomplete trailing JSONL record while the session is writing. + } + } + } + this.cache.set(sessionId, map); + return map; + } +} + +export class UsageInsightsStore { + constructor(copilotHome) { + const sessionStorePath = join(copilotHome, "session-store.db"); + if (!existsSync(sessionStorePath)) { + throw new Error(`Copilot session store was not found at ${sessionStorePath}`); + } + this.sessionDb = new DatabaseSync(sessionStorePath, { readOnly: true }); + + const appDbPath = join(copilotHome, "data.db"); + this.appDb = existsSync(appDbPath) + ? new DatabaseSync(appDbPath, { readOnly: true }) + : undefined; + this.appSessionColumns = new Set( + this.appDb + ?.prepare("PRAGMA table_info(sessions)") + .all() + .map((column) => column.name) || [], + ); + } + + getAppSession(sessionId, requestedColumns) { + if (!this.appDb || !this.appSessionColumns.has("id")) { + return {}; + } + const columns = requestedColumns.filter((column) => this.appSessionColumns.has(column)); + if (!columns.length) { + return {}; + } + return ( + this.appDb + .prepare(`SELECT ${columns.join(", ")} FROM sessions WHERE id = ?`) + .get(sessionId) || {} + ); + } + + queryAggregate(whereSql = "", params = []) { + const row = this.sessionDb + .prepare(` + SELECT + COUNT(*) AS calls, + COUNT(DISTINCT session_id) AS sessions, + COUNT(DISTINCT CASE WHEN NULLIF(agent_id, '') IS NOT NULL THEN agent_id END) AS subagents, + SUM(COALESCE(input_tokens, 0)) AS input_tokens, + SUM(COALESCE(output_tokens, 0)) AS output_tokens, + SUM(COALESCE(reasoning_tokens, 0)) AS reasoning_tokens, + SUM(COALESCE(cache_read_tokens, 0)) AS cache_read_tokens, + SUM(COALESCE(cache_write_tokens, 0)) AS cache_write_tokens, + SUM(COALESCE(total_nano_aiu, 0)) AS total_nano_aiu, + SUM(COALESCE(duration_ms, 0)) AS duration_ms + FROM assistant_usage_events + ${whereSql} + `) + .get(...params); + return normalizeAggregate(row); + } + + getSessionInfo(sessionId) { + const local = + this.sessionDb + .prepare(` + SELECT id, summary, repository, branch, cwd, created_at, updated_at + FROM sessions + WHERE id = ? + `) + .get(sessionId) || {}; + const app = this.getAppSession(sessionId, [ + "title", + "agent", + "model", + "mode", + "created_at", + "updated_at", + ]); + return { + agent: app.agent || "Copilot", + branch: local.branch, + createdAt: normalizeTimestamp(app.created_at || local.created_at), + cwd: local.cwd, + id: sessionId, + mode: app.mode, + model: app.model, + repository: local.repository, + summary: local.summary, + title: app.title || local.summary || `Session ${sessionId.slice(0, 8)}`, + updatedAt: normalizeTimestamp(app.updated_at || local.updated_at), + }; + } + + getSessionAgents(sessionId, metadata) { + const rows = this.sessionDb + .prepare(` + SELECT + COALESCE(NULLIF(agent_id, ''), 'root') AS agent_id, + GROUP_CONCAT(DISTINCT model) AS models, + COUNT(*) AS calls, + SUM(COALESCE(input_tokens, 0)) AS input_tokens, + SUM(COALESCE(output_tokens, 0)) AS output_tokens, + SUM(COALESCE(reasoning_tokens, 0)) AS reasoning_tokens, + SUM(COALESCE(cache_read_tokens, 0)) AS cache_read_tokens, + SUM(COALESCE(cache_write_tokens, 0)) AS cache_write_tokens, + SUM(COALESCE(total_nano_aiu, 0)) AS total_nano_aiu, + SUM(COALESCE(duration_ms, 0)) AS duration_ms, + MAX(created_at) AS last_seen + FROM assistant_usage_events + WHERE session_id = ? + GROUP BY COALESCE(NULLIF(agent_id, ''), 'root') + ORDER BY total_nano_aiu DESC + `) + .all(sessionId); + + return rows.map((row) => { + const meta = metadata.get(row.agent_id); + return { + ...normalizeAggregate(row), + agentId: row.agent_id, + displayName: row.agent_id === "root" ? "Root agent" : meta?.displayName || "Sub-agent", + internalName: meta?.internalName, + lastSeen: normalizeTimestamp(row.last_seen), + models: row.models ? String(row.models).split(",") : [], + status: row.agent_id === "root" ? "primary" : meta?.status || "recorded", + }; + }); + } + + getSessionModels(sessionId) { + return this.sessionDb + .prepare(` + SELECT + model, + COUNT(*) AS calls, + SUM(COALESCE(input_tokens, 0)) AS input_tokens, + SUM(COALESCE(output_tokens, 0)) AS output_tokens, + SUM(COALESCE(reasoning_tokens, 0)) AS reasoning_tokens, + SUM(COALESCE(cache_read_tokens, 0)) AS cache_read_tokens, + SUM(COALESCE(cache_write_tokens, 0)) AS cache_write_tokens, + SUM(COALESCE(total_nano_aiu, 0)) AS total_nano_aiu, + SUM(COALESCE(duration_ms, 0)) AS duration_ms + FROM assistant_usage_events + WHERE session_id = ? + GROUP BY model + ORDER BY total_nano_aiu DESC + `) + .all(sessionId) + .map((row) => ({ ...normalizeAggregate(row), model: row.model })); + } + + getSessionTimeline(sessionId) { + const calls = this.sessionDb + .prepare(` + SELECT + id, + COALESCE(NULLIF(agent_id, ''), 'root') AS agent_id, + model, + input_tokens, + output_tokens, + reasoning_tokens, + total_nano_aiu, + duration_ms, + initiator, + created_at + FROM assistant_usage_events + WHERE session_id = ? + ORDER BY datetime(created_at), id + `) + .all(sessionId) + .map((row) => { + const endedAtMs = timestampMs(row.created_at); + const durationMs = Math.max(1, number(row.duration_ms)); + return { + agentId: row.agent_id, + aiCredits: credits(row.total_nano_aiu), + createdAt: normalizeTimestamp(row.created_at), + durationMs, + endedAtMs, + id: row.id, + initiator: row.initiator, + inputTokens: number(row.input_tokens), + model: row.model, + outputTokens: number(row.output_tokens), + reasoningTokens: number(row.reasoning_tokens), + startedAtMs: endedAtMs - durationMs, + }; + }) + .filter((call) => Number.isFinite(call.endedAtMs)); + + if (!calls.length) { + return { calls: [], durationMs: 0 }; + } + + const startedAtMs = Math.min(...calls.map((call) => call.startedAtMs)); + const endedAtMs = Math.max(...calls.map((call) => call.endedAtMs)); + return { + calls, + durationMs: Math.max(1, endedAtMs - startedAtMs), + endedAt: new Date(endedAtMs).toISOString(), + endedAtMs, + startedAt: new Date(startedAtMs).toISOString(), + startedAtMs, + }; + } + + getRange(range) { + const cutoff = cutoffFor(range); + const whereSql = cutoff ? "WHERE datetime(created_at) >= datetime(?)" : ""; + const params = cutoff ? [cutoff] : []; + const totals = this.queryAggregate(whereSql, params); + + const split = this.sessionDb + .prepare(` + SELECT + CASE WHEN NULLIF(agent_id, '') IS NULL THEN 'root' ELSE 'subagents' END AS segment, + COUNT(*) AS calls, + SUM(COALESCE(input_tokens, 0)) AS input_tokens, + SUM(COALESCE(output_tokens, 0)) AS output_tokens, + SUM(COALESCE(reasoning_tokens, 0)) AS reasoning_tokens, + SUM(COALESCE(cache_read_tokens, 0)) AS cache_read_tokens, + SUM(COALESCE(cache_write_tokens, 0)) AS cache_write_tokens, + SUM(COALESCE(total_nano_aiu, 0)) AS total_nano_aiu, + SUM(COALESCE(duration_ms, 0)) AS duration_ms + FROM assistant_usage_events + ${whereSql} + GROUP BY segment + `) + .all(...params) + .map((row) => ({ ...normalizeAggregate(row), segment: row.segment })); + + const models = this.sessionDb + .prepare(` + SELECT + model, + COUNT(*) AS calls, + SUM(COALESCE(input_tokens, 0)) AS input_tokens, + SUM(COALESCE(output_tokens, 0)) AS output_tokens, + SUM(COALESCE(reasoning_tokens, 0)) AS reasoning_tokens, + SUM(COALESCE(cache_read_tokens, 0)) AS cache_read_tokens, + SUM(COALESCE(cache_write_tokens, 0)) AS cache_write_tokens, + SUM(COALESCE(total_nano_aiu, 0)) AS total_nano_aiu, + SUM(COALESCE(duration_ms, 0)) AS duration_ms + FROM assistant_usage_events + ${whereSql} + GROUP BY model + ORDER BY total_nano_aiu DESC + LIMIT 8 + `) + .all(...params) + .map((row) => ({ ...normalizeAggregate(row), model: row.model })); + + const topSessions = this.sessionDb + .prepare(` + SELECT + e.session_id, + MAX(e.created_at) AS last_seen, + s.summary, + s.repository, + COUNT(*) AS calls, + SUM(COALESCE(e.input_tokens, 0)) AS input_tokens, + SUM(COALESCE(e.output_tokens, 0)) AS output_tokens, + SUM(COALESCE(e.reasoning_tokens, 0)) AS reasoning_tokens, + SUM(COALESCE(e.cache_read_tokens, 0)) AS cache_read_tokens, + SUM(COALESCE(e.cache_write_tokens, 0)) AS cache_write_tokens, + SUM(COALESCE(e.total_nano_aiu, 0)) AS total_nano_aiu, + SUM(COALESCE(e.duration_ms, 0)) AS duration_ms + FROM assistant_usage_events e + LEFT JOIN sessions s ON s.id = e.session_id + ${cutoff ? "WHERE datetime(e.created_at) >= datetime(?)" : ""} + GROUP BY e.session_id, s.summary, s.repository + ORDER BY total_nano_aiu DESC + LIMIT 8 + `) + .all(...params) + .map((row) => { + const app = this.getAppSession(row.session_id, ["title", "agent", "model"]); + return { + ...normalizeAggregate(row), + agent: app.agent, + id: row.session_id, + lastSeen: normalizeTimestamp(row.last_seen), + model: app.model, + repository: row.repository, + title: app.title || row.summary || `Session ${String(row.session_id).slice(0, 8)}`, + }; + }); + + return { + id: range, + label: RANGE_LABELS[range], + models, + split, + topSessions, + totals, + }; + } + + buildDashboard({ + currentRuntime, + currentSessionId, + range, + selectedSessionId, + agentMetadata, + }) { + const dbTotals = this.queryAggregate("WHERE session_id = ?", [selectedSessionId]); + const liveWindow = + selectedSessionId === currentSessionId && currentRuntime + ? runtimeAggregate(currentRuntime) + : undefined; + const selectedTotals = dbTotals.calls > 0 ? dbTotals : liveWindow || dbTotals; + const agents = this.getSessionAgents(selectedSessionId, agentMetadata); + const selectedNanoAiu = selectedTotals.totalNanoAiu; + for (const agent of agents) { + agent.share = + selectedNanoAiu > 0 ? Math.min(1, agent.totalNanoAiu / selectedNanoAiu) : 0; + } + + return { + aiCreditScale: BILLION, + currentSessionId, + generatedAt: new Date().toISOString(), + range: this.getRange(range), + ranges: Object.entries(RANGE_LABELS).map(([id, label]) => ({ id, label })), + selected: { + agents, + info: this.getSessionInfo(selectedSessionId), + isCurrent: selectedSessionId === currentSessionId, + models: this.getSessionModels(selectedSessionId), + timeline: this.getSessionTimeline(selectedSessionId), + totals: selectedTotals, + }, + }; + } +} diff --git a/plugins/usage-insights/README.md b/plugins/usage-insights/README.md new file mode 100644 index 000000000..6c965c49e --- /dev/null +++ b/plugins/usage-insights/README.md @@ -0,0 +1,31 @@ +# Usage Insights Plugin + +Inspect current and historical GitHub Copilot token and AI-credit usage with +per-agent rollups and in-canvas session drill-down. + +## Installation + +```bash +copilot plugin install usage-insights@awesome-copilot +``` + +## Features + +- Live input, output, reasoning, cache, and AI-credit metrics +- Root-agent and sub-agent usage rollups +- Model-call counts, durations, and cumulative usage timeline +- Local session history with in-canvas drill-down +- Read-only local data access with no external telemetry + +## Privacy + +The marketplace preview contains only synthetic sample data and generic session +and agent names. + +## Source + +This plugin is part of [Awesome Copilot](https://github.com/github/awesome-copilot). + +## License + +MIT diff --git a/plugins/usage-insights/plugin.json b/plugins/usage-insights/plugin.json new file mode 100644 index 000000000..182b42a89 --- /dev/null +++ b/plugins/usage-insights/plugin.json @@ -0,0 +1,30 @@ +{ + "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", + "name": "usage-insights", + "description": "Inspect current and historical GitHub Copilot token and AI-credit usage with per-agent rollups and in-canvas session drill-down.", + "version": "1.0.0", + "author": { + "name": "David Kaya", + "url": "https://github.com/davidkaya" + }, + "repository": "https://github.com/github/awesome-copilot", + "license": "MIT", + "keywords": [ + "ai-credits", + "canvas", + "copilot-extension", + "session-analytics", + "token-usage", + "usage-insights" + ], + "extensions": { + "com.github.copilot": { + "logo": "assets/preview.png" + }, + "com.github.awesome-copilot": { + "extensions": [ + "./extensions/usage-insights" + ] + } + } +}