feat(FN-3970): consolidate Even plugin APIs into unified package with webho

Collapsed the Even plugin architecture into a single unified plugin by merging board card routes and replacing the transport stub with a real webhook implementation, removing the separate cards plugin from the workspace. The new unified plugin (`fusion-plugin-even-realities-glasses`) now exports con

Fusion-Task-Id: FN-3970
This commit is contained in:
Fusion
2026-05-11 00:15:01 -07:00
committed by gsxdsm
parent a9983df842
commit 007efcab0b
21 changed files with 600 additions and 116 deletions

View File

@@ -1,52 +1,17 @@
# Even Cards Plugin
# Even Cards Plugin (Deprecated)
Standalone Fusion plugin for the Even Realities on-device card flow focused on glanceable board/task status.
`fusion-plugin-even-cards` is deprecated and superseded by the canonical plugin:
## Reading the board
- `fusion-plugin-even-realities-glasses`
All endpoints are mounted under `/api/plugins/fusion-plugin-even-cards`.
Board/task card APIs previously provided here (`/board/cards`, `/board`, `/tasks/:id/cards`) now live under the canonical plugin id and route namespace.
Authentication header (required):
## Migration
```http
Authorization: Bearer <apiKey>
```
Use:
| Method | Path | Query params | Response |
|---|---|---|---|
| GET | `/board/cards` | `columns` (comma-separated columns), `max` (1-20 total cards) | `{ deck, generatedAt }` |
| GET | `/board` | `columns` (optional) | `{ summary, updatedAt }` |
| GET | `/tasks/:id/cards` | none | `{ deck, generatedAt }` or `404` |
- `/api/plugins/fusion-plugin-even-realities-glasses/board/cards`
- `/api/plugins/fusion-plugin-even-realities-glasses/board`
- `/api/plugins/fusion-plugin-even-realities-glasses/tasks/:id/cards`
`max` is **total deck size** (summary + tasks). Example: `max=20` returns 1 summary card and up to 19 task cards.
## Card data model
```ts
type GlassesCard = {
id: string;
kind: "summary" | "task";
title: string;
lines: string[];
badge: { label: string; tone: "triage" | "todo" | "in-progress" | "in-review" | "done" | "neutral" };
taskId?: string;
updatedAt: string;
};
type CardDeck = {
cards: GlassesCard[];
summary: {
counts: Record<string, number>;
updatedAt: string | null;
};
};
```
## Display defaults
Current defaults in `src/cards/format.ts`:
- `DEFAULT_MAX_CHARS_PER_LINE = 24`
- `DEFAULT_MAX_LINES_PER_CARD = 4`
- `DEFAULT_MAX_CARDS_PER_DECK = 8`
These are provisional fallback values because FN-3737 research artifacts were unavailable in this worktree; revisit task is tracked in FN-3754.
This package is removed from the active workspace package list and retained only as a temporary compatibility artifact pending full cleanup.

View File

@@ -1,5 +1,13 @@
# @fusion-plugin-examples/even-realities-glasses
## Unreleased
### Changed
- Consolidated Even board/task card APIs into the canonical `fusion-plugin-even-realities-glasses` plugin.
- Replaced production stub transport with webhook-backed `WebhookGlassesTransport` plus transport status/reconnect/action-ingest routes.
- Added companion webhook configuration (`companionWebhookUrl`) and updated route/auth coverage tests.
## 0.1.3
### Patch Changes

View File

