@juspay/neurolink 12.20.2 → 12.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,22 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * NeuroLink CLI Decide Command
4
+ *
5
+ * Calls `NeuroLink.decide()` — the third inference type, alongside `generate`
6
+ * and `stream`. Takes a `state` plus a map of named typed questions and
7
+ * prints one calibrated answer per question. See src/lib/types/decision.ts
8
+ * for the full vocabulary.
9
+ *
10
+ * Credentials are env-only here, exactly as every other CLI command: the
11
+ * decision provider (TypeSafe) reads `TYPESAFE_API_KEY` itself.
12
+ */
13
+ import type { CommandModule } from "yargs";
14
+ import type { CliDecideArgs } from "../../types/index.js";
15
+ export declare const decideCommand: CommandModule<object, CliDecideArgs>;
16
+ /**
17
+ * Create decide command factory for CLICommandFactory-style registration,
18
+ * matching the EvaluateCommandFactory pattern.
19
+ */
20
+ export declare class DecideCommandFactory {
21
+ static createDecideCommand(): CommandModule;
22
+ }
@@ -0,0 +1,287 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * NeuroLink CLI Decide Command
4
+ *
5
+ * Calls `NeuroLink.decide()` — the third inference type, alongside `generate`
6
+ * and `stream`. Takes a `state` plus a map of named typed questions and
7
+ * prints one calibrated answer per question. See src/lib/types/decision.ts
8
+ * for the full vocabulary.
9
+ *
10
+ * Credentials are env-only here, exactly as every other CLI command: the
11
+ * decision provider (TypeSafe) reads `TYPESAFE_API_KEY` itself.
12
+ */
13
+ import chalk from "chalk";
14
+ import ora from "ora";
15
+ import fs from "node:fs";
16
+ import { NeuroLink } from "../../neurolink.js";
17
+ import { readDecisionChoice } from "../../utils/decisionAnswers.js";
18
+ import { calculateCost, hasPricing } from "../../utils/pricing.js";
19
+ import { logger } from "../../utils/logger.js";
20
+ const VALID_QUESTION_TYPES = new Set(["boolean", "choice", "score"]);
21
+ function isRecord(value) {
22
+ return typeof value === "object" && value !== null && !Array.isArray(value);
23
+ }
24
+ /**
25
+ * Validates that parsed JSON is a non-empty map of `{ type, ... }` question
26
+ * objects. Runs before any provider work — a malformed `--questions` payload
27
+ * must never reach the network.
28
+ */
29
+ function isDecisionQuestionMap(value) {
30
+ if (!isRecord(value)) {
31
+ return false;
32
+ }
33
+ const ids = Object.keys(value);
34
+ return (ids.length > 0 &&
35
+ ids.every((id) => {
36
+ const question = value[id];
37
+ return (isRecord(question) &&
38
+ typeof question.type === "string" &&
39
+ VALID_QUESTION_TYPES.has(question.type));
40
+ }));
41
+ }
42
+ /** `state` is the raw text, or its parsed JSON when that JSON is an object or array. */
43
+ function parseState(raw) {
44
+ try {
45
+ const parsed = JSON.parse(raw);
46
+ if (isRecord(parsed) || Array.isArray(parsed)) {
47
+ // Single assertion (CLAUDE.md rule 14 allows this) — `isRecord`/
48
+ // `Array.isArray` narrow `unknown` down to a structural shape TS can't
49
+ // itself equate with the recursive `DecisionInput` union.
50
+ return parsed;
51
+ }
52
+ }
53
+ catch {
54
+ // Not JSON — treat as plain text.
55
+ }
56
+ return raw;
57
+ }
58
+ function isDecisionErrorCause(value) {
59
+ return (isRecord(value) &&
60
+ typeof value.kind === "string" &&
61
+ typeof value.message === "string");
62
+ }
63
+ const ERROR_KIND_MESSAGES = {
64
+ authentication: "Authentication failed — check the decision provider's API key.",
65
+ invalid_request: "The decision provider rejected the request as invalid.",
66
+ max_tokens_exceeded: "The decision request exceeded the provider's token limit.",
67
+ rate_limit: "The decision provider is rate-limiting requests — try again shortly.",
68
+ overloaded: "The decision provider is overloaded — try again shortly.",
69
+ server: "The decision provider returned a server error.",
70
+ timeout: "The decision request timed out.",
71
+ network: "A network error occurred while calling the decision provider.",
72
+ };
73
+ /** One clean line describing a `decide()` failure — never a stack trace. */
74
+ function describeDecideError(error) {
75
+ const cause = error instanceof Error
76
+ ? error.cause
77
+ : undefined;
78
+ if (isDecisionErrorCause(cause)) {
79
+ const generic = ERROR_KIND_MESSAGES[cause.kind];
80
+ if (!generic) {
81
+ return cause.message;
82
+ }
83
+ // The provider's own text is what says which field or question was
84
+ // rejected, so it is kept alongside the generic line.
85
+ const detail = cause.message.trim();
86
+ return detail && detail !== generic ? `${generic} (${detail})` : generic;
87
+ }
88
+ const message = error instanceof Error ? error.message : String(error);
89
+ if (message.includes("No decision provider is configured")) {
90
+ return "No decision provider is configured. Set TYPESAFE_API_KEY, or AI_GATEWAY_API_KEY for the Vercel AI Gateway route.";
91
+ }
92
+ return message;
93
+ }
94
+ function formatNumber(value) {
95
+ return value.toFixed(2);
96
+ }
97
+ function formatAnswerLine(id, answers) {
98
+ const answer = answers[id];
99
+ if (!answer) {
100
+ return `${id}: (no answer)`;
101
+ }
102
+ switch (answer.type) {
103
+ case "boolean":
104
+ return `${id}: probability ${formatNumber(answer.probability)}`;
105
+ case "choice": {
106
+ const reading = readDecisionChoice(answers, id);
107
+ if (!reading) {
108
+ return `${id}: (unreadable choice answer)`;
109
+ }
110
+ const alternatives = reading.ranked
111
+ .filter((entry) => entry.name !== reading.choice)
112
+ .slice(0, 2)
113
+ .map((entry) => `${entry.name} (${formatNumber(entry.probability)})`)
114
+ .join(", ");
115
+ return (`${id}: choice ${reading.choice}, confidence ${formatNumber(reading.confidence)}` +
116
+ (alternatives ? `, alternatives: ${alternatives}` : ""));
117
+ }
118
+ case "score":
119
+ return `${id}: score ${formatNumber(answer.score)}, confidence ${formatNumber(answer.confidence)}`;
120
+ default:
121
+ return `${id}: (unrecognised answer type)`;
122
+ }
123
+ }
124
+ function printTextResult(result) {
125
+ for (const id of Object.keys(result.answers)) {
126
+ logger.always(formatAnswerLine(id, result.answers));
127
+ }
128
+ logger.always("");
129
+ logger.always(chalk.gray(`Model: ${result.model}`));
130
+ logger.always(chalk.gray(`Latency: ${result.latencyMs}ms`));
131
+ if (hasPricing(result.provider, result.model)) {
132
+ const cost = calculateCost(result.provider, result.model, {
133
+ input: result.usage.inputTokens,
134
+ output: result.usage.outputTokens,
135
+ total: result.usage.inputTokens + result.usage.outputTokens,
136
+ });
137
+ logger.always(chalk.gray(`Cost: $${cost.toFixed(6)}`));
138
+ }
139
+ }
140
+ function readQuestionsSource(argv) {
141
+ if (argv.questionsFile) {
142
+ return fs.readFileSync(argv.questionsFile, "utf-8");
143
+ }
144
+ if (!argv.questions) {
145
+ // Unreachable given the exactly-one-of check in the handler; keeps this
146
+ // function sound on its own rather than asserting the type away.
147
+ throw new Error("No questions source provided.");
148
+ }
149
+ return argv.questions;
150
+ }
151
+ function resolveState(argv) {
152
+ if (argv.stateFile) {
153
+ return parseState(fs.readFileSync(argv.stateFile, "utf-8"));
154
+ }
155
+ if (argv.state) {
156
+ return parseState(argv.state);
157
+ }
158
+ return undefined;
159
+ }
160
+ export const decideCommand = {
161
+ command: "decide [state]",
162
+ describe: "Get typed, calibrated judgements from a decision model (no free text)",
163
+ builder: (yargs) => yargs
164
+ .positional("state", {
165
+ type: "string",
166
+ describe: "The content to judge (or use --state-file)",
167
+ })
168
+ .option("state-file", {
169
+ type: "string",
170
+ describe: "Path to a file holding the state (text or JSON)",
171
+ })
172
+ .option("questions", {
173
+ type: "string",
174
+ describe: "Inline JSON map of questions (exactly one of this or --questions-file)",
175
+ })
176
+ .option("questions-file", {
177
+ type: "string",
178
+ describe: "Path to a JSON file holding the questions map",
179
+ })
180
+ .option("provider", {
181
+ type: "string",
182
+ describe: "Decision provider to use",
183
+ })
184
+ .option("model", {
185
+ type: "string",
186
+ describe: "Overrides the provider's configured model for this call",
187
+ })
188
+ .option("timeout", {
189
+ type: "number",
190
+ describe: "Timeout in milliseconds",
191
+ })
192
+ .option("format", {
193
+ type: "string",
194
+ alias: ["f", "output-format"],
195
+ describe: "Output format",
196
+ choices: ["text", "json"],
197
+ default: "text",
198
+ })
199
+ .option("debug", {
200
+ type: "boolean",
201
+ alias: ["v", "verbose"],
202
+ default: false,
203
+ describe: "Enable debug logging (written to stderr when --format json is used)",
204
+ })
205
+ .example('$0 decide "Refund request for a damaged item" --questions \'{"urgent":{"type":"boolean","instructions":"Is this urgent?"}}\'', "Ask a single yes/no question")
206
+ .example("$0 decide --state-file ticket.json --questions-file questions.json --format json", "Read state and questions from files, emit raw JSON"),
207
+ handler: async (argv) => {
208
+ const outputFormat = argv.format ?? "text";
209
+ // --- Validate before any provider work ---------------------------------
210
+ if (!!argv.questions === !!argv.questionsFile) {
211
+ logger.error(chalk.red("Error: pass exactly one of --questions or --questions-file."));
212
+ process.exit(1);
213
+ }
214
+ let questionsRaw;
215
+ try {
216
+ questionsRaw = readQuestionsSource(argv);
217
+ }
218
+ catch (error) {
219
+ logger.error(chalk.red(`Error: could not read questions — ${error instanceof Error ? error.message : String(error)}`));
220
+ process.exit(1);
221
+ }
222
+ let parsedQuestions;
223
+ try {
224
+ parsedQuestions = JSON.parse(questionsRaw);
225
+ }
226
+ catch {
227
+ const flag = argv.questionsFile ? "--questions-file" : "--questions";
228
+ logger.error(chalk.red(`Error: ${flag} is not valid JSON.`));
229
+ process.exit(1);
230
+ }
231
+ if (argv.timeout !== undefined &&
232
+ !(Number.isFinite(argv.timeout) && argv.timeout > 0)) {
233
+ logger.error(chalk.red("Error: --timeout must be a positive number of milliseconds."));
234
+ process.exit(1);
235
+ }
236
+ if (!isDecisionQuestionMap(parsedQuestions)) {
237
+ logger.error(chalk.red('Error: questions must be a non-empty object of { type: "boolean" | "choice" | "score", ... }.'));
238
+ process.exit(1);
239
+ }
240
+ let state;
241
+ try {
242
+ state = resolveState(argv);
243
+ }
244
+ catch (error) {
245
+ logger.error(chalk.red(`Error: could not read state file — ${error instanceof Error ? error.message : String(error)}`));
246
+ process.exit(1);
247
+ }
248
+ if (state === undefined) {
249
+ logger.error(chalk.red("Error: provide state as a positional argument or via --state-file."));
250
+ process.exit(1);
251
+ }
252
+ // --- Provider work -------------------------------------------------
253
+ const spinner = outputFormat === "json" ? null : ora("Deciding...").start();
254
+ try {
255
+ const neurolink = new NeuroLink();
256
+ const result = await neurolink.decide({
257
+ state,
258
+ questions: parsedQuestions,
259
+ provider: argv.provider,
260
+ model: argv.model,
261
+ timeoutMs: argv.timeout,
262
+ });
263
+ spinner?.succeed("Decision complete");
264
+ if (outputFormat === "json") {
265
+ logger.always(JSON.stringify(result, null, 2));
266
+ }
267
+ else {
268
+ printTextResult(result);
269
+ }
270
+ }
271
+ catch (error) {
272
+ spinner?.fail("Decision failed");
273
+ logger.error(chalk.red(`Error: ${describeDecideError(error)}`));
274
+ process.exit(1);
275
+ }
276
+ },
277
+ };
278
+ /**
279
+ * Create decide command factory for CLICommandFactory-style registration,
280
+ * matching the EvaluateCommandFactory pattern.
281
+ */
282
+ export class DecideCommandFactory {
283
+ static createDecideCommand() {
284
+ return decideCommand;
285
+ }
286
+ }
287
+ //# sourceMappingURL=decide.js.map
@@ -29,6 +29,7 @@ import { proxyPeerCommand } from "./commands/proxyPeer.js";
29
29
  import { proxyExposeCommand } from "./commands/proxyExpose.js";
