@juspay/neurolink 13.6.5 → 13.7.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.
@@ -16,7 +16,7 @@ import { buildContextFromPointer, getEffectiveTokenThreshold, } from "../utils/c
16
16
  import { logger } from "../utils/logger.js";
17
17
  import { assistantTurnIdentity, isTurnAlreadyStored, turnMessageFields, userTurnIdentity, } from "../utils/conversationTurnIdentity.js";
18
18
  import { extractIsErrorText } from "../utils/toolResultStatus.js";
19
- import { deserializeConversation, encodeStoredMessages, getSessionMessagesKey, MESSAGES_KEY_SUFFIX, isSessionMessagesKey, parseStoredMessages, serializeConversationMetadata, usesSplitMessageStorage, getNormalizedConfig, getPooledRedisClient, getSessionKey, getUserSessionsKey, releasePooledRedisClient, scanKeys, serializeConversation, } from "../utils/redis.js";
19
+ import { addToSetWithTtl, deserializeConversation, encodeStoredMessages, getSessionMessagesKey, MESSAGES_KEY_SUFFIX, isSessionMessagesKey, parseStoredMessages, serializeConversationMetadata, usesSplitMessageStorage, getNormalizedConfig, getPooledRedisClient, getSessionKey, getUserSessionsKey, releasePooledRedisClient, scanKeys, serializeConversation, setWithTtl, } from "../utils/redis.js";
20
20
  const redisTracer = tracers.redis;
21
21
  const REDIS_TIMEOUT_MS = 5000;
22
22
  /**
@@ -265,10 +265,7 @@ export class RedisConversationMemoryManager {
265
265
  }
266
266
  try {
267
267
  const userSessionsKey = getUserSessionsKey(this.redisConfig, userId);
268
- await this.redisClient.sAdd(userSessionsKey, sessionId);
269
- if (this.redisConfig.ttl > 0) {
270
- await this.redisClient.expire(userSessionsKey, this.redisConfig.ttl);
271
- }
268
+ await addToSetWithTtl(this.redisClient, userSessionsKey, sessionId, this.redisConfig.ttl);
272
269
  }
273
270
  catch (error) {
274
271
  logger.error("[RedisConversationMemoryManager] Failed to add session to user set", {
@@ -420,9 +417,8 @@ export class RedisConversationMemoryManager {
420
417
  updatedConversation.title = title;
421
418
  updatedConversation.updatedAt = new Date().toISOString();
422
419
  const serializedData = serializeConversation(updatedConversation);
423
- await this.redisClient?.set(updatedRedisKey, serializedData);
424
- if (this.redisConfig.ttl > 0) {
425
- await this.redisClient?.expire(updatedRedisKey, this.redisConfig.ttl);
420
+ if (this.redisClient) {
421
+ await setWithTtl(this.redisClient, updatedRedisKey, serializedData, this.redisConfig.ttl);
426
422
  }
427
423
  }
428
424
  }
@@ -628,10 +624,7 @@ export class RedisConversationMemoryManager {
628
624
  const freshSerialized = usesSplitMessageStorage(latestConversation)
629
625
  ? serializeConversationMetadata(latestConversation)
630
626
  : serializeConversation(latestConversation);
631
- await this.redisClient.set(redisKey, freshSerialized);
632
- if (this.redisConfig.ttl > 0) {
633
- await this.redisClient.expire(redisKey, this.redisConfig.ttl);
634
- }
627
+ await setWithTtl(this.redisClient, redisKey, freshSerialized, this.redisConfig.ttl);
635
628
  }
636
629
  }
637
630
  }
@@ -1695,10 +1688,7 @@ User message: "${userMessage}"`;
1695
1688
  const serializedData = usesSplitMessageStorage(conversation)
1696
1689
  ? serializeConversationMetadata(conversation)
1697
1690
  : serializeConversation(conversation);
1698
- await withTimeout(this.redisClient.set(redisKey, serializedData), 5000);
1699
- if (this.redisConfig.ttl > 0) {
1700
- await withTimeout(this.redisClient.expire(redisKey, this.redisConfig.ttl), 5000);
1701
- }
1691
+ await withTimeout(setWithTtl(this.redisClient, redisKey, serializedData, this.redisConfig.ttl), 5000);
1702
1692
  logger.info("[RedisConversationMemoryManager] Successfully updated agentic loop report", {
1703
1693
  sessionId,
1704
1694
  userId,
@@ -137,7 +137,7 @@ export declare function recordsSpanContent(): boolean;
137
137
  */
