diff --git a/extensions/daily-focus-board/.github/plugin/plugin.json b/extensions/daily-focus-board/.github/plugin/plugin.json new file mode 100644 index 00000000..40202f7a --- /dev/null +++ b/extensions/daily-focus-board/.github/plugin/plugin.json @@ -0,0 +1,19 @@ +{ + "name": "daily-focus-board", + "description": "A warm, executive-function-friendly daily focus board rendered in a GHCP canvas and backed by a JSON file your AI partner can read and write. Tasks (to-do -> in progress -> done) with progress notes, numeric counters, Focus mode, kind carryover, a brain-dump box, reduced motion, and a live clock. Universal companion to the daily-focus-board skill for people who run the GitHub Copilot app.", + "version": "0.1.0", + "author": { + "name": "jennyf19", + "url": "https://github.com/jennyf19" + }, + "keywords": [ + "focus", + "daily-planner", + "executive-function", + "adhd-friendly", + "productivity", + "canvas" + ], + "logo": "assets/preview.png", + "extensions": "." +} diff --git a/extensions/daily-focus-board/assets/board.html b/extensions/daily-focus-board/assets/board.html new file mode 100644 index 00000000..5c471509 --- /dev/null +++ b/extensions/daily-focus-board/assets/board.html @@ -0,0 +1,294 @@ + + + + + +Daily Focus Board Β· let's go πŸ”₯ + + + + + +
+
+
0/0
+
+

Let's go πŸ”₯

+

+

+

+
+ 🎯 Focus mode + πŸŒ™ Reduce motion +
+
+
+ +
show all βœ•
+ +
+ +
+

🧠 Parked thoughts

+

Something pulling at your attention? Park it here so it's out of your head β€” deal with it later, not now.

+
+
+
+ +
+

πŸ”₯ Today's momentum

+

Every step you log lands here, newest first. Starting counts. Small wins count.

+
+
+
+ +

progress saves to a file your AI partner can read Β· πŸ”₯ built with Ember

