@juspay/neurolink 12.20.2 → 12.21.1

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;
@@ -11,6 +11,15 @@ import { normalizeAnthropicAccountKey } from "./accountSelection.js";
11
11
  import { CODEX_USAGE_URL, CODEX_USER_AGENT, decodeCodexAccessToken, resolveCodexAccountId, } from "../auth/codexOAuth.js";
12
12
  import { logger } from "../utils/logger.js";
13
13
  export const CODEX_ACCOUNT_PREFIX = "codex:";
14
+ /**
15
+ * Native fallback legs (e.g. Claude-on-Vertex) key their usage rows as
16
+ * `vertex/<model>` rather than a provider-qualified `<provider>:<label>`
17
+ * pair — there is no OAuth account behind them, just a served model. They
18
+ * are not served by Codex, so without this prefix they fell through to the
19
+ * generic "other" branch below and lost both their identity and their live
20
+ * status in the CLI's proxy status output.
21
+ */
22
+ const VERTEX_ACCOUNT_PREFIX = "vertex/";
14
23
  /** Keep an account label in the Codex namespace used by its token store. */
15
24
  export function normalizeCodexAccountKey(value) {
16
25
  const trimmed = value.trim();
@@ -32,12 +41,18 @@ export function resolveProxyStatusAccountIdentity(label, type, persistedKey) {
32
41
  if (persistedKey?.startsWith(CODEX_ACCOUNT_PREFIX)) {
33
42
  return { provider: "codex", key: persistedKey };
34
43
  }
44
+ if (persistedKey?.startsWith(VERTEX_ACCOUNT_PREFIX)) {
45
+ return { provider: "vertex", key: persistedKey };
46
+ }
35
47
  if (type === "oauth" || type === "api_key") {
36
48
  return { provider: "anthropic", key: normalizeAnthropicAccountKey(label) };
37
49
  }
38
50
  if (type === "codex-oauth") {
39
51
  return { provider: "codex", key: normalizeCodexAccountKey(label) };
40
52
  }
53
+ if (type === "vertex") {
54
+ return { provider: "vertex", key: persistedKey ?? label };
55
+ }
41
56
  return { provider: "other", key: null };
42
57
  }
43
58
  /** Enumerate Codex OAuth accounts from the token store for usage refresh. */
@@ -739,7 +739,21 @@ export async function createCodexFallbackStream(response, model) {
739
739
  try {
740
740
  yield* serializer.start();
741
741
  while (true) {
742
- const chunk = await reader.read();
742
+ let chunk;
743
+ try {
744
+ chunk = await reader.read();
745
+ }
746
+ catch (err) {
747
+ // A premature stream close after a complete SSE stream (but before
748
+ // the zero-length terminator) rejects here. If response.completed
749
+ // already landed, treat the close as a normal end-of-stream rather
750
+ // than failing a fully successful turn; otherwise this is genuine
751
+ // truncation and must still fail visibly.
752
+ if (completed) {
753
+ break;
754
+ }
755
+ throw err;
756
+ }
743
757
  carry += decoder.decode(chunk.value, { stream: !chunk.done });
744
758
  // Search only new bytes plus the boundary overlap. Long tool payloads
745
759
  // split over many chunks must not rescan their accumulated prefix.
@@ -21,6 +21,17 @@ import type { VertexAccessTokenProvider, UsageContext, VertexPassthroughTerminal
21
21
  * `effort` key, never the sibling settings beside it.
22
22
  */
23
23
  export declare function mapVertexOutputConfig(value: unknown): Record<string, unknown> | undefined;
24
+ /**
25
+ * Strip what Vertex rejects and pin the API version it requires.
26
+ *
27
+ * Prompt caching constrains every rewrite here. Anthropic caches only up to an
28
+ * explicit `cache_control` marker, at most four per request, over a prefix
29
+ * rendered `tools → system → messages`; anything removed or inserted before a
30
+ * marker invalidates it and everything after. So: never drop a marker, never
31
+ * reorder `system`, and keep each rewrite deterministic so consecutive turns
32
+ * share a prefix. Vertex holds its own cache, apart from the first-party one,
33
+ * so nothing here can disturb first-party hits.
34
+ */
24
35
  export declare function buildVertexAnthropicPayload(body: Readonly<Record<string, unknown>>): Record<string, unknown>;
25
36
  /**
26
37
  * Publisher endpoint for a Claude model on Vertex.
@@ -45,6 +56,13 @@ export declare function buildVertexAnthropicUrl(target: VertexAnthropicPassthrou
45
56
  * Pass `undefined` to restore the real credential lookup.
46
57
  */
47
58
  export declare function setVertexAccessTokenProviderForTests(provider: VertexAccessTokenProvider | undefined): void;
59
+ /**
60
+ * Run once more after a short pause when the first attempt hit a transient
61
+ * network failure: anything the shared proxy classifier retries, plus a DNS
62
+ * blip. One retry absorbs a blip without hiding a real outage: a second
63
+ * failure, or any other error, surfaces unchanged.
64
+ */
65
+ export declare function withTransientNetworkRetry<T>(run: () => Promise<T>, delayMs?: number): Promise<T>;
48
66
  /**
49
67
  * Send one Anthropic-shaped request to Vertex and return the upstream response
50
68
  * untouched, so the caller can stream its bytes straight to the client.