138
138
  export declare function isFinishedAttribute(value: string): boolean;
139
139
  /**
140
- * @description Returns a copy of a value with credentials masked: values under keys such as `access_token`, `password`, `authorization` or `set-cookie`, bearer tokens, and `name=value` credentials inside text. Purely numeric values after a credential-looking name (e.g. `max_tokens: 4096`) are left alone.
140
+ * @description Returns a copy of a value with credentials masked: values under keys such as `access_token`, `password`, `authorization` or `set-cookie`, bearer tokens, and `name=value` credentials inside text. Purely numeric values after a credential-looking name (e.g. `max_tokens: 4096`) are left alone. With `NEUROLINK_MASK_CREDENTIAL_ENTRIES=true` it also masks the value of a `{ name, value }` or `{ key, value }` entry whose name is such a key (a setting such as `max_tokens`, `password_policy` or `secret_version` is not, and the value is not consulted, so a numeric PIN is masked) and a credential inside an object key; unset, it does neither, as in the released build.
141
141
  * @param value {JsonValue | object} - Content to redact; objects and arrays are walked recursively.
142
142
  * @returns {JsonValue | object} A redacted copy, serializing exactly as the input when nothing in it is sensitive.
143
143
  * @throws {Error} When the value cannot be walked; tracing callers use `spanContentAttribute`, which turns that into an omitted value.
@@ -227,7 +227,74 @@ function redactText(text) {
227
227
  });
228
228
  return redacted;
229
229
  }
230
- function redactNode(value, ancestors) {
230
+ // Opt-in stricter masking (`NEUROLINK_MASK_CREDENTIAL_ENTRIES=true`): the value
231
+ // of a `{ name, value }` or `{ key, value }` entry whose name is a credential,
232
+ // and a credential inside an object key. The released masker reads keys and
233
+ // text only, so with the variable unset the output is exactly what it was.
234
+ function masksCredentialEntries() {
235
+ return (process.env.NEUROLINK_MASK_CREDENTIAL_ENTRIES?.trim().toLowerCase() ===
236
+ "true");
237
+ }
238
+ // A name that says how many, how long or what kind of thing rather than
239
+ // holding the credential (`max_tokens`, `password_policy`, `credential_type`,
240
+ // `secret_version`, `password_min_length`). A name that merely ends in `tokens`
241
+ // is not one: `access_tokens` and `refresh_tokens` are credentials, so only a
242
+ // bare `tokens` and the count-like names of a model's token budget and usage
243
+ // (`max_tokens`, `prompt_tokens`, `cache_read_input_tokens`, ...) are exempt.
244
+ // Decided on the name's segments, not with a pattern: a key comes from content
245
+ // and may be very long, so the check must stay linear in its length.
246
+ const MEASUREMENT_SUFFIXES = new Set([
247
+ "policy",
248
+ "type",
249
+ "algorithm",
250
+ "count",
251
+ "limit",
252
+ "version",
253
+ "length",
254
+ ]);
255
+ const TOKEN_COUNT_PREFIXES = new Set([
256
+ "max",
257
+ "min",
258
+ "prompt",
259
+ "completion",
260
+ "input",
261
+ "output",
262
+ "total",
263
+ "cache",
264
+ "cached",
265
+ "reasoning",
266
+ "thinking",
267
+ "budget",
268
+ "context",
269
+ ]);
270
+ function isMeasurementEntryName(name) {
271
+ const segments = snakeCased(name).split("_");
272
+ const last = segments[segments.length - 1];
273
+ if (segments.length === 1) {
274
+ return last === "tokens";
275
+ }
276
+ return (MEASUREMENT_SUFFIXES.has(last) ||
277
+ (last === "tokens" && TOKEN_COUNT_PREFIXES.has(segments[0])));
278
+ }
279
+ function snakeCased(name) {
280
+ return name
281
+ .replace(/([a-z0-9])([A-Z])/g, "$1_$2")
282
+ .replace(/[\s.-]+/g, "_")
283
+ .toLowerCase();
284
+ }
285
+ // Headers, environment variables and form fields are often lists of
286
+ // `{ name, value }` or `{ key, value }` entries; when the name is a credential
287
+ // the value is one, exactly as if it were stored under that key, whatever the
288
+ // value looks like (a numeric PIN is a credential). Only the name decides: a
289
+ // setting that merely mentions a credential is not one.
290
+ function credentialEntryName(node) {
291
+ const name = "name" in node ? node.name : "key" in node ? node.key : undefined;
292
+ if (typeof name !== "string" || !("value" in node) || !isSensitiveKey(name)) {
293
+ return undefined;
294
+ }
295
+ return isMeasurementEntryName(name) ? undefined : name;
296
+ }
297
+ function redactNode(value, ancestors, strict) {
231
298
  if (typeof value === "string") {
232
299
  return redactText(describeDataUrls(value));
233
300
  }
@@ -243,25 +310,34 @@ function redactNode(value, ancestors) {
243
310
  ancestors.add(value);
244
311
  try {
245
312
  if ("toJSON" in value && typeof value.toJSON === "function") {
246
- return redactNode(value.toJSON(), ancestors);
313
+ return redactNode(value.toJSON(), ancestors, strict);
247
314
  }
248
315
  if (Array.isArray(value)) {
249
- return value.map((entry) => redactNode(entry, ancestors));
316
+ return value.map((entry) => redactNode(entry, ancestors, strict));
250
317
  }
251
318
  // No prototype, so a key named __proto__ is data rather than an assignment.
252
319
  const redacted = Object.create(null);
253
320
  const mimeType = declaredMimeType(value);
321
+ const entryName = strict ? credentialEntryName(value) : undefined;
254
322
  for (const [key, entry] of Object.entries(value)) {
255
- if (isSensitiveKey(key) &&
323
+ // Opt-in: a key can carry a credential too, such as a URL with an
324
+ // api_key parameter used as a map key.
325
+ const safeKey = strict ? redactText(key) : key;
326
+ const sensitiveName = key === "value" && entryName !== undefined
327
+ ? entryName
328
+ : isSensitiveKey(key)
329
+ ? key
330
+ : undefined;
331
+ if (sensitiveName !== undefined &&
256
332
  (typeof entry === "string" ||
257
333
  (typeof entry === "object" && entry !== null))) {
258
- redacted[key] = redactSensitiveEntry(key, entry);
334
+ redacted[safeKey] = redactSensitiveEntry(sensitiveName, entry);
259
335
  continue;
260
336
  }
261
337
  const placeholder = isBinaryKey(key)
262
338
  ? binaryPlaceholder(entry, mimeType)
263
339
  : undefined;
264
- redacted[key] = placeholder ?? redactNode(entry, ancestors);
340
+ redacted[safeKey] = placeholder ?? redactNode(entry, ancestors, strict);
265
341
  }
266
342
  return redacted;
267
343
  }
@@ -270,13 +346,13 @@ function redactNode(value, ancestors) {
270
346
  }
271
347
  }
272
348
  /**
273
- * @description Returns a copy of a value with credentials masked: values under keys such as `access_token`, `password`, `authorization` or `set-cookie`, bearer tokens, and `name=value` credentials inside text. Purely numeric values after a credential-looking name (e.g. `max_tokens: 4096`) are left alone.
349
+ * @description Returns a copy of a value with credentials masked: values under keys such as `access_token`, `password`, `authorization` or `set-cookie`, bearer tokens, and `name=value` credentials inside text. Purely numeric values after a credential-looking name (e.g. `max_tokens: 4096`) are left alone. With `NEUROLINK_MASK_CREDENTIAL_ENTRIES=true` it also masks the value of a `{ name, value }` or `{ key, value }` entry whose name is such a key (a setting such as `max_tokens`, `password_policy` or `secret_version` is not, and the value is not consulted, so a numeric PIN is masked) and a credential inside an object key; unset, it does neither, as in the released build.
274
350
  * @param value {JsonValue | object} - Content to redact; objects and arrays are walked recursively.
275
351
  * @returns {JsonValue | object} A redacted copy, serializing exactly as the input when nothing in it is sensitive.
276
352
  * @throws {Error} When the value cannot be walked; tracing callers use `spanContentAttribute`, which turns that into an omitted value.
277
353
  */