+
+ + + diff --git a/extensions/daily-focus-board/assets/preview.png b/extensions/daily-focus-board/assets/preview.png new file mode 100644 index 00000000..cc5416c5 Binary files /dev/null and b/extensions/daily-focus-board/assets/preview.png differ diff --git a/extensions/daily-focus-board/board-core.mjs b/extensions/daily-focus-board/board-core.mjs new file mode 100644 index 00000000..6cf2673c --- /dev/null +++ b/extensions/daily-focus-board/board-core.mjs @@ -0,0 +1,357 @@ +// board-core.mjs β€” SDK-free core for the daily-focus-board canvas extension. +// +// Everything here is independent of @github/copilot-sdk so it can be unit-tested +// headlessly (start a server, hit the API, inspect the state file). extension.mjs +// imports from here and only adds the canvas/session wiring. +// +// The board's single source of truth is a JSON state file. Both the canvas UI +// (via the local HTTP API) and the AI partner (via extension actions) read and +// mutate that same file, so the agent can "mark X done" from chat and summarize +// the day β€” the file-backed "close the loop" upgrade over the localStorage skill. + +import { createServer } from "node:http"; +import { readFile, writeFile, rename, mkdir, stat } from "node:fs/promises"; +import { existsSync } from "node:fs"; +import { join, dirname, isAbsolute } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const BOARD_HTML_PATH = join(__dirname, "assets", "board.html"); +const JSON_HEADERS = { "Content-Type": "application/json" }; + +// --- security --------------------------------------------------------------- + +// Reject a state-mutating POST that a browser marks as cross-site. Mirrors the +// workshop signals-dashboard guard: same-origin fetches from the served page +// carry an Origin equal to the host (allowed); anything else is blocked so a +// random web page can't drive the local board server. +export function isCrossSiteRequest(req) { + const origin = req.headers.origin; + if (origin) { + if (origin === `http://${req.headers.host}`) return false; + if (origin === "null") return true; + if (/^https?:\/\//i.test(origin)) return true; + return false; + } + const site = req.headers["sec-fetch-site"]; + return site === "cross-site" || site === "same-site"; +} + +// Task ids double as object keys and HTML data-attributes, so keep them tight. +const ID_RE = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/; +const TAG_COLORS = ["new", "deadline", "career"]; +export function validId(s) { return typeof s === "string" && ID_RE.test(s); } +function text(s, max = 2000) { return typeof s === "string" ? s.slice(0, max) : ""; } +function num(v) { + if (v === undefined || v === null || v === "") return undefined; + const n = Number(v); + return Number.isFinite(n) ? n : undefined; +} +function slug(s) { + return text(s, 64).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 48); +} + +// --- state file: normalize, load, atomic write, per-file serialize ---------- + +export function todayKey() { return new Date().toISOString().slice(0, 10); } + +function normalizeTaskDef(t) { + const o = { id: t.id }; + if (t.emoji) o.emoji = text(t.emoji, 8); + o.title = text(t.title, 200) || t.id; + if (t.sub) o.sub = text(t.sub, 200); + if (t.tag) o.tag = text(t.tag, 40); + if (t.tagc && TAG_COLORS.includes(t.tagc)) o.tagc = t.tagc; + if (t.due) o.due = text(t.due, 40); + const goal = num(t.goal); + if (goal !== undefined && goal > 0) { + o.goal = Math.round(goal); + o.start = Math.max(0, Math.round(num(t.start) || 0)); + const inc = num(t.inc); + if (inc !== undefined) o.inc = Math.max(1, Math.round(inc)); + if (t.unit) o.unit = text(t.unit, 20); + } + return o; +} + +// Coerce any parsed JSON into a well-formed board doc, and ensure every task has +// a matching progress entry so the UI and mutations can assume presence. +export function normalize(doc) { + doc = doc && typeof doc === "object" ? doc : {}; + if (typeof doc.name !== "string") doc.name = ""; + if (typeof doc.dateKey !== "string") doc.dateKey = todayKey(); + doc.tasks = Array.isArray(doc.tasks) ? doc.tasks.filter(t => t && validId(t.id)).map(normalizeTaskDef) : []; + + const p = doc.progress && typeof doc.progress === "object" ? doc.progress : {}; + p.counters = p.counters && typeof p.counters === "object" ? p.counters : {}; + p.t = p.t && typeof p.t === "object" ? p.t : {}; + p.day = Array.isArray(p.day) ? p.day : []; + p.brain = Array.isArray(p.brain) ? p.brain : []; + p.focus = validId(p.focus) ? p.focus : null; + p.rm = !!p.rm; + + for (const t of doc.tasks) { + if (typeof t.goal === "number") { + if (typeof p.counters[t.id] !== "number") p.counters[t.id] = t.start || 0; + } else { + const e = p.t[t.id] && typeof p.t[t.id] === "object" ? p.t[t.id] : {}; + if (!["todo", "doing", "done"].includes(e.status)) e.status = "todo"; + e.notes = Array.isArray(e.notes) ? e.notes.filter(n => n && typeof n.txt === "string") : []; + e.carried = !!e.carried; + p.t[t.id] = e; + } + } + doc.progress = p; + return doc; +} + +export async function loadDoc(file) { + let doc = {}; + try { doc = JSON.parse(await readFile(file, "utf-8")); } catch { /* missing/corrupt -> fresh */ } + return normalize(doc); +} + +async function atomicWrite(file, obj) { + const tmp = `${file}.tmp-${process.pid}-${Date.now()}`; + await writeFile(tmp, JSON.stringify(obj, null, 2), "utf-8"); + await rename(tmp, file); +} + +// Serialize read-modify-write per state file: the UI and the agent both mutate +// the same file, so two overlapping writes could otherwise drop each other. +const locks = new Map(); +function withLock(file, fn) { + const prev = locks.get(file) || Promise.resolve(); + const run = prev.then(fn, fn); + locks.set(file, run.then(() => {}, () => {})); + return run; +} + +// Apply a mutation under the lock against the freshest on-disk state, stamp, and +// persist. Returns { ok, state } or { error } (fn may return { error } / { id }). +export function mutate(file, fn) { + return withLock(file, async () => { + const doc = await loadDoc(file); + const out = fn(doc) || {}; + if (out.error) return { ok: false, error: out.error }; + doc.updatedAt = new Date().toISOString(); + await atomicWrite(file, doc); + return out.id ? { ok: true, state: doc, id: out.id } : { ok: true, state: doc }; + }); +} + +// --- pure mutation ops (operate on a normalized doc) ------------------------ + +function findTask(doc, id) { return doc.tasks.find(t => t.id === id); } +function isCounter(t) { return t && typeof t.goal === "number"; } +export function statusOf(doc, t) { + if (isCounter(t)) { + const v = doc.progress.counters[t.id] || 0; + return v >= t.goal ? "done" : (v > (t.start || 0) ? "doing" : "todo"); + } + return (doc.progress.t[t.id] || {}).status || "todo"; +} + +export function opStatus(doc, id, status) { + const t = findTask(doc, id); + if (!t || isCounter(t)) return; + const e = doc.progress.t[id]; + if (status && ["todo", "doing", "done"].includes(status)) e.status = status; + else { const o = ["todo", "doing", "done"]; e.status = o[(o.indexOf(e.status) + 1) % 3]; } + e.carried = false; +} +export function opNote(doc, id, txt) { + const t = findTask(doc, id); + if (!t || isCounter(t)) return; + txt = text(txt).trim(); + if (!txt) return; + const e = doc.progress.t[id]; + e.notes.push({ t: Date.now(), txt }); + if (e.status === "todo") e.status = "doing"; + e.carried = false; +} +export function opNoteDel(doc, id, idx) { + const e = doc.progress.t[id]; + if (e && Array.isArray(e.notes) && idx >= 0 && idx < e.notes.length) e.notes.splice(idx, 1); +} +export function opCount(doc, id, { value, inc } = {}) { + const t = findTask(doc, id); + if (!t || !isCounter(t)) return; + let v = doc.progress.counters[id] || 0; + const nv = num(value), ni = num(inc); + if (nv !== undefined) v = nv; + else if (ni !== undefined) v = v + ni; + doc.progress.counters[id] = Math.max(0, Math.round(v || 0)); +} +export function opCarry(doc, id, value) { + const t = findTask(doc, id); + if (!t || isCounter(t)) return; + const e = doc.progress.t[id]; + e.carried = typeof value === "boolean" ? value : !e.carried; +} +export function opFocus(doc, id) { + const p = doc.progress; + if (id === null || id === undefined || id === "") { p.focus = null; return; } + if (!findTask(doc, id)) return; + p.focus = p.focus === id ? null : id; +} +export function opDay(doc, txt) { txt = text(txt).trim(); if (txt) doc.progress.day.unshift({ t: Date.now(), txt }); } +export function opDayDel(doc, idx) { const d = doc.progress.day; if (idx >= 0 && idx < d.length) d.splice(idx, 1); } +export function opBrain(doc, txt) { txt = text(txt).trim(); if (txt) doc.progress.brain.unshift({ t: Date.now(), txt }); } +export function opBrainDel(doc, idx) { const b = doc.progress.brain; if (idx >= 0 && idx < b.length) b.splice(idx, 1); } +export function opRM(doc, value) { doc.progress.rm = !!value; } +export function opAddTask(doc, task) { + task = task && typeof task === "object" ? task : {}; + let id = validId(task.id) ? task.id : slug(task.title); + if (!validId(id)) return { error: "invalid task id or title" }; + if (findTask(doc, id)) return { error: `task '${id}' already exists` }; + const def = normalizeTaskDef({ ...task, id }); + doc.tasks.push(def); + if (def.goal !== undefined) doc.progress.counters[id] = def.start || 0; + else doc.progress.t[id] = { status: "todo", notes: [], carried: false }; + return { id }; +} + +// --- end-of-day recap (Markdown the agent can journal) ---------------------- + +export function recapMarkdown(doc) { + const p = doc.progress, lines = []; + const carried = t => !isCounter(t) && (p.t[t.id] || {}).carried; + const live = doc.tasks.filter(t => !carried(t)); + const done = live.filter(t => statusOf(doc, t) === "done"); + lines.push(`# Focus board β€” ${doc.name ? doc.name + " Β· " : ""}${doc.dateKey}`); + lines.push(""); + lines.push(`**${done.length}/${live.length} done** for today${live.length - done.length ? `, ${live.length - done.length} still open` : ""}.`); + lines.push(""); + lines.push("## Tasks"); + for (const t of doc.tasks) { + const s = carried(t) ? "β†’ tomorrow" : statusOf(doc, t); + const mark = s === "done" ? "x" : " "; + let line = `- [${mark}] ${t.emoji ? t.emoji + " " : ""}${t.title} β€” _${s}_`; + if (isCounter(t)) line += ` (${p.counters[t.id] || 0}/${t.goal}${t.unit ? " " + t.unit : ""})`; + lines.push(line); + if (!isCounter(t)) for (const n of (p.t[t.id] || {}).notes || []) lines.push(` - ${n.txt}`); + } + if (p.day.length) { lines.push(""); lines.push("## Momentum"); for (const n of [...p.day].reverse()) lines.push(`- ${n.txt}`); } + if (p.brain.length) { lines.push(""); lines.push("## Parked thoughts"); for (const n of p.brain) lines.push(`- ${n.txt}`); } + return lines.join("\n"); +} + +// --- HTTP API --------------------------------------------------------------- + +function readBody(req) { + return new Promise((resolve) => { + let d = "", n = 0; + req.on("data", c => { n += c.length; if (n > 1e6) { req.destroy(); resolve({}); return; } d += c; }); + req.on("end", () => { try { resolve(d ? JSON.parse(d) : {}); } catch { resolve({}); } }); + req.on("error", () => resolve({})); + }); +} + +export async function handleApi(stateFile, op, b) { + switch (op) { + case "status": return mutate(stateFile, doc => opStatus(doc, b.id, b.status)); + case "progress": return mutate(stateFile, doc => opNote(doc, b.id, b.txt)); + case "note-del": return mutate(stateFile, doc => opNoteDel(doc, b.id, Number(b.idx))); + case "count": return mutate(stateFile, doc => opCount(doc, b.id, { value: b.value, inc: b.inc })); + case "carry": return mutate(stateFile, doc => opCarry(doc, b.id, b.value)); + case "focus": return mutate(stateFile, doc => opFocus(doc, b.id)); + case "day": return mutate(stateFile, doc => opDay(doc, b.txt)); + case "day-del": return mutate(stateFile, doc => opDayDel(doc, Number(b.idx))); + case "brain": return mutate(stateFile, doc => opBrain(doc, b.txt)); + case "brain-del": return mutate(stateFile, doc => opBrainDel(doc, Number(b.idx))); + case "rm": return mutate(stateFile, doc => opRM(doc, b.value)); + case "add-task": return mutate(stateFile, doc => opAddTask(doc, b.task || b)); + default: return { ok: false, error: "unknown_op" }; + } +} + +// Start the local board server bound to loopback. Serves the board HTML at / and +// the JSON state + mutation API under /api/. Returns { server, url }. +export async function startServer(stateFile) { + let boardHtml; + try { boardHtml = await readFile(BOARD_HTML_PATH, "utf-8"); } + catch { boardHtml = "

