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
67 changes: 58 additions & 9 deletions build.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -80,22 +80,46 @@ for (const skillName of ["supermemory-status"]) {
);
}

// Codex custom TUI pets use a fixed 8x9 spritesheet. Every frame in this
// sheet is intentionally identical: Supermemory needs a persistent activity
// badge, not an animated mascot that competes with the coding surface.
// Codex custom TUI pets use a fixed 8x9 spritesheet. Keep Supermemory as a
// quiet badge, but use the standard animation rows to reflect Codex activity.
const PET_FRAME_WIDTH = 192;
const PET_FRAME_HEIGHT = 208;
const PET_COLUMNS = 8;
const PET_ROWS = 9;

const PET_ROW_STYLES = [
{ state: "ACTIVE", accent: [139, 124, 255, 255], frames: 6 },
{ state: "RUNNING", accent: [59, 130, 246, 255], frames: 8 },
{ state: "RUNNING", accent: [59, 130, 246, 255], frames: 8 },
{ state: "READY", accent: [34, 197, 94, 255], frames: 4 },
{ state: "READY", accent: [34, 197, 94, 255], frames: 5 },
{ state: "BLOCKED", accent: [239, 68, 68, 255], frames: 8 },
{ state: "NEEDS INPUT", accent: [245, 158, 11, 255], frames: 6 },
{ state: "RUNNING", accent: [59, 130, 246, 255], frames: 6 },
{ state: "READY", accent: [34, 197, 94, 255], frames: 6 },
];

const PET_PULSE = [0.72, 0.84, 1, 0.9, 0.78, 0.9, 1, 0.84];

const PET_FONT = {
A: ["01110", "10001", "10001", "11111", "10001", "10001", "10001"],
B: ["11110", "10001", "10001", "11110", "10001", "10001", "11110"],
C: ["01111", "10000", "10000", "10000", "10000", "10000", "01111"],
D: ["11110", "10001", "10001", "10001", "10001", "10001", "11110"],
E: ["11111", "10000", "10000", "11110", "10000", "10000", "11111"],
G: ["01111", "10000", "10000", "10111", "10001", "10001", "01111"],
I: ["11111", "00100", "00100", "00100", "00100", "00100", "11111"],
K: ["10001", "10010", "10100", "11000", "10100", "10010", "10001"],
L: ["10000", "10000", "10000", "10000", "10000", "10000", "11111"],
M: ["10001", "11011", "10101", "10101", "10001", "10001", "10001"],
N: ["10001", "11001", "11001", "10101", "10011", "10011", "10001"],
O: ["01110", "10001", "10001", "10001", "10001", "10001", "01110"],
P: ["11110", "10001", "10001", "11110", "10000", "10000", "10000"],
R: ["11110", "10001", "10001", "11110", "10100", "10010", "10001"],
S: ["01111", "10000", "10000", "01110", "00001", "00001", "11110"],
T: ["11111", "00100", "00100", "00100", "00100", "00100", "00100"],
U: ["10001", "10001", "10001", "10001", "10001", "10001", "01110"],
V: ["10001", "10001", "10001", "10001", "10001", "01010", "00100"],
Y: ["10001", "10001", "01010", "00100", "00100", "00100", "00100"],
};