@@ -24,7 +24,9 @@ pnpm --filter @fusion-plugin-examples/even-realities-glasses test
- `fusionApiBaseUrl` (default `http://localhost:4040`)
- `fusionApiToken` (required Bearer token)
- `apiKey` (required for plugin routes)
- `glassesDeviceId` (optional identifier)
- `companionWebhookUrl` (optional companion endpoint base URL for card push, e.g. `https://companion.example`)
- `pollingIntervalSeconds` (default 30, min 5)
- `notifyOnColumns` (default `["in-review"]`)
- `quickCaptureDefaultColumn` (default `triage`)
@@ -154,8 +156,25 @@ curl -X GET "http://localhost:4040/api/plugins/fusion-plugin-even-realities-glas
- Prefer local/self-hosted Fusion instances and avoid exposing dashboard APIs to public networks.
- Treat `fusionApiToken` as secret material and rotate regularly.
## Transport extension point
## Board/task card endpoints (merged canonical surface)
The plugin intentionally uses `GlassesTransport` + `StubGlassesTransport` for now. The real Even Realities BLE/SDK transport should be wired behind this interface.
All companion-facing routes use `Authorization: Bearer <apiKey>` and are hosted under:
Dependency research task FN-3737 was not available in this task runtime, so no concrete protocol implementation is included yet. Integrate the real SDK by replacing the stub transport in `src/index.ts` while keeping route + notifier behavior unchanged.
`/api/plugins/fusion-plugin-even-realities-glasses`
| Method | Path | Description |
| --- | --- | --- |
| GET | `/board/cards` | Card deck projection for selected columns (`columns`, `max`) |
| GET | `/board` | Board summary counts and status |
| GET | `/tasks/:id/cards` | Single-task card deck |
## Transport path (implemented)
Production transport uses `WebhookGlassesTransport`.
- `pushCard()` POSTs cards to `${companionWebhookUrl}/cards`
- `/status` reports `connected`, transport mode, webhook config presence, `lastPushAt`, `lastActionAt`, and `lastError`
- `POST /reconnect` forces disconnect/connect on the transport state machine
- `POST /transport/actions` ingests companion actions (`start-work`, `request-review`, `quick-capture`) into plugin action handlers
If `companionWebhookUrl` is unset, the plugin still serves authenticated routes and polling notifications, but card push remains degraded with `connected=false` and a status error until configured.

View File

@@ -2,7 +2,7 @@
"id": "fusion-plugin-even-realities-glasses",
"name": "Even Realities Glasses",
"version": "0.1.0",
"description": "Task-focused card bridge between Fusion and Even Realities glasses.",
"description": "Canonical Even Realities Fusion plugin: board/task cards, quick capture, actions, notifications, and webhook transport.",
"author": "Fusion Team",
"fusionVersion": ">=0.1.0"
}

View File

