/** * The client. One `Jev` holds a resolved backend plus the defaults every call * inherits; questions are written with `check` / `pick` / `rate` and answers * come back keyed by the names you asked under. * * ```ts * const jev = new Jev(); * const { answers } = await jev.judge(state, { * next: pick("Next action?", { merge: "all green", rerun: "looks flaky", hold: "needs attention" }), * passed: check("Did the run fully succeed?"), * }); * answers.next.answer; // "merge" | "rerun" | "hold" | null * ``` * * This is a layer over the engine in `judge.ts` (screen → backend → hand back * what is unsure), not a second implementation of it. The wire surfaces — the * MCP tools, the pi tools, `jev-use judge` — call that engine directly, so * their JSON payload stays exactly the ordered `JudgeResult` an agent reads. */ import { createBackend, type BackendName } from "./backends/index.js"; import type { JevBackend } from "./backends/types.js"; import { gate as runGate, judge as runJudge } from "./judge.js"; import { defaultQuestionId, type AnswerOf, type GateAction, type GateResult, type JudgeResult, type Question, type State, type Verdict, } from "./protocol.js"; /** How to build a client. Every field has a working default. */ export interface JevOptions { /** * Which backend to judge with: a provider name, `"mock"` for a keyless dry * run, or a ready-made backend (tests, custom transports). Default: resolved * from the environment — `JEV_BACKEND` if set, otherwise the first * credential found, and a named error if there is none. */ backend?: BackendName | JevBackend; /** Environment consulted while resolving the backend. Default `process.env`. */ env?: Record; /** * Escalate verdicts below this confidence, whatever its source. Default: the * threshold for each verdict's own `confidenceFrom` — 0.5 for a confidence * the model reported, 0.4 for one estimated off the distribution. */ confidenceThreshold?: number; /** Model id to send with every call, e.g. "jev-latest". Default: the backend's. */ model?: string; } /** Per-call overrides of the client's defaults. */ export interface CallOptions { /** Escalate verdicts below this confidence, for this call only. */ confidenceThreshold?: number; /** Model id for this call only. */ model?: string; } /** Questions about one state, keyed by the name each answer comes back under. */ export type QuestionMap = Record; /** * What one `judge` call produced: the engine's ordered verdicts, plus the same * verdicts keyed by name — `answers.next` for the question you asked as `next`. */ export interface Judgment extends JudgeResult { /** * Verdicts keyed by your question names. Each answer is typed by its * question: an option label for `pick`, a number for `check` and `rate`, * and `null` when the question never reached Jev. */ answers: { [K in keyof Q]: Verdict> }; } export class Jev { /** The backend serving this client; its `name` is what every result reports. */ readonly backend: JevBackend; /** How that backend was chosen, e.g. "auto: TYPESAFE_API_KEY found". */ readonly via: string; private readonly confidenceThreshold?: number; private readonly model?: string; constructor(options: JevOptions = {}) { if (options.backend !== undefined && typeof options.backend !== "string") { this.backend = options.backend; this.via = `given: ${options.backend.name}`; } else { const resolved = createBackend(options.backend, options.env); this.backend = resolved.backend; this.via = resolved.via; } this.confidenceThreshold = options.confidenceThreshold; this.model = options.model; } /** * Ask Jev everything you want to know about one state, in one call. Batching * is where the speedup lives: latency is nearly flat in question count. * * Never throws for reachable-world reasons — a question Jev cannot take and * an unreachable backend both come back as verdicts with `escalate: true` * and a typed `reason`. */ async judge( state: State, questions: Q, options?: CallOptions, ): Promise>; /** Array form: answers are keyed by each question's id (`q0`, `q1`, ... by default). */ async judge( state: State, questions: Question[], options?: CallOptions, ): Promise; async judge( state: State, questions: QuestionMap | Question[], options: CallOptions = {}, ): Promise { const asked = keyedQuestions(questions); const result = await runJudge(this.backend, { state, questions: asked.map(([key, question]) => ({ ...question, id: question.id ?? key })), confidenceThreshold: options.confidenceThreshold ?? this.confidenceThreshold, model: options.model ?? this.model, }); const answers: Record = {}; asked.forEach(([key], index) => { answers[key] = result.verdicts[index]; }); return { ...result, answers }; } /** * Risk-check one proposed action against the current state — one allow/deny * choice under the hood. * * ```ts * const verdict = await jev.gate(state, { tool: "Bash", input: { command } }); * verdict.decision; // "allow" | "deny" | "escalate" * ``` * * `escalate` means Jev is not sure enough either way, so a human or the LLM * decides; an unreachable backend escalates too, never denies. */ async gate( state: State, action: GateAction, options: CallOptions = {}, ): Promise { return runGate(this.backend, { state, action, confidenceThreshold: options.confidenceThreshold ?? this.confidenceThreshold, model: options.model ?? this.model, }); } } /** * One [name, question] pair per question, in the caller's order: the map's own * keys, or — for the array form — each question's id, defaulted the same way * the engine defaults it. */ function keyedQuestions(questions: QuestionMap | Question[]): [string, Question][] { return Array.isArray(questions) ? questions.map((question, index) => [ question.id ?? defaultQuestionId(index), question, ]) : Object.entries(questions); }