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

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Consolidate Even Realities plugin support into `fusion-plugin-even-realities-glasses` and remove `fusion-plugin-even-cards` from the active workspace package list to avoid duplicate user-facing integrations.

View File

@@ -1205,21 +1205,16 @@ Example plugin demonstrating settings schema and runtime configuration with all
- Features: Configurable greeting message, tag limit, logging toggle, log level selector - Features: Configurable greeting message, tag limit, logging toggle, log level selector
- **Install from Settings**: Designed to be installed via the dashboard Settings → Plugins UI - **Install from Settings**: Designed to be installed via the dashboard Settings → Plugins UI
### [Even Cards Plugin](../../plugins/fusion-plugin-even-cards/)
Read-only board/task card projection plugin for Even Realities companion flows.
- Demonstrates: plugin routes protected with API-key auth, store reads via `ctx.taskStore`, and card-deck projection helpers
- Features: `GET /board/cards`, `GET /board`, and `GET /tasks/:id/cards` endpoints with compact card payloads
### [Even Realities Glasses Plugin](../../plugins/fusion-plugin-even-realities-glasses/) ### [Even Realities Glasses Plugin](../../plugins/fusion-plugin-even-realities-glasses/)
Task-focused card bridge plugin for Even Realities glasses companion flows. Task-focused card bridge plugin for Even Realities glasses companion flows.
- Features: quick capture text into new tasks via the plugin route - Features: quick capture text into new tasks via the plugin route
- Features: polling-based task transition notifications on configured columns (default `in-review`) - Features: polling-based task transition notifications on configured columns (default `in-review`)
- Features: board/task card endpoints (`GET /board/cards`, `GET /board`, `GET /tasks/:id/cards`)
- Features: agent actions for start work (`in-progress`) and request review (`in-review`), gated by `enableAgentActions` - Features: agent actions for start work (`in-progress`) and request review (`in-review`), gated by `enableAgentActions`
- Demonstrates: settings schema for `fusionApiBaseUrl`, `fusionApiToken`, `glassesDeviceId`, `pollingIntervalSeconds`, `notifyOnColumns`, `quickCaptureDefaultColumn`, and `enableAgentActions` - Features: webhook transport bridge with companion action ingestion (`POST /transport/actions`) and reconnect/status endpoints
- Demonstrates: settings schema for `fusionApiBaseUrl`, `fusionApiToken`, `apiKey`, `glassesDeviceId`, `companionWebhookUrl`, `pollingIntervalSeconds`, `notifyOnColumns`, `quickCaptureDefaultColumn`, and `enableAgentActions`
- Demonstrates FN-3737-aligned display limits: `EVEN_CARD_MAX_CHARS_PER_LINE = 28`, `EVEN_CARD_MAX_LINES_PER_CARD = 8`, `EVEN_CARD_MAX_DECK_SIZE = 12` - Demonstrates FN-3737-aligned display limits: `EVEN_CARD_MAX_CHARS_PER_LINE = 28`, `EVEN_CARD_MAX_LINES_PER_CARD = 8`, `EVEN_CARD_MAX_DECK_SIZE = 12`
### Installing Example Plugins from Settings ### Installing Example Plugins from Settings

View File

@@ -3,6 +3,11 @@
## 1. Summary & Recommendation ## 1. Summary & Recommendation
Recommend **Option A** for v1: a **Fusion plugin + Even companion app bridge**. The companion app (or Even Hub-hosted webview app) handles device/app bridge APIs and forwards minimal HTTP calls to Fusion (`fn serve`/dashboard API). This aligns with currently visible Even SDK positioning (WebView ↔ Even App bridge, not direct glasses→LAN HTTP) and lets Fusion reuse existing task/agent REST APIs and plugin route hosting. Recommend **Option A** for v1: a **Fusion plugin + Even companion app bridge**. The companion app (or Even Hub-hosted webview app) handles device/app bridge APIs and forwards minimal HTTP calls to Fusion (`fn serve`/dashboard API). This aligns with currently visible Even SDK positioning (WebView ↔ Even App bridge, not direct glasses→LAN HTTP) and lets Fusion reuse existing task/agent REST APIs and plugin route hosting.
### Implementation status (FN-3970)
- Implemented in Fusion plugin: canonical `fusion-plugin-even-realities-glasses` route surface now includes board/task card endpoints, quick capture, actions, notifications, and transport status/reconnect routes.
- Production transport path now uses webhook-based `WebhookGlassesTransport` (`companionWebhookUrl`) instead of a stub transport.
- Companion app delivery remains out of scope for this document/task chain; this doc still defines the recommended topology and open ecosystem questions.
## 2. Even Realities Platform Overview ## 2. Even Realities Platform Overview
### 2.1 Developer surface (SDKs, docs, supported hosts) ### 2.1 Developer surface (SDKs, docs, supported hosts)

