Skip to content
Closed
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": "."
}
38 changes: 38 additions & 0 deletions extensions/brainmax-canvas/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# BrainMax Canvas

BrainMax Canvas is the interactive dashboard for codebase-grounded concept-mastery quizes. It presents detected knowledge domains, accepts freeform answers, shows rubric scores, and compiles completed domains into a competency report.

Check failure on line 3 in extensions/brainmax-canvas/README.md

View workflow job for this annotation

GitHub Actions / codespell

quizes ==> quizzes

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.

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. 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.
536 changes: 536 additions & 0 deletions extensions/brainmax-canvas/extension.mjs

Large diffs are not rendered by default.

164 changes: 164 additions & 0 deletions extensions/brainmax-canvas/lib/http-server.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
// 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 { 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 = 16 * 1024;
const SSE_HEARTBEAT_MS = 20_000;

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} getState - 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();

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") {
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") {
writeHeaders(res, 200, MIME[".json"]);
res.end(JSON.stringify(getStateFn()));
return;
}

if (url.pathname === "/event" && req.method === "POST") {
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;
}
let body = "";
for await (const chunk of req) {
body += chunk;
if (Buffer.byteLength(body, "utf8") > MAX_EVENT_BODY_BYTES) {
writeHeaders(res, 413, MIME[".json"]);
res.end(JSON.stringify({ ok: false, error: "event payload is too large" }));
return;
}
}
Comment on lines +81 to +89
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}/`,
broadcastState,
close: () =>
new Promise((resolve) => {
clearInterval(heartbeat);
for (const res of sseClients) res.end();
server.close(() => resolve());
}),
};
}
76 changes: 76 additions & 0 deletions extensions/brainmax-canvas/lib/state.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
// In-memory per-instance state for the BrainMax canvas.
//
// Each open canvas instance (identified by instanceId) gets its own state
// object. Action handlers in extension.mjs mutate this state; the HTTP
// server (lib/http-server.mjs) serializes it to connected SSE clients so the
// browser re-renders without polling.

/** @type {Map<string, InstanceState>} */
const instances = new Map();

/**
* @typedef {Object} InstanceState
* @property {"domains"|"quiz"|"summary"|"report"} view
* @property {Array<{id: string, name: string}>} domains
* @property {Array<{id: string, name: string, score: number, max: number, percentage: number}>} completed
* @property {null|Object} quiz - { domainId, domainName, total, index, runningScore, runningMax, history: [] }
* @property {null|Object} question - { id, index, total, prompt, type }
* @property {"idle"|"submitting"|"error"} domainSelectionStatus
* @property {null|string} domainSelectionError
* @property {"idle"|"submitting"|"scored"|"error"} answerStatus
* @property {null|string} answerError
* @property {null|Object} lastScore - { index, score, max, feedback, tier } (transient reveal)
* @property {null|Object} summary - payload from complete_domain
* @property {null|Object} report - payload from show_report
* @property {"idle"|"submitting"|"error"} reportRequestStatus
* @property {null|string} reportRequestError
* @property {string} announcement - latest aria-live text
*/

function freshState() {
return {
view: "domains",
domains: [],
completed: [],
quiz: null,
question: null,
domainSelectionStatus: "idle",
domainSelectionError: null,
answerStatus: "idle",
answerError: null,
lastScore: null,
summary: null,
report: null,
reportRequestStatus: "idle",
reportRequestError: null,
announcement: "",
};
}

export function getState(instanceId) {
let state = instances.get(instanceId);
if (!state) {
state = freshState();
instances.set(instanceId, state);
}
return state;
}

export function deleteState(instanceId) {
instances.delete(instanceId);
}

/** Map a 0-100 percentage to a score tier index 0-3 (matches shared/scoring-rubric.md bands). */
export function tierForPercentage(percentage) {
if (percentage >= 90) return 3;
if (percentage >= 70) return 2;
if (percentage >= 50) return 1;
return 0;
}

/** Map a raw 0-3 question score directly to its tier (same scale, no banding needed). */
export function tierForScore(score) {
return Math.max(0, Math.min(3, Math.round(score)));
}

export const TIER_LABELS = ["No understanding", "Recognition", "Application", "Mastery"];
15 changes: 15 additions & 0 deletions extensions/brainmax-canvas/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"name": "brainmax-canvas",
"version": "1.0.0",
"private": true,
"type": "module",
"main": "extension.mjs",
"description": "Canvas UI for codebase-grounded BrainMax concept-mastery quizzes.",
"scripts": {
"check": "node --check extension.mjs && node --check lib/http-server.mjs && node --check lib/state.mjs && node --check public/app.js",
"test": "node --test"
},
Comment on lines +8 to +11
"engines": {
"node": ">=20"
}
}
Loading
Loading