Expand Down Expand Up @@ -155,6 +179,10 @@ function fillRoundedRect(pixels, width, x, y, rectWidth, rectHeight, radius, col
function drawText(pixels, width, text, x, y, scale, color) {
let cursorX = x;
for (const character of text) {
if (character === " ") {
cursorX += 4 * scale;
continue;
}
const glyph = PET_FONT[character];
if (!glyph) continue;
glyph.forEach((row, rowIndex) => {
Expand All @@ -176,11 +204,20 @@ function drawText(pixels, width, text, x, y, scale, color) {
}
}

function drawPetFrame(pixels, sheetWidth, frameX, frameY) {
function pulseColor(color, column) {
const factor = PET_PULSE[column % PET_PULSE.length];
return color.map((channel, index) => index === 3 ? channel : Math.round(channel * factor));
}

function drawPetFrame(pixels, sheetWidth, frameX, frameY, row, column) {
const style = PET_ROW_STYLES[row];
if (!style || column >= style.frames) return;

const accent = pulseColor(style.accent, column);
const badgeX = frameX + 6;
const badgeY = frameY + 164;
fillRoundedRect(pixels, sheetWidth, badgeX, badgeY, 180, 36, 10, [24, 24, 27, 235]);
fillRoundedRect(pixels, sheetWidth, badgeX + 10, badgeY + 9, 18, 18, 3, [139, 124, 255, 255]);
const badgeY = frameY + 156;
fillRoundedRect(pixels, sheetWidth, badgeX, badgeY, 180, 44, 10, [24, 24, 27, 235]);
fillRoundedRect(pixels, sheetWidth, badgeX + 10, badgeY + 13, 18, 18, 3, accent);

// A tiny diagonal cut inside the square echoes the mark used by hook notices.
for (let row = 0; row < 12; row += 1) {
Expand All @@ -189,7 +226,7 @@ function drawPetFrame(pixels, sheetWidth, frameX, frameY) {
pixels,
sheetWidth,
badgeX + 13 + column,
badgeY + 12 + row,
badgeY + 16 + row,
[242, 240, 255, 255],
);
}
Expand All @@ -200,10 +237,20 @@ function drawPetFrame(pixels, sheetWidth, frameX, frameY) {
sheetWidth,
"SUPERMEMORY",
badgeX + 36,
badgeY + 11,
badgeY + 7,
2,
[226, 222, 255, 255],
);

drawText(
pixels,
sheetWidth,
style.state,
badgeX + 36,
badgeY + 27,
1,
accent,
);
}

function writePetSpritesheet(outputPath) {
Expand All @@ -218,6 +265,8 @@ function writePetSpritesheet(outputPath) {
width,
column * PET_FRAME_WIDTH,
row * PET_FRAME_HEIGHT,
row,
column,
);
}
}
Expand Down
2 changes: 1 addition & 1 deletion src/pet/pet.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"id": "supermemory",
"displayName": "Supermemory",
"description": "A quiet persistent indicator that Supermemory is installed",
"description": "A quiet state-aware indicator that Supermemory is installed",
"spritesheetPath": "spritesheet.png",
"frame": {
"width": 192,
Expand Down
51 changes: 51 additions & 0 deletions test/unit.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { writeFileSync, readFileSync, mkdirSync, rmSync, existsSync } from "node
import { join } from "node:path";
import { tmpdir } from "node:os";
import { fileURLToPath } from "node:url";
import { inflateSync } from "node:zlib";
import * as TOML from "@iarna/toml";

// ─── helpers ────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -47,6 +48,38 @@ function hash16(input) {
return createHash("sha256").update(input).digest("hex").slice(0, 16);
}

function readGeneratedPetPng(path) {
const png = readFileSync(path);
let offset = 8;
let width = 0;
let height = 0;
const idat = [];

while (offset < png.length) {
const length = png.readUInt32BE(offset);
const type = png.toString("ascii", offset + 4, offset + 8);
const data = png.subarray(offset + 8, offset + 8 + length);
if (type === "IHDR") {
width = data.readUInt32BE(0);
height = data.readUInt32BE(4);
} else if (type === "IDAT") {
idat.push(data);
}
offset += 12 + length;
}

const scanlines = inflateSync(Buffer.concat(idat));
const stride = width * 4 + 1;
return {
width,
height,
pixel(x, y) {
assert.equal(scanlines[y * stride], 0, "generated pet PNG must use filter 0");
return [...scanlines.subarray(y * stride + 1 + x * 4, y * stride + 1 + x * 4 + 4)];
},
};
}

// Inline the stripPrivateContent logic (mirrors src/services/privacy.ts exactly)
function stripPrivateContent(s) {
return s.replace(/<private>[\s\S]*?<\/private>/gi, "[REDACTED]");
Expand Down Expand Up @@ -790,6 +823,24 @@ describe("hooks.json format", () => {
describe("integration: install/uninstall", () => {
const cliBin = fileURLToPath(new URL("../dist/cli.js", import.meta.url));

test("generated pet badge reflects Codex states", () => {
const pet = readGeneratedPetPng(fileURLToPath(new URL("../dist/pet/spritesheet.png", import.meta.url)));
assert.equal(pet.width, 1536);
assert.equal(pet.height, 1872);

const accentAtRow = (row) => pet.pixel(17, row * 208 + 178);
const running = accentAtRow(7);
const ready = accentAtRow(8);
const blocked = accentAtRow(5);
const waiting = accentAtRow(6);

assert.ok(running[2] > running[0] && running[2] > running[1], "running accent should be blue");
assert.ok(ready[1] > ready[0] && ready[1] > ready[2], "ready accent should be green");
assert.ok(blocked[0] > blocked[1] && blocked[0] > blocked[2], "blocked accent should be red");
assert.ok(waiting[0] > waiting[1] && waiting[1] > waiting[2], "needs-input accent should be amber");
assert.equal(pet.pixel(7 * 192 + 17, 178)[3], 0, "unused idle frames should be transparent");
});

test("install keeps only status skill and registers hosted MCP", (t) => {
const { tmpDir, codexDir } = setupCodexHome(t);

Expand Down