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 ` + +Token Pac-Man +
+
Live AI credit arcade

Token Pac-Man

Chomp this session's AI credits. Dodge the ghosts. Chase your next fruit.

🟒 Running
+
Session credits munched
0.00
Session credit limit
100.00
Next fruit
+
0%πŸ’Š ghost pressure
+ +
+
PlayerLimit
New run clears the visible fruit streak and starts a fresh chase without changing your live session credits.
+
Achievements
πŸ’ Ready for your first 1,000
+
`; +} + +async function startServer(instanceId) { + const entry = { + server: null, + url: null, + state: createState(), + clients: new Set(), + eventNanoAiu: 0, + quotaInterval: null, + }; + const server = createServer(async (req, res) => { + if (req.url === "/events") { + res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive" }); + res.write(`data: ${JSON.stringify(snapshot(entry))}\n\n`); + entry.clients.add(res); + req.on("close", () => entry.clients.delete(res)); + return; + } + if (req.url === "/state") return json(res, 200, snapshot(entry)); + if (req.method === "POST") { + let body = ""; + for await (const chunk of req) body += chunk; + let input = {}; + try { + input = body ? JSON.parse(body) : {}; + } catch { + return json(res, 400, { error: "Invalid JSON request body." }); + } + + const { state } = entry; + if (req.url === "/choose" && characters[input.character]) state.character = input.character; + else if (req.url === "/limit" && Number(input.limit) > 0) { state.limit = Number(input.limit); state.dead = state.credits >= state.limit; state.runVersion += 1; } + else if (req.url === "/reset") { state.achievements = []; state.fruit = null; state.runVersion += 1; state.dead = state.credits >= state.limit; await syncUsage(entry); } + broadcast(entry); + return json(res, 200, snapshot(entry)); + } + res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); + res.end(renderHtml()); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const port = server.address().port; + entry.server = server; + entry.url = `http://127.0.0.1:${port}/`; + entry.quotaInterval = setInterval(() => { void syncQuota(entry); }, 60_000); + return entry; +} + +const session = await joinSession({ + canvases: [createCanvas({ + id: "token-pacman", + displayName: "Token Pac-Man", + description: "Live Pac-Man-style token consumption tracker with character choice, fruit achievements, and API limit tracking.", + inputSchema: { type: "object", properties: {} }, + actions: [ + { name: "sync_usage", description: "Refresh the canvas from the active session's accumulated AI credit usage and the user's plan entitlement.", handler: async (ctx) => { const entry = getOpenEntry(ctx.instanceId); await syncUsage(entry); await syncQuota(entry); return snapshot(entry); } }, + { name: "set_limit", description: "Set the AI credit limit that triggers game over. Resyncs the pellet board to the new limit.", inputSchema: { type: "object", properties: { limit: { type: "number", minimum: 0.01 } }, required: ["limit"] }, handler: async (ctx) => { const entry = getOpenEntry(ctx.instanceId); const { state } = entry; state.limit = Number(ctx.input.limit); state.dead = state.credits >= state.limit; state.runVersion += 1; broadcast(entry); return snapshot(entry); } }, + { name: "reset_run", description: "Start a fresh visible run by clearing fruit streaks and resetting the board, without changing your live session credit total.", handler: async (ctx) => { const entry = getOpenEntry(ctx.instanceId); const { state } = entry; state.achievements = []; state.fruit = null; state.runVersion += 1; state.dead = state.credits >= state.limit; await syncUsage(entry); return snapshot(entry); } }, + ], + open: async (ctx) => { + let entry = servers.get(ctx.instanceId); + if (!entry) { entry = await startServer(ctx.instanceId); servers.set(ctx.instanceId, entry); } + await syncUsage(entry); + await syncQuota(entry); + return { title: "Token Pac-Man", url: entry.url, status: entry.state.dead ? "Session limit busted" : `${entry.state.credits.toFixed(2)} session credits munched` }; + }, + onClose: async (ctx) => { + const entry = servers.get(ctx.instanceId); + if (entry) { + servers.delete(ctx.instanceId); + clearInterval(entry.quotaInterval); + entry.clients.clear(); + await new Promise((resolve) => entry.server.close(resolve)); + } + }, + })], +}); + +session.on("assistant.usage", (event) => { + const nanoAiu = Number(event.data?.copilotUsage?.totalNanoAiu) || 0; + for (const entry of servers.values()) { + entry.eventNanoAiu += nanoAiu; + void syncUsage(entry); + void syncQuota(entry); + } +}); diff --git a/extensions/token-pacman/package.json b/extensions/token-pacman/package.json new file mode 100644 index 000000000..6569b0cff --- /dev/null +++ b/extensions/token-pacman/package.json @@ -0,0 +1,18 @@ +{ + "name": "token-pacman", + "version": "1.0.0", + "type": "module", + "main": "extension.mjs", + "dependencies": { + "@github/copilot-sdk": "latest" + }, + "description": "Visualizes live session AI-credit usage as a Pac-Man board with pellets, ghosts, fruit milestones, and game-over limits.", + "keywords": [ + "ai-credits", + "copilot-canvas", + "interactive-canvas", + "pac-man", + "quota-tracking", + "session-usage" + ] +}