-
Notifications
You must be signed in to change notification settings - Fork 4.6k
Add Brainmax Canvas Extension #2361
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
juliamuiruri4
wants to merge
11
commits into
github:main
Choose a base branch
from
juliamuiruri4:main
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
11 commits
Select commit
Hold shift + click to select a range
9a6c40a
add brainmax canvas extension
juliamuiruri4 910aa27
fix: address BrainMax canvas review
juliamuiruri4 02aa829
fix: align BrainMax review contracts
juliamuiruri4 f45ebe5
fix: align BrainMax completion contract
juliamuiruri4 3bb1f25
fix: secure BrainMax canvas endpoints
juliamuiruri4 cccccc8
fix: support BrainMax chat answers
juliamuiruri4 1810008
fix: harden BrainMax canvas accessibility
juliamuiruri4 938fe51
fix: clarify BrainMax domain status
juliamuiruri4 c978201
fix: improve BrainMax canvas feedback
juliamuiruri4 cd5bea5
fix: recover stalled BrainMax requests
juliamuiruri4 8d2598f
fix: preserve BrainMax report state
juliamuiruri4 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,19 @@ | ||
| { | ||
| "name": "brainmax-canvas", | ||
| "description": "Interactive concept-mastery dashboard for codebase-grounded BrainMax quizzes, scores and competency reports.", | ||
| "version": "1.0.0", | ||
| "author": { | ||
| "name": "Julia Muiruri", | ||
| "url": "https://github.com/juliamuiruri4" | ||
| }, | ||
| "keywords": [ | ||
| "assessment", | ||
| "student-developer", | ||
| "developer-education", | ||
| "quiz", | ||
| "conceptual-understanding", | ||
| "skills-mastery" | ||
| ], | ||
| "logo": "assets/preview.png", | ||
| "extensions": "." | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,43 @@ | ||
| # BrainMax Canvas | ||
|
|
||
| BrainMax Canvas is the interactive dashboard for codebase-grounded concept-mastery quizzes. It presents detected knowledge domains, accepts freeform answers, shows rubric scores, and compiles completed domains into a competency report. | ||
|
|
||
| The Canvas is an optional visual layer. Question generation and scoring stay with the [BrainMaxxing Agent Skills](https://gh.io/brainmaxxing/skills), and the skills continue to work in clients without Canvas support. | ||
|
|
||
|  | ||
|
|
||
| ## Install | ||
|
|
||
| Install the BrainMax Canvas extension through GitHub Copilot or place this directory at `.github/extensions/brainmax-canvas/` in a project. For a project-scoped installation, install the extension's dependencies: | ||
|
|
||
| ```bash | ||
| cd .github/extensions/brainmax-canvas | ||
| npm install | ||
| ``` | ||
|
|
||
| Install the companion BrainMaxxing Agent Skills separately: | ||
|
|
||
| ```bash | ||
| npx skills add juliamuiruri4/brainmaxxing | ||
| ``` | ||
|
|
||
| **Reload your extensions and skills.** | ||
|
|
||
| ## How to use | ||
|
|
||
| 1. In a new agent session, invoke `/brainmax` in a repository. | ||
| 1. Wait while BrainMax detects the knowledge domains represented in the codebase, and opens the Canvas. | ||
| 1. Select a domain in the Canvas. | ||
| 1. Answer each code-grounded question in the Canvas or chat. Canvas answers are relayed to the active session for scoring. | ||
| 1. Pick another domain or compile a cross-domain report. | ||
|
|
||
| ## Architecture | ||
|
|
||
| - `extension.mjs` declares the Canvas and validates state transitions. | ||
| - `lib/state.mjs` owns per-instance quiz state and score-tier helpers. | ||
| - `lib/http-server.mjs` serves the UI and streams state over server-sent events. | ||
| - `public/` contains the dependency-free browser interface. | ||
|
|
||
| ## License | ||
|
|
||
| MIT |
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,200 @@ | ||
| // Local HTTP server for one BrainMax canvas instance: serves the static | ||
| // frontend (public/), streams state updates to it over Server-Sent Events, | ||
| // and accepts interactions from the page to relay back into chat | ||
| // via session.send(). | ||
|
|
||
| import { createServer } from "node:http"; | ||
| import { randomBytes, timingSafeEqual } from "node:crypto"; | ||
| import { readFile } from "node:fs/promises"; | ||
| import { fileURLToPath } from "node:url"; | ||
| import path from "node:path"; | ||
|
|
||
| const __dirname = path.dirname(fileURLToPath(import.meta.url)); | ||
| const PUBLIC_DIR = path.join(__dirname, "..", "public"); | ||
|
|
||
| const MIME = { | ||
| ".html": "text/html; charset=utf-8", | ||
| ".css": "text/css; charset=utf-8", | ||
| ".js": "text/javascript; charset=utf-8", | ||
| ".json": "application/json; charset=utf-8", | ||
| }; | ||
| const MAX_EVENT_BODY_BYTES = 64 * 1024; | ||
| const SSE_HEARTBEAT_MS = 20_000; | ||
|
|
||
| export async function readRequestBody(request, maxBytes = MAX_EVENT_BODY_BYTES) { | ||
| const bodyChunks = []; | ||
| let bodyByteLength = 0; | ||
| for await (const chunk of request) { | ||
| const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); | ||
| bodyByteLength += bytes.length; | ||
| if (bodyByteLength > maxBytes) return null; | ||
| bodyChunks.push(bytes); | ||
| } | ||
| return Buffer.concat(bodyChunks).toString("utf8"); | ||
| } | ||
|
|
||
| function writeHeaders(res, status, contentType, extra = {}) { | ||
| res.writeHead(status, { | ||
| "Content-Type": contentType, | ||
| "Cache-Control": "no-store", | ||
| "X-Content-Type-Options": "nosniff", | ||
| "Cross-Origin-Resource-Policy": "same-origin", | ||
| "Content-Security-Policy": "default-src 'self'; connect-src 'self'; img-src 'self' data:; script-src 'self'; style-src 'self'; base-uri 'none'; form-action 'self'", | ||
| ...extra, | ||
| }); | ||
| } | ||
|
|
||
| /** | ||
| * @param {string} instanceId | ||
| * @param {() => import("./state.mjs").InstanceState} getStateFn - fn returning current state | ||
| * @param {(event: {type: string, [k: string]: unknown}) => void} onClientEvent | ||
| */ | ||
| export async function startInstanceServer(instanceId, getStateFn, onClientEvent) { | ||
| /** @type {Set<import("node:http").ServerResponse>} */ | ||
| const sseClients = new Set(); | ||
| const capabilityToken = randomBytes(32).toString("base64url"); | ||
| const capabilityTokenBytes = Buffer.from(capabilityToken); | ||
|
|
||
| function hasCapability(url) { | ||
| const candidate = url.searchParams.get("token"); | ||
| if (!candidate) return false; | ||
| const candidateBytes = Buffer.from(candidate); | ||
| return candidateBytes.length === capabilityTokenBytes.length && timingSafeEqual(candidateBytes, capabilityTokenBytes); | ||
| } | ||
|
|
||
| function rejectUnauthorized(res) { | ||
| writeHeaders(res, 403, MIME[".json"]); | ||
| res.end(JSON.stringify({ ok: false, error: "invalid canvas capability" })); | ||
| } | ||
|
|
||
| function broadcastState() { | ||
| const payload = `data: ${JSON.stringify(getStateFn())}\n\n`; | ||
| for (const res of sseClients) { | ||
| res.write(payload); | ||
| } | ||
| } | ||
|
|
||
| const heartbeat = setInterval(() => { | ||
| for (const res of sseClients) { | ||
| res.write(`: heartbeat ${Date.now()}\n\n`); | ||
| } | ||
| }, SSE_HEARTBEAT_MS); | ||
| heartbeat.unref?.(); | ||
|
|
||
| const server = createServer(async (req, res) => { | ||
| try { | ||
| const url = new URL(req.url, "http://localhost"); | ||
|
|
||
| if (url.pathname === "/events" && req.method === "GET") { | ||
| if (!hasCapability(url)) { | ||
| rejectUnauthorized(res); | ||
| return; | ||
| } | ||
| writeHeaders(res, 200, "text/event-stream", { Connection: "keep-alive" }); | ||
| res.write(`data: ${JSON.stringify(getStateFn())}\n\n`); | ||
| sseClients.add(res); | ||
| req.on("close", () => sseClients.delete(res)); | ||
| return; | ||
| } | ||
|
|
||
| if (url.pathname === "/state" && req.method === "GET") { | ||
| if (!hasCapability(url)) { | ||
| rejectUnauthorized(res); | ||
| return; | ||
| } | ||
| writeHeaders(res, 200, MIME[".json"]); | ||
| res.end(JSON.stringify(getStateFn())); | ||
| return; | ||
| } | ||
|
|
||
| if (url.pathname === "/event" && req.method === "POST") { | ||
| if (!hasCapability(url)) { | ||
| rejectUnauthorized(res); | ||
| return; | ||
| } | ||
| if (!String(req.headers["content-type"] || "").toLowerCase().startsWith("application/json")) { | ||
| writeHeaders(res, 415, MIME[".json"]); | ||
| res.end(JSON.stringify({ ok: false, error: "content type must be application/json" })); | ||
| return; | ||
| } | ||
| const body = await readRequestBody(req); | ||
| if (body === null) { | ||
| writeHeaders(res, 413, MIME[".json"]); | ||
| res.end(JSON.stringify({ ok: false, error: "event payload is too large" })); | ||
| return; | ||
| } | ||
| let event; | ||
| try { | ||
| event = JSON.parse(body || "{}"); | ||
| } catch { | ||
| writeHeaders(res, 400, MIME[".json"]); | ||
| res.end(JSON.stringify({ ok: false, error: "invalid JSON" })); | ||
| return; | ||
| } | ||
| if (!event || typeof event !== "object" || Array.isArray(event)) { | ||
| writeHeaders(res, 400, MIME[".json"]); | ||
| res.end(JSON.stringify({ ok: false, error: "event payload must be an object" })); | ||
| return; | ||
| } | ||
| const result = onClientEvent(event) || { ok: true }; | ||
| writeHeaders(res, result.ok === false ? 400 : 202, MIME[".json"]); | ||
| res.end(JSON.stringify(result)); | ||
| return; | ||
| } | ||
|
|
||
| // Static file serving for everything else, defaulting to index.html. | ||
| if (req.method !== "GET" && req.method !== "HEAD") { | ||
| writeHeaders(res, 405, "text/plain; charset=utf-8", { Allow: "GET, HEAD" }); | ||
| res.end("Method not allowed"); | ||
| return; | ||
| } | ||
| let relPath = url.pathname === "/" ? "/index.html" : url.pathname; | ||
| const filePath = path.resolve(PUBLIC_DIR, `.${relPath}`); | ||
| const relativePath = path.relative(PUBLIC_DIR, filePath); | ||
| if (relativePath.startsWith(`..${path.sep}`) || relativePath === ".." || path.isAbsolute(relativePath)) { | ||
| writeHeaders(res, 403, "text/plain; charset=utf-8"); | ||
| res.end("Forbidden"); | ||
| return; | ||
| } | ||
| const ext = path.extname(filePath); | ||
| const data = await readFile(filePath); | ||
| writeHeaders(res, 200, MIME[ext] || "application/octet-stream"); | ||
| res.end(req.method === "HEAD" ? undefined : data); | ||
| } catch (err) { | ||
| if (err && err.code === "ENOENT") { | ||
| writeHeaders(res, 404, "text/plain; charset=utf-8"); | ||
| res.end("Not found"); | ||
| return; | ||
| } | ||
| console.error(`BrainMax canvas server error for ${instanceId}`, err); | ||
| writeHeaders(res, 500, "text/plain; charset=utf-8"); | ||
| res.end("Internal server error"); | ||
| } | ||
| }); | ||
|
|
||
| try { | ||
| await new Promise((resolve, reject) => { | ||
| server.once("error", reject); | ||
| server.listen(0, "127.0.0.1", () => { | ||
| server.off("error", reject); | ||
| resolve(); | ||
| }); | ||
| }); | ||
| } catch (error) { | ||
| clearInterval(heartbeat); | ||
| throw error; | ||
| } | ||
| const address = server.address(); | ||
| const port = typeof address === "object" && address ? address.port : 0; | ||
|
|
||
| return { | ||
| url: `http://127.0.0.1:${port}/?token=${capabilityToken}`, | ||
| broadcastState, | ||
| close: () => | ||
| new Promise((resolve) => { | ||
| clearInterval(heartbeat); | ||
| for (const res of sseClients) res.end(); | ||
| server.close(() => resolve()); | ||
| }), | ||
| }; | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,79 @@ | ||
| import assert from "node:assert/strict"; | ||
| import { test } from "node:test"; | ||
| import { readRequestBody, startInstanceServer } from "./http-server.mjs"; | ||
|
|
||
| test("readRequestBody decodes UTF-8 after collecting every chunk", async () => { | ||
| const body = Buffer.from(JSON.stringify({ answer: "I can explain \ud83e\udde0 and \u6f22\u5b57." })); | ||
| const emojiStart = body.indexOf(Buffer.from("\ud83e\udde0")); | ||
|
|
||
| async function* chunks() { | ||
| yield body.subarray(0, emojiStart + 2); | ||
| yield body.subarray(emojiStart + 2); | ||
| } | ||
|
|
||
| assert.equal(await readRequestBody(chunks()), body.toString("utf8")); | ||
| }); | ||
|
|
||
| test("readRequestBody rejects payloads over the byte limit", async () => { | ||
| async function* chunks() { | ||
| yield Buffer.alloc(3); | ||
| yield Buffer.alloc(2); | ||
| } | ||
|
|
||
| assert.equal(await readRequestBody(chunks(), 4), null); | ||
| }); | ||
|
|
||
| test("readRequestBody accepts an 8,000-character UTF-8 answer", async () => { | ||
| const body = JSON.stringify({ type: "submit-answer", answer: "漢".repeat(8000) }); | ||
|
|
||
| async function* chunks() { | ||
| yield Buffer.from(body); | ||
| } | ||
|
|
||
| assert.equal(await readRequestBody(chunks()), body); | ||
| }); | ||
|
|
||
| test("instance APIs require their capability token", async (t) => { | ||
| const events = []; | ||
| const server = await startInstanceServer("test-instance", () => ({ view: "domains" }), (event) => { | ||
| events.push(event); | ||
| return { ok: true }; | ||
| }); | ||
| t.after(() => server.close()); | ||
|
|
||
| const stateUrl = new URL(server.url); | ||
| stateUrl.pathname = "/state"; | ||
| const unauthorizedStateUrl = new URL(stateUrl); | ||
| unauthorizedStateUrl.search = ""; | ||
| assert.equal((await fetch(unauthorizedStateUrl)).status, 403); | ||
| unauthorizedStateUrl.searchParams.set("token", "漢".repeat(43)); | ||
| assert.equal((await fetch(unauthorizedStateUrl)).status, 403); | ||
| assert.deepEqual(await (await fetch(stateUrl)).json(), { view: "domains" }); | ||
|
|
||
| const eventsUrl = new URL(server.url); | ||
| eventsUrl.pathname = "/events"; | ||
| eventsUrl.search = ""; | ||
| assert.equal((await fetch(eventsUrl)).status, 403); | ||
|
|
||
| const eventUrl = new URL(server.url); | ||
| eventUrl.pathname = "/event"; | ||
| const unauthorizedEventUrl = new URL(eventUrl); | ||
| unauthorizedEventUrl.search = ""; | ||
| assert.equal( | ||
| (await fetch(unauthorizedEventUrl, { | ||
| method: "POST", | ||
| headers: { "Content-Type": "application/json" }, | ||
| body: JSON.stringify({ type: "compile-report" }), | ||
| })).status, | ||
| 403, | ||
| ); | ||
| assert.equal( | ||
| (await fetch(eventUrl, { | ||
| method: "POST", | ||
| headers: { "Content-Type": "application/json" }, | ||
| body: JSON.stringify({ type: "compile-report" }), | ||
| })).status, | ||
| 202, | ||
| ); | ||
| assert.deepEqual(events, [{ type: "compile-report" }]); | ||
| }); |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.