UNPKG

@juspay/neurolink

Version:

The pipe layer of an AI nervous system: one interface connecting provider neurons to your application, across three inference types — generate, stream and decide. `decide` returns typed, calibrated judgements from a non-generative model (~400ms, ~$0.00002

308 lines • 12.5 kB
#!/usr/bin/env node /** * NeuroLink CLI Decide Command * * Calls `NeuroLink.decide()` — the third inference type, alongside `generate` * and `stream`. Takes a `state` plus a map of named typed questions and * prints one calibrated answer per question. See src/lib/types/decision.ts * for the full vocabulary. * * Credentials are env-only here, exactly as every other CLI command: the * decision provider reads its own settings (TYPESAFE_API_KEY, LAYA_API_KEY and * LAYA_BASE_URL, XOR_API_KEY and XOR_BASE_URL, or PERPLEXITY_API_KEY) itself. */ import chalk from "chalk"; import ora from "ora"; import fs from "node:fs"; import { describeDecisionProviderKeys } from "../../factories/providerDescriptors.js"; import { NeuroLink } from "../../neurolink.js"; import { readDecisionChoice } from "../../utils/decisionAnswers.js"; import { calculateCost, hasPricing } from "../../utils/pricing.js"; import { logger } from "../../utils/logger.js"; const VALID_QUESTION_TYPES = new Set(["boolean", "choice", "score"]); function isRecord(value) { return typeof value === "object" && value !== null && !Array.isArray(value); } /** * Validates that parsed JSON is a non-empty map of `{ type, ... }` question * objects. Runs before any provider work — a malformed `--questions` payload * must never reach the network. */ function isDecisionQuestionMap(value) { if (!isRecord(value)) { return false; } const ids = Object.keys(value); return (ids.length > 0 && ids.every((id) => { const question = value[id]; return (isRecord(question) && typeof question.type === "string" && VALID_QUESTION_TYPES.has(question.type)); })); } /** `state` is the raw text, or its parsed JSON when that JSON is an object or array. */ function parseState(raw) { try { const parsed = JSON.parse(raw); if (isRecord(parsed) || Array.isArray(parsed)) { // Single assertion (CLAUDE.md rule 14 allows this) — `isRecord`/ // `Array.isArray` narrow `unknown` down to a structural shape TS can't // itself equate with the recursive `DecisionInput` union. return parsed; } } catch { // Not JSON — treat as plain text. } return raw; } function isDecisionErrorCause(value) { return (isRecord(value) && typeof value.kind === "string" && typeof value.message === "string"); } const ERROR_KIND_MESSAGES = { authentication: "Authentication failed — check the decision provider's API key.", invalid_request: "The decision provider rejected the request as invalid.", max_tokens_exceeded: "The decision request exceeded the provider's token limit.", rate_limit: "The decision provider is rate-limiting requests — try again shortly.", overloaded: "The decision provider is overloaded — try again shortly.", server: "The decision provider returned a server error.", timeout: "The decision request timed out.", network: "A network error occurred while calling the decision provider.", }; /** One clean line describing a `decide()` failure — never a stack trace. */ function describeDecideError(error) { const cause = error instanceof Error ? error.cause : undefined; if (isDecisionErrorCause(cause)) { const generic = ERROR_KIND_MESSAGES[cause.kind]; if (!generic) { return cause.message; } // The provider's own text is what says which field or question was // rejected, so it is kept alongside the generic line. const detail = cause.message.trim(); return detail && detail !== generic ? `${generic} (${detail})` : generic; } const message = error instanceof Error ? error.message : String(error); if (message.includes("No decision provider is configured")) { return `No decision provider is configured. Set ${describeDecisionProviderKeys()}.`; } return message; } function formatNumber(value) { return value.toFixed(2); } function formatAnswerLine(id, answers) { const answer = answers[id]; if (!answer) { return `${id}: (no answer)`; } switch (answer.type) { case "boolean": return `${id}: probability ${formatNumber(answer.probability)}`; case "choice": { const reading = readDecisionChoice(answers, id); if (!reading) { return `${id}: (unreadable choice answer)`; } const alternatives = reading.ranked .filter((entry) => entry.name !== reading.choice) .slice(0, 2) .map((entry) => `${entry.name} (${formatNumber(entry.probability)})`) .join(", "); return (`${id}: choice ${reading.choice}, confidence ${formatNumber(reading.confidence)}` + (alternatives ? `, alternatives: ${alternatives}` : "")); } case "score": return `${id}: score ${formatNumber(answer.score)}, confidence ${formatNumber(answer.confidence)}`; default: return `${id}: (unrecognised answer type)`; } } function printTextResult(result) { for (const id of Object.keys(result.answers)) { logger.always(formatAnswerLine(id, result.answers)); } logger.always(""); logger.always(chalk.gray(`Model: ${result.model}`)); logger.always(chalk.gray(`Latency: ${result.latencyMs}ms`)); if (hasPricing(result.provider, result.model)) { const cost = calculateCost(result.provider, result.model, { input: result.usage.inputTokens, output: result.usage.outputTokens, total: result.usage.inputTokens + result.usage.outputTokens, }); logger.always(chalk.gray(`Cost: $${cost.toFixed(6)}`)); } } function readQuestionsSource(argv) { if (argv.questionsFile) { return fs.readFileSync(argv.questionsFile, "utf-8"); } if (!argv.questions) { // Unreachable given the exactly-one-of check in the handler; keeps this // function sound on its own rather than asserting the type away. throw new Error("No questions source provided."); } return argv.questions; } function resolveState(argv) { if (argv.stateFile) { return parseState(fs.readFileSync(argv.stateFile, "utf-8")); } if (argv.state) { return parseState(argv.state); } return undefined; } export const decideCommand = { command: "decide [state]", describe: "Get typed, calibrated judgements from a decision model (no free text)", builder: (yargs) => yargs .positional("state", { type: "string", describe: "The content to judge (or use --state-file)", }) .option("state-file", { type: "string", describe: "Path to a file holding the state (text or JSON)", }) .option("questions", { type: "string", describe: "Inline JSON map of questions (exactly one of this or --questions-file)", }) .option("questions-file", { type: "string", describe: "Path to a JSON file holding the questions map", }) .option("provider", { type: "string", describe: "Decision provider to use", }) .option("model", { type: "string", describe: "Overrides the provider's configured model for this call", }) .option("image", { type: "string", array: true, nargs: 1, describe: "Image for the model to read: a file path or a data: URL. Repeat for several (XOR and Perplexity take up to 8, Cloudflare Clef up to 4)", }) .option("video", { type: "string", nargs: 1, describe: "One video for the model to read: a file path or a data: URL", }) .option("timeout", { type: "number", describe: "Timeout in milliseconds", }) .option("format", { type: "string", alias: ["f", "output-format"], describe: "Output format", choices: ["text", "json"], default: "text", }) .option("debug", { type: "boolean", alias: ["v", "verbose"], default: false, describe: "Enable debug logging (written to stderr when --format json is used)", }) .example('$0 decide "Refund request for a damaged item" --questions \'{"urgent":{"type":"boolean","instructions":"Is this urgent?"}}\'', "Ask a single yes/no question") .example("$0 decide --state-file ticket.json --questions-file questions.json --format json", "Read state and questions from files, emit raw JSON") .example('$0 decide "What color is this?" --provider xor --image ./photo.png --questions \'{"color":{"type":"choice","instructions":"What color is the image?","criteria":{"red":"red","blue":"blue"}}}\'', "Ask about an image (XOR, Perplexity and Cloudflare Clef read images)"), handler: async (argv) => { const outputFormat = argv.format ?? "text"; // --- Validate before any provider work --------------------------------- if (!!argv.questions === !!argv.questionsFile) { logger.error(chalk.red("Error: pass exactly one of --questions or --questions-file.")); process.exit(1); } let questionsRaw; try { questionsRaw = readQuestionsSource(argv); } catch (error) { logger.error(chalk.red(`Error: could not read questions — ${error instanceof Error ? error.message : String(error)}`)); process.exit(1); } let parsedQuestions; try { parsedQuestions = JSON.parse(questionsRaw); } catch { const flag = argv.questionsFile ? "--questions-file" : "--questions"; logger.error(chalk.red(`Error: ${flag} is not valid JSON.`)); process.exit(1); } if (argv.timeout !== undefined && !(Number.isFinite(argv.timeout) && argv.timeout > 0)) { logger.error(chalk.red("Error: --timeout must be a positive number of milliseconds.")); process.exit(1); } const videos = [].concat(argv.video ?? []); if (videos.length > 1) { logger.error(chalk.red("Error: pass at most one --video.")); process.exit(1); } if (!isDecisionQuestionMap(parsedQuestions)) { logger.error(chalk.red('Error: questions must be a non-empty object of { type: "boolean" | "choice" | "score", ... }.')); process.exit(1); } let state; try { state = resolveState(argv); } catch (error) { logger.error(chalk.red(`Error: could not read state file — ${error instanceof Error ? error.message : String(error)}`)); process.exit(1); } if (state === undefined) { logger.error(chalk.red("Error: provide state as a positional argument or via --state-file.")); process.exit(1); } // --- Provider work ------------------------------------------------- const spinner = outputFormat === "json" ? null : ora("Deciding...").start(); try { const neurolink = new NeuroLink(); const result = await neurolink.decide({ state, questions: parsedQuestions, provider: argv.provider, model: argv.model, timeoutMs: argv.timeout, ...(argv.image && argv.image.length > 0 ? { images: argv.image } : {}), ...(videos.length === 1 ? { video: videos[0] } : {}), }); spinner?.succeed("Decision complete"); if (outputFormat === "json") { logger.always(JSON.stringify(result, null, 2)); } else { printTextResult(result); } } catch (error) { spinner?.fail("Decision failed"); logger.error(chalk.red(`Error: ${describeDecideError(error)}`)); process.exit(1); } }, }; /** * Create decide command factory for CLICommandFactory-style registration, * matching the EvaluateCommandFactory pattern. */ export class DecideCommandFactory { static createDecideCommand() { return decideCommand; } } //# sourceMappingURL=decide.js.map