feat(FN-3742): unify agent action gating classifications across permanent a
Merges a significant refactor of agent action gating into a unified `gating-classifications.ts` module shared across permanent agents and executor gating logic, plus new glasses quick-capture support with dedicated routes and parser, and a fix restoring step progress visibility during task execution Fusion-Task-Id: FN-3742
This commit is contained in:
@@ -1118,14 +1118,14 @@
|
||||
|
||||
/* Larger touch targets for file entries on mobile */
|
||||
.changed-files-sidebar.mobile .changed-files-entry {
|
||||
padding: 10px var(--space-md);
|
||||
padding: calc(var(--space-sm) + var(--space-xs) / 2) var(--space-md);
|
||||
min-height: 36px;
|
||||
}
|
||||
|
||||
/* Clearer active state on mobile */
|
||||
.changed-files-sidebar.mobile .changed-files-entry.active {
|
||||
background: var(--card-hover);
|
||||
border-left: 3px solid var(--in-progress);
|
||||
border-left: calc(var(--btn-border-width) * 3) solid var(--in-progress);
|
||||
}
|
||||
|
||||
.changed-files-content.mobile {
|
||||
|
||||
@@ -210,7 +210,7 @@ async function runConfiguredCommand(
|
||||
// ── Tool parameter schemas (module-level for reuse in ToolDefinition generics) ──
|
||||
|
||||
const taskUpdateParams = Type.Object({
|
||||
step: Type.Number({ description: "Step number (0-indexed)" }),
|
||||
step: Type.Number({ description: "Step number (1-indexed)" }),
|
||||
status: Type.Union(
|
||||
STEP_STATUSES.map((s) => Type.Literal(s)),
|
||||
{ description: "New status: pending, in-progress, done, or skipped" },
|
||||
@@ -3916,18 +3916,28 @@ export class TaskExecutor {
|
||||
};
|
||||
}
|
||||
|
||||
if (!Number.isInteger(step) || step < 0) {
|
||||
if (!Number.isInteger(step) || step < 1) {
|
||||
return {
|
||||
content: [{
|
||||
type: "text" as const,
|
||||
text: `Invalid step number: ${step}. Steps are 0-indexed.`,
|
||||
text: `Invalid step number: ${step}. Steps are 1-indexed.`,
|
||||
}],
|
||||
details: {},
|
||||
};
|
||||
}
|
||||
|
||||
const task = await store.updateStep(taskId, step, status as StepStatus);
|
||||
const stepInfo = task.steps[step];
|
||||
const stepIndex = step - 1;
|
||||
const task = await store.updateStep(taskId, stepIndex, status as StepStatus);
|
||||
const stepInfo = task.steps[stepIndex];
|
||||
if (!stepInfo) {
|
||||
return {
|
||||
content: [{
|
||||
type: "text" as const,
|
||||
text: `Invalid step number: ${step}. This task has ${task.steps.length} step(s) (1-indexed).`,
|
||||
}],
|
||||
details: {},
|
||||
};
|
||||
}
|
||||
const persistedStatus = stepInfo.status;
|
||||
const progress = task.steps.filter((s) => s.status === "done").length;
|
||||
|
||||
|
||||
@@ -31,6 +31,47 @@ pnpm --filter @fusion-plugin-examples/even-realities-glasses test
|
||||
- `quickCaptureDefaultColumn` (default `triage`)
|
||||
- `enableAgentActions` (default `true`)
|
||||
|
||||
## Quick capture
|
||||
|
||||
Use `POST /quick-capture` for one-gesture glasses capture (`POST /tasks` is still the general-purpose route).
|
||||
|
||||
Pipeline:
|
||||
1. Strip leading wake phrase (`hey fusion`, `fusion`, `ok fusion`, `note`, `task`, `capture`)
|
||||
2. Strip filler tokens (`um`, `uh`, `er`, `like`, `you know`) and transcript punctuation noise
|
||||
3. Split first sentence into title + description (title capped at 80 chars; overflow moved into description)
|
||||
4. Resolve column from request `column` or plugin setting `quickCaptureDefaultColumn` (fallback `triage`)
|
||||
|
||||
Example:
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:4040/api/plugins/fusion-plugin-even-realities-glasses/quick-capture \
|
||||
-H "Authorization: Bearer <apiKey>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"text":"hey fusion, file a bug about the merge gate"}'
|
||||
```
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{
|
||||
"task": {
|
||||
"id": "FN-1234",
|
||||
"description": "file a bug about the merge gate\nfile a bug about the merge gate",
|
||||
"column": "triage"
|
||||
},
|
||||
"card": {
|
||||
"id": "task-FN-1234",
|
||||
"kind": "task",
|
||||
"title": "FN-1234: file a bug about the merge gate",
|
||||
"bodyLines": [
|
||||
"file a bug about the merge gate\nfile a bug about the merge gate",
|
||||
"Column: triage"
|
||||
],
|
||||
"accentColor": "yellow"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Security notes
|
||||
|
||||
- Uses `Authorization: Bearer <token>` for all API requests.
|
||||
|
||||
@@ -25,6 +25,7 @@ describe("cards", () => {
|
||||
"Column: in-review",
|
||||
],
|
||||
"id": "task-FN-1",
|
||||
"kind": "task",
|
||||
"title": "FN-1: Ship",
|
||||
}
|
||||
`);
|
||||
@@ -42,6 +43,7 @@ describe("cards", () => {
|
||||
"done: 1",
|
||||
],
|
||||
"id": "board-summary",
|
||||
"kind": "summary",
|
||||
"title": "Fusion Board Summary",
|
||||
}
|
||||
`);
|
||||
@@ -64,6 +66,7 @@ describe("cards", () => {
|
||||
"entered notify column",
|
||||
],
|
||||
"id": "notification-FN-2-entered notify column",
|
||||
"kind": "notification",
|
||||
"title": "Task update: FN-2",
|
||||
}
|
||||
`);
|
||||
|
||||
@@ -5,6 +5,7 @@ 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([
|
||||
"apiKey",
|
||||
"enableAgentActions",
|
||||
"fusionApiBaseUrl",
|
||||
"fusionApiToken",
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { quickCaptureRoutes } from "../routes/quick-capture-routes.js";
|
||||
|
||||
function getRoute() {
|
||||
const route = quickCaptureRoutes.find((entry) => entry.method === "POST" && entry.path === "/quick-capture");
|
||||
if (!route) throw new Error("missing /quick-capture route");
|
||||
return route;
|
||||
}
|
||||
|
||||
function createCtx(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
pluginId: "fusion-plugin-even-realities-glasses",
|
||||
settings: { apiKey: "secret", quickCaptureDefaultColumn: "triage" },
|
||||
taskStore: {
|
||||
createTask: vi.fn(async (input) => ({ id: "FN-100", ...input, title: "write the spec", column: input.column })),
|
||||
},
|
||||
logger: { error: vi.fn(), warn: vi.fn(), info: vi.fn() },
|
||||
...overrides,
|
||||
} as never;
|
||||
}
|
||||
|
||||
describe("quickCaptureRoutes", () => {
|
||||
it("returns 401 for missing or wrong api key", async () => {
|
||||
const route = getRoute();
|
||||
const ctx = createCtx();
|
||||
const missing = await route.handler({ body: { text: "hello" }, headers: {} } as never, ctx);
|
||||
const wrong = await route.handler(
|
||||
{ body: { text: "hello" }, headers: { authorization: "Bearer nope" } } as never,
|
||||
ctx,
|
||||
);
|
||||
expect(missing).toMatchObject({ status: 401 });
|
||||
expect(wrong).toMatchObject({ status: 401 });
|
||||
});
|
||||
|
||||
it.each([undefined, "", " "])("returns 400 for empty text: %p", async (text) => {
|
||||
const route = getRoute();
|
||||
const ctx = createCtx();
|
||||
const res = await route.handler(
|
||||
{ body: { text }, headers: { authorization: "Bearer secret" } } as never,
|
||||
ctx,
|
||||
);
|
||||
expect(res).toMatchObject({ status: 400, body: { error: "empty utterance" } });
|
||||
});
|
||||
|
||||
it("returns 400 for invalid column", async () => {
|
||||
const route = getRoute();
|
||||
const ctx = createCtx();
|
||||
const res = await route.handler(
|
||||
{ body: { text: "hello", column: "bad" }, headers: { authorization: "Bearer secret" } } as never,
|
||||
ctx,
|
||||
);
|
||||
expect(res).toMatchObject({ status: 400 });
|
||||
});
|
||||
|
||||
it("returns 201 with task and card, using default column", async () => {
|
||||
const route = getRoute();
|
||||
const createTask = vi.fn(async (input) => ({ id: "FN-9", ...input, title: "write the spec", column: input.column }));
|
||||
const ctx = createCtx({ taskStore: { createTask } });
|
||||
|
||||
const res = await route.handler(
|
||||
{ body: { text: "hey fusion, um, write the spec" }, headers: { authorization: "Bearer secret" } } as never,
|
||||
ctx,
|
||||
);
|
||||
|
||||
expect(res).toMatchObject({ status: 201, body: { task: { id: "FN-9" }, card: { id: expect.any(String), kind: "task" } } });
|
||||
expect(createTask).toHaveBeenCalledTimes(1);
|
||||
expect(createTask).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
column: "triage",
|
||||
description: expect.stringContaining("write the spec"),
|
||||
source: expect.objectContaining({ sourceMetadata: expect.objectContaining({ channel: "glasses-quick-capture" }) }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns 201 for valid column override", async () => {
|
||||
const route = getRoute();
|
||||
const createTask = vi.fn(async (input) => ({ id: "FN-10", ...input, title: "x", column: input.column }));
|
||||
const ctx = createCtx({ taskStore: { createTask } });
|
||||
const res = await route.handler(
|
||||
{ body: { text: "write docs", column: "done" }, headers: { authorization: "Bearer secret" } } as never,
|
||||
ctx,
|
||||
);
|
||||
expect(res).toMatchObject({ status: 201 });
|
||||
expect(createTask).toHaveBeenCalledWith(expect.objectContaining({ column: "done" }));
|
||||
});
|
||||
|
||||
it("returns 500 with generic message when createTask throws", async () => {
|
||||
const route = getRoute();
|
||||
const ctx = createCtx({ taskStore: { createTask: vi.fn(async () => {
|
||||
throw new Error("db down");
|
||||
}) } });
|
||||
const res = await route.handler(
|
||||
{ body: { text: "write docs" }, headers: { authorization: "Bearer secret" } } as never,
|
||||
ctx,
|
||||
);
|
||||
expect(res).toMatchObject({ status: 500, body: { error: "quick capture failed" } });
|
||||
});
|
||||
});
|
||||
@@ -1,21 +1,80 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { runQuickCapture } from "../quick-capture.js";
|
||||
import {
|
||||
FILLER_TOKENS,
|
||||
GlassesInputError,
|
||||
parseUtterance,
|
||||
runQuickCapture,
|
||||
splitTitleAndDescription,
|
||||
stripFillerTokens,
|
||||
stripWakePhrases,
|
||||
} 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");
|
||||
describe("quick-capture parsing", () => {
|
||||
it("strips wake phrases only at start", () => {
|
||||
expect(stripWakePhrases("hey fusion add a feature")).toBe("add a feature");
|
||||
expect(stripWakePhrases("call hey fusion later")).toBe("call hey fusion later");
|
||||
});
|
||||
|
||||
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" });
|
||||
it("removes filler tokens as whole words", () => {
|
||||
expect(FILLER_TOKENS).toContain("um");
|
||||
expect(stripFillerTokens("um, ship it")).toBe("ship it");
|
||||
expect(stripFillerTokens("summary")).toBe("summary");
|
||||
});
|
||||
|
||||
it("splits title and description on first sentence boundary", () => {
|
||||
expect(splitTitleAndDescription("Ship parser. Add tests")).toEqual({
|
||||
title: "Ship parser.",
|
||||
description: "Add tests",
|
||||
});
|
||||
expect(splitTitleAndDescription("No boundary text")).toEqual({
|
||||
title: "No boundary text",
|
||||
description: "No boundary text",
|
||||
});
|
||||
});
|
||||
|
||||
it("truncates long title and pushes overflow to description", () => {
|
||||
const { title, description } = splitTitleAndDescription(
|
||||
"this title is intentionally very long and should be truncated before eighty characters with overflow kept",
|
||||
{ maxTitleChars: 80 },
|
||||
);
|
||||
expect(title.length).toBeLessThanOrEqual(80);
|
||||
expect(description).toContain("overflow kept");
|
||||
});
|
||||
|
||||
it("throws on empty utterance", () => {
|
||||
expect(() => parseUtterance("")).toThrowError(GlassesInputError);
|
||||
expect(() => parseUtterance(" ")).toThrowError(/empty utterance/);
|
||||
});
|
||||
|
||||
it("creates task with default column and channel metadata", async () => {
|
||||
const createTask = vi.fn(async (input) => ({ id: "FN-1", ...input, title: "t", column: input.column }));
|
||||
await runQuickCapture(
|
||||
{ text: "hey fusion, write docs" },
|
||||
{ taskStore: { createTask } as never, pluginId: "fusion-plugin-even-realities-glasses", defaultColumn: "triage" },
|
||||
);
|
||||
expect(createTask).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
column: "triage",
|
||||
source: expect.objectContaining({
|
||||
sourceMetadata: expect.objectContaining({ channel: "glasses-quick-capture" }),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("honors valid column override and rejects invalid column", async () => {
|
||||
const createTask = vi.fn(async (input) => ({ id: "FN-2", ...input, title: "t", column: input.column }));
|
||||
await runQuickCapture(
|
||||
{ text: "ship it", column: "done" },
|
||||
{ taskStore: { createTask } as never, pluginId: "fusion-plugin-even-realities-glasses", defaultColumn: "triage" },
|
||||
);
|
||||
expect(createTask).toHaveBeenCalledWith(expect.objectContaining({ column: "done" }));
|
||||
|
||||
await expect(
|
||||
runQuickCapture(
|
||||
{ text: "ship it", column: "bad-column" },
|
||||
{ taskStore: { createTask } as never, pluginId: "fusion-plugin-even-realities-glasses", defaultColumn: "triage" },
|
||||
),
|
||||
).rejects.toThrowError(GlassesInputError);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@ export type GlassesCardAction = {
|
||||
|
||||
export type GlassesCard = {
|
||||
id: string;
|
||||
kind?: "task" | "summary" | "notification";
|
||||
title: string;
|
||||
bodyLines: string[];
|
||||
accentColor: string;
|
||||
@@ -25,6 +26,7 @@ const COLUMN_COLORS: Record<string, string> = {
|
||||
export function taskToCard(task: FusionTask): GlassesCard {
|
||||
return {
|
||||
id: `task-${task.id}`,
|
||||
kind: "task",
|
||||
title: `${task.id}: ${task.title}`,
|
||||
bodyLines: [task.description, `Column: ${task.column}`],
|
||||
accentColor: COLUMN_COLORS[task.column] ?? "blue",
|
||||
@@ -39,6 +41,7 @@ export function boardSummaryCard(tasksByColumn: Record<string, number>): Glasses
|
||||
const ordered = ["triage", "todo", "in-progress", "in-review", "done"];
|
||||
return {
|
||||
id: "board-summary",
|
||||
kind: "summary",
|
||||
title: "Fusion Board Summary",
|
||||
bodyLines: ordered.map((column) => `${column}: ${tasksByColumn[column] ?? 0}`),
|
||||
accentColor: "blue",
|
||||
@@ -48,6 +51,7 @@ export function boardSummaryCard(tasksByColumn: Record<string, number>): Glasses
|
||||
export function notificationCard(task: FusionTask, reason: string): GlassesCard {
|
||||
return {
|
||||
id: `notification-${task.id}-${reason}`,
|
||||
kind: "notification",
|
||||
title: `Task update: ${task.id}`,
|
||||
bodyLines: [task.title, `Now in ${task.column}`, reason],
|
||||
accentColor: COLUMN_COLORS[task.column] ?? "blue",
|
||||
|
||||
@@ -3,14 +3,13 @@ import type { FusionPlugin, PluginContext, PluginRouteDefinition, PluginRouteRes
|
||||
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 { quickCaptureRoutes } from "./routes/quick-capture-routes.js";
|
||||
import {
|
||||
agentActionsEnabled,
|
||||
getFusionBaseUrl,
|
||||
getFusionToken,
|
||||
getNotifyColumns,
|
||||
getPollingIntervalMs,
|
||||
getQuickCaptureColumn,
|
||||
settingsSchema,
|
||||
} from "./settings.js";
|
||||
import { StubGlassesTransport } from "./transport.js";
|
||||
@@ -61,18 +60,6 @@ const routes: PluginRouteDefinition[] = [
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
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",
|
||||
@@ -121,7 +108,7 @@ const plugin: FusionPlugin = definePlugin({
|
||||
settingsSchema,
|
||||
},
|
||||
state: "installed",
|
||||
routes,
|
||||
routes: [...routes, ...quickCaptureRoutes],
|
||||
hooks: {
|
||||
onSchemaInit: (db) => {
|
||||
(db as PluginDb).exec(`
|
||||
|
||||
@@ -1,26 +1,121 @@
|
||||
import type { PluginContext } from "@fusion/plugin-sdk";
|
||||
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)";
|
||||
export const FILLER_TOKENS = ["um", "uh", "er", "like", "you know"] as const;
|
||||
|
||||
const task = await deps.apiClient.createTask({
|
||||
title,
|
||||
description,
|
||||
column: deps.defaultColumn,
|
||||
const DEFAULT_MAX_TITLE_CHARS = 80;
|
||||
|
||||
export class GlassesInputError extends Error {
|
||||
constructor(
|
||||
public readonly status: number,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "GlassesInputError";
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeDescription(value: unknown): string {
|
||||
if (typeof value === "string") return value.trim();
|
||||
if (value == null) return "";
|
||||
return String(value).trim();
|
||||
}
|
||||
|
||||
export function stripWakePhrases(text: string): string {
|
||||
const trimmed = text.trim();
|
||||
return trimmed
|
||||
.replace(/^\s*(?:hey\s+fusion|ok\s+fusion|fusion|note|task|capture)\s*,?\s*/i, "")
|
||||
.trim();
|
||||
}
|
||||
|
||||
export function stripFillerTokens(text: string): string {
|
||||
let cleaned = text.replace(/\s+/g, " ").trim();
|
||||
cleaned = cleaned.replace(/[.\s]+$/g, "").trim();
|
||||
|
||||
for (const token of FILLER_TOKENS) {
|
||||
const escaped = token.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
cleaned = cleaned.replace(new RegExp(`(^|[\\s,;:!?()-])${escaped}(?=$|[\\s,;:!?()-])`, "gi"), "$1");
|
||||
}
|
||||
|
||||
return cleaned.replace(/\s+/g, " ").replace(/\s+([,;:!?])/g, "$1").trim().replace(/^[,;:!?]+\s*/, "");
|
||||
}
|
||||
|
||||
export function splitTitleAndDescription(
|
||||
text: string,
|
||||
opts: { maxTitleChars?: number } = {},
|
||||
): { title: string; description: string } {
|
||||
const maxTitleChars = opts.maxTitleChars ?? DEFAULT_MAX_TITLE_CHARS;
|
||||
const normalized = text.trim();
|
||||
if (!normalized) return { title: "", description: "" };
|
||||
|
||||
const boundaryMatch = normalized.match(/[.!?]\s|\n/);
|
||||
const boundaryIndex = boundaryMatch?.index ?? -1;
|
||||
|
||||
let title = boundaryIndex >= 0 ? normalized.slice(0, boundaryIndex + (boundaryMatch?.[0] === "\n" ? 0 : 1)).trim() : normalized;
|
||||
let remainder = boundaryIndex >= 0 ? normalized.slice(boundaryIndex + (boundaryMatch?.[0].length ?? 0)).trim() : "";
|
||||
|
||||
if (title.length > maxTitleChars) {
|
||||
const candidate = title.slice(0, maxTitleChars);
|
||||
const lastSpace = candidate.lastIndexOf(" ");
|
||||
const cut = lastSpace > 0 ? lastSpace : maxTitleChars;
|
||||
const overflow = title.slice(cut).trim();
|
||||
title = title.slice(0, cut).trim();
|
||||
remainder = [overflow, remainder].filter(Boolean).join(" ").trim();
|
||||
}
|
||||
|
||||
const descriptionBase = remainder || title;
|
||||
const description = normalizeDescription(descriptionBase).slice(0, 280);
|
||||
return { title, description };
|
||||
}
|
||||
|
||||
export function parseUtterance(raw: unknown, opts: { maxTitleChars?: number } = {}): { title: string; description: string } {
|
||||
const text = normalizeDescription(raw);
|
||||
const stripped = stripFillerTokens(stripWakePhrases(text));
|
||||
if (!stripped) {
|
||||
throw new GlassesInputError(400, "empty utterance");
|
||||
}
|
||||
return splitTitleAndDescription(stripped, opts);
|
||||
}
|
||||
|
||||
function normalizeCaptureColumn(value: unknown, fallback: TaskColumn): TaskColumn {
|
||||
const raw = normalizeDescription(value);
|
||||
if (raw === "triage" || raw === "todo" || raw === "in-progress" || raw === "in-review" || raw === "done") {
|
||||
return raw;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export async function runQuickCapture(
|
||||
input: { text: unknown; column?: unknown },
|
||||
deps: {
|
||||
taskStore: PluginContext["taskStore"];
|
||||
pluginId: string;
|
||||
defaultColumn: TaskColumn;
|
||||
},
|
||||
): Promise<{ task: Awaited<ReturnType<PluginContext["taskStore"]["createTask"]>>; card: GlassesCard }> {
|
||||
const { title, description } = parseUtterance(input.text);
|
||||
const requested = input.column;
|
||||
const normalizedColumn = normalizeCaptureColumn(requested, deps.defaultColumn);
|
||||
if (requested !== undefined && normalizeDescription(requested) !== normalizedColumn) {
|
||||
throw new GlassesInputError(400, "invalid column");
|
||||
}
|
||||
|
||||
const persistedDescription = `${title}\n${description}`.trim();
|
||||
const task = await deps.taskStore.createTask({
|
||||
description: persistedDescription,
|
||||
column: normalizedColumn,
|
||||
source: {
|
||||
sourceType: "api",
|
||||
sourceMetadata: {
|
||||
pluginId: deps.pluginId,
|
||||
channel: "glasses-quick-capture",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
taskId: task.id,
|
||||
confirmationCard: taskToCard(task),
|
||||
task,
|
||||
card: taskToCard(task as never),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { createHash, timingSafeEqual } from "node:crypto";
|
||||
import type { PluginContext, PluginRouteDefinition, PluginRouteResponse } from "@fusion/plugin-sdk";
|
||||
import { runQuickCapture, GlassesInputError } from "../quick-capture.js";
|
||||
import { getQuickCaptureColumn } from "../settings.js";
|
||||
|
||||
function toDigest(value: string): Buffer {
|
||||
return createHash("sha256").update(value).digest();
|
||||
}
|
||||
|
||||
function readBearer(headers: Record<string, string | string[] | undefined>): string | undefined {
|
||||
const auth = headers.authorization ?? headers.Authorization;
|
||||
const header = Array.isArray(auth) ? auth[0] : auth;
|
||||
if (!header) return undefined;
|
||||
const match = header.match(/^Bearer\s+(.+)$/i);
|
||||
return match?.[1]?.trim() || undefined;
|
||||
}
|
||||
|
||||
export function requireApiKey(
|
||||
ctx: PluginContext,
|
||||
req: { headers?: Record<string, string | string[] | undefined> },
|
||||
): { ok: true } | { ok: false; response: PluginRouteResponse } {
|
||||
const expected = typeof ctx.settings.apiKey === "string" ? ctx.settings.apiKey.trim() : "";
|
||||
if (!expected) {
|
||||
return { ok: false, response: { status: 503, body: { error: "plugin not configured" } } };
|
||||
}
|
||||
const provided = readBearer(req.headers ?? {});
|
||||
if (!provided) {
|
||||
return { ok: false, response: { status: 401, body: { error: "unauthorized" } } };
|
||||
}
|
||||
const valid = timingSafeEqual(toDigest(expected), toDigest(provided));
|
||||
if (!valid) {
|
||||
return { ok: false, response: { status: 401, body: { error: "unauthorized" } } };
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export const quickCaptureRoutes: PluginRouteDefinition[] = [
|
||||
{
|
||||
method: "POST",
|
||||
path: "/quick-capture",
|
||||
handler: async (req, ctx) => {
|
||||
const auth = requireApiKey(ctx, req as { headers?: Record<string, string | string[] | undefined> });
|
||||
if (!auth.ok) return auth.response;
|
||||
|
||||
const body = (req as { body?: unknown }).body;
|
||||
const payload = typeof body === "object" && body ? (body as Record<string, unknown>) : {};
|
||||
|
||||
const defaultColumn = getQuickCaptureColumn(ctx.settings);
|
||||
|
||||
try {
|
||||
const result = await runQuickCapture(
|
||||
{ text: payload.text, column: payload.column },
|
||||
{ taskStore: ctx.taskStore, pluginId: ctx.pluginId, defaultColumn },
|
||||
);
|
||||
return { status: 201, body: result };
|
||||
} catch (error) {
|
||||
if (error instanceof GlassesInputError) {
|
||||
return { status: error.status, body: { error: error.message } };
|
||||
}
|
||||
ctx.logger?.error?.("quick capture failed", error);
|
||||
return { status: 500, body: { error: "quick capture failed" } };
|
||||
}
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -20,6 +20,10 @@ export const settingsSchema: Record<string, PluginSettingSchema> = {
|
||||
type: "password",
|
||||
label: "Fusion API Token",
|
||||
},
|
||||
apiKey: {
|
||||
type: "password",
|
||||
label: "Glasses Route API Key",
|
||||
},
|
||||
glassesDeviceId: {
|
||||
type: "string",
|
||||
label: "Glasses Device ID",
|
||||
|
||||
Reference in New Issue
Block a user