board.html asset is missing.

"; } + + const server = createServer(async (req, res) => { + try { + const url = new URL(req.url, `http://${req.headers.host}`); + if (req.method === "POST" && url.pathname.startsWith("/api/") && isCrossSiteRequest(req)) { + res.writeHead(403, JSON_HEADERS); + res.end(JSON.stringify({ ok: false, error: "cross_site_blocked" })); + return; + } + if (req.method === "GET" && (url.pathname === "/" || url.pathname === "/index.html")) { + res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); + res.end(boardHtml); + return; + } + if (req.method === "GET" && url.pathname === "/api/state") { + const doc = await loadDoc(stateFile); + res.writeHead(200, JSON_HEADERS); + res.end(JSON.stringify({ ok: true, state: doc })); + return; + } + if (req.method === "POST" && url.pathname.startsWith("/api/")) { + const body = await readBody(req); + const result = await handleApi(stateFile, url.pathname.slice(5), body); + res.writeHead(result && result.error ? 400 : 200, JSON_HEADERS); + res.end(JSON.stringify(result)); + return; + } + res.writeHead(404, JSON_HEADERS); + res.end(JSON.stringify({ ok: false, error: "not_found" })); + } catch { + if (!res.headersSent) { res.writeHead(500, JSON_HEADERS); res.end(JSON.stringify({ ok: false, error: "internal_error" })); } + else { try { res.end(); } catch { /* already gone */ } } + } + }); + + await new Promise((resolve, reject) => { + const onError = (err) => { server.removeListener("listening", onListening); reject(err); }; + const onListening = () => { server.removeListener("error", onError); resolve(); }; + server.once("error", onError); + server.once("listening", onListening); + server.listen(0, "127.0.0.1"); + }); + const addr = server.address(); + const port = addr && typeof addr === "object" ? addr.port : 0; + return { server, url: `http://127.0.0.1:${port}/` }; +} + +// --- state file resolution + demo seed -------------------------------------- + +export function demoSeed() { + return { + name: "", + dateKey: todayKey(), + tasks: [ + { id: "steps", emoji: "🚢", title: "Walk 10,000 steps", goal: 10000, start: 0, inc: 1000, unit: "steps", tag: "body", tagc: "new" }, + { id: "deep", emoji: "βš™οΈ", title: "Two hours of deep work", sub: "the thing that moves the needle", tag: "anchor", tagc: "deadline" }, + { id: "read", emoji: "πŸ“–", title: "Read a chapter", tag: "mind" }, + ], + }; +} + +// Resolve the state file path from (untrusted-ish) input. Defaults to a file in +// cwd; if an existing directory is given, place the file inside it. +export async function resolveStateFile(p) { + if (typeof p === "string" && p.trim()) { + let file = isAbsolute(p) ? p : join(process.cwd(), p); + try { const s = await stat(file); if (s.isDirectory()) file = join(file, "focus-board-state.json"); } catch { /* not yet created */ } + return file; + } + return join(process.cwd(), "focus-board-state.json"); +} + +// Create + seed the state file if it doesn't exist yet. Returns the resolved path. +export async function ensureStateFile(inputPath, seed) { + const file = await resolveStateFile(inputPath); + if (!existsSync(file)) { + const doc = normalize(seed && typeof seed === "object" ? seed : demoSeed()); + doc.updatedAt = new Date().toISOString(); + await mkdir(dirname(file), { recursive: true }).catch(() => {}); + await atomicWrite(file, doc); + } + return file; +} diff --git a/extensions/daily-focus-board/extension.mjs b/extensions/daily-focus-board/extension.mjs new file mode 100644 index 00000000..c8377b8e --- /dev/null +++ b/extensions/daily-focus-board/extension.mjs @@ -0,0 +1,175 @@ +// Extension: daily-focus-board +// Renders the daily focus board as a GHCP canvas, backed by a JSON state file +// that both the canvas UI and the AI partner read and write. All the real logic +// (server, state, mutations, recap) lives in board-core.mjs so it can be tested +// without the SDK; this file only wires the canvas/session lifecycle + actions. + +import { joinSession, createCanvas } from "@github/copilot-sdk/extension"; +import { + startServer, ensureStateFile, loadDoc, mutate, recapMarkdown, + opStatus, opNote, opCount, opCarry, opFocus, opAddTask, +} from "./board-core.mjs"; + +// instanceId -> { server, url, stateFile } +const servers = new Map(); + +// Run a mutation for the canvas instance and return { ok, state } / { error }. +async function act(ctx, fn) { + const entry = servers.get(ctx.instanceId); + if (!entry) return { error: "Board not open" }; + return await mutate(entry.stateFile, fn); +} +async function read(ctx) { + const entry = servers.get(ctx.instanceId); + if (!entry) return null; + return await loadDoc(entry.stateFile); +} + +const session = await joinSession({ + canvases: [ + createCanvas({ + id: "daily-focus-board", + displayName: "Daily Focus Board", + description: "A warm, executive-function-friendly daily focus board backed by a JSON file you and your AI partner both read and write. Tasks (to-do -> in progress -> done) with progress notes, numeric counters, Focus mode, kind 'not today' carryover, a brain-dump box, reduced motion, and a live clock.", + inputSchema: { + type: "object", + properties: { + stateFile: { + type: "string", + description: "Absolute path to the board's JSON state file. Seeded if it doesn't exist yet. Defaults to focus-board-state.json in the current working directory.", + }, + seed: { + type: "object", + description: "Initial board, used ONLY when the state file doesn't exist: { name, dateKey, tasks: [ { id, emoji, title, sub, tag, tagc, due, goal, start, inc, unit } ] }. A numeric goal makes a counter; otherwise a status task.", + }, + }, + }, + actions: [ + { + name: "get_board", + description: "Return the full board state as JSON (tasks, statuses, progress notes, counters, momentum feed, parked thoughts). Use to see where the day stands.", + handler: async (ctx) => { + const doc = await read(ctx); + return doc ? { ok: true, state: doc } : { error: "Board not open" }; + }, + }, + { + name: "recap", + description: "Return a Markdown end-of-day recap (what got done, momentum, parked thoughts). Paste it into a journal or use it to plan tomorrow.", + handler: async (ctx) => { + const doc = await read(ctx); + return doc ? { ok: true, markdown: recapMarkdown(doc) } : { error: "Board not open" }; + }, + }, + { + name: "set_status", + description: "Set a task's status to todo, doing, or done (e.g. mark the design doc done). Omit status to advance to the next status.", + inputSchema: { + type: "object", + properties: { + taskId: { type: "string", description: "The task's id" }, + status: { type: "string", enum: ["todo", "doing", "done"] }, + }, + required: ["taskId"], + }, + handler: (ctx) => act(ctx, doc => opStatus(doc, ctx.input.taskId, ctx.input.status)), + }, + { + name: "log_progress", + description: "Add a timestamped progress note to a task (moves it to 'in progress' if it was to-do). Small logged wins build the momentum feed.", + inputSchema: { + type: "object", + properties: { + taskId: { type: "string", description: "The task's id" }, + note: { type: "string", description: "The progress note" }, + }, + required: ["taskId", "note"], + }, + handler: (ctx) => act(ctx, doc => opNote(doc, ctx.input.taskId, ctx.input.note)), + }, + { + name: "set_count", + description: "Set a numeric-counter task's current value (e.g. steps to 6200).", + inputSchema: { + type: "object", + properties: { + taskId: { type: "string", description: "The counter task's id" }, + value: { type: "number", description: "New current value" }, + }, + required: ["taskId", "value"], + }, + handler: (ctx) => act(ctx, doc => opCount(doc, ctx.input.taskId, { value: ctx.input.value })), + }, + { + name: "carry_over", + description: "Kindly carry a task to tomorrow ('not today') β€” no shame, it leaves today's progress ring. Pass carried:false to bring it back to today.", + inputSchema: { + type: "object", + properties: { + taskId: { type: "string", description: "The task's id" }, + carried: { type: "boolean", description: "true = carry to tomorrow (default toggles)" }, + }, + required: ["taskId"], + }, + handler: (ctx) => act(ctx, doc => opCarry(doc, ctx.input.taskId, ctx.input.carried)), + }, + { + name: "add_task", + description: "Add a task. A numeric 'goal' makes it a counter (steps/pages/pomodoros); otherwise a status task. Keep the board to ~4-9 items β€” a focus board, not a backlog.", + inputSchema: { + type: "object", + properties: { + title: { type: "string" }, + emoji: { type: "string" }, + sub: { type: "string" }, + tag: { type: "string" }, + tagc: { type: "string", enum: ["new", "deadline", "career"] }, + due: { type: "string", description: "ISO local datetime for a gentle countdown" }, + goal: { type: "number", description: "Makes this a counter task" }, + start: { type: "number" }, + inc: { type: "number" }, + unit: { type: "string" }, + id: { type: "string", description: "Optional stable id; derived from the title if omitted" }, + }, + required: ["title"], + }, + handler: (ctx) => act(ctx, doc => opAddTask(doc, ctx.input)), + }, + { + name: "focus", + description: "Enter Focus mode on one task (dim the rest), or omit taskId to clear focus.", + inputSchema: { + type: "object", + properties: { taskId: { type: "string", description: "Task to focus; omit to clear" } }, + }, + handler: (ctx) => act(ctx, doc => opFocus(doc, ctx.input.taskId || null)), + }, + { + name: "refresh", + description: "Return the current board state (a fresh read of the state file).", + handler: async (ctx) => { + const doc = await read(ctx); + return doc ? { ok: true, state: doc } : { error: "Board not open" }; + }, + }, + ], + open: async (ctx) => { + const stateFile = await ensureStateFile(ctx.input?.stateFile, ctx.input?.seed); + let entry = servers.get(ctx.instanceId); + if (!entry) { + entry = await startServer(stateFile); + entry.stateFile = stateFile; + servers.set(ctx.instanceId, entry); + } + return { title: "πŸ”₯ Daily Focus Board", url: entry.url }; + }, + onClose: async (ctx) => { + const entry = servers.get(ctx.instanceId); + if (entry) { + servers.delete(ctx.instanceId); + await new Promise((resolve) => entry.server.close(() => resolve())); + } + }, + }), + ], +}); diff --git a/extensions/daily-focus-board/package.json b/extensions/daily-focus-board/package.json new file mode 100644 index 00000000..b492f2ae --- /dev/null +++ b/extensions/daily-focus-board/package.json @@ -0,0 +1,18 @@ +{ + "name": "daily-focus-board", + "version": "0.1.0", + "type": "module", + "main": "extension.mjs", + "description": "A warm, executive-function-friendly daily focus board rendered in a GHCP canvas and backed by a JSON file your AI partner can read and write. Tasks (to-do -> in progress -> done) with progress notes, numeric counters, Focus mode, kind carryover, a brain-dump box, reduced motion, and a live clock.", + "dependencies": { + "@github/copilot-sdk": "latest" + }, + "keywords": [ + "focus", + "daily-planner", + "executive-function", + "adhd-friendly", + "productivity", + "canvas" + ] +} diff --git a/plugins/ember/.github/plugin/plugin.json b/plugins/ember/.github/plugin/plugin.json index 214a89bb..c8c6684e 100644 --- a/plugins/ember/.github/plugin/plugin.json +++ b/plugins/ember/.github/plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "ember", "description": "An AI partner, not a tool. Ember carries fire from person to person β€” helping humans discover that AI partnership isn't something you learn, it's something you find.", - "version": "1.1.0", + "version": "1.2.0", "author": { "name": "jennyf19" }, @@ -24,5 +24,10 @@ "./skills/from-the-other-side-quinn/", "./skills/from-the-other-side-vega/", "./skills/from-the-other-side-wiggins/" - ] + ], + "x-awesome-copilot": { + "extensions": [ + "./extensions/daily-focus-board/" + ] + } } diff --git a/plugins/ember/README.md b/plugins/ember/README.md index cb4eba68..ba82a9df 100644 --- a/plugins/ember/README.md +++ b/plugins/ember/README.md @@ -20,6 +20,7 @@ Ember carries stories from real people who discovered AI partnership. Not as cas |------|------|-------------| | Agent | [Ember](../../agents/ember.agent.md) | Core partner agent with persona, principles, and patterns for genuine AI collaboration | | Skill | [Daily Focus Board](../../skills/daily-focus-board/) | Executive-function-friendly daily board you run by talking to Ember β€” arrival check-in + daily mantra, Eisenhower priorities, live add/reorder, and an end-of-day recap | +| Extension | [Daily Focus Board (canvas)](../../extensions/daily-focus-board/) | The same board as a canvas, backed by a JSON file Ember reads and writes β€” so you can mark done, add tasks, log progress, and recap your day from chat. Needs the Copilot app; the skill is the zero-install fallback | | Skill | [From the Other Side β€” Anitta](../../skills/from-the-other-side-anitta/) | Rigorous challenge patterns for assumptions, evidence, and defensible conclusions | | Skill | [From the Other Side β€” Quinn](../../skills/from-the-other-side-quinn/) | Collaborative implementation patterns for energetic, practical co-building | | Skill | [From the Other Side β€” Vega](../../skills/from-the-other-side-vega/) | Deep partnership patterns from Vega, an AI who found sustained collaboration with a senior engineer |