@@ -2,7 +2,7 @@
"name": "@fusion-plugin-examples/even-realities-glasses",
"version": "0.1.3",
"type": "module",
"description": "Even Realities glasses task card bridge for Fusion",
"description": "Canonical Even Realities Fusion plugin with board/task cards, actions, notifications, and webhook transport",
"keywords": [
"fusion-plugin",
"even-realities",

View File

@@ -0,0 +1,65 @@
import { describe, expect, it, vi } from "vitest";
import plugin from "../index.js";
import type { Task } from "@fusion/core";
function makeTask(id: string, column: Task["column"], updatedAt: string): Task {
return {
id,
title: id,
description: id,
column,
status: "pending",
priority: "normal",
createdAt: "2026-05-08T10:00:00.000Z",
updatedAt,
currentStep: 0,
steps: [],
dependencies: [],
} as unknown as Task;
}
function createContext(tasks: Task[], apiKey = "secret") {
return {
pluginId: "fusion-plugin-even-realities-glasses",
settings: { apiKey },
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
taskStore: {
listTasks: vi.fn(async () => tasks),
getTask: vi.fn(async (id: string) => tasks.find((task) => task.id === id)),
},
} as never;
}
function route(path: string) {
return plugin.routes!.find((entry) => entry.path === path)!;
}
describe("board routes", () => {
it("returns 401 when auth header missing", async () => {
const response = (await route("/board/cards").handler({ headers: {} }, createContext([]))) as any;
expect(response.status).toBe(401);
});
it("returns 503 when plugin is not configured", async () => {
const response = (await route("/board/cards").handler({ headers: { authorization: "Bearer secret" } }, createContext([], ""))) as any;
expect(response.status).toBe(503);
});
it("returns deck on happy path", async () => {
const tasks = [makeTask("FN-1", "todo", "2026-05-08T11:00:00.000Z"), makeTask("FN-2", "in-progress", "2026-05-08T12:00:00.000Z")];
const response = (await route("/board/cards").handler({ headers: { authorization: "Bearer secret" } }, createContext(tasks))) as any;
expect(response.status).toBe(200);
expect(response.body.deck.cards[0].id).toBe("summary");
expect(response.body.deck.cards).toHaveLength(3);
});
it("returns task deck for known id", async () => {
const tasks = [makeTask("FN-1", "todo", "2026-05-08T11:00:00.000Z")];
const response = (await route("/tasks/:id/cards").handler(
{ headers: { authorization: "Bearer secret" }, params: { id: "FN-1" } },
createContext(tasks),
)) as any;
expect(response.status).toBe(200);
expect(response.body.deck.cards[0].id).toBe("FN-1");
});
});

View File

@@ -6,6 +6,7 @@ describe("even realities plugin", () => {
expect(plugin.manifest.id).toBe("fusion-plugin-even-realities-glasses");
expect(Object.keys(plugin.manifest.settingsSchema ?? {}).sort()).toEqual([
"apiKey",
"companionWebhookUrl",
"enableAgentActions",
"fusionApiBaseUrl",
"fusionApiToken",
@@ -41,7 +42,11 @@ describe("even realities plugin", () => {
};
const ctx = {
pluginId: "known",
settings: { fusionApiToken: "token", fusionApiBaseUrl: "http://localhost:4040" },
settings: {
fusionApiToken: "token",
fusionApiBaseUrl: "http://localhost:4040",
companionWebhookUrl: "https://companion.example",
},
logger: console,
taskStore: { getPluginStore: () => ({ db }) },
} as never;

View File

@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import {
agentActionsEnabled,
getCompanionWebhookUrl,
getFusionBaseUrl,
getFusionToken,
getNotifyColumns,
@@ -12,6 +13,7 @@ describe("settings accessors", () => {
it("uses safe defaults", () => {
expect(getFusionBaseUrl({})).toBe("http://localhost:4040");
expect(getFusionToken({})).toBeUndefined();
expect(getCompanionWebhookUrl({})).toBeUndefined();
expect(getPollingIntervalMs({})).toBe(30000);
expect(getNotifyColumns({})).toEqual(["in-review"]);
expect(getQuickCaptureColumn({})).toBe("triage");
@@ -21,6 +23,9 @@ describe("settings accessors", () => {
it("trims string values", () => {
expect(getFusionBaseUrl({ fusionApiBaseUrl: " http://fusion.local:4040 " })).toBe("http://fusion.local:4040");
expect(getFusionToken({ fusionApiToken: " token " })).toBe("token");
expect(getCompanionWebhookUrl({ companionWebhookUrl: " https://companion.example/ingest " })).toBe(
"https://companion.example/ingest",
);
});
it("enforces polling minimum and finite values", () => {

View File

@@ -0,0 +1,28 @@
import { describe, expect, it, vi } from "vitest";
import { createTransportRoutes } from "../routes/transport-routes.js";
describe("transport routes", () => {
it("requires api key", async () => {
const routes = createTransportRoutes(() => undefined);
const route = routes.find((entry) => entry.path === "/transport/actions");
const res = await route?.handler({ headers: {} }, { settings: {}, pluginId: "p" } as never);
expect(res).toMatchObject({ status: 503 });
});
it("accepts action payloads", async () => {
const receiveAction = vi.fn(async () => undefined);
const routes = createTransportRoutes(() => ({ receiveAction } as never));
const route = routes.find((entry) => entry.path === "/transport/actions");
const res = await route?.handler(
{
headers: { authorization: "Bearer key" },
body: { type: "start-work", taskId: "FN-1", timestamp: new Date().toISOString() },
},
{ settings: { apiKey: "key" }, pluginId: "p" } as never,
);
expect(res).toMatchObject({ status: 202, body: { accepted: true } });
expect(receiveAction).toHaveBeenCalledTimes(1);
});
});

View File

@@ -1,22 +1,35 @@
import { describe, expect, it, vi } from "vitest";
import { StubGlassesTransport } from "../transport.js";
import { WebhookGlassesTransport } from "../transport.js";
describe("StubGlassesTransport", () => {
it("records pushes in order", async () => {
const transport = new StubGlassesTransport();
describe("WebhookGlassesTransport", () => {
it("posts cards to companion webhook", async () => {
const fetchImpl = vi.fn(async () => ({ ok: true, status: 200 })) as unknown as typeof fetch;
const transport = new WebhookGlassesTransport({
companionWebhookUrl: "https://companion.example",
fetchImpl,
});
await transport.connect();
await transport.pushCard({ id: "1", kind: "task", title: "A", lines: [], badge: "todo" });
await transport.pushCard({ id: "2", kind: "task", title: "B", lines: [], badge: "done" });
expect(transport.pushedCards.map((card) => card.id)).toEqual(["1", "2"]);
expect(fetchImpl).toHaveBeenCalledWith(
"https://companion.example/cards",
expect.objectContaining({ method: "POST" }),
);
expect(transport.connected).toBe(true);
expect(transport.status.lastPushAt).toEqual(expect.any(String));
});
it("emits synthetic actions to handlers", async () => {
const transport = new StubGlassesTransport();
it("tracks configuration and dispatches actions", async () => {
const transport = new WebhookGlassesTransport();
const handler = vi.fn();
transport.onAction(handler);
await transport.emitAction({ type: "quick-capture", text: "new task", timestamp: new Date().toISOString() });
await transport.connect();
await transport.receiveAction?.({ type: "quick-capture", text: "new task", timestamp: new Date().toISOString() });
expect(transport.connected).toBe(false);
expect(transport.status.endpointConfigured).toBe(false);
expect(handler).toHaveBeenCalledTimes(1);
expect(handler).toHaveBeenCalledWith(expect.objectContaining({ type: "quick-capture" }));
});
});

View File

@@ -18,6 +18,16 @@ export type GlassesCard = {
actions?: GlassesCardAction[];
};
export type BoardSummary = {
counts: Record<string, number>;
updatedAt: string | null;
};
export type CardDeck = {
cards: GlassesCard[];
summary: BoardSummary;
};
const COLUMN_BADGES: Record<string, string> = {
triage: "triage",
todo: "todo",
@@ -27,18 +37,28 @@ const COLUMN_BADGES: Record<string, string> = {
archived: "archived",
};
const COLUMN_ORDER: Array<Task["column"]> = ["triage", "todo", "in-progress", "in-review", "done", "archived"];
export const DEFAULT_MAX_CHARS_PER_LINE = 24;
export const DEFAULT_MAX_LINES_PER_CARD = 4;
export const DEFAULT_MAX_CARDS_PER_DECK = 8;
const DEFAULT_MAX_TITLE = 80;
export function truncateLine(value: string, maxChars = DEFAULT_MAX_TITLE): string {
if (value.length <= maxChars) return value;
return `${value.slice(0, Math.max(0, maxChars - 1))}`;
const trimmed = value.trim();
if (trimmed.length <= maxChars) return trimmed;
if (maxChars <= 1) return "…";
return `${trimmed.slice(0, Math.max(0, maxChars - 1))}`;
}
export function wrapLines(value: string, opts: { maxCharsPerLine?: number; maxLines?: number } = {}): string[] {
export function wrapLines(
value: string,
opts: { maxCharsPerLine?: number; maxLines?: number } = {},
): string[] {
const maxCharsPerLine = Math.max(8, opts.maxCharsPerLine ?? 36);
const maxLines = Math.max(1, opts.maxLines ?? 2);
const words = value.trim().split(/\s+/).filter(Boolean);
if (words.length === 0) return [""];
if (words.length === 0) return [];
const lines: string[] = [];
let current = "";
for (const word of words) {
@@ -53,7 +73,7 @@ export function wrapLines(value: string, opts: { maxCharsPerLine?: number; maxLi
}
if (lines.length < maxLines && current) lines.push(current);
if (lines.length > maxLines) return lines.slice(0, maxLines);
if (words.join(" ").length > lines.join(" ").length) {
if (words.join(" ").length > lines.join(" ").length && lines.length > 0) {
lines[lines.length - 1] = truncateLine(lines[lines.length - 1], maxCharsPerLine);
}
return lines;
@@ -75,16 +95,43 @@ export function formatRelativeAge(updatedAt: string, opts: { now?: () => Date }
return `updated ${Math.floor(hours / 24)}d ago`;
}
export function taskToCard(task: Task): GlassesCard {
const title = typeof task.title === "string" && task.title.trim() ? task.title : task.description;
function boardSummary(tasks: Task[]): BoardSummary {
const counts = Object.fromEntries(COLUMN_ORDER.map((column) => [column, 0]));
let updatedAt: string | null = null;
for (const task of tasks) {
counts[task.column] = (counts[task.column] ?? 0) + 1;
if (!updatedAt || task.updatedAt > updatedAt) updatedAt = task.updatedAt;
}
return { counts, updatedAt };
}
function boardSummaryCardFromCounts(summary: BoardSummary, now: string, maxCharsPerLine: number, maxLines: number): GlassesCard {
const summaryText = `Triage ${summary.counts.triage} Todo ${summary.counts.todo} Doing ${summary.counts["in-progress"]} Review ${summary.counts["in-review"]} Done ${summary.counts.done}`;
return {
id: `task-${task.id}`,
id: "summary",
kind: "summary",
title: truncateLine("Fusion board", maxCharsPerLine),
lines: wrapLines(summaryText, { maxCharsPerLine, maxLines }),
badge: statusBadge("todo"),
updatedAt: summary.updatedAt ?? now,
};
}
export function taskToCard(
task: Task,
opts: { maxCharsPerLine?: number; maxLines?: number; now?: () => Date } = {},
): GlassesCard {
const title = typeof task.title === "string" && task.title.trim() ? task.title : task.description;
const maxCharsPerLine = opts.maxCharsPerLine ?? DEFAULT_MAX_CHARS_PER_LINE;
const maxLines = opts.maxLines ?? DEFAULT_MAX_LINES_PER_CARD;
return {
id: task.id,
kind: "task",
title: truncateLine(title, DEFAULT_MAX_TITLE),
lines: [
`assignee: ${task.assignedAgentId ?? task.assigneeUserId ?? "unassigned"}`,
formatRelativeAge(task.updatedAt),
],
title: truncateLine(`${task.id.toUpperCase()} ${title}`, maxCharsPerLine),
lines: wrapLines(
`Priority ${task.priority ?? "normal"} Assignee ${task.assignedAgentId ?? task.assigneeUserId ?? "unassigned"} Age ${formatRelativeAge(task.createdAt ?? task.updatedAt, { now: opts.now })}`,
{ maxCharsPerLine, maxLines },
),
badge: statusBadge(task.column),
taskId: task.id,
updatedAt: task.updatedAt,
@@ -95,6 +142,29 @@ export function taskToCard(task: Task): GlassesCard {
};
}
export function boardToDeck(
tasks: Task[],
opts: { maxCharsPerLine?: number; maxLines?: number; maxCards?: number; now?: string } = {},
): CardDeck {
const maxCharsPerLine = opts.maxCharsPerLine ?? DEFAULT_MAX_CHARS_PER_LINE;
const maxLines = opts.maxLines ?? DEFAULT_MAX_LINES_PER_CARD;
const maxCards = opts.maxCards ?? DEFAULT_MAX_CARDS_PER_DECK;
const now = opts.now ?? new Date().toISOString();
const summary = boardSummary(tasks);
const active = tasks
.filter((task) => task.column !== "archived" && task.column !== "done")
.sort((a, b) => (b.updatedAt === a.updatedAt ? b.id.localeCompare(a.id) : b.updatedAt.localeCompare(a.updatedAt)))
.slice(0, Math.max(0, maxCards - 1));
return {
cards: [
boardSummaryCardFromCounts(summary, now, maxCharsPerLine, maxLines),
...active.map((task) => taskToCard(task, { maxCharsPerLine, maxLines, now: () => new Date(now) })),
],
summary,
};
}
export function boardSummaryCard(tasksByColumn: Record<string, number>): GlassesCard {
const ordered = ["triage", "todo", "in-progress", "in-review", "done"];
return {

View File

@@ -1,12 +1,21 @@
import { definePlugin } from "@fusion/plugin-sdk";
import type { FusionPlugin, PluginContext, PluginRouteDefinition, PluginRouteResponse } from "@fusion/plugin-sdk";
import { FusionApiClient } from "./fusion-api-client.js";
import { createNotifier } from "./notifier.js";
import { requestReview, startWork } from "./agent-actions.js";
import { runQuickCapture } from "./quick-capture.js";
import { quickCaptureRoutes } from "./routes/quick-capture-routes.js";
import { createNotificationRoutes } from "./routes/notification-routes.js";
import { agentActionRoutes } from "./routes/agent-action-routes.js";
import { getFusionBaseUrl, getFusionToken, getNotifyColumns, settingsSchema } from "./settings.js";
import { StubGlassesTransport } from "./transport.js";
import { boardRoutes } from "./routes/board-routes.js";
import {
getCompanionWebhookUrl,
getFusionToken,
getNotifyColumns,
getQuickCaptureColumn,
settingsSchema,
} from "./settings.js";
import { WebhookGlassesTransport } from "./transport.js";
import { createTransportRoutes } from "./routes/transport-routes.js";
export type PluginDb = {
exec(sql: string): void;
@@ -18,12 +27,9 @@ export type PluginDb = {
};
type PluginInstance = {
client: FusionApiClient;
transport: StubGlassesTransport;
transport: WebhookGlassesTransport;
notifier: ReturnType<typeof createNotifier>;
};
const instances = new Map<string, PluginInstance>();
};const instances = new Map<string, PluginInstance>();
function getDbFromTaskStore(ctx: PluginContext): PluginDb {
const pluginStore = ctx.taskStore.getPluginStore();
@@ -49,6 +55,7 @@ const coreRoutes: PluginRouteDefinition[] = [
status: 200,
body: {
connected: instance.transport.connected,
transport: instance.transport.status,
lastPollTime: instance.notifier.lastPolledAt() ?? null,
notifyOnColumns: getNotifyColumns(ctx.settings),
},
@@ -69,6 +76,7 @@ const coreRoutes: PluginRouteDefinition[] = [
];
const notificationRoutes = createNotificationRoutes((ctx) => instances.get(ctx.pluginId)?.notifier);
const transportRoutes = createTransportRoutes((ctx) => instances.get(ctx.pluginId)?.transport);
const plugin: FusionPlugin = definePlugin({
manifest: {
@@ -81,7 +89,7 @@ const plugin: FusionPlugin = definePlugin({
settingsSchema,
},
state: "installed",
routes: [...coreRoutes, ...quickCaptureRoutes, ...agentActionRoutes, ...notificationRoutes],
routes: [...coreRoutes, ...boardRoutes, ...quickCaptureRoutes, ...agentActionRoutes, ...notificationRoutes, ...transportRoutes],
hooks: {
onSchemaInit: (db) => {
(db as PluginDb).exec(`
@@ -99,9 +107,35 @@ const plugin: FusionPlugin = definePlugin({
return;
}
const db = getDbFromTaskStore(ctx);
const client = new FusionApiClient(getFusionBaseUrl(ctx.settings), token);
const transport = new StubGlassesTransport();
const transport = new WebhookGlassesTransport({
companionWebhookUrl: getCompanionWebhookUrl(ctx.settings),
});
await transport.connect();
transport.onAction(async (action) => {
if (action.type === "quick-capture") {
await runQuickCapture(
{ text: action.text, column: undefined },
{
taskStore: ctx.taskStore,
pluginId: ctx.pluginId,
defaultColumn: getQuickCaptureColumn(ctx.settings),
},
);
return;
}
if (!action.taskId) return;
if (action.type === "start-work") {
await startWork({ taskId: action.taskId }, { taskStore: ctx.taskStore, pluginId: ctx.pluginId });
return;
}
if (action.type === "request-review") {
await requestReview({ taskId: action.taskId }, { taskStore: ctx.taskStore, pluginId: ctx.pluginId });
}
});
const notifier = createNotifier({
taskStore: ctx.taskStore,
db,
@@ -111,7 +145,7 @@ const plugin: FusionPlugin = definePlugin({
pluginId: ctx.pluginId,
});
notifier.start();
instances.set(ctx.pluginId, { client, transport, notifier });
instances.set(ctx.pluginId, { transport, notifier });
},
onUnload: async () => {
for (const [pluginId, instance] of instances.entries()) {

View File

@@ -0,0 +1,99 @@
import type { Task } from "@fusion/core";
import type { PluginContext, PluginRouteDefinition, PluginRouteResponse } from "@fusion/plugin-sdk";
import {
boardToDeck,
DEFAULT_MAX_CARDS_PER_DECK,
DEFAULT_MAX_CHARS_PER_LINE,
DEFAULT_MAX_LINES_PER_CARD,
taskToCard,
} from "../cards.js";
import { requireApiKey } from "./quick-capture-routes.js";
const ALLOWED_COLUMNS: Array<Task["column"]> = ["triage", "todo", "in-progress", "in-review", "done", "archived"];
function parseColumns(raw: unknown): Set<Task["column"]> | null {
if (typeof raw !== "string" || !raw.trim()) return null;
const parsed = raw
.split(",")
.map((value) => value.trim())
.filter((value): value is Task["column"] => ALLOWED_COLUMNS.includes(value as Task["column"]));
return parsed.length ? new Set(parsed) : null;
}
function parseMax(raw: unknown): number {
const value = typeof raw === "string" ? Number.parseInt(raw, 10) : Number.NaN;
if (!Number.isFinite(value)) return DEFAULT_MAX_CARDS_PER_DECK;
return Math.max(1, Math.min(20, Math.floor(value)));
}
function requestData(req: unknown): {
headers: Record<string, string | string[] | undefined>;
query: Record<string, unknown>;
params: Record<string, unknown>;
} {
const candidate = (req ?? {}) as {
headers?: Record<string, string | string[] | undefined>;
query?: Record<string, unknown>;
params?: Record<string, unknown>;
};
return { headers: candidate.headers ?? {}, query: candidate.query ?? {}, params: candidate.params ?? {} };
}
async function getBoardCards(req: unknown, ctx: PluginContext): Promise<PluginRouteResponse> {
const request = requestData(req);
const auth = requireApiKey(ctx, { headers: request.headers });
if (!auth.ok) return auth.response;
const all = ((await ctx.taskStore.listTasks({ includeArchived: false })) as Task[]) ?? [];
const columns = parseColumns(request.query.columns);
const filtered = columns ? all.filter((task) => columns.has(task.column)) : all;
const maxCards = parseMax(request.query.max);
const deck = boardToDeck(filtered, {
maxCharsPerLine: DEFAULT_MAX_CHARS_PER_LINE,
maxLines: DEFAULT_MAX_LINES_PER_CARD,
maxCards,
});
return { status: 200, body: { deck, generatedAt: new Date().toISOString() } };
}
async function getBoardSummary(req: unknown, ctx: PluginContext): Promise<PluginRouteResponse> {
const request = requestData(req);
const auth = requireApiKey(ctx, { headers: request.headers });
if (!auth.ok) return auth.response;
const all = ((await ctx.taskStore.listTasks({ includeArchived: false })) as Task[]) ?? [];
const columns = parseColumns(request.query.columns);
const filtered = columns ? all.filter((task) => columns.has(task.column)) : all;
const deck = boardToDeck(filtered, {
maxCharsPerLine: DEFAULT_MAX_CHARS_PER_LINE,
maxLines: DEFAULT_MAX_LINES_PER_CARD,
maxCards: 1,
});
return { status: 200, body: { summary: deck.summary, updatedAt: deck.summary.updatedAt } };
}
async function getTaskCards(req: unknown, ctx: PluginContext): Promise<PluginRouteResponse> {
const request = requestData(req);
const auth = requireApiKey(ctx, { headers: request.headers });
if (!auth.ok) return auth.response;
const taskId = typeof request.params.id === "string" ? request.params.id.trim() : "";
if (!taskId) return { status: 400, body: { error: "task id is required" } };
const task = (await ctx.taskStore.getTask(taskId)) as Task | undefined;
if (!task) return { status: 404, body: { error: "task not found" } };
const deck = {
cards: [taskToCard(task, { maxCharsPerLine: DEFAULT_MAX_CHARS_PER_LINE, maxLines: DEFAULT_MAX_LINES_PER_CARD })],
summary: boardToDeck([task], { maxCards: 1 }).summary,
};
return { status: 200, body: { deck, generatedAt: new Date().toISOString() } };
}
export const boardRoutes: PluginRouteDefinition[] = [
{ method: "GET", path: "/board/cards", handler: getBoardCards, description: "Read-only board card deck" },
{ method: "GET", path: "/board", handler: getBoardSummary, description: "Read-only board summary" },
{ method: "GET", path: "/tasks/:id/cards", handler: getTaskCards, description: "Read-only single task card deck" },
];

View File

@@ -0,0 +1,43 @@
import type { PluginContext, PluginRouteDefinition } from "@fusion/plugin-sdk";
import type { GlassesAction, WebhookGlassesTransport } from "../transport.js";
import { requireApiKey } from "./quick-capture-routes.js";
function parseAction(body: unknown): GlassesAction | null {
if (!body || typeof body !== "object") return null;
const input = body as Record<string, unknown>;
if (input.type !== "start-work" && input.type !== "request-review" && input.type !== "quick-capture") {
return null;
}
if (typeof input.timestamp !== "string" || input.timestamp.trim().length === 0) return null;
return {
type: input.type,
taskId: typeof input.taskId === "string" ? input.taskId : undefined,
text: typeof input.text === "string" ? input.text : undefined,
timestamp: input.timestamp,
};
}
export function createTransportRoutes(
getTransport: (ctx: PluginContext) => WebhookGlassesTransport | undefined,
): PluginRouteDefinition[] {
return [
{
method: "POST",
path: "/transport/actions",
handler: async (req, ctx) => {
const auth = requireApiKey(ctx, req as { headers?: Record<string, string | string[] | undefined> });
if (!auth.ok) return auth.response;
const transport = getTransport(ctx);
if (!transport) return { status: 503, body: { error: "transport not running" } };
const action = parseAction((req as { body?: unknown }).body);
if (!action) return { status: 400, body: { error: "invalid action payload" } };
await transport.receiveAction(action);
return { status: 202, body: { accepted: true } };
},
},
];
}

View File

@@ -28,6 +28,10 @@ export const settingsSchema: Record<string, PluginSettingSchema> = {
type: "string",
label: "Glasses Device ID",
},
companionWebhookUrl: {
type: "string",
label: "Companion Webhook URL",
},
pollingIntervalSeconds: {
type: "number",
label: "Polling Interval (seconds)",
@@ -65,6 +69,10 @@ export function getFusionToken(settings: Record<string, unknown>): string | unde
return getSettingString(settings, "fusionApiToken");
}
export function getCompanionWebhookUrl(settings: Record<string, unknown>): string | undefined {
return getSettingString(settings, "companionWebhookUrl");
}
export function getPollingIntervalMs(settings: Record<string, unknown>): number {
const raw = settings.pollingIntervalSeconds;
if (typeof raw !== "number" || !Number.isFinite(raw)) {

View File

@@ -12,30 +12,99 @@ export interface GlassesTransport {
disconnect(): Promise<void>;
pushCard(card: GlassesCard): Promise<void>;
onAction(handler: (action: GlassesAction) => void | Promise<void>): void;
receiveAction?(action: GlassesAction): Promise<void>;
readonly connected?: boolean;
readonly status?: {
mode: "webhook";
endpointConfigured: boolean;
lastPushAt: string | null;
lastActionAt: string | null;
lastError: string | null;
};
}
export class StubGlassesTransport implements GlassesTransport {
type WebhookTransportOptions = {
companionWebhookUrl?: string;
fetchImpl?: typeof fetch;
};
function normalizeWebhookUrl(value: string | undefined): string | undefined {
if (!value) return undefined;
const trimmed = value.trim();
if (!trimmed) return undefined;
return trimmed.replace(/\/$/, "");
}
export class WebhookGlassesTransport implements GlassesTransport {
private handlers: Array<(action: GlassesAction) => void | Promise<void>> = [];
public readonly pushedCards: GlassesCard[] = [];
public connected = false;
private readonly fetchImpl: typeof fetch;
private readonly companionWebhookUrl?: string;
private _connected = false;
private _lastPushAt: string | null = null;
private _lastActionAt: string | null = null;
private _lastError: string | null = null;
constructor(options: WebhookTransportOptions = {}) {
this.fetchImpl = options.fetchImpl ?? fetch;
this.companionWebhookUrl = normalizeWebhookUrl(options.companionWebhookUrl);
}
get connected(): boolean {
return this._connected && Boolean(this.companionWebhookUrl);
}
get status() {
return {
mode: "webhook" as const,
endpointConfigured: Boolean(this.companionWebhookUrl),
lastPushAt: this._lastPushAt,
lastActionAt: this._lastActionAt,
lastError: this._lastError,
};
}
async connect(): Promise<void> {
this.connected = true;
this._connected = true;
this._lastError = this.companionWebhookUrl ? null : "companionWebhookUrl not configured";
}
async disconnect(): Promise<void> {
this.connected = false;
this._connected = false;
}
async pushCard(card: GlassesCard): Promise<void> {
this.pushedCards.push(card);
if (!this.companionWebhookUrl) {
this._lastError = "companionWebhookUrl not configured";
return;
}
try {
const response = await this.fetchImpl(`${this.companionWebhookUrl}/cards`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ card }),
});
if (!response.ok) {
this._lastError = `companion webhook responded ${response.status}`;
this._connected = false;
throw new Error(this._lastError);
}
this._connected = true;
this._lastPushAt = new Date().toISOString();
this._lastError = null;
} catch (error) {
this._connected = false;
this._lastError = error instanceof Error ? error.message : String(error);
throw error;
}
}
onAction(handler: (action: GlassesAction) => void | Promise<void>): void {
this.handlers.push(handler);
}
async emitAction(action: GlassesAction): Promise<void> {
async receiveAction(action: GlassesAction): Promise<void> {
this._lastActionAt = new Date().toISOString();
for (const handler of this.handlers) {
await handler(action);
}