Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ OpenDex is an **Electron** desktop app (electron-vite + React + Tailwind v4). It

## Skills + permission gate (Phase 5a)
- **Skills are self-contained folders** under `src/skills/<name>/` (clock, weather, web-search, open, computer), each spanning the process boundary: `meta.ts` (dependency-free id/label/sensitivity + tool-name constants, shared), `skill.ts` (the executable `Skill` — zod/node/ai, MAIN only), `view.tsx` (optional renderer UI: tool labels + result cards, auto-registered). The two halves link only by the tool `name` string declared in `meta.ts`. A skill bundles tools `{ name, description, inputSchema (zod), execute }`; `sensitive: true` routes every call through the permission gate. `src/skills/registry.ts` `buildToolSet({config, requestPermission})` assembles enabled skills' tools (explicit `BUILTIN_SKILLS` list — kept explicit so `pnpm smoke:chat` works under tsx), wrapping sensitive tools' `execute` to `await requestPermission(...)` first. The renderer side (`@skills` alias): `metas.ts` + `tool-views.ts` auto-discover `meta.ts`/`view.tsx` via `import.meta.glob`. **To add a skill, drop a folder — see CONTRIBUTING.md.**
- **Jev fast path (open/computer routing):** before the LLM loop, `tryJevDesktopFastPath` (`src/main/agent/jev/`) calls TypeSafe Jev via the Gateway `/v4/ai/evaluation-model` HTTP route (keeps us on AI SDK 6). Clear `open_app`/`open_url`/`open_path` intents with a parseable target skip the language model and run the Open skill directly (still permission-gated). High-confidence but unparseable open intents constrain the tool set to Open; `computer` intents add a system-prompt bias. Gated by `config.skills.jevFastPath` (default on) and `AI_GATEWAY_API_KEY`. Smoke: `pnpm smoke:jev` / `pnpm smoke:jev --live`.
- **Permission gate** (`agent/permissions.ts`): per-request `makePermissionRequester(sender)` checks the standing decision in `config.skills.permissions[id]` (`always`→run, `never`→deny, else prompt via the injected `PermissionUi` → dedicated popup window → `permission:respond`). `allow_once|always|deny|never`; `always`/`never` persist via `updateConfig`. The `chatStart` handler builds tools per-turn with a sender-bound requester. (The prompt UI now lives in its own always-on-top popup window, not the main window — see "Window model".)
- Config: `skills: { enabled, permissions }`. A skill may set `optIn: true` (off until the user enables it — see computer-use); `isSkillEnabled(skill, config)` is the single source of truth (`optIn` → on only when `enabled[id] === true`, otherwise on unless `=== false`). Renderer: `components/permission-prompt.tsx` overlay + `lib/use-permission.ts` (queues prompts), rendered as global chrome in `App.tsx`; Settings "Skills & tools" section (enable toggle + Ask/Always/Never) driven by `@skills/metas` (`SKILL_METAS`, auto-discovered from each skill's `meta.ts` — dependency-free, so the renderer never pulls the electron-backed `skill.ts`).
- **Session grant:** `makePermissionRequester(sender)` holds a per-requester `sessionAllow` set, so an "Allow once" approval covers the rest of *that command's* multi-step tool loop (a fresh requester is built per `chatStart`, so it never silently persists across commands). Needed so computer-use doesn't re-prompt on every click.
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
"typecheck": "tsc --noEmit",
"smoke:chat": "tsx scripts/smoke-chat.ts",
"smoke:realtime": "tsx scripts/smoke-realtime.ts",
"smoke:jev": "tsx scripts/smoke-jev.ts",
"dist": "electron-vite build && electron-builder",
"release": "electron-vite build && electron-builder --publish always",
"cut:patch": "pnpm version patch -m \"Release v%s\" && git push --follow-tags",
Expand Down
73 changes: 73 additions & 0 deletions scripts/smoke-jev.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
// Smoke-test the Jev desktop router without Electron.
// Usage:
// pnpm smoke:jev # local extractors only
// pnpm smoke:jev --live # also hit Gateway evaluate (needs AI_GATEWAY_API_KEY)
import { config as loadEnv } from "dotenv";
import {
extractOpenTarget,
routeDesktopIntent,
} from "../src/main/agent/jev/desktop-route";

loadEnv();

const cases: Array<{
text: string;
route: "open_app" | "open_url" | "open_path";
expect: string | null;
}> = [
{ text: "open Safari", route: "open_app", expect: "Safari" },
{ text: "please launch Slack", route: "open_app", expect: "Slack" },
{ text: "can you open the Notes app", route: "open_app", expect: "Notes" },
{ text: "open Google Chrome", route: "open_app", expect: "Google Chrome" },
{ text: "open https://example.com/foo", route: "open_url", expect: "https://example.com/foo" },
{ text: "go to github.com", route: "open_url", expect: "https://github.com" },
{ text: "open ~/Documents", route: "open_path", expect: "~/Documents" },
// Conjunctions / UI instructions → reject (fall through to LLM)
{ text: "open Chrome and click the first tab", route: "open_app", expect: null },
{ text: "open Safari and then go to GitHub", route: "open_app", expect: null },
];

async function main() {
let failed = 0;
console.log("[smoke:jev] extractor checks");
for (const c of cases) {
const got = extractOpenTarget(c.text, c.route);
const value =
got?.kind === "app" ? got.name : got?.kind === "url" ? got.url : got?.kind === "path" ? got.path : null;
const ok = value === c.expect;
console.log(` ${ok ? "ok" : "FAIL"} "${c.text}" → ${JSON.stringify(value)}`);
if (!ok) failed++;
}

if (process.argv.includes("--live")) {
if (!process.env.AI_GATEWAY_API_KEY) {
console.error("[smoke:jev] --live needs AI_GATEWAY_API_KEY");
process.exit(1);
}
const samples = [
"open Safari",
"click the blue Submit button",
"what's the weather in SF",
"go to github.com",
];
console.log("[smoke:jev] live Jev routes");
for (const text of samples) {
const t0 = Date.now();
const routed = await routeDesktopIntent(text);
console.log(
` "${text}" → ${routed ? `${routed.route} p=${routed.probability.toFixed(2)} (${routed.latencyMs}ms)` : "null"} (wall ${Date.now() - t0}ms)`,
);
}
}

if (failed) {
console.error(`[smoke:jev] FAIL: ${failed} extractor case(s)`);
process.exit(1);
}
console.log("[smoke:jev] ok");
}

main().catch((err) => {
console.error("[smoke:jev] error", err);
process.exit(1);
});
151 changes: 151 additions & 0 deletions src/main/agent/jev/desktop-route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
/**
* Turn-start desktop routing via Jev.
*
* Opening apps through the full LLM+tool loop is the slow path users hit most
* often ("open Safari", "launch Slack"). Jev classifies the utterance into a
* typed route in one cheap parallel evaluate call; clear open_* routes can skip
* the language model entirely.
*
* Computer-use still needs a vision LLM for clicks/typing — Jev only decides
* whether that's the right bucket (and biases the system prompt when it is).
*/

import { evaluateWithJev } from "./evaluate";

export const DESKTOP_ROUTES = [
"open_app",
"open_url",
"open_path",
"computer",
"other",
] as const;

export type DesktopRoute = (typeof DESKTOP_ROUTES)[number];

export interface DesktopRouteResult {
route: DesktopRoute;
/** Probability of the selected choice (0..1), when the provider returned a distribution. */
probability: number;
latencyMs: number;
}

const ROUTE_CRITERIA: Record<DesktopRoute, string> = {
open_app:
"Launch an installed desktop application by name (Safari, Chrome, Slack, Notes, Spotify, Terminal, Finder, …). No clicking UI elements — just start the app.",
open_url:
"Open a website or http(s)/mailto URL in the default browser. Includes 'go to example.com' / 'open github.com'.",
open_path:
"Open a file or folder on disk by path (e.g. ~/Documents, /tmp/report.pdf).",
computer:
"Control the already-visible desktop with mouse/keyboard: click buttons, type into fields, drag, scroll, take screenshots, or multi-step UI work inside apps. Not a simple 'open/launch X' request.",
other:
"Questions, search, weather, time, conversation, or anything that is not opening something or controlling the screen.",
};

/** Minimum selected-option probability before we trust the route. */
export const ROUTE_CONFIDENCE = 0.8;

export async function routeDesktopIntent(
utterance: string,
signal?: AbortSignal,
): Promise<DesktopRouteResult | null> {
const text = utterance.trim();
if (!text || !process.env.AI_GATEWAY_API_KEY) return null;

const started = Date.now();
try {
const result = await evaluateWithJev({
signal,
state: { utterance: text },
questions: {
route: {
type: "choice",
instructions:
"You are routing a single voice command for a desktop voice assistant. Pick the single best handler.",
criteria: ROUTE_CRITERIA,
},
},
});

const answer = result.answers.route;
if (answer.type !== "choice") return null;
const route = answer.choice as DesktopRoute;
if (!DESKTOP_ROUTES.includes(route)) return null;
// Native Jev returns a full distribution; if a provider omits it, trust the
// chosen option rather than treating missing mass as p=0 (which would skip
// every confident route under ROUTE_CONFIDENCE).
const probability = answer.probabilities?.[answer.choice] ?? 1;
return { route, probability, latencyMs: Date.now() - started };
} catch (err) {
console.warn("[opendex jev] route failed, falling back to LLM", err);
return null;
}
}

export type OpenTarget =
| { kind: "app"; name: string }
| { kind: "url"; url: string }
| { kind: "path"; path: string };

/** Pull an open target out of a short utterance once Jev has classified the route.
* Jev itself has no free-form string output — extraction is local. Returns null
* when the target is too ambiguous to trust (caller falls back to the LLM). */
export function extractOpenTarget(
utterance: string,
route: Extract<DesktopRoute, "open_app" | "open_url" | "open_path">,
): OpenTarget | null {
const text = utterance.trim();
if (!text) return null;

if (route === "open_url") {
const mailto = text.match(/\b(mailto:[^\s]+)/i)?.[1];
if (mailto) return { kind: "url", url: mailto };

const explicit = text.match(/\b(https?:\/\/[^\s]+)/i)?.[1];
if (explicit) return { kind: "url", url: stripTrailingPunct(explicit) };

// "open github.com" / "go to example.org/foo"
const domain = text.match(
/\b(?:open|launch|go to|visit|browse(?:\s+to)?)\s+((?:[a-z0-9-]+\.)+[a-z]{2,}(?:\/[^\s]*)?)/i,
)?.[1];
if (domain) return { kind: "url", url: `https://${stripTrailingPunct(domain)}` };

return null;
}

if (route === "open_path") {
const path = text.match(
/\b(?:open|show|reveal)\s+((?:~|\/)[^\s]+)/i,
)?.[1];
if (path) return { kind: "path", path: stripTrailingPunct(path) };
return null;
}

// open_app
const app = text.match(
/\b(?:please\s+)?(?:can you\s+|could you\s+)?(?:open|launch|start|run|fire up|bring up|pull up)\s+(?:the\s+)?(.+?)(?:\s+app(?:lication)?)?(?:\s+for me)?(?:\s+please)?[.!?]*$/i,
)?.[1];
if (!app) return null;
const name = cleanAppName(app);
return name ? { kind: "app", name } : null;
}

function stripTrailingPunct(s: string): string {
return s.replace(/[.,!?;:]+$/g, "");
}

function cleanAppName(raw: string): string | null {
let name = stripTrailingPunct(raw.trim());
// Drop leading articles left over from "open the Notes app".
name = name.replace(/^(?:the|my|a|an)\s+/i, "");
// Reject leftovers that look like a full sentence / UI instruction.
if (!name || name.length > 48) return null;
if (/\b(and|then|click|type|into|on the|window)\b/i.test(name)) return null;
return name;
}

export function isOpenRoute(
route: DesktopRoute,
): route is "open_app" | "open_url" | "open_path" {
return route === "open_app" || route === "open_url" || route === "open_path";
}
93 changes: 93 additions & 0 deletions src/main/agent/jev/evaluate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
/**
* Thin Gateway client for evaluation models (Jev). Evaluation is not exposed on
* the OpenAI-compatible Gateway endpoints and needs AI SDK 7's
* `experimental_evaluate` — we call the Gateway `/v4/ai/evaluation-model`
* route directly so the rest of OpenDex can stay on AI SDK 6 (realtime canary).
*
* Requires `AI_GATEWAY_API_KEY` in the environment. Callers that want a silent
* no-op when the key is missing should check first (see `routeDesktopIntent`).
*/

const GATEWAY_EVAL_URL = "https://ai-gateway.vercel.sh/v4/ai/evaluation-model";
const JEV_MODEL_ID = "typesafe-ai/jev";

export type EvalQuestion =
| {
type: "boolean";
instructions: string;
criteria?: { true?: string; false?: string };
}
| {
type: "choice";
instructions: string;
criteria: Record<string, string>;
}
| {
type: "score";
instructions: string;
criteria: string[];
};

export type EvalAnswer =
| { type: "boolean"; probability: number }
| {
type: "choice";
choice: string;
probabilities?: Record<string, number>;
}
| {
type: "score";
score: number;
probabilities?: Record<string, number>;
};

export interface EvaluateResult<Q extends Record<string, EvalQuestion>> {
answers: { [K in keyof Q]: EvalAnswer };
usage?: { inputTokens?: number; outputTokens?: number };
providerMetadata?: Record<string, Record<string, unknown>>;
}

export async function evaluateWithJev<Q extends Record<string, EvalQuestion>>(opts: {
state: unknown;
questions: Q;
signal?: AbortSignal;
}): Promise<EvaluateResult<Q>> {
const apiKey = process.env.AI_GATEWAY_API_KEY;
if (!apiKey) throw new Error("no AI Gateway API key is set");

const res = await fetch(GATEWAY_EVAL_URL, {
method: "POST",
signal: opts.signal,
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
"ai-evaluation-model-specification-version": "4",
"ai-model-id": JEV_MODEL_ID,
},
body: JSON.stringify({
state: opts.state,
questions: opts.questions,
providerOptions: {
gateway: { zeroDataRetention: true },
},
}),
});

if (!res.ok) {
const body = await res.text().catch(() => "");
throw new Error(`Jev evaluate failed (${res.status}): ${body.slice(0, 240) || res.statusText}`);
}

const json = (await res.json()) as {
answers?: Record<string, EvalAnswer>;
usage?: { inputTokens?: number; outputTokens?: number };
providerMetadata?: Record<string, Record<string, unknown>>;
};
if (!json.answers) throw new Error("Jev evaluate returned no answers");

return {
answers: json.answers as EvaluateResult<Q>["answers"],
usage: json.usage,
providerMetadata: json.providerMetadata,
};
}
Loading
Loading