Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,12 @@
"description": "Quickly swipe through backlog issues to triage decisions like assign, needs-info, defer, close, or ignore.",
"version": "1.0.2"
},
{
"name": "brainmax-canvas",
"source": "extensions/brainmax-canvas",
"description": "Interactive concept-mastery dashboard for codebase-grounded BrainMax quizzes, scores and competency reports.",
"version": "1.0.0"
},
{
"name": "cast-imaging",
"source": "plugins/cast-imaging",
Expand Down
19 changes: 19 additions & 0 deletions extensions/brainmax-canvas/.github/plugin/plugin.json
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": "."
}
43 changes: 43 additions & 0 deletions extensions/brainmax-canvas/README.md
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.

![BrainMax domain selection](assets/preview.png)

## 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
Binary file added extensions/brainmax-canvas/assets/preview.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
541 changes: 541 additions & 0 deletions extensions/brainmax-canvas/extension.mjs

Large diffs are not rendered by default.

200 changes: 200 additions & 0 deletions extensions/brainmax-canvas/lib/http-server.mjs
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")) {
Comment thread
juliamuiruri4 marked this conversation as resolved.
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());
}),
};
}
79 changes: 79 additions & 0 deletions extensions/brainmax-canvas/lib/http-server.test.mjs
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" }]);
});
Loading
Loading