View File

@@ -1,17 +1,66 @@
# Even Realities Plugin API # Even Realities Plugin API
## Test Coverage Canonical plugin id: `fusion-plugin-even-realities-glasses`
| Endpoint | Contract Row | Test Suite | Assertion Type | All plugin routes are mounted under:
| --- | --- | --- | --- |
| `GET /api/plugins/fusion-plugin-even-realities-glasses/board` | Auth required when daemon token enabled | `auth.test.ts` | `it.each` matrix for valid bearer / missing / wrong / `?fn_token=` fallback | `/api/plugins/fusion-plugin-even-realities-glasses`
| `GET /api/plugins/fusion-plugin-even-realities-glasses/board` | Board response includes expected columns/task summaries | `integration.test.ts` | Integration response contract assertion |
| `GET /api/plugins/fusion-plugin-even-realities-glasses/tasks/:id` | Optional `summary` capped at 200 chars | `integration.test.ts` | Integration field-boundary assertion | All companion-facing routes require:
| `GET /api/plugins/fusion-plugin-even-realities-glasses/changes` | Polling contract: strict `updatedAt > since`, ASC ordering, default/capped limits, `hasMore`, monotonic `serverTime` | `polling.test.ts` | Cursor + pagination contract assertions |
| `GET /api/plugins/fusion-plugin-even-realities-glasses/changes` | Empty diff response shape `{ changes, hasMore, serverTime }` | `polling.test.ts` | Empty-state response assertion | `Authorization: Bearer <apiKey>`
| `POST /api/plugins/fusion-plugin-even-realities-glasses/quick-capture` | Input validation (required title, title cap) and default column behavior | `quick-capture.test.ts` | Validation + creation side-effect assertions |
| `POST /api/plugins/fusion-plugin-even-realities-glasses/quick-capture` | Task-summary-only response (no description/prompt/log leakage), payload bounded | `response-shape.test.ts`, `quick-capture.test.ts` | Whitelist + payload-size assertions | ## Endpoint Contract
| `POST /api/plugins/fusion-plugin-even-realities-glasses/actions/start-work` | Action enabled/disabled by `enableAgentActions` | `agent-actions.test.ts` | Feature-gate integration assertions |
| `POST /api/plugins/fusion-plugin-even-realities-glasses/actions/request-review` | Action enabled/disabled by `enableAgentActions` | `agent-actions.test.ts` | Feature-gate integration assertions | ### Board / Task Cards
| `POST /api/plugins/fusion-plugin-even-realities-glasses/actions/*` | Unknown task id rejected | `agent-actions.test.ts` | 404 contract assertion |
| `/api/plugins/fusion-plugin-even-realities-glasses/<plugin-route>` and `/api/plugins/fusion-plugin-even-realities-glasses/enable` | Plugin route mount + management-route collision safety | `packages/dashboard/src/__tests__/plugin-routes-wiring.test.ts` | Routing/wiring integration assertions | - `GET /board/cards` → compact deck for selected columns (`columns`, `max`)
- `GET /board` → board summary counts and updated timestamp
- `GET /tasks/:id/cards` → single-task deck (`404` when missing)
### Quick Capture
- `POST /quick-capture`
- Body: `{ "text": string, "column"?: TaskColumn }`
- Creates a task using normalization + configured `quickCaptureDefaultColumn`
### Agent Actions
- `POST /actions/start-work`
- `POST /actions/request-review`
- `POST /actions/approve-plan`
- `POST /actions/accept-review`
- `POST /actions/return-to-agent`
- `POST /actions/retry`
Body: `{ "taskId": "FN-123" }`
### Notifications
- `GET /notifications` (`limit`, optional `drain=true`)
- `POST /notifications/ack` with `{ "taskIds": string[] }`
- `POST /notifications/poll-now`
### Transport Status / Control
- `GET /status``connected`, transport mode/config/error, last poll
- `POST /reconnect` → reconnect transport lifecycle
- `POST /transport/actions` → ingest companion actions (`start-work`, `request-review`, `quick-capture`)
## Transport Contract
Production transport is `WebhookGlassesTransport`:
- pushes cards to `${companionWebhookUrl}/cards`
- reports degraded status when webhook URL is missing/unreachable
- records `lastPushAt`, `lastActionAt`, and `lastError` for `/status`
## Test Coverage Map
- `src/__tests__/board-routes.test.ts` — auth + board/task card route contracts
- `src/__tests__/cards.test.ts` — card/deck projection budgets and formatting
- `src/__tests__/quick-capture-routes.test.ts` — quick capture auth/input/error behavior
- `src/__tests__/agent-action-routes.test.ts` + `agent-actions.test.ts` — action route contract and state transitions
- `src/__tests__/notification-routes.test.ts` + `notifier.test.ts` — polling queue, ack, forced poll behavior
- `src/__tests__/transport.test.ts` + `transport-routes.test.ts` — webhook push path, connection status, inbound action ingestion
- `src/__tests__/index.test.ts` — manifest/settings keys and plugin lifecycle initialization behavior

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 Use:
Authorization: Bearer <apiKey>
```
| Method | Path | Query params | Response | - `/api/plugins/fusion-plugin-even-realities-glasses/board/cards`
|---|---|---|---| - `/api/plugins/fusion-plugin-even-realities-glasses/board`
| GET | `/board/cards` | `columns` (comma-separated columns), `max` (1-20 total cards) | `{ deck, generatedAt }` | - `/api/plugins/fusion-plugin-even-realities-glasses/tasks/:id/cards`
| GET | `/board` | `columns` (optional) | `{ summary, updatedAt }` |
| GET | `/tasks/:id/cards` | none | `{ deck, generatedAt }` or `404` |
`max` is **total deck size** (summary + tasks). Example: `max=20` returns 1 summary card and up to 19 task cards. This package is removed from the active workspace package list and retained only as a temporary compatibility artifact pending full cleanup.
## 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.

View File

@@ -1,5 +1,13 @@
# @fusion-plugin-examples/even-realities-glasses # @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 ## 0.1.3
### Patch Changes ### Patch Changes

View File

@@ -24,7 +24,9 @@ pnpm --filter @fusion-plugin-examples/even-realities-glasses test
- `fusionApiBaseUrl` (default `http://localhost:4040`) - `fusionApiBaseUrl` (default `http://localhost:4040`)
- `fusionApiToken` (required Bearer token) - `fusionApiToken` (required Bearer token)
- `apiKey` (required for plugin routes)
- `glassesDeviceId` (optional identifier) - `glassesDeviceId` (optional identifier)
- `companionWebhookUrl` (optional companion endpoint base URL for card push, e.g. `https://companion.example`)
- `pollingIntervalSeconds` (default 30, min 5) - `pollingIntervalSeconds` (default 30, min 5)
- `notifyOnColumns` (default `["in-review"]`) - `notifyOnColumns` (default `["in-review"]`)
- `quickCaptureDefaultColumn` (default `triage`) - `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. - Prefer local/self-hosted Fusion instances and avoid exposing dashboard APIs to public networks.
- Treat `fusionApiToken` as secret material and rotate regularly. - 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", "id": "fusion-plugin-even-realities-glasses",
"name": "Even Realities Glasses", "name": "Even Realities Glasses",
"version": "0.1.0", "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", "author": "Fusion Team",
"fusionVersion": ">=0.1.0" "fusionVersion": ">=0.1.0"
} }

View File

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

View File

@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { import {
agentActionsEnabled, agentActionsEnabled,
getCompanionWebhookUrl,
getFusionBaseUrl, getFusionBaseUrl,
getFusionToken, getFusionToken,
getNotifyColumns, getNotifyColumns,
@@ -12,6 +13,7 @@ describe("settings accessors", () => {
it("uses safe defaults", () => { it("uses safe defaults", () => {
expect(getFusionBaseUrl({})).toBe("http://localhost:4040"); expect(getFusionBaseUrl({})).toBe("http://localhost:4040");
expect(getFusionToken({})).toBeUndefined(); expect(getFusionToken({})).toBeUndefined();
expect(getCompanionWebhookUrl({})).toBeUndefined();
expect(getPollingIntervalMs({})).toBe(30000); expect(getPollingIntervalMs({})).toBe(30000);
expect(getNotifyColumns({})).toEqual(["in-review"]); expect(getNotifyColumns({})).toEqual(["in-review"]);
expect(getQuickCaptureColumn({})).toBe("triage"); expect(getQuickCaptureColumn({})).toBe("triage");
@@ -21,6 +23,9 @@ describe("settings accessors", () => {
it("trims string values", () => { it("trims string values", () => {
expect(getFusionBaseUrl({ fusionApiBaseUrl: " http://fusion.local:4040 " })).toBe("http://fusion.local:4040"); expect(getFusionBaseUrl({ fusionApiBaseUrl: " http://fusion.local:4040 " })).toBe("http://fusion.local:4040");
expect(getFusionToken({ fusionApiToken: " token " })).toBe("token"); 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", () => { 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 { describe, expect, it, vi } from "vitest";
import { StubGlassesTransport } from "../transport.js"; import { WebhookGlassesTransport } from "../transport.js";
describe("StubGlassesTransport", () => { describe("WebhookGlassesTransport", () => {
it("records pushes in order", async () => { it("posts cards to companion webhook", async () => {
const transport = new StubGlassesTransport(); 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: "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 () => { it("tracks configuration and dispatches actions", async () => {
const transport = new StubGlassesTransport(); const transport = new WebhookGlassesTransport();
const handler = vi.fn(); const handler = vi.fn();
transport.onAction(handler); 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).toHaveBeenCalledTimes(1);
expect(handler).toHaveBeenCalledWith(expect.objectContaining({ type: "quick-capture" }));
}); });
}); });

View File

@@ -18,6 +18,16 @@ export type GlassesCard = {
actions?: GlassesCardAction[]; 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> = { const COLUMN_BADGES: Record<string, string> = {
triage: "triage", triage: "triage",
todo: "todo", todo: "todo",
@@ -27,18 +37,28 @@ const COLUMN_BADGES: Record<string, string> = {
archived: "archived", 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; const DEFAULT_MAX_TITLE = 80;
export function truncateLine(value: string, maxChars = DEFAULT_MAX_TITLE): string { export function truncateLine(value: string, maxChars = DEFAULT_MAX_TITLE): string {
if (value.length <= maxChars) return value; const trimmed = value.trim();
return `${value.slice(0, Math.max(0, maxChars - 1))}`; 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 maxCharsPerLine = Math.max(8, opts.maxCharsPerLine ?? 36);
const maxLines = Math.max(1, opts.maxLines ?? 2); const maxLines = Math.max(1, opts.maxLines ?? 2);
const words = value.trim().split(/\s+/).filter(Boolean); const words = value.trim().split(/\s+/).filter(Boolean);
if (words.length === 0) return [""]; if (words.length === 0) return [];
const lines: string[] = []; const lines: string[] = [];
let current = ""; let current = "";
for (const word of words) { 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 && current) lines.push(current);
if (lines.length > maxLines) return lines.slice(0, maxLines); 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); lines[lines.length - 1] = truncateLine(lines[lines.length - 1], maxCharsPerLine);
} }
return lines; return lines;
@@ -75,16 +95,43 @@ export function formatRelativeAge(updatedAt: string, opts: { now?: () => Date }
return `updated ${Math.floor(hours / 24)}d ago`; return `updated ${Math.floor(hours / 24)}d ago`;
} }
export function taskToCard(task: Task): GlassesCard { function boardSummary(tasks: Task[]): BoardSummary {
const title = typeof task.title === "string" && task.title.trim() ? task.title : task.description; 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 { 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", kind: "task",
title: truncateLine(title, DEFAULT_MAX_TITLE), title: truncateLine(`${task.id.toUpperCase()} ${title}`, maxCharsPerLine),
lines: [ lines: wrapLines(
`assignee: ${task.assignedAgentId ?? task.assigneeUserId ?? "unassigned"}`, `Priority ${task.priority ?? "normal"} Assignee ${task.assignedAgentId ?? task.assigneeUserId ?? "unassigned"} Age ${formatRelativeAge(task.createdAt ?? task.updatedAt, { now: opts.now })}`,
formatRelativeAge(task.updatedAt), { maxCharsPerLine, maxLines },
], ),
badge: statusBadge(task.column), badge: statusBadge(task.column),
taskId: task.id, taskId: task.id,
updatedAt: task.updatedAt, 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 { export function boardSummaryCard(tasksByColumn: Record<string, number>): GlassesCard {
const ordered = ["triage", "todo", "in-progress", "in-review", "done"]; const ordered = ["triage", "todo", "in-progress", "in-review", "done"];
return { return {

View File

@@ -1,12 +1,21 @@
import { definePlugin } from "@fusion/plugin-sdk"; import { definePlugin } from "@fusion/plugin-sdk";
import type { FusionPlugin, PluginContext, PluginRouteDefinition, PluginRouteResponse } 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 { 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 { quickCaptureRoutes } from "./routes/quick-capture-routes.js";
import { createNotificationRoutes } from "./routes/notification-routes.js"; import { createNotificationRoutes } from "./routes/notification-routes.js";
import { agentActionRoutes } from "./routes/agent-action-routes.js"; import { agentActionRoutes } from "./routes/agent-action-routes.js";
import { getFusionBaseUrl, getFusionToken, getNotifyColumns, settingsSchema } from "./settings.js"; import { boardRoutes } from "./routes/board-routes.js";
import { StubGlassesTransport } from "./transport.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 = { export type PluginDb = {
exec(sql: string): void; exec(sql: string): void;
@@ -18,12 +27,9 @@ export type PluginDb = {
}; };
type PluginInstance = { type PluginInstance = {
client: FusionApiClient; transport: WebhookGlassesTransport;
transport: StubGlassesTransport;
notifier: ReturnType<typeof createNotifier>; notifier: ReturnType<typeof createNotifier>;
}; };const instances = new Map<string, PluginInstance>();
const instances = new Map<string, PluginInstance>();
function getDbFromTaskStore(ctx: PluginContext): PluginDb { function getDbFromTaskStore(ctx: PluginContext): PluginDb {
const pluginStore = ctx.taskStore.getPluginStore(); const pluginStore = ctx.taskStore.getPluginStore();
@@ -49,6 +55,7 @@ const coreRoutes: PluginRouteDefinition[] = [
status: 200, status: 200,
body: { body: {
connected: instance.transport.connected, connected: instance.transport.connected,
transport: instance.transport.status,
lastPollTime: instance.notifier.lastPolledAt() ?? null, lastPollTime: instance.notifier.lastPolledAt() ?? null,
notifyOnColumns: getNotifyColumns(ctx.settings), notifyOnColumns: getNotifyColumns(ctx.settings),
}, },
@@ -69,6 +76,7 @@ const coreRoutes: PluginRouteDefinition[] = [
]; ];
const notificationRoutes = createNotificationRoutes((ctx) => instances.get(ctx.pluginId)?.notifier); const notificationRoutes = createNotificationRoutes((ctx) => instances.get(ctx.pluginId)?.notifier);
const transportRoutes = createTransportRoutes((ctx) => instances.get(ctx.pluginId)?.transport);
const plugin: FusionPlugin = definePlugin({ const plugin: FusionPlugin = definePlugin({
manifest: { manifest: {
@@ -81,7 +89,7 @@ const plugin: FusionPlugin = definePlugin({
settingsSchema, settingsSchema,
}, },
state: "installed", state: "installed",
routes: [...coreRoutes, ...quickCaptureRoutes, ...agentActionRoutes, ...notificationRoutes], routes: [...coreRoutes, ...boardRoutes, ...quickCaptureRoutes, ...agentActionRoutes, ...notificationRoutes, ...transportRoutes],
hooks: { hooks: {
onSchemaInit: (db) => { onSchemaInit: (db) => {
(db as PluginDb).exec(` (db as PluginDb).exec(`
@@ -99,9 +107,35 @@ const plugin: FusionPlugin = definePlugin({
return; return;
} }
const db = getDbFromTaskStore(ctx); const db = getDbFromTaskStore(ctx);
const client = new FusionApiClient(getFusionBaseUrl(ctx.settings), token); const transport = new WebhookGlassesTransport({
const transport = new StubGlassesTransport(); companionWebhookUrl: getCompanionWebhookUrl(ctx.settings),
});
await transport.connect(); 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({ const notifier = createNotifier({
taskStore: ctx.taskStore, taskStore: ctx.taskStore,
db, db,
@@ -111,7 +145,7 @@ const plugin: FusionPlugin = definePlugin({
pluginId: ctx.pluginId, pluginId: ctx.pluginId,
}); });
notifier.start(); notifier.start();
instances.set(ctx.pluginId, { client, transport, notifier }); instances.set(ctx.pluginId, { transport, notifier });
}, },
onUnload: async () => { onUnload: async () => {
for (const [pluginId, instance] of instances.entries()) { 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", type: "string",
label: "Glasses Device ID", label: "Glasses Device ID",
}, },
companionWebhookUrl: {
type: "string",
label: "Companion Webhook URL",
},
pollingIntervalSeconds: { pollingIntervalSeconds: {
type: "number", type: "number",
label: "Polling Interval (seconds)", label: "Polling Interval (seconds)",
@@ -65,6 +69,10 @@ export function getFusionToken(settings: Record<string, unknown>): string | unde
return getSettingString(settings, "fusionApiToken"); 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 { export function getPollingIntervalMs(settings: Record<string, unknown>): number {
const raw = settings.pollingIntervalSeconds; const raw = settings.pollingIntervalSeconds;
if (typeof raw !== "number" || !Number.isFinite(raw)) { if (typeof raw !== "number" || !Number.isFinite(raw)) {

View File

@@ -12,30 +12,99 @@ export interface GlassesTransport {
disconnect(): Promise<void>; disconnect(): Promise<void>;
pushCard(card: GlassesCard): Promise<void>; pushCard(card: GlassesCard): Promise<void>;
onAction(handler: (action: GlassesAction) => void | Promise<void>): 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>> = []; private handlers: Array<(action: GlassesAction) => void | Promise<void>> = [];
public readonly pushedCards: GlassesCard[] = []; private readonly fetchImpl: typeof fetch;
public connected = false; 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> { async connect(): Promise<void> {
this.connected = true; this._connected = true;
this._lastError = this.companionWebhookUrl ? null : "companionWebhookUrl not configured";
} }
async disconnect(): Promise<void> { async disconnect(): Promise<void> {
this.connected = false; this._connected = false;
} }
async pushCard(card: GlassesCard): Promise<void> { 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 { onAction(handler: (action: GlassesAction) => void | Promise<void>): void {
this.handlers.push(handler); 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) { for (const handler of this.handlers) {
await handler(action); await handler(action);
} }

View File

@@ -12,5 +12,4 @@ packages:
- "plugins/fusion-plugin-whatsapp-chat" - "plugins/fusion-plugin-whatsapp-chat"
- "plugins/fusion-plugin-roadmap" - "plugins/fusion-plugin-roadmap"
- "plugins/fusion-plugin-even-realities-glasses" - "plugins/fusion-plugin-even-realities-glasses"
- "plugins/fusion-plugin-even-cards"
- "plugins/fusion-plugin-reports" - "plugins/fusion-plugin-reports"