278
354
  export function redactSpanContent(value) {
279
- return redactNode(value, new WeakSet());
355
+ return redactNode(value, new WeakSet(), masksCredentialEntries());
280
356
  }
281
357
  function redactSafely(value) {
282
358
  try {
@@ -81,6 +81,23 @@ export declare function deserializeConversation(data: string | Buffer | null): R
81
81
  * Checks if Redis client is healthy
82
82
  */
83
83
  export declare function isRedisHealthy(client: RedisClient): Promise<boolean>;
84
+ /**
85
+ * SET a string key together with its expiry, as a single command.
86
+ *
87
+ * A plain SET clears any TTL the key had, so SET followed by a separate
88
+ * EXPIRE leaves a window — and a dropped connection inside it — in which the
89
+ * key has no expiry at all and never ages out. A `ttlSeconds` of zero or less
90
+ * means "no expiry", exactly as the two-command form treated it.
91
+ */
92
+ export declare function setWithTtl(client: RedisClient, key: string, value: string, ttlSeconds: number): Promise<void>;
93
+ /**
94
+ * SADD a member and give the set its expiry in one MULTI/EXEC.
95
+ *
96
+ * SADD on a missing key creates it without a TTL, so a separate EXPIRE leaves
97
+ * the same unbounded-lifetime window as SET followed by EXPIRE. A
98
+ * `ttlSeconds` of zero or less adds the member and sets no expiry.
99
+ */
100
+ export declare function addToSetWithTtl(client: RedisClient, key: string, member: string, ttlSeconds: number): Promise<void>;
84
101
  /**
85
102
  * Scan Redis keys matching a pattern without blocking the server
86
103
  * This is a non-blocking alternative to the KEYS command
@@ -399,6 +399,37 @@ export async function isRedisHealthy(client) {
399
399
  return false;
400
400
  }
401
401
  }
402
+ /**
403
+ * SET a string key together with its expiry, as a single command.
404
+ *
405
+ * A plain SET clears any TTL the key had, so SET followed by a separate
406
+ * EXPIRE leaves a window — and a dropped connection inside it — in which the
407
+ * key has no expiry at all and never ages out. A `ttlSeconds` of zero or less
408
+ * means "no expiry", exactly as the two-command form treated it.
409
+ */
410
+ export async function setWithTtl(client, key, value, ttlSeconds) {
411
+ if (ttlSeconds > 0) {
412
+ await client.set(key, value, {
413
+ expiration: { type: "EX", value: ttlSeconds },
414
+ });
415
+ return;
416
+ }
417
+ await client.set(key, value);
418
+ }
419
+ /**
420
+ * SADD a member and give the set its expiry in one MULTI/EXEC.
421
+ *
422
+ * SADD on a missing key creates it without a TTL, so a separate EXPIRE leaves
423
+ * the same unbounded-lifetime window as SET followed by EXPIRE. A
424
+ * `ttlSeconds` of zero or less adds the member and sets no expiry.
425
+ */
426
+ export async function addToSetWithTtl(client, key, member, ttlSeconds) {
427
+ if (ttlSeconds > 0) {
428
+ await client.multi().sAdd(key, member).expire(key, ttlSeconds).exec();
429
+ return;
430
+ }
431
+ await client.sAdd(key, member);
432
+ }
402
433
  /**
403
434
  * Scan Redis keys matching a pattern without blocking the server
404
435
  * This is a non-blocking alternative to the KEYS command
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@juspay/neurolink",
3
- "version": "13.6.5",
3
+ "version": "13.7.0",
4
4
  "packageManager": "pnpm@10.15.1",
5
5
  "description": "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/call) for routing, tool selection and context budgeting. MCP-native (4 transports), voice TTS/STT/realtime, RAG, agents, memory, compaction, 9 observability exporters. OpenAI · Anthropic · Gemini · Bedrock · Azure · Ollama · TypeSafe Jev and more.",
6
6
  "author": {
@@ -115,6 +115,7 @@
115
115
  "test:forced-tool-declared": "pnpm exec tsx test/continuous-test-suite-forced-tool-declared.ts",
116
116
  "test:span-content-redaction": "pnpm exec tsx test/continuous-test-suite-span-content-redaction.ts",
117
117
  "test:voice-turn-memory": "pnpm exec tsx test/continuous-test-suite-voice-turn-memory.ts",
118
+ "test:redis-atomic-expiry": "pnpm exec tsx test/continuous-test-suite-redis-atomic-expiry.ts",
118
119
  "test:realtime-voice": "pnpm exec tsx test/continuous-test-suite-realtime-voice.ts",
119
120
  "test:error-classification-e2e": "pnpm exec tsx test/continuous-test-suite-error-classification-e2e.ts",
120
121
  "test:error-classifier-contract": "pnpm exec tsx test/continuous-test-suite-error-classifier-contract.ts",