@juspay/neurolink 13.6.4 → 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`, the value of a `{ name, value }` or `{ key, value }` entry whose name is such a key, bearer tokens, and `name=value` credentials inside text and inside object keys. Purely numeric values after a credential-looking name (e.g. `max_tokens: 4096`) are left alone, and so is an entry whose name ends in `tokens`, `policy`, `type`, `algorithm`, `count` or `limit` (a setting, not a credential).
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,10 +227,55 @@ function redactText(text) {
227
227
  });
228
228
  return redacted;
229
229
  }
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
+ }
230
238
  // A name that says how many, how long or what kind of thing rather than
231
- // holding the credential (`max_tokens`, `password_policy`, `credential_type`).
232
- const MEASUREMENT_ENTRY_NAME = /(?:^|_)tokens$|_(?:policy|type|algorithm|count|limit)$/;
233
- const NUMERIC_TEXT = /^\s*\d+(?:\.\d+)?\s*$/;
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
+ }
234
279
  function snakeCased(name) {
235
280
  return name
236
281
  .replace(/([a-z0-9])([A-Z])/g, "$1_$2")
@@ -239,18 +284,17 @@ function snakeCased(name) {
239
284
  }
240
285
  // Headers, environment variables and form fields are often lists of
241
286
  // `{ name, value }` or `{ key, value }` entries; when the name is a credential
242
- // the value is one, exactly as if it were stored under that key. A setting
243
- // that merely mentions a credential, or a purely numeric value, is not one.
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.
244
290
  function credentialEntryName(node) {
245
291
  const name = "name" in node ? node.name : "key" in node ? node.key : undefined;
246
292
  if (typeof name !== "string" || !("value" in node) || !isSensitiveKey(name)) {
247
293
  return undefined;
248
294
  }
249
- const isSetting = MEASUREMENT_ENTRY_NAME.test(snakeCased(name)) ||
250
- (typeof node.value === "string" && NUMERIC_TEXT.test(node.value));
251
- return isSetting ? undefined : name;
295
+ return isMeasurementEntryName(name) ? undefined : name;
252
296
  }
253
- function redactNode(value, ancestors) {
297
+ function redactNode(value, ancestors, strict) {
254
298
  if (typeof value === "string") {
255
299
  return redactText(describeDataUrls(value));
256
300
  }
@@ -266,19 +310,19 @@ function redactNode(value, ancestors) {
266
310
  ancestors.add(value);
267
311
  try {
268
312
  if ("toJSON" in value && typeof value.toJSON === "function") {
269
- return redactNode(value.toJSON(), ancestors);
313
+ return redactNode(value.toJSON(), ancestors, strict);
270
314
  }
271
315
  if (Array.isArray(value)) {
272
- return value.map((entry) => redactNode(entry, ancestors));
316
+ return value.map((entry) => redactNode(entry, ancestors, strict));
273
317
  }
274
318
  // No prototype, so a key named __proto__ is data rather than an assignment.
275
319
  const redacted = Object.create(null);
276
320
  const mimeType = declaredMimeType(value);
277
- const entryName = credentialEntryName(value);
321
+ const entryName = strict ? credentialEntryName(value) : undefined;
278
322
  for (const [key, entry] of Object.entries(value)) {
279
- // A key can carry a credential too, such as a URL with an api_key
280
- // parameter used as a map key.
281
- const safeKey = redactText(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;
282
326
  const sensitiveName = key === "value" && entryName !== undefined
283
327
  ? entryName
284
328
  : isSensitiveKey(key)
@@ -293,7 +337,7 @@ function redactNode(value, ancestors) {
293
337
  const placeholder = isBinaryKey(key)
294
338
  ? binaryPlaceholder(entry, mimeType)
295
339
  : undefined;
296
- redacted[safeKey] = placeholder ?? redactNode(entry, ancestors);
340
+ redacted[safeKey] = placeholder ?? redactNode(entry, ancestors, strict);
297
341
  }
298
342
  return redacted;
299
343
  }
@@ -302,13 +346,13 @@ function redactNode(value, ancestors) {
302
346
  }
303
347
  }
304
348
  /**
305
- * @description Returns a copy of a value with credentials masked: values under keys such as `access_token`, `password`, `authorization` or `set-cookie`, the value of a `{ name, value }` or `{ key, value }` entry whose name is such a key, bearer tokens, and `name=value` credentials inside text and inside object keys. Purely numeric values after a credential-looking name (e.g. `max_tokens: 4096`) are left alone, and so is an entry whose name ends in `tokens`, `policy`, `type`, `algorithm`, `count` or `limit` (a setting, not a credential).
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.
306
350
  * @param value {JsonValue | object} - Content to redact; objects and arrays are walked recursively.
307
351
  * @returns {JsonValue | object} A redacted copy, serializing exactly as the input when nothing in it is sensitive.
308
352
  * @throws {Error} When the value cannot be walked; tracing callers use `spanContentAttribute`, which turns that into an omitted value.
309
353
  */
310
354
  export function redactSpanContent(value) {
311
- return redactNode(value, new WeakSet());
355
+ return redactNode(value, new WeakSet(), masksCredentialEntries());
312
356
  }
313
357
  function redactSafely(value) {
314
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.4",
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",