feat(FN-3738): add fusion-plugin-even-realities-glasses plugin package with

Implements a new Fusion plugin package (`fusion-plugin-even-realities-glasses`) providing settings schema, a Fusion HTTP API client, cards, quick capture actions, a notifier, and transport stub — plus plugin routes and lifecycle hooks wired into the pi extension. The branch concludes with a small fi

Fusion-Task-Id: FN-3738
This commit is contained in:
Fusion
2026-05-08 12:05:46 -07:00
committed by gsxdsm
parent ab1398eb52
commit a6ec5b9ce6
43 changed files with 1358 additions and 36 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": minor
---
Add a new global experimental feature flag, `experimentalFeatures.evalsView`, and default it to off for Evals surfaces. When disabled, the dashboard Evals view, Settings → Scheduled Evals section, header/mobile Evals navigation entries, and in-process scheduled-eval cron execution are hidden or short-circuited. Projects already using `evalSettings.enabled` must also enable `evalsView` to expose and run scheduled eval workflows.

View File

@@ -256,6 +256,8 @@ For mission planning context and handoff structure, see [Missions guide](./missi
Evals view is a dedicated dashboard surface for reviewing scheduled task-evaluation output.
> Available when `experimentalFeatures.evalsView` is enabled.
Navigation:
- Desktop: **Header → More views → Evals**
- Mobile: **More** sheet → **Evals**

View File

@@ -4,6 +4,8 @@
## Overview
> **Feature flag:** Evals surfaces are gated by `experimentalFeatures.evalsView`. When disabled, the dashboard Evals view, Settings → Scheduled Evals section, and scheduled-eval cron execution are all dormant.
Fusion task evaluations use one canonical 0100 integer scoring system for three categories: `agentPerformance`, `taskOutcomeQuality`, and `processCompliance`.
Authoritative score math lives in `packages/core/src/eval-scoring.ts`. AI output is advisory input only.

View File

@@ -96,7 +96,7 @@ Defaults from `DEFAULT_GLOBAL_SETTINGS`; key scope from `GLOBAL_SETTINGS_KEYS`.
| `researchGlobalMaxSearchResults` | `number` | `undefined` | Maximum search results per provider query. |
| `researchGlobalFetchTimeoutMs` | `number` | `30000` | Timeout for individual HTTP fetches in milliseconds. |
| `researchGlobalUserAgent` | `string` | `"FusionResearchBot/1.0"` | User-Agent header for HTTP requests made by research providers. |
| `experimentalFeatures` | `Record<string, boolean>` | `{}` | Global-scoped experimental feature flags. Includes `experimentalFeatures.researchView`, which gates all Research surfaces and tools (dashboard view, engine task-session tools, and CLI `fn_research_*` tools). |
| `experimentalFeatures` | `Record<string, boolean>` | `{}` | Global-scoped experimental feature flags. Includes `experimentalFeatures.researchView`, which gates all Research surfaces and tools (dashboard view, engine task-session tools, and CLI `fn_research_*` tools), and `experimentalFeatures.evalsView`, which gates Evals surfaces (dashboard view, Settings → Scheduled Evals, and scheduled-eval cron execution). |
| `remoteAccess` | `RemoteAccessSettings` | `{ activeProvider: null, providers: {...}, tokenStrategy: {...}, lifecycle: {...} }` | Global-scoped remote access provider + token strategy configuration used by Remote Access routes and tunnel lifecycle controls. |
### Notification providers (pluggable)
@@ -898,6 +898,7 @@ Common built-in dashboard flags include:
- `devServerView`
- `todoView` (enables dashboard Todo View; see [Todo View](./todo-view.md))
- `researchView`
- `evalsView` (gates Evals dashboard view, Settings → Scheduled Evals section, and scheduled-eval cron execution)
- `remoteAccess`
- `agentOnboarding` (enables the **AI Interview** option inside the New Agent dialog)

View File

@@ -1,5 +1,31 @@
import { describe, expect, it } from "vitest";
import { resolveEvalSettings } from "../eval-settings.js";
import { isEvalsExperimentalEnabled, resolveEvalSettings } from "../eval-settings.js";
describe("isEvalsExperimentalEnabled", () => {
it("returns false when settings are undefined", () => {
expect(isEvalsExperimentalEnabled(undefined)).toBe(false);
});
it("returns false when experimentalFeatures are missing", () => {
expect(isEvalsExperimentalEnabled({})).toBe(false);
});
it("returns false when evalsView is false", () => {
expect(
isEvalsExperimentalEnabled({
experimentalFeatures: { evalsView: false },
}),
).toBe(false);
});
it("returns true when evalsView is true", () => {
expect(
isEvalsExperimentalEnabled({
experimentalFeatures: { evalsView: true },
}),
).toBe(true);
});
});
describe("resolveEvalSettings", () => {
it("returns deterministic defaults when eval settings are unset", () => {

View File

@@ -1,3 +1,4 @@
import { isExperimentalFeatureEnabled } from "./experimental-features.js";
import { resolveValidatorSettingsModel } from "./model-resolution.js";
import type { ResolvedEvalSettings, Settings } from "./types.js";
@@ -8,6 +9,10 @@ const DEFAULT_EVAL_SETTINGS: Omit<ResolvedEvalSettings, "evaluatorProvider" | "e
retentionDays: 30,
};
export function isEvalsExperimentalEnabled(settings: Partial<Settings> | undefined): boolean {
return isExperimentalFeatureEnabled(settings, "evalsView");
}
export function resolveEvalSettings(settings: Partial<Settings> | undefined): ResolvedEvalSettings {
const scopedSettings = settings?.evalSettings;
const validatorModel = resolveValidatorSettingsModel(settings);

View File

@@ -710,7 +710,7 @@ export type {
export { isExperimentalFeatureEnabled } from "./experimental-features.js";
export { isResearchExperimentalEnabled, resolveResearchSettings } from "./research-settings.js";
export type { ResolvedResearchSettings } from "./research-settings.js";
export { resolveEvalSettings } from "./eval-settings.js";
export { isEvalsExperimentalEnabled, resolveEvalSettings } from "./eval-settings.js";
export { TodoStore } from "./todo-store.js";
export type { TodoStoreEvents } from "./todo-store.js";

View File

@@ -546,6 +546,7 @@ function AppInner() {
const skillsEnabled = experimentalFeatures.skillsView === true;
const nodesEnabled = experimentalFeatures.nodesView === true;
const researchEnabled = experimentalFeatures.researchView === true;
const evalsEnabled = experimentalFeatures.evalsView === true;
const agentOnboardingEnabled = experimentalFeatures.agentOnboarding === true;
const agentsEnabled = true;
@@ -588,7 +589,10 @@ function AppInner() {
if (taskView === "research" && !researchEnabled) {
handleChangeTaskView("board");
}
}, [taskView, settingsLoaded, skillsEnabled, insightsEnabled, roadmapEnabled, handleChangeTaskView, agentsEnabled, memoryEnabled, devServerEnabled, researchEnabled, graphPluginTaskView]);
if (taskView === "evals" && !evalsEnabled) {
handleChangeTaskView("board");
}
}, [taskView, settingsLoaded, skillsEnabled, insightsEnabled, roadmapEnabled, handleChangeTaskView, agentsEnabled, memoryEnabled, devServerEnabled, researchEnabled, evalsEnabled, graphPluginTaskView]);
// Auto-close nodes overlay if feature flag is toggled off while overlay is open
useEffect(() => {
@@ -1195,6 +1199,9 @@ function AppInner() {
}
if (taskView === "evals") {
if (!settingsLoaded || !evalsEnabled) {
return null;
}
return (
<PageErrorBoundary>
<Suspense fallback={null}>
@@ -1395,6 +1402,7 @@ function AppInner() {
devServer: devServerEnabled,
devServerView: devServerEnabled,
researchView: researchEnabled,
evalsView: evalsEnabled,
}}
pluginDashboardViews={pluginDashboardViews}
shellConnectionControl={
@@ -1501,6 +1509,7 @@ function AppInner() {
devServerView: devServerEnabled,
todoView: todosEnabled,
researchView: researchEnabled,
evalsView: evalsEnabled,
nodesView: nodesEnabled,
}}
pluginDashboardViews={pluginDashboardViews}

View File

@@ -237,7 +237,7 @@ export interface HeaderProps {
/** Whether the current view is a remote node */
isRemote?: boolean;
/** Experimental feature flags controlling visibility of nav items. */
experimentalFeatures?: { insights?: boolean; roadmap?: boolean; memoryView?: boolean; devServer?: boolean; devServerView?: boolean; researchView?: boolean };
experimentalFeatures?: { insights?: boolean; roadmap?: boolean; memoryView?: boolean; devServer?: boolean; devServerView?: boolean; researchView?: boolean; evalsView?: boolean };
pluginDashboardViews?: PluginDashboardViewEntry[];
shellConnectionControl?: ReactNode;
}
@@ -1178,7 +1178,7 @@ export function Header({
<>
<button
ref={viewOverflowTriggerRef}
className={`view-toggle-btn${["research", "skills", "roadmaps", "insights", "memory", "dev-server", "devserver", "graph"].includes(view) || (todosEnabled && todosOpen) || isPluginViewId(view) ? " active" : ""}`}
className={`view-toggle-btn${["research", "skills", "roadmaps", "insights", "memory", "dev-server", "devserver", "graph"].includes(view) || (experimentalFeatures?.evalsView && view === "evals") || (todosEnabled && todosOpen) || isPluginViewId(view) ? " active" : ""}`}
onClick={() => setIsViewOverflowOpen((prev) => !prev)}
title="More views"
aria-label="More views"
@@ -1195,18 +1195,20 @@ export function Header({
role="menu"
aria-label="More views"
>
<button
className={`view-toggle-overflow-item${view === "evals" ? " active" : ""}`}
onClick={() => {
onChangeView("evals");
setIsViewOverflowOpen(false);
}}
role="menuitem"
data-testid="view-overflow-evals"
>
<Target size={14} />
<span>Evals</span>
</button>
{experimentalFeatures?.evalsView && (
<button
className={`view-toggle-overflow-item${view === "evals" ? " active" : ""}`}
onClick={() => {
onChangeView("evals");
setIsViewOverflowOpen(false);
}}
role="menuitem"
data-testid="view-overflow-evals"
>
<Target size={14} />
<span>Evals</span>
</button>
)}
{experimentalFeatures?.researchView && (
<button
className={`view-toggle-overflow-item${view === "research" ? " active" : ""}`}

View File

@@ -82,6 +82,7 @@ export interface MobileNavBarProps {
devServerView?: boolean;
todoView?: boolean;
researchView?: boolean;
evalsView?: boolean;
nodesView?: boolean;
};
onOpenNodes?: () => void;
@@ -228,7 +229,7 @@ export function MobileNavBar({
const isMoreActive =
view === "documents"
|| view === "evals"
|| (Boolean(experimentalFeatures?.evalsView) && view === "evals")
|| view === "research"
|| view === "insights"
|| view === "memory"
@@ -610,15 +611,17 @@ export function MobileNavBar({
<FileText />
<span>Documents</span>
</button>
<button
type="button"
className="mobile-more-item"
data-testid="mobile-more-item-evals"
onClick={() => handleMoreAction(() => onChangeView("evals"))}
>
<Target />
<span>Evals</span>
</button>
{experimentalFeatures?.evalsView && (
<button
type="button"
className="mobile-more-item"
data-testid="mobile-more-item-evals"
onClick={() => handleMoreAction(() => onChangeView("evals"))}
>
<Target />
<span>Evals</span>
</button>
)}
{showSkillsInMore && (
<button

View File

@@ -275,6 +275,7 @@ const KNOWN_EXPERIMENTAL_FEATURES: Record<string, string> = {
devServerView: "Dev Server",
todoView: "Todo List",
researchView: "Research View",
evalsView: "Evals View",
agentOnboarding: "Planning-style Agent Onboarding",
};
@@ -444,6 +445,7 @@ export function SettingsModal({
const experimentalFeatures = form.experimentalFeatures ?? {};
const remoteAccessEnabled = isExperimentalFeatureEnabled(experimentalFeatures, "remoteAccess");
const researchViewEnabled = isExperimentalFeatureEnabled(experimentalFeatures, "researchView");
const evalsViewEnabled = isExperimentalFeatureEnabled(experimentalFeatures, "evalsView");
const visibleSections = SETTINGS_SECTIONS.filter((section) => {
if (section.id === "remote") {
return remoteAccessEnabled;
@@ -453,6 +455,10 @@ export function SettingsModal({
return researchViewEnabled;
}
if (section.id === "scheduled-evals") {
return evalsViewEnabled;
}
return true;
});
const firstVisibleSectionId = visibleSections.find((section) => !section.isGroupHeader)?.id ?? "general";
@@ -471,10 +477,15 @@ export function SettingsModal({
return;
}
if (activeSection === "scheduled-evals" && !evalsViewEnabled) {
setActiveSection(firstVisibleSectionId);
return;
}
if (!visibleSections.some((section) => section.id === activeSection)) {
setActiveSection(firstVisibleSectionId);
}
}, [activeSection, remoteAccessEnabled, researchViewEnabled, firstVisibleSectionId, visibleSections]);
}, [activeSection, remoteAccessEnabled, researchViewEnabled, evalsViewEnabled, firstVisibleSectionId, visibleSections]);
// Auth state (independent of the settings save flow)
const [authProviders, setAuthProviders] = useState<AuthProvider[]>([]);

View File

@@ -16,7 +16,7 @@ const defaultSettings: Settings = {
worktreeInitCommand: "",
testCommand: "",
buildCommand: "",
experimentalFeatures: { insights: true, roadmap: true, skillsView: true, agentsView: true, memoryView: true },
experimentalFeatures: { insights: true, roadmap: true, skillsView: true, agentsView: true, memoryView: true, evalsView: true },
};
@@ -1695,6 +1695,29 @@ describe("App view switching", () => {
localStorage.removeItem("kb-dashboard-view-mode");
localStorage.removeItem(taskViewStorageKey());
});
it("falls back to board when evals view is feature-disabled", async () => {
localStorage.setItem("kb-dashboard-view-mode", "project");
localStorage.setItem(taskViewStorageKey(), "evals");
(fetchSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
...defaultSettings,
experimentalFeatures: {
...defaultSettings.experimentalFeatures,
evalsView: false,
},
});
render(<App />);
await waitFor(() => {
expect(document.querySelector(".board")).toBeTruthy();
});
expect(screen.queryByTestId("evals-view")).not.toBeInTheDocument();
localStorage.removeItem("kb-dashboard-view-mode");
localStorage.removeItem(taskViewStorageKey());
});
it("renders Board view by default", async () => {
// Set project mode so board view is available
localStorage.setItem("kb-dashboard-view-mode", "project");
@@ -3702,6 +3725,7 @@ describe("App shell connection status plumbing", () => {
await waitFor(() => {
expect(mockGetShellConnectionNativeResult).toHaveBeenCalledWith(mockShellHostContextValue.host);
expect(screen.getByTestId("mobile-nav-tab-more")).toBeInTheDocument();
});
expect(screen.queryByTestId("shell-connection-status-button")).toBeNull();

View File

@@ -274,9 +274,16 @@ describe("Header", () => {
expect(screen.queryByTestId("view-overflow-research")).toBeNull();
});
it("routes to evals from the desktop view overflow", () => {
it("hides evals in the desktop view overflow when evalsView is disabled", () => {
renderHeader({ onChangeView: noop, experimentalFeatures: { evalsView: false } });
fireEvent.click(screen.getByTestId("view-toggle-overflow-trigger"));
expect(screen.queryByTestId("view-overflow-evals")).toBeNull();
});
it("routes to evals from the desktop view overflow when evalsView is enabled", () => {
const onChangeView = vi.fn();
renderHeader({ onChangeView });
renderHeader({ onChangeView, experimentalFeatures: { evalsView: true } });
fireEvent.click(screen.getByTestId("view-toggle-overflow-trigger"));
fireEvent.click(screen.getByTestId("view-overflow-evals"));

View File

@@ -488,9 +488,16 @@ describe("MobileNavBar", () => {
expect(props.onChangeView).toHaveBeenCalledWith("research");
});
it("evals item in more sheet calls onChangeView with 'evals'", () => {
it("hides evals item in more sheet when evalsView is not enabled", () => {
render(<MobileNavBar {...createDefaultProps()} experimentalFeatures={{}} />);
fireEvent.click(screen.getByTestId("mobile-nav-tab-more"));
expect(screen.queryByTestId("mobile-more-item-evals")).toBeNull();
});
it("evals item in more sheet calls onChangeView with 'evals' when evalsView is enabled", () => {
const props = createDefaultProps();
const { container } = render(<MobileNavBar {...props} />);
const { container } = render(<MobileNavBar {...props} experimentalFeatures={{ evalsView: true }} />);
fireEvent.click(screen.getByTestId("mobile-nav-tab-more"));
fireEvent.click(screen.getByTestId("mobile-more-item-evals"));

View File

@@ -1887,6 +1887,14 @@ describe("SettingsModal", () => {
expect(screen.getByLabelText("Research View")).toBeInTheDocument();
});
it("shows evalsView in the Experimental Features list", async () => {
renderModal();
await openExperimentalFeaturesSection();
expect(screen.getByLabelText("Evals View")).toBeInTheDocument();
});
it("shows agentOnboarding in the Experimental Features list", async () => {
renderModal();
@@ -2007,6 +2015,43 @@ describe("SettingsModal", () => {
expect(screen.queryByRole("button", { name: /Research Defaults/i })).not.toBeInTheDocument();
expect(screen.getByRole("heading", { name: "Authentication" })).toBeInTheDocument();
});
it("hides scheduled evals nav item when experimentalFeatures.evalsView is disabled", async () => {
mockFetchSettings.mockResolvedValue({
...defaultSettings,
experimentalFeatures: {},
});
renderModal();
await waitForSettingsModalReady();
expect(screen.queryByRole("button", { name: /Scheduled Evals/i })).not.toBeInTheDocument();
});
it("shows scheduled evals nav item when experimentalFeatures.evalsView is enabled", async () => {
mockFetchSettings.mockResolvedValue({
...defaultSettings,
experimentalFeatures: { evalsView: true },
});
renderModal();
await waitForSettingsModalReady();
expect(screen.getByRole("button", { name: /Scheduled Evals/i })).toBeInTheDocument();
});
it("falls back to the first selectable section when opening scheduled evals while evalsView is disabled", async () => {
mockFetchSettings.mockResolvedValue({
...defaultSettings,
experimentalFeatures: {},
});
renderModal({ initialSection: "scheduled-evals" });
await waitForSettingsModalReady();
expect(screen.queryByRole("button", { name: /Scheduled Evals/i })).not.toBeInTheDocument();
expect(screen.getByRole("heading", { name: "Authentication" })).toBeInTheDocument();
});
});
it("sends canonical devServerView=false and devServer=null when disabling legacy dev server flag", async () => {
@@ -2774,6 +2819,7 @@ describe("SettingsModal", () => {
it("renders controls and disables interval controls when evals are disabled", async () => {
mockFetchSettings.mockResolvedValueOnce({
...defaultSettings,
experimentalFeatures: { evalsView: true },
evalSettings: {
enabled: false,
intervalMs: 86_400_000,
@@ -2797,6 +2843,7 @@ describe("SettingsModal", () => {
it("saves edited project eval settings payload", async () => {
mockFetchSettings.mockResolvedValueOnce({
...defaultSettings,
experimentalFeatures: { evalsView: true },
evalSettings: {
enabled: true,
intervalMs: 86_400_000,
@@ -2836,6 +2883,7 @@ describe("SettingsModal", () => {
it("clears evaluator provider and model as unset when left blank", async () => {
mockFetchSettings.mockResolvedValueOnce({
...defaultSettings,
experimentalFeatures: { evalsView: true },
evalSettings: {
enabled: true,
intervalMs: 86_400_000,

View File

@@ -1,3 +1,4 @@
import { createRequire } from "node:module";
import { Router, type Request, type Response } from "express";
import { createRequire } from "node:module";
import { getCreateAiSessionFactory, type PluginContext, type PluginRouteDefinition, type TaskStore } from "@fusion/core";

View File

@@ -1855,4 +1855,45 @@ describe("CronRunner", () => {
expect(isInProcessScheduledEvalCommand("echo fn eval --scheduled-batch")).toBe(false);
});
});
describe("scheduled eval command feature gating", () => {
it("returns a disabled result when evalsView experimental feature is off", async () => {
const store = createMockStore({ experimentalFeatures: { evalsView: false } as Settings["experimentalFeatures"] });
const schedule = createMockSchedule({ id: "eval-off", command: "fn eval --scheduled-batch" });
runner = new CronRunner(store, createMockAutomationStore());
const runResult = await (runner as unknown as { executeLegacyCommand: (s: ScheduledTask, startedAt: string) => Promise<AutomationRunResult> })
.executeLegacyCommand(schedule, new Date().toISOString());
expect(runResult.success).toBe(false);
expect(runResult.output).toBe("evals-experimental-disabled");
expect(runResult.error).toBe("Evals experimental feature is disabled");
});
it("does not return the disabled sentinel when evalsView experimental feature is on", async () => {
const store = createMockStore({ experimentalFeatures: { evalsView: true } as Settings["experimentalFeatures"] }) as unknown as {
getEvalStore: () => {
listRuns: () => Array<{ id: string; status: string; metadata?: Record<string, unknown>; window: { until?: string } }>;
createRun: (input: { projectId: string; trigger: string; scope: string; window: { since?: string; until: string }; metadata: Record<string, unknown> }) => { id: string; startedAt?: string; window: { until: string } };
appendRunEvent: (runId: string, event: Record<string, unknown>) => void;
updateRun: (runId: string, patch: Record<string, unknown>) => void;
};
listTasks: (opts: { column: string }) => Promise<Array<{ id: string; column: string; createdAt: string; updatedAt: string; executionCompletedAt?: string; title: string; summary?: string }>>;
};
store.getEvalStore = () => ({
listRuns: () => [],
createRun: () => ({ id: "run-1", window: { until: new Date().toISOString() } }),
appendRunEvent: () => {},
updateRun: () => {},
});
store.listTasks = async () => [];
const schedule = createMockSchedule({ id: "eval-on", command: "fn eval --scheduled-batch" });
runner = new CronRunner(store as unknown as TaskStore, createMockAutomationStore());
const runResult = await (runner as unknown as { executeLegacyCommand: (s: ScheduledTask, startedAt: string) => Promise<AutomationRunResult> })
.executeLegacyCommand(schedule, new Date().toISOString());
expect(runResult.output).not.toBe("evals-experimental-disabled");
});
});
});

View File

@@ -4,6 +4,7 @@ import {
resolveProjectDefaultModel,
runScheduledEvalBatch,
resolveTaskEvaluationSettings,
isEvalsExperimentalEnabled,
type TaskStore,
type AutomationStore,
type ScheduledTask,
@@ -487,6 +488,15 @@ export class CronRunner {
startedAt: string,
): Promise<AutomationRunResult> {
const settings = await this.store.getSettings();
if (!isEvalsExperimentalEnabled(settings)) {
return {
success: false,
output: "evals-experimental-disabled",
error: "Evals experimental feature is disabled",
startedAt,
completedAt: new Date().toISOString(),
};
}
const evalSettings = resolveTaskEvaluationSettings(settings);
const evaluator = new HybridEvaluatorService({ cwd: this.options.workingDirectory ?? process.cwd(), store: this.store });

View File

@@ -0,0 +1,44 @@
# Even Realities Glasses Plugin (Fusion)
`@fusion-plugin-examples/even-realities-glasses` is a standalone Fusion plugin that provides a task-centric card workflow for Even Realities glasses.
## Scope (v1)
- Read board/task status through Fusion dashboard HTTP APIs (`/api/tasks*`)
- Quick capture text into new tasks
- Polling-based task transition notifications
- Agent actions: start work (`in-progress`) and request review (`in-review`)
Out of scope in v1: missions, roadmaps, search, multi-project routing, cloud/remote deployment orchestration.
## Install (workspace local)
From repo root:
```bash
pnpm install
pnpm --filter @fusion-plugin-examples/even-realities-glasses build
pnpm --filter @fusion-plugin-examples/even-realities-glasses test
```
## Required settings
- `fusionApiBaseUrl` (default `http://localhost:4040`)
- `fusionApiToken` (required Bearer token)
- `glassesDeviceId` (optional identifier)
- `pollingIntervalSeconds` (default 30, min 5)
- `notifyOnColumns` (default `["in-review"]`)
- `quickCaptureDefaultColumn` (default `triage`)
- `enableAgentActions` (default `true`)
## Security notes
- Uses `Authorization: Bearer <token>` for all API requests.
- 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
The plugin intentionally uses `GlassesTransport` + `StubGlassesTransport` for now. The real Even Realities BLE/SDK transport should be wired behind this interface.
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.

View File

@@ -0,0 +1,8 @@
{
"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.",
"author": "Fusion Team",
"fusionVersion": ">=0.1.0"
}

View File

@@ -0,0 +1,31 @@
{
"name": "@fusion-plugin-examples/even-realities-glasses",
"version": "0.1.0",
"type": "module",
"description": "Even Realities glasses task card bridge for Fusion",
"keywords": [
"fusion-plugin",
"even-realities",
"glasses",
"tasks"
],
"exports": {
".": {
"types": "./src/index.ts",
"import": "./dist/index.js"
}
},
"private": true,
"scripts": {
"build": "tsc",
"test": "vitest run --silent=passed-only --reporter=dot"
},
"dependencies": {
"@fusion/plugin-sdk": "workspace:*"
},
"devDependencies": {
"@types/node": "^25.5.2",
"typescript": "^5.7.0",
"vitest": "^3.2.4"
}
}

View File

@@ -0,0 +1,28 @@
import { describe, expect, it, vi } from "vitest";
import { requestReview, startWork } from "../agent-actions.js";
describe("agent actions", () => {
it("moves task to in-progress when enabled", async () => {
const moveTask = vi.fn(async () => ({ id: "FN-1", title: "Task", description: "", column: "in-progress" }));
const card = await startWork("FN-1", {
apiClient: { moveTask } as never,
enableAgentActions: true,
logger: console,
});
expect(moveTask).toHaveBeenCalledWith("FN-1", "in-progress");
expect(card?.id).toBe("task-FN-1");
});
it("skips when disabled", async () => {
const moveTask = vi.fn();
const warn = vi.fn();
const card = await requestReview("FN-1", {
apiClient: { moveTask } as never,
enableAgentActions: false,
logger: { warn },
});
expect(card).toBeUndefined();
expect(moveTask).not.toHaveBeenCalled();
expect(warn).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,71 @@
import { describe, expect, it } from "vitest";
import { boardSummaryCard, notificationCard, taskToCard } from "../cards.js";
describe("cards", () => {
it("maps task to card", () => {
expect(
taskToCard({ id: "FN-1", title: "Ship", description: "desc", column: "in-review" }),
).toMatchInlineSnapshot(`
{
"accentColor": "purple",
"actions": [
{
"label": "Start work",
"taskId": "FN-1",
"type": "start-work",
},
{
"label": "Request review",
"taskId": "FN-1",
"type": "request-review",
},
],
"bodyLines": [
"desc",
"Column: in-review",
],
"id": "task-FN-1",
"title": "FN-1: Ship",
}
`);
});
it("creates board summary", () => {
expect(boardSummaryCard({ todo: 2, done: 1 })).toMatchInlineSnapshot(`
{
"accentColor": "blue",
"bodyLines": [
"triage: 0",
"todo: 2",
"in-progress: 0",
"in-review: 0",
"done: 1",
],
"id": "board-summary",
"title": "Fusion Board Summary",
}
`);
});
it("creates notification cards", () => {
expect(notificationCard({ id: "FN-2", title: "Review", description: "", column: "in-review" }, "entered notify column")).toMatchInlineSnapshot(`
{
"accentColor": "purple",
"actions": [
{
"label": "Open",
"taskId": "FN-2",
"type": "request-review",
},
],
"bodyLines": [
"Review",
"Now in in-review",
"entered notify column",
],
"id": "notification-FN-2-entered notify column",
"title": "Task update: FN-2",
}
`);
});
});

View File

@@ -0,0 +1,64 @@
import { describe, expect, it, vi } from "vitest";
import { FusionApiClient, FusionApiError } from "../fusion-api-client.js";
function makeResponse(status: number, body: unknown) {
return {
ok: status >= 200 && status < 300,
status,
json: async () => body,
} as Response;
}
describe("FusionApiClient", () => {
it("sends auth header and parses list tasks", async () => {
const fetchImpl = vi.fn(async () => makeResponse(200, [{ id: "FN-1", title: "a", description: "d", column: "todo", status: "todo" }]));
const client = new FusionApiClient("http://localhost:4040", "secret", fetchImpl as typeof fetch);
const tasks = await client.listTasks({ column: "todo", q: "abc" });
expect(tasks).toHaveLength(1);
const [url, options] = fetchImpl.mock.calls[0]! as unknown as [string, RequestInit];
expect(url).toContain("/api/tasks?q=abc");
expect(options.headers).toMatchObject({ Authorization: "Bearer secret", "Content-Type": "application/json" });
});
it("filters by status client-side", async () => {
const fetchImpl = vi.fn(async () =>
makeResponse(200, [
{ id: "FN-1", title: "a", description: "d", column: "todo", status: "todo" },
{ id: "FN-2", title: "b", description: "d", column: "in-review", status: "in-review" },
]),
);
const client = new FusionApiClient("http://localhost:4040", "secret", fetchImpl as typeof fetch);
const tasks = await client.listTasks({ status: "in-review" });
expect(tasks.map((task) => task.id)).toEqual(["FN-2"]);
});
it("encodes json body for create and move", async () => {
const fetchImpl = vi
.fn()
.mockImplementationOnce(async () => makeResponse(200, { id: "FN-3", title: "x", description: "y", column: "triage" }))
.mockImplementationOnce(async () => makeResponse(200, { id: "FN-3", title: "x", description: "y", column: "in-progress" }));
const client = new FusionApiClient("http://localhost:4040", "secret", fetchImpl as typeof fetch);
await client.createTask({ title: "x", description: "y", column: "triage" });
await client.moveTask("FN-3", "in-progress");
const [, firstOptions] = fetchImpl.mock.calls[0]! as unknown as [string, RequestInit];
const [secondUrl, secondOptions] = fetchImpl.mock.calls[1]! as unknown as [string, RequestInit];
expect(firstOptions.body).toBe(JSON.stringify({ title: "x", description: "y", column: "triage" }));
expect(secondUrl).toContain("/api/tasks/FN-3/move");
expect(secondOptions.method).toBe("POST");
expect(secondOptions.body).toBe(JSON.stringify({ column: "in-progress" }));
});
it("maps non-2xx errors", async () => {
const fetchImpl = vi.fn(async () => makeResponse(400, { error: "bad request" }));
const client = new FusionApiClient("http://localhost:4040", "secret", fetchImpl as typeof fetch);
await expect(client.getTask("FN-404")).rejects.toEqual(expect.any(FusionApiError));
await expect(client.getTask("FN-404")).rejects.toMatchObject({ status: 400, body: { error: "bad request" } });
});
});

View File

@@ -0,0 +1,55 @@
import { describe, expect, it, vi } from "vitest";
import plugin from "../index.js";
describe("even realities plugin", () => {
it("has expected manifest and settings keys", () => {
expect(plugin.manifest.id).toBe("fusion-plugin-even-realities-glasses");
expect(Object.keys(plugin.manifest.settingsSchema ?? {}).sort()).toEqual([
"enableAgentActions",
"fusionApiBaseUrl",
"fusionApiToken",
"glassesDeviceId",
"notifyOnColumns",
"pollingIntervalSeconds",
"quickCaptureDefaultColumn",
]);
});
it("creates notifier dedupe table on schema init", () => {
const exec = vi.fn();
plugin.hooks?.onSchemaInit?.({ exec } as never);
expect(exec).toHaveBeenCalledWith(expect.stringContaining("CREATE TABLE IF NOT EXISTS even_realities_seen_tasks"));
});
it("returns 503 for unknown instance routes", async () => {
const ctx = { pluginId: "unknown", settings: {}, logger: console } as never;
const statusRoute = (plugin.routes ?? []).find((route) => route.method === "GET" && route.path === "/status");
const actionRoute = (plugin.routes ?? []).find((route) => route.method === "POST" && route.path === "/actions/start-work");
const statusRes = await statusRoute?.handler({}, ctx);
const actionRes = await actionRoute?.handler({ body: { taskId: "FN-1" } }, ctx);
expect(statusRes).toMatchObject({ status: 503, body: { error: expect.any(String) } });
expect(actionRes).toMatchObject({ status: 503, body: { error: expect.any(String) } });
});
it("handles known instance route after load", async () => {
const db = {
exec: vi.fn(),
prepare: vi.fn(() => ({ all: () => [], run: vi.fn() })),
};
const ctx = {
pluginId: "known",
settings: { fusionApiToken: "token", fusionApiBaseUrl: "http://localhost:4040" },
logger: console,
taskStore: { getPluginStore: () => ({ db }) },
} as never;
await plugin.hooks?.onLoad?.(ctx);
const statusRoute = (plugin.routes ?? []).find((route) => route.method === "GET" && route.path === "/status");
const res = await statusRoute?.handler({}, ctx);
expect(res).toMatchObject({ status: 200, body: { connected: true } });
await plugin.hooks?.onUnload?.();
});
});

View File

@@ -0,0 +1,9 @@
import { describe, expect, it } from "vitest";
import manifest from "../../manifest.json";
import { validatePluginManifest } from "@fusion/plugin-sdk";
describe("manifest", () => {
it("is valid", () => {
expect(validatePluginManifest(manifest)).toMatchObject({ valid: true });
});
});

View File

@@ -0,0 +1,68 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { createNotifier } from "../notifier.js";
afterEach(() => {
vi.useRealTimers();
});
function createDbMock(seed: Array<{ taskId: string; lastColumn: string }> = []) {
const run = vi.fn();
return {
run,
db: {
prepare: (sql: string) => ({
all: () => (sql.includes("SELECT") ? seed : []),
run,
}),
},
};
}
describe("notifier", () => {
it("notifies only on transitions after initial load", async () => {
vi.useFakeTimers();
const listTasks = vi
.fn()
.mockResolvedValueOnce([{ id: "FN-1", title: "Task", description: "", column: "todo" }])
.mockResolvedValueOnce([{ id: "FN-1", title: "Task", description: "", column: "in-review" }])
.mockResolvedValueOnce([{ id: "FN-1", title: "Task", description: "", column: "in-review" }]);
const pushCard = vi.fn(async () => undefined);
const { db } = createDbMock();
const notifier = createNotifier({
apiClient: { listTasks } as never,
transport: { pushCard } as never,
getSettings: () => ({ pollingIntervalMs: 1000, notifyColumns: ["in-review"] }),
logger: console,
db,
});
notifier.start();
await vi.runOnlyPendingTimersAsync();
await vi.runOnlyPendingTimersAsync();
await vi.runOnlyPendingTimersAsync();
expect(pushCard).toHaveBeenCalledTimes(1);
notifier.stop();
});
it("hydrates from db and catches poll errors", async () => {
vi.useFakeTimers();
const listTasks = vi.fn().mockRejectedValue(new Error("boom"));
const error = vi.fn();
const { db } = createDbMock([{ taskId: "FN-1", lastColumn: "todo" }]);
const notifier = createNotifier({
apiClient: { listTasks } as never,
transport: { pushCard: vi.fn() } as never,
getSettings: () => ({ pollingIntervalMs: 1000, notifyColumns: ["in-review"] }),
logger: { warn: vi.fn(), error },
db,
});
notifier.start();
await vi.runOnlyPendingTimersAsync();
expect(error).toHaveBeenCalled();
expect(notifier.getLastSnapshot().get("FN-1")).toBe("todo");
notifier.stop();
});
});

View File

@@ -0,0 +1,21 @@
import { describe, expect, it, vi } from "vitest";
import { runQuickCapture } from "../quick-capture.js";
describe("runQuickCapture", () => {
it("uses first line as title and rest as description", async () => {
const createTask = vi.fn(async (input) => ({ id: "FN-1", ...input }));
const result = await runQuickCapture("Title\nDetail line", {
apiClient: { createTask } as never,
defaultColumn: "triage",
});
expect(createTask).toHaveBeenCalledWith({ title: "Title", description: "Detail line", column: "triage" });
expect(result.taskId).toBe("FN-1");
});
it("uses description fallback", async () => {
const createTask = vi.fn(async (input) => ({ id: "FN-2", ...input }));
await runQuickCapture("Only title", { apiClient: { createTask } as never, defaultColumn: "todo" });
expect(createTask).toHaveBeenCalledWith({ title: "Only title", description: "(captured from glasses)", column: "todo" });
});
});

View File

@@ -0,0 +1,47 @@
import { describe, expect, it } from "vitest";
import {
agentActionsEnabled,
getFusionBaseUrl,
getFusionToken,
getNotifyColumns,
getPollingIntervalMs,
getQuickCaptureColumn,
} from "../settings.js";
describe("settings accessors", () => {
it("uses safe defaults", () => {
expect(getFusionBaseUrl({})).toBe("http://localhost:4040");
expect(getFusionToken({})).toBeUndefined();
expect(getPollingIntervalMs({})).toBe(30000);
expect(getNotifyColumns({})).toEqual(["in-review"]);
expect(getQuickCaptureColumn({})).toBe("triage");
expect(agentActionsEnabled({})).toBe(true);
});
it("trims string values", () => {
expect(getFusionBaseUrl({ fusionApiBaseUrl: " http://fusion.local:4040 " })).toBe("http://fusion.local:4040");
expect(getFusionToken({ fusionApiToken: " token " })).toBe("token");
});
it("enforces polling minimum and finite values", () => {
expect(getPollingIntervalMs({ pollingIntervalSeconds: 2 })).toBe(5000);
expect(getPollingIntervalMs({ pollingIntervalSeconds: 8.9 })).toBe(8000);
expect(getPollingIntervalMs({ pollingIntervalSeconds: Number.NaN })).toBe(30000);
});
it("filters notify columns and falls back when invalid", () => {
expect(getNotifyColumns({ notifyOnColumns: ["todo", " nope ", "in-review", 4] })).toEqual(["todo", "in-review"]);
expect(getNotifyColumns({ notifyOnColumns: ["nope"] })).toEqual(["in-review"]);
});
it("validates quick capture column", () => {
expect(getQuickCaptureColumn({ quickCaptureDefaultColumn: "done" })).toBe("done");
expect(getQuickCaptureColumn({ quickCaptureDefaultColumn: "bad-column" })).toBe("triage");
});
it("respects explicit boolean for agent actions", () => {
expect(agentActionsEnabled({ enableAgentActions: false })).toBe(false);
expect(agentActionsEnabled({ enableAgentActions: true })).toBe(true);
expect(agentActionsEnabled({ enableAgentActions: "true" })).toBe(true);
});
});

View File

@@ -0,0 +1,22 @@
import { describe, expect, it, vi } from "vitest";
import { StubGlassesTransport } from "../transport.js";
describe("StubGlassesTransport", () => {
it("records pushes in order", async () => {
const transport = new StubGlassesTransport();
await transport.pushCard({ id: "1", title: "A", bodyLines: [], accentColor: "blue" });
await transport.pushCard({ id: "2", title: "B", bodyLines: [], accentColor: "green" });
expect(transport.pushedCards.map((card) => card.id)).toEqual(["1", "2"]);
});
it("emits synthetic actions to handlers", async () => {
const transport = new StubGlassesTransport();
const handler = vi.fn();
transport.onAction(handler);
await transport.emitAction({ type: "quick-capture", text: "new task", timestamp: new Date().toISOString() });
expect(handler).toHaveBeenCalledTimes(1);
expect(handler).toHaveBeenCalledWith(expect.objectContaining({ type: "quick-capture" }));
});
});

View File

@@ -0,0 +1,26 @@
import { taskToCard, type GlassesCard } from "./cards.js";
import type { FusionApiClient } from "./fusion-api-client.js";
export async function startWork(
taskId: string,
deps: { apiClient: FusionApiClient; enableAgentActions: boolean; logger: Pick<Console, "warn"> },
): Promise<GlassesCard | undefined> {
if (!deps.enableAgentActions) {
deps.logger.warn("Agent actions are disabled; skipping start-work action");
return undefined;
}
const task = await deps.apiClient.moveTask(taskId, "in-progress");
return taskToCard(task);
}
export async function requestReview(
taskId: string,
deps: { apiClient: FusionApiClient; enableAgentActions: boolean; logger: Pick<Console, "warn"> },
): Promise<GlassesCard | undefined> {
if (!deps.enableAgentActions) {
deps.logger.warn("Agent actions are disabled; skipping request-review action");
return undefined;
}
const task = await deps.apiClient.moveTask(taskId, "in-review");
return taskToCard(task);
}

View File

@@ -0,0 +1,56 @@
import type { FusionTask } from "./fusion-api-client.js";
export type GlassesCardAction = {
type: "start-work" | "request-review" | "quick-capture";
taskId?: string;
label: string;
};
export type GlassesCard = {
id: string;
title: string;
bodyLines: string[];
accentColor: string;
actions?: GlassesCardAction[];
};
const COLUMN_COLORS: Record<string, string> = {
triage: "yellow",
todo: "blue",
"in-progress": "cyan",
"in-review": "purple",
done: "green",
};
export function taskToCard(task: FusionTask): GlassesCard {
return {
id: `task-${task.id}`,
title: `${task.id}: ${task.title}`,
bodyLines: [task.description, `Column: ${task.column}`],
accentColor: COLUMN_COLORS[task.column] ?? "blue",
actions: [
{ type: "start-work", taskId: task.id, label: "Start work" },
{ type: "request-review", taskId: task.id, label: "Request review" },
],
};
}
export function boardSummaryCard(tasksByColumn: Record<string, number>): GlassesCard {
const ordered = ["triage", "todo", "in-progress", "in-review", "done"];
return {
id: "board-summary",
title: "Fusion Board Summary",
bodyLines: ordered.map((column) => `${column}: ${tasksByColumn[column] ?? 0}`),
accentColor: "blue",
};
}
export function notificationCard(task: FusionTask, reason: string): GlassesCard {
return {
id: `notification-${task.id}-${reason}`,
title: `Task update: ${task.id}`,
bodyLines: [task.title, `Now in ${task.column}`, reason],
accentColor: COLUMN_COLORS[task.column] ?? "blue",
actions: [{ type: "request-review", taskId: task.id, label: "Open" }],
};
}

View File

@@ -0,0 +1,93 @@
import type { TaskColumn } from "./settings.js";
export type FusionTask = {
id: string;
title: string;
description: string;
column: TaskColumn;
status?: string;
};
export type ListTasksFilter = {
limit?: number;
offset?: number;
column?: TaskColumn;
status?: string;
q?: string;
includeArchived?: boolean;
};
export class FusionApiError extends Error {
constructor(
public readonly status: number,
public readonly body: unknown,
) {
super(`Fusion API request failed: ${status}`);
}
}
type FetchLike = typeof fetch;
export class FusionApiClient {
constructor(
private readonly baseUrl: string,
private readonly token: string,
private readonly fetchImpl: FetchLike = fetch,
) {}
async listTasks(filter: ListTasksFilter = {}): Promise<FusionTask[]> {
const params = new URLSearchParams();
if (typeof filter.limit === "number") params.set("limit", String(Math.floor(filter.limit)));
if (typeof filter.offset === "number") params.set("offset", String(Math.floor(filter.offset)));
if (typeof filter.q === "string" && filter.q.trim()) params.set("q", filter.q.trim());
if (typeof filter.includeArchived === "boolean") params.set("includeArchived", String(filter.includeArchived));
const query = params.toString();
const data = await this.request<FusionTask[]>("GET", `/api/tasks${query ? `?${query}` : ""}`);
let tasks = Array.isArray(data) ? data : [];
if (filter.column) tasks = tasks.filter((task) => task.column === filter.column);
if (filter.status) tasks = tasks.filter((task) => task.status === filter.status);
return tasks;
}
async getTask(id: string): Promise<FusionTask> {
return this.request<FusionTask>("GET", `/api/tasks/${encodeURIComponent(id)}`);
}
async createTask(input: { title: string; description: string; column?: TaskColumn }): Promise<FusionTask> {
return this.request<FusionTask>("POST", "/api/tasks", input);
}
async updateTask(id: string, patch: Partial<Pick<FusionTask, "title" | "description" | "status">>): Promise<FusionTask> {
return this.request<FusionTask>("PATCH", `/api/tasks/${encodeURIComponent(id)}`, patch);
}
async moveTask(id: string, column: TaskColumn): Promise<FusionTask> {
return this.request<FusionTask>("POST", `/api/tasks/${encodeURIComponent(id)}/move`, { column });
}
async retryTask(id: string): Promise<FusionTask> {
return this.request<FusionTask>("POST", `/api/tasks/${encodeURIComponent(id)}/retry`, {});
}
async refineTask(id: string, feedback: string): Promise<FusionTask> {
return this.request<FusionTask>("POST", `/api/tasks/${encodeURIComponent(id)}/refine`, { feedback });
}
private async request<T>(method: string, path: string, body?: unknown): Promise<T> {
const response = await this.fetchImpl(`${this.baseUrl.replace(/\/$/, "")}${path}`, {
method,
headers: {
Authorization: `Bearer ${this.token}`,
"Content-Type": "application/json",
},
body: body === undefined ? undefined : JSON.stringify(body),
});
const payload = await response.json().catch(() => undefined);
if (!response.ok) {
throw new FusionApiError(response.status, payload);
}
return payload as T;
}
}

View File

@@ -0,0 +1,165 @@
import { definePlugin } from "@fusion/plugin-sdk";
import type { FusionPlugin, PluginContext, PluginRouteDefinition, PluginRouteResponse } from "@fusion/plugin-sdk";
import { requestReview, startWork } from "./agent-actions.js";
import { FusionApiClient } from "./fusion-api-client.js";
import { createNotifier } from "./notifier.js";
import { runQuickCapture } from "./quick-capture.js";
import {
agentActionsEnabled,
getFusionBaseUrl,
getFusionToken,
getNotifyColumns,
getPollingIntervalMs,
getQuickCaptureColumn,
settingsSchema,
} from "./settings.js";
import { StubGlassesTransport } from "./transport.js";
type PluginDb = {
exec(sql: string): void;
prepare(sql: string): {
all(...args: unknown[]): unknown;
run(...args: unknown[]): unknown;
};
};
type PluginInstance = {
client: FusionApiClient;
transport: StubGlassesTransport;
notifier: ReturnType<typeof createNotifier>;
};
const instances = new Map<string, PluginInstance>();
function getDbFromTaskStore(ctx: PluginContext): PluginDb {
const pluginStore = ctx.taskStore.getPluginStore();
const db = (pluginStore as unknown as { db?: PluginDb }).db;
if (!db) throw new Error("Plugin database unavailable");
return db;
}
function getInstanceOrResponse(ctx: PluginContext): { instance?: PluginInstance; error?: PluginRouteResponse } {
const instance = instances.get(ctx.pluginId);
if (!instance) return { error: { status: 503, body: { error: "Plugin instance not initialized" } } };
return { instance };
}
const routes: PluginRouteDefinition[] = [
{
method: "GET",
path: "/status",
handler: async (_req, ctx) => {
const { instance, error } = getInstanceOrResponse(ctx);
if (!instance) return error as PluginRouteResponse;
return {
status: 200,
body: {
connected: instance.transport.connected,
lastPollTime: instance.notifier.getLastPollTime() ?? null,
notifyOnColumns: getNotifyColumns(ctx.settings),
},
};
},
},
{
method: "POST",
path: "/quick-capture",
handler: async (req, ctx) => {
const { instance, error } = getInstanceOrResponse(ctx);
if (!instance) return error as PluginRouteResponse;
const text = typeof (req as { body?: { text?: unknown } }).body?.text === "string" ? (req as { body?: { text?: string } }).body?.text ?? "" : "";
if (!text.trim()) return { status: 400, body: { error: "text is required" } };
const result = await runQuickCapture(text, { apiClient: instance.client, defaultColumn: getQuickCaptureColumn(ctx.settings) });
return { status: 200, body: result };
},
},
{
method: "POST",
path: "/actions/start-work",
handler: async (req, ctx) => {
const { instance, error } = getInstanceOrResponse(ctx);
if (!instance) return error as PluginRouteResponse;
const taskId = typeof (req as { body?: { taskId?: unknown } }).body?.taskId === "string" ? (req as { body?: { taskId?: string } }).body?.taskId : undefined;
if (!taskId) return { status: 400, body: { error: "taskId is required" } };
const card = await startWork(taskId, { apiClient: instance.client, enableAgentActions: agentActionsEnabled(ctx.settings), logger: ctx.logger });
return { status: 200, body: { ok: true, card: card ?? null } };
},
},
{
method: "POST",
path: "/actions/request-review",
handler: async (req, ctx) => {
const { instance, error } = getInstanceOrResponse(ctx);
if (!instance) return error as PluginRouteResponse;
const taskId = typeof (req as { body?: { taskId?: unknown } }).body?.taskId === "string" ? (req as { body?: { taskId?: string } }).body?.taskId : undefined;
if (!taskId) return { status: 400, body: { error: "taskId is required" } };
const card = await requestReview(taskId, { apiClient: instance.client, enableAgentActions: agentActionsEnabled(ctx.settings), logger: ctx.logger });
return { status: 200, body: { ok: true, card: card ?? null } };
},
},
{
method: "POST",
path: "/reconnect",
handler: async (_req, ctx) => {
const { instance, error } = getInstanceOrResponse(ctx);
if (!instance) return error as PluginRouteResponse;
await instance.transport.disconnect();
await instance.transport.connect();
return { status: 200, body: { ok: true } };
},
},
];
const plugin: FusionPlugin = definePlugin({
manifest: {
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.",
author: "Fusion Team",
fusionVersion: ">=0.1.0",
settingsSchema,
},
state: "installed",
routes,
hooks: {
onSchemaInit: (db) => {
(db as PluginDb).exec(`
CREATE TABLE IF NOT EXISTS even_realities_seen_tasks (
taskId TEXT PRIMARY KEY,
lastColumn TEXT NOT NULL,
updatedAt TEXT NOT NULL
)
`);
},
onLoad: async (ctx) => {
const token = getFusionToken(ctx.settings);
if (!token) {
ctx.logger.warn("fusionApiToken is missing; even-realities plugin not initialized");
return;
}
const db = getDbFromTaskStore(ctx);
const client = new FusionApiClient(getFusionBaseUrl(ctx.settings), token);
const transport = new StubGlassesTransport();
await transport.connect();
const notifier = createNotifier({
apiClient: client,
transport,
getSettings: () => ({ pollingIntervalMs: getPollingIntervalMs(ctx.settings), notifyColumns: getNotifyColumns(ctx.settings) }),
logger: ctx.logger,
db,
});
notifier.start();
instances.set(ctx.pluginId, { client, transport, notifier });
},
onUnload: async () => {
for (const [pluginId, instance] of instances.entries()) {
instance.notifier.stop();
await instance.transport.disconnect();
instances.delete(pluginId);
}
},
},
});
export default plugin;

View File

@@ -0,0 +1,107 @@
import { notificationCard } from "./cards.js";
import type { FusionApiClient, FusionTask } from "./fusion-api-client.js";
import type { GlassesTransport } from "./transport.js";
type NotifierSettings = { pollingIntervalMs: number; notifyColumns: string[] };
type PluginDb = {
prepare(sql: string): {
all(...args: unknown[]): unknown;
run(...args: unknown[]): unknown;
};
};
export function createNotifier({
apiClient,
transport,
getSettings,
logger,
db,
now = () => new Date().toISOString(),
}: {
apiClient: FusionApiClient;
transport: GlassesTransport;
getSettings: () => NotifierSettings;
logger: Pick<Console, "warn" | "error">;
db: PluginDb;
now?: () => string;
}) {
let timer: ReturnType<typeof setTimeout> | undefined;
let running = false;
let inFlight = false;
let lastPollTime: string | undefined;
let lastSnapshot = new Map<string, string>();
const hydrateSnapshot = () => {
const rows = db.prepare("SELECT taskId, lastColumn FROM even_realities_seen_tasks").all() as
| Array<{ taskId: string; lastColumn: string }>
| undefined;
for (const row of rows ?? []) {
if (typeof row.taskId === "string" && typeof row.lastColumn === "string") {
lastSnapshot.set(row.taskId, row.lastColumn);
}
}
};
const persistTask = (taskId: string, lastColumn: string) => {
db.prepare(`
INSERT INTO even_realities_seen_tasks(taskId, lastColumn, updatedAt)
VALUES(?, ?, ?)
ON CONFLICT(taskId) DO UPDATE SET lastColumn = excluded.lastColumn, updatedAt = excluded.updatedAt
`).run(taskId, lastColumn, now());
};
const poll = async () => {
if (!running || inFlight) return;
inFlight = true;
try {
const settings = getSettings();
const tasks = await apiClient.listTasks();
const notifyColumns = new Set(settings.notifyColumns);
const nextSnapshot = new Map<string, string>();
for (const task of tasks) {
nextSnapshot.set(task.id, task.column);
const previousColumn = lastSnapshot.get(task.id);
if (previousColumn !== undefined && previousColumn !== task.column && notifyColumns.has(task.column)) {
await transport.pushCard(notificationCard(task as FusionTask, "entered notify column"));
}
persistTask(task.id, task.column);
}
lastSnapshot = nextSnapshot;
lastPollTime = now();
} catch (error) {
logger.error("Notifier poll failed", error);
} finally {
inFlight = false;
if (running) {
timer = setTimeout(() => {
void poll();
}, getSettings().pollingIntervalMs);
}
}
};
return {
start() {
if (running) return;
running = true;
hydrateSnapshot();
void poll();
},
stop() {
running = false;
if (timer) {
clearTimeout(timer);
timer = undefined;
}
},
getLastPollTime() {
return lastPollTime;
},
getLastSnapshot() {
return new Map(lastSnapshot);
},
};
}

View File

@@ -0,0 +1,26 @@
import { taskToCard, type GlassesCard } from "./cards.js";
import type { FusionApiClient } from "./fusion-api-client.js";
import type { TaskColumn } from "./settings.js";
export async function runQuickCapture(
text: string,
deps: { apiClient: FusionApiClient; defaultColumn: TaskColumn },
): Promise<{ taskId: string; confirmationCard: GlassesCard }> {
const lines = text
.split(/\r?\n/)
.map((line) => line.trim())
.filter((line) => line.length > 0);
const title = lines[0] ?? "Quick capture";
const description = lines.slice(1).join("\n") || "(captured from glasses)";
const task = await deps.apiClient.createTask({
title,
description,
column: deps.defaultColumn,
});
return {
taskId: task.id,
confirmationCard: taskToCard(task),
};
}

View File

@@ -0,0 +1,95 @@
import type { PluginSettingSchema } from "@fusion/plugin-sdk";
const DEFAULT_BASE_URL = "http://localhost:4040";
const DEFAULT_POLLING_INTERVAL_SECONDS = 30;
const MIN_POLLING_INTERVAL_SECONDS = 5;
const DEFAULT_NOTIFY_COLUMNS = ["in-review"];
const DEFAULT_QUICK_CAPTURE_COLUMN = "triage";
type TaskColumn = "triage" | "todo" | "in-progress" | "in-review" | "done";
const COLUMN_SET = new Set<TaskColumn>(["triage", "todo", "in-progress", "in-review", "done"]);
export const settingsSchema: Record<string, PluginSettingSchema> = {
fusionApiBaseUrl: {
type: "string",
label: "Fusion API Base URL",
defaultValue: DEFAULT_BASE_URL,
},
fusionApiToken: {
type: "password",
label: "Fusion API Token",
},
glassesDeviceId: {
type: "string",
label: "Glasses Device ID",
},
pollingIntervalSeconds: {
type: "number",
label: "Polling Interval (seconds)",
defaultValue: DEFAULT_POLLING_INTERVAL_SECONDS,
},
notifyOnColumns: {
type: "array",
label: "Notify on Columns",
itemType: "string",
defaultValue: DEFAULT_NOTIFY_COLUMNS,
},
quickCaptureDefaultColumn: {
type: "enum",
label: "Quick Capture Default Column",
enumValues: [...COLUMN_SET],
defaultValue: DEFAULT_QUICK_CAPTURE_COLUMN,
},
enableAgentActions: {
type: "boolean",
label: "Enable Agent Actions",
defaultValue: true,
},
};
function getSettingString(settings: Record<string, unknown>, key: string): string | undefined {
const value = settings[key];
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
}
export function getFusionBaseUrl(settings: Record<string, unknown>): string {
return getSettingString(settings, "fusionApiBaseUrl") ?? DEFAULT_BASE_URL;
}
export function getFusionToken(settings: Record<string, unknown>): string | undefined {
return getSettingString(settings, "fusionApiToken");
}
export function getPollingIntervalMs(settings: Record<string, unknown>): number {
const raw = settings.pollingIntervalSeconds;
if (typeof raw !== "number" || !Number.isFinite(raw)) {
return DEFAULT_POLLING_INTERVAL_SECONDS * 1000;
}
const seconds = Math.max(MIN_POLLING_INTERVAL_SECONDS, Math.floor(raw));
return seconds * 1000;
}
export function getNotifyColumns(settings: Record<string, unknown>): TaskColumn[] {
const raw = settings.notifyOnColumns;
if (!Array.isArray(raw)) {
return [...DEFAULT_NOTIFY_COLUMNS] as TaskColumn[];
}
const columns = raw
.filter((value): value is string => typeof value === "string")
.map((value) => value.trim())
.filter((value): value is TaskColumn => COLUMN_SET.has(value as TaskColumn));
return columns.length > 0 ? columns : ([...DEFAULT_NOTIFY_COLUMNS] as TaskColumn[]);
}
export function getQuickCaptureColumn(settings: Record<string, unknown>): TaskColumn {
const raw = getSettingString(settings, "quickCaptureDefaultColumn");
return raw && COLUMN_SET.has(raw as TaskColumn) ? (raw as TaskColumn) : DEFAULT_QUICK_CAPTURE_COLUMN;
}
export function agentActionsEnabled(settings: Record<string, unknown>): boolean {
const raw = settings.enableAgentActions;
return typeof raw === "boolean" ? raw : true;
}
export type { TaskColumn };

View File

@@ -0,0 +1,43 @@
import type { GlassesCard } from "./cards.js";
export type GlassesAction = {
type: "start-work" | "request-review" | "quick-capture";
taskId?: string;
text?: string;
timestamp: string;
};
export interface GlassesTransport {
connect(): Promise<void>;
disconnect(): Promise<void>;
pushCard(card: GlassesCard): Promise<void>;
onAction(handler: (action: GlassesAction) => void | Promise<void>): void;
}
export class StubGlassesTransport implements GlassesTransport {
private handlers: Array<(action: GlassesAction) => void | Promise<void>> = [];
public readonly pushedCards: GlassesCard[] = [];
public connected = false;
async connect(): Promise<void> {
this.connected = true;
}
async disconnect(): Promise<void> {
this.connected = false;
}
async pushCard(card: GlassesCard): Promise<void> {
this.pushedCards.push(card);
}
onAction(handler: (action: GlassesAction) => void | Promise<void>): void {
this.handlers.push(handler);
}
async emitAction(action: GlassesAction): Promise<void> {
for (const handler of this.handlers) {
await handler(action);
}
}
}

View File

@@ -0,0 +1,8 @@
{
"extends": "../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": ["src/**/*"]
}

View File

@@ -0,0 +1,14 @@
import { fileURLToPath } from "node:url";
import { defineConfig } from "vitest/config";
export default defineConfig({
resolve: {
alias: {
"@fusion/plugin-sdk": fileURLToPath(new URL("../../packages/plugin-sdk/src/index.ts", import.meta.url)),
},
},
test: {
environment: "node",
include: ["src/__tests__/**/*.test.ts"],
},
});

View File

@@ -196,7 +196,7 @@ export async function generateMilestoneSuggestions(goalPrompt, count = DEFAULT_S
dispose?.();
}
})(),
new Promise((_, reject) => setTimeout(() => reject(new ServiceUnavailableError("AI suggestion generation timed out. Please try again.")), SUGGESTION_TIMEOUT_MS)),
new Promise((_, reject) => globalThis.setTimeout(() => reject(new ServiceUnavailableError("AI suggestion generation timed out. Please try again.")), SUGGESTION_TIMEOUT_MS)),
]);
return result;
}
@@ -263,7 +263,7 @@ export async function generateFeatureSuggestions(context, count = DEFAULT_SUGGES
dispose?.();
}
})(),
new Promise((_, reject) => setTimeout(() => reject(new ServiceUnavailableError("AI suggestion generation timed out. Please try again.")), SUGGESTION_TIMEOUT_MS)),
new Promise((_, reject) => globalThis.setTimeout(() => reject(new ServiceUnavailableError("AI suggestion generation timed out. Please try again.")), SUGGESTION_TIMEOUT_MS)),
]);
return result;
}

16
pnpm-lock.yaml generated
View File

@@ -708,6 +708,22 @@ importers:
specifier: ^3.2.4
version: 3.2.4(@types/debug@4.1.13)(@types/node@25.5.2)(jiti@2.6.1)(jsdom@29.0.1)(tsx@4.21.0)(yaml@2.8.3)
plugins/fusion-plugin-even-realities-glasses:
dependencies:
'@fusion/plugin-sdk':
specifier: workspace:*
version: link:../../packages/plugin-sdk
devDependencies:
'@types/node':
specifier: ^25.5.2
version: 25.5.2
typescript:
specifier: ^5.7.0
version: 5.9.3
vitest:
specifier: ^3.2.4
version: 3.2.4(@types/debug@4.1.13)(@types/node@25.5.2)(jiti@2.6.1)(jsdom@29.0.1)(tsx@4.21.0)(yaml@2.8.3)
plugins/fusion-plugin-hermes-runtime:
dependencies:
'@fusion/plugin-sdk':

View File

@@ -10,3 +10,4 @@ packages:
- "plugins/fusion-plugin-agent-browser"
- "plugins/fusion-plugin-whatsapp-chat"
- "plugins/fusion-plugin-roadmap"
- "plugins/fusion-plugin-even-realities-glasses"