Token Pac-Man
Chomp this session's AI credits. Dodge the ghosts. Chase your next fruit.
diff --git a/extensions/token-pacman/README.md b/extensions/token-pacman/README.md new file mode 100644 index 000000000..5f73792fd --- /dev/null +++ b/extensions/token-pacman/README.md @@ -0,0 +1,32 @@ +# Token Pac-Man + +A GitHub Copilot canvas that visualizes live session AI-credit usage as a Pac-Man board. Pac-Man eats pellets as credits are consumed, ghosts chase him, fruit milestones appear, and the game ends when the configured session credit limit is exceeded. + +## Files + +- `extension.mjs` - canvas declaration, loopback server, live usage/quota syncing, and agent actions. +- `assets/preview.png` - gallery preview image required by the Awesome Copilot canvas catalog. +- `assets/token-pacman.jpg` - source screenshot included for the gallery. +- `copilot-extension.json` - Copilot extension name/version metadata for gist installs. +- `canvas.json` - Awesome Copilot gallery metadata. +- `package.json` - extension metadata used by the generated website catalog. + +## Install + +Ask Copilot to install the committed extension URL: + +```text +Install this extension: https://github.com/github/awesome-copilot/tree/main/extensions/token-pacman +``` + +The shared gist version is also available at: + +```text +https://gist.github.com/jamesmontemagno/75d701d25f49c94ba332529fb8ec1346 +``` + +## Agent actions + +- `sync_usage` - refresh the canvas from the active session's accumulated AI-credit usage and plan entitlement. +- `set_limit { limit }` - set the AI-credit limit that triggers game over and resync the pellet board. +- `reset_run` - clear the visible fruit streak and start a fresh chase without changing the live session credit total. diff --git a/extensions/token-pacman/assets/preview.png b/extensions/token-pacman/assets/preview.png new file mode 100644 index 000000000..4e2d215eb Binary files /dev/null and b/extensions/token-pacman/assets/preview.png differ diff --git a/extensions/token-pacman/assets/token-pacman.jpg b/extensions/token-pacman/assets/token-pacman.jpg new file mode 100644 index 000000000..06cf13796 Binary files /dev/null and b/extensions/token-pacman/assets/token-pacman.jpg differ diff --git a/extensions/token-pacman/canvas.json b/extensions/token-pacman/canvas.json new file mode 100644 index 000000000..32651e808 --- /dev/null +++ b/extensions/token-pacman/canvas.json @@ -0,0 +1,28 @@ +{ + "id": "token-pacman", + "name": "Token Pac-Man", + "description": "Visualizes live session AI-credit usage as a Pac-Man board with pellets, ghosts, fruit milestones, and game-over limits.", + "version": "1.0.0", + "author": { + "name": "James Montemagno", + "url": "https://github.com/jamesmontemagno" + }, + "keywords": [ + "ai-credits", + "copilot-canvas", + "interactive-canvas", + "pac-man", + "quota-tracking", + "session-usage" + ], + "screenshots": { + "icon": { + "path": "assets/preview.png", + "type": "image/png" + }, + "gallery": { + "path": "assets/preview.png", + "type": "image/png" + } + } +} \ No newline at end of file diff --git a/extensions/token-pacman/copilot-extension.json b/extensions/token-pacman/copilot-extension.json new file mode 100644 index 000000000..c33dd8d32 --- /dev/null +++ b/extensions/token-pacman/copilot-extension.json @@ -0,0 +1,4 @@ +{ + "name": "token-pacman", + "version": 1 +} diff --git a/extensions/token-pacman/extension.mjs b/extensions/token-pacman/extension.mjs new file mode 100644 index 000000000..50c5d3d13 --- /dev/null +++ b/extensions/token-pacman/extension.mjs @@ -0,0 +1,396 @@ +import { createServer } from "node:http"; +import { createCanvas, joinSession } from "@github/copilot-sdk/extension"; + +const servers = new Map(); + +const characters = { + mr: { name: "Mr. Pac-Man", color: "#ffd43b" }, + mrs: { name: "Mrs. Pac-Man", color: "#ff78b7" }, +}; +const milestoneThresholds = [10, 25, 50, 100, 250, 500, 1000, 2500, 5000, 10000, 25000, 50000, 100000]; + +function createState() { + return { + credits: 0, + limit: 100, + character: "mr", + dead: false, + achievements: [], + fruit: null, + fruitVersion: 0, + entitlement: null, + runVersion: 0, + }; +} + +function nextMilestone(credits) { + return milestoneThresholds.find((threshold) => threshold > credits) || milestoneThresholds[milestoneThresholds.length - 1]; +} + +function snapshot(entry) { + const { state } = entry; + return { + ...state, + percent: Math.min(100, Math.round((state.credits / state.limit) * 100)), + character: characters[state.character], + nextMilestone: nextMilestone(state.credits), + }; +} + +function broadcast(entry) { + const message = `data: ${JSON.stringify(snapshot(entry))}\n\n`; + for (const client of entry.clients) client.write(message); +} + +function applyUsage(entry, totalCredits) { + const { state } = entry; + const safeTotal = Math.max(0, Number(totalCredits) || 0); + const before = state.credits; + state.credits = safeTotal; + const awarded = []; + for (const threshold of milestoneThresholds) { + if (threshold <= state.credits && threshold > before) { + awarded.push(threshold); + } + } + for (const threshold of awarded) { + state.fruit = ["π", "π", "π", "π", "π"][Math.min(4, Math.floor((milestoneThresholds.indexOf(threshold)) / 2))]; + state.fruitVersion += 1; + state.achievements.unshift({ label: `${threshold.toLocaleString()} session credits munched`, fruit: state.fruit }); + state.achievements = state.achievements.slice(0, 5); + } + state.dead = state.credits >= state.limit; + broadcast(entry); +} + +async function syncUsage(entry) { + const metrics = await session.rpc.usage.getMetrics(); + const nanoAiu = Number(metrics.totalNanoAiu) || entry.eventNanoAiu; + const credits = nanoAiu > 0 ? nanoAiu / 1_000_000_000 : Number(metrics.totalPremiumRequestCost) || 0; + applyUsage(entry, credits); +} + +// Pull the authenticated user's real Copilot entitlement (premium request quota). +async function syncQuota(entry) { + try { + const result = await session.connection.sendRequest("account.getQuota", {}); + const snaps = result?.quotaSnapshots || {}; + const snap = snaps.premium_interactions || snaps.chat || Object.values(snaps)[0]; + if (!snap) return; + entry.state.entitlement = { + type: snaps.premium_interactions ? "Premium requests" : (snaps.chat ? "Chat requests" : "Requests"), + unlimited: !!snap.isUnlimitedEntitlement, + max: Number(snap.entitlementRequests), + used: Number(snap.usedRequests) || 0, + remainingPercentage: Number(snap.remainingPercentage), + overage: Number(snap.overage) || 0, + resetDate: snap.resetDate || null, + }; + broadcast(entry); + } catch (err) { + await session.log(`Token Pac-Man: quota lookup unavailable (${err?.message || err})`, { level: "warning", ephemeral: true }); + } +} + +function getOpenEntry(instanceId) { + const entry = servers.get(instanceId); + if (!entry) throw new Error("Token Pac-Man canvas is not open."); + return entry; +} + +function json(res, status, body) { + res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" }); + res.end(JSON.stringify(body)); +} + +function renderHtml() { + return ` +
+Chomp this session's AI credits. Dodge the ghosts. Chase your next fruit.