@juspay/neurolink 11.22.0 → 11.22.2

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.
@@ -9,6 +9,17 @@ import { AIProviderName } from "../../types/index.js";
9
9
  /**
10
10
  * Factory for creating Ollama CLI commands using the Factory Pattern
11
11
  */
12
+ /** See the note on the identically-named constant in cli/utils/ollamaUtils.ts. */
13
+ const OLLAMA_QUERY_TIMEOUT_MS = 15_000;
14
+ /**
15
+ * Explicit opt-out from the query bound, for commands that are long by design.
16
+ *
17
+ * `spawnSync` treats 0 as "no timeout" (verified: a 3s child under
18
+ * `timeout: 0` runs to completion with no error, while `timeout: 1000` returns
19
+ * at 1.0s with ETIMEDOUT). Named rather than written as a bare 0 at the call
20
+ * site, because `timeout: 0` reads like "expire immediately".
21
+ */
22
+ const OLLAMA_NO_TIMEOUT = 0;
12
23
  export class OllamaCommandFactory {
13
24
  /**
14
25
  * Secure wrapper around spawnSync to prevent command injection.
@@ -16,6 +27,12 @@ export class OllamaCommandFactory {
16
27
  static safeSpawn(command, args, options = {}) {
17
28
  // Command validation is now handled by TypeScript with AllowedCommand type
18
29
  const defaultOptions = {
30
+ // spawnSync blocks the event loop, so an unresponsive daemon hangs the
31
+ // whole CLI with no output and no error. SIGKILL because spawnSync's
32
+ // timeout otherwise sends SIGTERM and keeps waiting. Callers with
33
+ // legitimately long commands override via `options`.
34
+ timeout: OLLAMA_QUERY_TIMEOUT_MS,
35
+ killSignal: "SIGKILL",
19
36
  ...options,
20
37
  encoding: "utf8", // Always enforce utf8 encoding
21
38
  };
@@ -24,31 +41,42 @@ export class OllamaCommandFactory {
24
41
  /**
25
42
  * Create the Ollama command group
26
43
  */
44
+ /**
45
+ * Every handler is registered `.bind(this)`.
46
+ *
47
+ * yargs invokes the handler as a plain function, so a bare `this.xHandler`
48
+ * reference arrives with `this` unset and the first `this.safeSpawn(...)`
49
+ * throws `TypeError: this.safeSpawn is not a function`. That made EVERY
50
+ * subcommand here fail on its first line — list-models, pull, remove, status
51
+ * and stop all reported their generic "Failed to ..." message regardless of
52
+ * whether Ollama was installed or running, which is exactly why the real
53
+ * cause stayed hidden.
54
+ */
27
55
  static createOllamaCommands() {
28
56
  return {
29
57
  command: "ollama <command>",
30
58
  describe: "Manage Ollama local AI models",
31
59
  builder: (yargs) => {
32
60
  return yargs
33
- .command("list-models", "List installed Ollama models", {}, this.listModelsHandler)
61
+ .command("list-models", "List installed Ollama models", {}, this.listModelsHandler.bind(this))
34
62
  .command("pull <model>", "Download an Ollama model", {
35
63
  model: {
36
64
  describe: "Model name to download",
37
65
  type: "string",
38
66
  demandOption: true,
39
67
  },
40
- }, this.pullModelHandler)
68
+ }, this.pullModelHandler.bind(this))
41
69
  .command("remove <model>", "Remove an Ollama model", {
42
70
  model: {
43
71
  describe: "Model name to remove",
44
72
  type: "string",
45
73
  demandOption: true,
46
74
  },
47
- }, this.removeModelHandler)
48
- .command("status", "Check Ollama service status", {}, this.statusHandler)
49
- .command("start", "Start Ollama service", {}, this.startHandler)
50
- .command("stop", "Stop Ollama service", {}, this.stopHandler)
51
- .command("setup", "Interactive Ollama setup", {}, this.setupHandler)
75
+ }, this.removeModelHandler.bind(this))
76
+ .command("status", "Check Ollama service status", {}, this.statusHandler.bind(this))
77
+ .command("start", "Start Ollama service", {}, this.startHandler.bind(this))
78
+ .command("stop", "Stop Ollama service", {}, this.stopHandler.bind(this))
79
+ .command("setup", "Interactive Ollama setup", {}, this.setupHandler.bind(this))
52
80
  .demandCommand(1, "Please specify a command");