30
30
  import { proxyReplayCommand } from "./commands/proxyReplay.js";
31
31
  import { EvaluateCommandFactory } from "./commands/evaluate.js";
32
+ import { DecideCommandFactory } from "./commands/decide.js";
32
33
  import { TaskCommandFactory } from "./commands/task.js";
33
34
  import { AutoresearchCommandFactory } from "./commands/autoresearch.js";
34
35
  import { voiceServerCommand } from "./commands/voiceServer.js";
@@ -72,6 +73,9 @@ export function initializeCliParser() {
72
73
  // Always set to false when debug is not enabled (including when not provided)
73
74
  process.env.NEUROLINK_DEBUG = "false";
74
75
  }
76
+ // With --format json, stdout carries the payload; a debug or info
77
+ // line there would make it unparseable, so diagnostics go to stderr.
78
+ logger.setDiagnosticsToStderr(argv.format === "json");
75
79
  // Keep existing quiet middleware
76
80
  if (process.env.NEUROLINK_QUIET === "true" &&
77
81
  typeof argv.quiet === "undefined") {
@@ -164,6 +168,8 @@ export function initializeCliParser() {
164
168
  .command(UsageCommandFactory.createUsageCommands())
165
169
  // Stream Text Command - Using CLICommandFactory
166
170
  .command(CLICommandFactory.createStreamCommand())
171
+ // Decide Command - typed, calibrated judgements (no free text)
172
+ .command(DecideCommandFactory.createDecideCommand())
167
173
  // Batch Processing Command - Using CLICommandFactory
168
174
  .command(CLICommandFactory.createBatchCommand())
169
175
  // Provider Command Group - Using CLICommandFactory
package/dist/neurolink.js CHANGED
@@ -4019,6 +4019,12 @@ Current user's request: ${currentInput}`;
4019
4019
  const sessionId = typeof opt.context?.sessionId === "string"
4020
4020
  ? opt.context.sessionId
4021
4021
  : undefined;
4022
+ // Routing runs before conversation memory is loaded, so only the history
4023
+ // the caller passed in is countable here.
4024
+ const sessionBound = sessionId !== undefined;
4025
+ const priorMessageCount = Array.isArray(opt.conversationMessages)
4026
+ ? opt.conversationMessages.length
4027
+ : undefined;
4022
4028
  try {
4023
4029
  const decision = await this.classifierRouter.route({
4024
4030
  prompt: promptText,
@@ -4027,6 +4033,8 @@ Current user's request: ${currentInput}`;
4027
4033
  requiresVision,
4028
4034
  thinkingLevel,
4029
4035
  sessionId,
4036
+ sessionBound,
4037
+ priorMessageCount,
4030
4038
  });
4031
4039
  if (!decision) {
4032
4040
  return;
@@ -295,6 +295,9 @@ export async function classifyJev(input, decide, timeoutMs, candidates, threshol
295
295
  // are already assembled by the caller. Sending them costs almost nothing
296
296
  // and they are exactly what distinguishes a long tool-using request from a
297
297
  // short conversational one.
298
+ // Continuity is sent as `session_bound` / `prior_messages`, never as the
299
+ // raw sessionId: the id is an opaque identifier that tells a third-party
300
+ // model nothing, so it stays in-process.
298
301
  const result = await decide({
299
302
  state: {
300
303
  request: (input.prompt ?? "").slice(0, 8000),
@@ -302,6 +305,10 @@ export async function classifyJev(input, decide, timeoutMs, candidates, threshol
302
305
  has_tools_available: input.hasTools ?? false,
303
306
  request_includes_images: input.requiresVision ?? false,
304
307
  caller_requested_thinking: input.thinkingLevel ?? "none",
308
+ session_bound: input.sessionBound ?? false,
309
+ ...(typeof input.priorMessageCount === "number"
310
+ ? { prior_messages: input.priorMessageCount }
311
+ : {}),
305
312
  },
306
313
  questions,
307
314
  timeoutMs,
@@ -296,6 +296,10 @@ export type ClassifierRouterInput = {
296
296
  requiresVision?: boolean;
297
297
  thinkingLevel?: string;
298
298
  sessionId?: string;
299
+ /** Whether this request is tied to a session, independent of the (withheld) session id itself. */
300
+ sessionBound?: boolean;
301
+ /** Number of prior conversation messages the caller supplied, when known. */
302
+ priorMessageCount?: number;
299
303
  };
300
304
  /** Enriched per-model metadata used while ranking pool members. */
301
305
  export type ClassifierModelMeta = {
@@ -1296,6 +1296,18 @@ export type AutoresearchInitArgs = {
1296
1296
  provider?: string;
1297
1297
  model?: string;
1298
1298
  };
1299
+ /** Arguments for `neurolink decide` — the third inference type's CLI surface. */
1300
+ export type CliDecideArgs = {
1301
+ state?: string;
1302
+ stateFile?: string;
1303
+ questions?: string;
1304
+ questionsFile?: string;
1305
+ provider?: string;
1306
+ model?: string;
1307
+ timeout?: number;
1308
+ format?: "text" | "json";
1309
+ debug?: boolean;
1310
+ };
1299
1311
  /** Base options shared across all `neurolink evaluate` subcommands. */
1300
1312
  export type BaseEvaluateArgs = {
1301
1313
  json?: boolean;
@@ -19,6 +19,7 @@ declare class NeuroLinkLogger {
19
19
  private logs;
20
20
  private maxLogs;
21
21
  private isDebugMode;
22
+ private diagnosticsToStderr;
22
23
  private eventEmitter?;
23
24
  constructor();
24
25
  /**
@@ -49,6 +50,14 @@ declare class NeuroLinkLogger {
49
50
  * @param level - The minimum log level to process ("debug", "info", "warn", or "error")
50
51
  */
51
52
  setLogLevel(level: LogLevel): void;
53
+ /**
54
+ * Routes debug and info output to stderr instead of stdout. The CLI turns
55
+ * this on for `--format json`, where stdout carries the payload and any
56
+ * diagnostic line in it would make the output unparseable.
57
+ *
58
+ * @param enabled - True to send debug/info to stderr; false restores stdout
59
+ */
60
+ setDiagnosticsToStderr(enabled: boolean): void;
52
61
  /**
53
62
  * Determines whether a message with the given log level should be processed.
54
63
  * This method considers both the configured log level and the current debug mode.
@@ -222,6 +231,7 @@ export declare const logger: {
222
231
  table: (data: unknown) => void;
223
232
  shouldLog: (level: LogLevel) => boolean;
224
233
  setLogLevel: (level: LogLevel) => void;
234
+ setDiagnosticsToStderr: (enabled: boolean) => void;
225
235
  getLogs: (level?: LogLevel) => LogEntry[];
226
236
  clearLogs: () => void;
227
237
  setEventEmitter: (emitter: {
@@ -36,11 +36,24 @@ const UPPERCASE_LOG_LEVELS = {
36
36
  warn: "WARN",
37
37
  error: "ERROR",
38
38
  };
39
+ const FORMAT_FLAGS = ["--format", "-f", "--output-format"];
40
+ /**
41
+ * True when the process was started with JSON output requested. Read from
42
+ * argv on every write, like the `--debug` check in `shouldLog`: modules log
43
+ * while they are still being imported, before the CLI middleware that calls
44
+ * `setDiagnosticsToStderr` has run.
45
+ */
46
+ function argvRequestsJsonOutput() {
47
+ const argv = process.argv;
48
+ return argv.some((arg, index) => (FORMAT_FLAGS.includes(arg) && argv[index + 1] === "json") ||
49
+ FORMAT_FLAGS.some((flag) => arg === `${flag}=json`));
50
+ }
39
51
  class NeuroLinkLogger {
40
52
  logLevel = "info";
41
53
  logs = [];
42
54
  maxLogs = 1000;
43
55
  isDebugMode;
56
+ diagnosticsToStderr = false;
44
57
  eventEmitter;
45
58
  constructor() {
46
59
  // Cache debug mode check to avoid repeated array searches
@@ -86,6 +99,16 @@ class NeuroLinkLogger {
86
99
  setLogLevel(level) {
87
100
  this.logLevel = level;
88
101
  }
102
+ /**
103
+ * Routes debug and info output to stderr instead of stdout. The CLI turns
104
+ * this on for `--format json`, where stdout carries the payload and any
105
+ * diagnostic line in it would make the output unparseable.
106
+ *
107
+ * @param enabled - True to send debug/info to stderr; false restores stdout
108
+ */
109
+ setDiagnosticsToStderr(enabled) {
110
+ this.diagnosticsToStderr = enabled;
111
+ }
89
112
  /**
90
113
  * Determines whether a message with the given log level should be processed.
91
114
  * This method considers both the configured log level and the current debug mode.
@@ -209,9 +232,10 @@ class NeuroLinkLogger {
209
232
  * @param data - Optional additional data to log.
210
233
  */
211
234
  outputToConsole(level, prefix, message, data) {
235
+ const toStderr = this.diagnosticsToStderr || argvRequestsJsonOutput();
212
236
  const logMethod = {
213
- debug: console.debug,
214
- info: console.info,
237
+ debug: toStderr ? console.error : console.debug,
238
+ info: toStderr ? console.error : console.info,
215
239
  warn: console.warn,
216
240
  error: console.error,
217
241
  }[level];
@@ -467,6 +491,7 @@ export const logger = {
467
491
  shouldLog: (level) => neuroLinkLogger.shouldLog(level),
468
492
  // Expose structured logging methods
469
493
  setLogLevel: (level) => neuroLinkLogger.setLogLevel(level),
494
+ setDiagnosticsToStderr: (enabled) => neuroLinkLogger.setDiagnosticsToStderr(enabled),
470
495
  getLogs: (level) => neuroLinkLogger.getLogs(level),
471
496
  clearLogs: () => neuroLinkLogger.clearLogs(),
472
497
  setEventEmitter: (emitter) => neuroLinkLogger.setEventEmitter(emitter),