53
81
  },
54
82
  handler: () => { }, // No-op handler as subcommands handle everything
@@ -89,8 +117,16 @@ export class OllamaCommandFactory {
89
117
  logger.always(chalk.blue(`Downloading model: ${model}`));
90
118
  logger.always(chalk.gray("This may take several minutes..."));
91
119
  try {
120
+ // A model pull is hundreds of megabytes and legitimately runs for many
121
+ // minutes, so it MUST opt out of safeSpawn's default query bound.
122
+ // Passing only `stdio` here is what made the first revision of this
123
+ // change kill downloads at 15s: the wrapper spreads `options` over its
124
+ // defaults, so an unmentioned `timeout` keeps the default rather than
125
+ // being absent. It inherits stdio, so the user sees progress and can
126
+ // interrupt it — the two things a wedged query lacks.
92
127
  const res = this.safeSpawn("ollama", ["pull", model], {
93
128
  stdio: "inherit",
129
+ timeout: OLLAMA_NO_TIMEOUT,
94
130
  });
95
131
  if (res.error || res.status !== 0) {
96
132
  throw res.error || new Error("pull failed");
@@ -211,25 +247,36 @@ export class OllamaCommandFactory {
211
247
  */
212
248
  static async stopHandler() {
213
249
  const spinner = ora("Stopping Ollama service...").start();
250
+ // `safeSpawn` RETURNS a result; it does not throw on a failed command. So
251
+ // the try/catch fallbacks that used to sit here never fired — `killall`
252
+ // and the pkill fallback were unreachable, and the handler reported
253
+ // "stopped" whatever happened. Bounding these calls made that worse rather
254
+ // than better: a timeout now yields `error: ETIMEDOUT` with `status: null`,
255
+ // which is still not a throw, so a wedged kill would be reported as a
256
+ // successful stop while Ollama stayed up.
257
+ //
258
+ // Branch on the result instead, which both restores the fallback and makes
259
+ // the success message conditional on something actually succeeding.
260
+ const ok = (r) => !r.error && r.status === 0;
214
261
  try {
262
+ let stopped;
215
263
  if (process.platform === "darwin") {
216
- try {
217
- this.safeSpawn("pkill", ["ollama"]);
218
- }
219
- catch {
220
- this.safeSpawn("killall", ["Ollama"]);
221
- }
264
+ stopped =
265
+ ok(this.safeSpawn("pkill", ["ollama"])) ||
266
+ ok(this.safeSpawn("killall", ["Ollama"]));
222
267
  }
223
268
  else if (process.platform === "linux") {
224
- try {
225
- this.safeSpawn("systemctl", ["stop", "ollama"]);
226
- }
227
- catch {
228
- this.safeSpawn("pkill", ["ollama"]);
229
- }
269
+ stopped =
270
+ ok(this.safeSpawn("systemctl", ["stop", "ollama"])) ||
271
+ ok(this.safeSpawn("pkill", ["ollama"]));
230
272
  }
231
273
  else {
232
- this.safeSpawn("taskkill", ["/F", "/IM", "ollama.exe"]);
274
+ stopped = ok(this.safeSpawn("taskkill", ["/F", "/IM", "ollama.exe"]));
275
+ }
276
+ if (!stopped) {
277
+ spinner.fail("Could not confirm Ollama service stopped");
278
+ logger.error(chalk.red("No stop command succeeded — it may not have been running, or it may still be up. Check with: ollama list"));
279
+ return;
233
280
  }
234
281
  spinner.succeed("Ollama service stopped");
235
282
  }
@@ -8,6 +8,15 @@ export declare class OllamaUtils {
8
8
  * Secure wrapper around spawnSync to prevent command injection.
9
9
  */
10
10
  static safeSpawn(command: AllowedCommand, args: string[], options?: SpawnSyncOptions): SpawnSyncReturns<string>;
11
+ /**
12
+ * Whether a `safeSpawn` call actually succeeded.
13
+ *
14
+ * `spawnSync` reports failure by RETURNING — `error` set (ENOENT, ETIMEDOUT)
15
+ * or a non-zero `status` — never by throwing. Every `try/catch` around one of
16
+ * these calls was therefore dead code, which is why several fallbacks in this
17
+ * file had never run.
18
+ */
19
+ private static spawnSucceeded;
11
20
  /**
12
21
  * Check if Ollama command line is available
13
22
  */
@@ -2,6 +2,32 @@ import { spawnSync, spawn, } from "child_process";
2
2
  import chalk from "chalk";
3
3
  import ora from "ora";
4
4
  import { logger } from "../../utils/logger.js";
5
+ /**
6
+ * Ceiling for a synchronous Ollama query (`list`, `--version`, `rm`, …).
7
+ *
8
+ * `spawnSync` blocks the event loop, so an unresponsive daemon does not make
9
+ * the CLI slow — it makes it unkillable by anything short of Ctrl-C, with no
10
+ * output and no error. These calls are local IPC that normally answer in
11
+ * milliseconds, so a ceiling this generous only ever fires on a genuine wedge.
12
+ *
13
+ * `killSignal: "SIGKILL"` is not decoration. `spawnSync`'s `timeout` sends
14
+ * SIGTERM by default and then keeps waiting, so a child that ignores SIGTERM
15
+ * hangs forever anyway and the timeout buys nothing.
16
+ *
17
+ * Long-running commands (`ollama pull`) must pass their own `timeout` — the
18
+ * spread below lets a caller override this — because a model download
19
+ * legitimately runs for many minutes.
20
+ */
21
+ const OLLAMA_QUERY_TIMEOUT_MS = 15_000;
22
+ /**
23
+ * Ceiling for the whole readiness loop, not one probe inside it.
24
+ *
25
+ * Ollama normally answers within seconds of starting; a wait longer than this
26
+ * means it is not coming up, and continuing to spin helps nobody. Sized to
27
+ * outlast a slow cold start while staying far below the ~15 minutes that 30
28
+ * attempts of bounded probes could otherwise reach.
29
+ */
30
+ const READINESS_DEADLINE_MS = 90_000;
5
31
  /**
6
32
  * Shared Ollama utilities for CLI commands
7
33
  */
@@ -11,11 +37,24 @@ export class OllamaUtils {
11
37
  */
12
38
  static safeSpawn(command, args, options = {}) {
13
39
  const defaultOptions = {
40
+ timeout: OLLAMA_QUERY_TIMEOUT_MS,
41
+ killSignal: "SIGKILL",
14
42
  ...options,
15
43
  encoding: "utf8", // Always enforce utf8 encoding
16
44
  };
17
45
  return spawnSync(command, args, defaultOptions);
18
46
  }
47
+ /**
48
+ * Whether a `safeSpawn` call actually succeeded.
49
+ *
50
+ * `spawnSync` reports failure by RETURNING — `error` set (ENOENT, ETIMEDOUT)
51
+ * or a non-zero `status` — never by throwing. Every `try/catch` around one of
52
+ * these calls was therefore dead code, which is why several fallbacks in this
53
+ * file had never run.
54
+ */
55
+ static spawnSucceeded(result) {
56
+ return !result.error && result.status === 0;
57
+ }
19
58
  /**
20
59
  * Check if Ollama command line is available
21
60
  */
@@ -70,7 +109,16 @@ export class OllamaUtils {
70
109
  */
71
110
  static async waitForOllamaReady(maxAttempts = 30, initialDelay = 500) {
72
111
  let delay = initialDelay;
112
+ // A per-call bound alone is not enough here, and adding one made this
113
+ // worse before it made it better: each attempt can now burn the full
114
+ // command bound plus the API bound, so 30 attempts is up to ~15 minutes of
115
+ // spinner rather than the seconds this loop was written to take. The
116
+ // per-attempt bound stops one wedged call; this stops the loop around it.
117
+ const deadline = Date.now() + READINESS_DEADLINE_MS;
73
118
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
119
+ if (Date.now() >= deadline) {
120
+ return false;
121
+ }
74
122
  try {
75
123
  // Try command line check first
76
124
  if (!this.isOllamaCommandReady()) {
@@ -117,11 +165,17 @@ export class OllamaUtils {
117
165
  try {
118
166
  if (process.platform === "darwin") {
119
167
  logger.always(chalk.gray("Starting Ollama on macOS..."));
120
- try {
121
- this.safeSpawn("open", ["-a", "Ollama"]);
168
+ // `safeSpawn` RETURNS a result; it does not throw on a failed command,
169
+ // so this catch only ever fired on a programming error and the
170
+ // `ollama serve` fallback beneath it was effectively unreachable.
171
+ // Bounding these calls made that worse: a timeout yields
172
+ // `error: ETIMEDOUT` with `status: null`, still not a throw, so a
173
+ // wedged launcher would have been reported as a successful start.
174
+ // Branch on the result so the fallback actually runs.
175
+ if (this.spawnSucceeded(this.safeSpawn("open", ["-a", "Ollama"]))) {
122
176
  logger.always(chalk.green("✅ Ollama app started"));
123
177
  }
124
- catch {
178
+ else {
125
179
  const child = spawn("ollama", ["serve"], {
126
180
  stdio: "ignore",
127
181
  detached: true,
@@ -135,11 +189,10 @@ export class OllamaUtils {
135
189
  }
136
190
  else if (process.platform === "linux") {
137
191
  logger.always(chalk.gray("Starting Ollama service on Linux..."));
138
- try {
139
- this.safeSpawn("systemctl", ["start", "ollama"]);
192
+ if (this.spawnSucceeded(this.safeSpawn("systemctl", ["start", "ollama"]))) {
140
193
  logger.always(chalk.green("✅ Ollama service started"));
141
194
  }
142
- catch {
195
+ else {
143
196
  const child = spawn("ollama", ["serve"], {
144
197
  stdio: "ignore",
145
198
  detached: true,
@@ -156,11 +209,16 @@ export class OllamaUtils {
156
209
  // Security Note: Windows shell=true usage is intentional here for 'start' command.
157
210
  // Arguments are controlled internally (no user input) and safeSpawn validates command names.
158
211
  // This is safer than alternative Windows process creation methods for this specific use case.
159
- this.safeSpawn("start", ["ollama", "serve"], {
212
+ const started = this.safeSpawn("start", ["ollama", "serve"], {
160
213
  stdio: "ignore",
161
214
  shell: true,
162
215
  });
163
- logger.always(chalk.green("✅ Ollama service started"));
216
+ if (this.spawnSucceeded(started)) {
217
+ logger.always(chalk.green("✅ Ollama service started"));
218
+ }
219
+ else {
220
+ logger.always(chalk.yellow("⚠️ Could not confirm Ollama started — check with: ollama list"));
221
+ }
164
222
  }
165
223
  // Wait for service to become ready with readiness probe
166
224
  const readinessSpinner = ora("Waiting for Ollama service to be ready...").start();
@@ -179,10 +179,22 @@ export async function createClaudeCodeReader() {
179
179
  const unpriced = new Set();
180
180
  const files = [];
181
181
  await collectTranscripts(projectsRoot(), files);
182
- const sinceDays = options?.sinceDays ?? DEFAULT_SINCE_DAYS;
183
- const cutoff = Number.isFinite(sinceDays) && sinceDays > 0
184
- ? Date.now() - sinceDays * 86_400_000
185
- : undefined;
182
+ // Only Infinity means "no time filter". A non-positive sinceDays used to
183
+ // leave the cutoff undefined and read EVERYTHING measured at 17,534
184
+ // files and 35.9s for `sinceDays: 0`, which is the widest possible scan
185
+ // in answer to the narrowest possible request. Zero now means a
186
+ // zero-length window, which is what it reads as.
187
+ // NaN is not a window, and it is the case the previous fix missed:
188
+ // Math.max(0, NaN) is NaN, every comparison against NaN is false, so the
189
+ // filter passes EVERY file. Measured: sinceDays NaN read 17,537 files in
190
+ // 32.8s — the same unbounded sweep this guard exists to prevent, reached
191
+ // by a different door. The old guard caught it with Number.isFinite and
192
+ // the replacement dropped that check.
193
+ const requestedDays = options?.sinceDays ?? DEFAULT_SINCE_DAYS;
194
+ const sinceDays = Number.isNaN(requestedDays) ? 0 : requestedDays;
195
+ const cutoff = sinceDays === Infinity
196
+ ? undefined
197
+ : Date.now() - Math.max(0, sinceDays) * 86_400_000;
186
198
  let filesScanned = 0;
187
199
  for (const file of files) {
188
200
  try {
@@ -167,10 +167,22 @@ export async function createCodexReader() {
167
167
  const models = new Set();
168
168
  const files = [];
169
169
  await collectRollouts(sessionsRoot(), files);
170
- const sinceDays = options?.sinceDays ?? DEFAULT_SINCE_DAYS;
171
- const cutoff = Number.isFinite(sinceDays) && sinceDays > 0
172
- ? Date.now() - sinceDays * 86_400_000
173
- : undefined;
170
+ // Only Infinity means "no time filter". A non-positive sinceDays used to
171
+ // leave the cutoff undefined and read EVERYTHING measured at 17,534
172
+ // files and 35.9s for `sinceDays: 0`, which is the widest possible scan
173
+ // in answer to the narrowest possible request. Zero now means a
174
+ // zero-length window, which is what it reads as.
175
+ // NaN is not a window, and it is the case the previous fix missed:
176
+ // Math.max(0, NaN) is NaN, every comparison against NaN is false, so the
177
+ // filter passes EVERY file. Measured: sinceDays NaN read 17,537 files in
178
+ // 32.8s — the same unbounded sweep this guard exists to prevent, reached
179
+ // by a different door. The old guard caught it with Number.isFinite and
180
+ // the replacement dropped that check.
181
+ const requestedDays = options?.sinceDays ?? DEFAULT_SINCE_DAYS;
182
+ const sinceDays = Number.isNaN(requestedDays) ? 0 : requestedDays;
183
+ const cutoff = sinceDays === Infinity
184
+ ? undefined
185
+ : Date.now() - Math.max(0, sinceDays) * 86_400_000;
174
186
  let filesScanned = 0;
175
187
  for (const file of files) {
176
188
  try {
@@ -114,10 +114,28 @@ export async function createOpenCodeReader() {
114
114
  });
115
115
  return { cliId: CLI_ID, totals, filesScanned: 0, errors };
116
116
  }
117
- const sinceDays = options?.sinceDays ?? DEFAULT_SINCE_DAYS;
118
- const cutoffMs = Number.isFinite(sinceDays) && sinceDays > 0
119
- ? Date.now() - sinceDays * 86_400_000
120
- : 0;
117
+ // Only Infinity means "no time filter". A non-positive sinceDays used to
118
+ // leave the cutoff undefined and read EVERYTHING — measured at 17,534
119
+ // files and 35.9s for `sinceDays: 0`, which is the widest possible scan
120
+ // in answer to the narrowest possible request. Zero now means a
121
+ // zero-length window, which is what it reads as.
122
+ // NaN is not a window, and it is the case the previous fix missed:
123
+ // Math.max(0, NaN) is NaN, every comparison against NaN is false, so the
124
+ // filter passes EVERY file. Measured: sinceDays NaN read 17,537 files in
125
+ // 32.8s — the same unbounded sweep this guard exists to prevent, reached
126
+ // by a different door. The old guard caught it with Number.isFinite and
127
+ // the replacement dropped that check.
128
+ //
129
+ // On this reader NaN was worse than an unbounded scan: the value went
130
+ // into the SQL text, and SQLite parses a bare NaN as an identifier —
131
+ // "no such column: NaN" — so the whole scan failed rather than
132
+ // over-reading. The cutoff is a bound parameter now, so a value can
133
+ // never be SQL syntax whatever it is.
134
+ const requestedDays = options?.sinceDays ?? DEFAULT_SINCE_DAYS;
135
+ const sinceDays = Number.isNaN(requestedDays) ? 0 : requestedDays;
136
+ const cutoffMs = sinceDays === Infinity
137
+ ? 0
138
+ : Date.now() - Math.max(0, sinceDays) * 86_400_000;
121
139
  let db;
122
140
  try {
123
141
  // Read-only: this is the user's live store and OpenCode may be running.
@@ -125,9 +143,13 @@ export async function createOpenCodeReader() {
125
143
  // Filtered in SQL rather than in JS. `time_created` is epoch ms, and
126
144
  // the table holds thousands of rows whose `data` blobs are large — the
127
145
  // point of the time window is not reading them at all.
146
+ // Bound parameter, not interpolation. A number spliced into SQL text
147
+ // is still SQL text: NaN parsed as an identifier and failed the whole
148
+ // scan with "no such column: NaN". A bound value cannot become syntax
149
+ // whatever it holds.
128
150
  const rows = db
129
- .prepare(`SELECT data FROM message WHERE time_created >= ${cutoffMs}`)
130
- .all();
151
+ .prepare("SELECT data FROM message WHERE time_created >= ?")
152
+ .all(cutoffMs);
131
153
  for (const row of rows) {
132
154
  if (typeof row.data !== "string") {
133
155
  continue;
@@ -121,7 +121,7 @@ export type LocalUsageAggregateReport = {
121
121
  generatedAt: string;
122
122
  /** Only CLIs whose store was detected AND scanned appear here. */
123
123
  totals: Partial<Record<LocalUsageCliId, LocalUsageTotals>>;
124
- /** CLIs that were registered but produced nothing, and why. */
124
+ /** CLIs whose reader could not be created, detected, or scanned, and why. */
125
125
  failures: LocalUsageReaderFailure[];
126
126
  /** CLIs with no local store on this machine — absent, not failed. */
127
127
  notInstalled: LocalUsageCliId[];
@@ -161,7 +161,7 @@ export type LocalUsageCodexSessionRollup = {
161
161
  */
162
162
  export type LocalUsageSqliteDatabase = {
163
163
  prepare: (sql: string) => {
164
- all: () => unknown[];
164
+ all: (...params: unknown[]) => unknown[];
165
165
  };
166
166
  close: () => void;
167
167
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@juspay/neurolink",
3
- "version": "11.22.0",
3
+ "version": "11.22.2",
4
4
  "packageManager": "pnpm@10.15.1",
5
5
  "description": "TypeScript AI SDK with 24+ LLM providers behind one consistent API. MCP-native (connect any MCP server), voice TTS/STT/realtime, RAG, agents, memory, context compaction. OpenAI · Anthropic · Gemini · Bedrock · Azure · Ollama · DeepSeek · NVIDIA NIM and more.",
6
6
  "author": {
@@ -1,3 +0,0 @@
1
- import type { Argv } from "yargs";
2
- export declare function addOllamaCommands(cli: Argv): void;
3
- export default addOllamaCommands;