@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.
- package/CHANGELOG.md +6 -2
- package/dist/auth/sessionManager.js +8 -3
- package/dist/browser/neurolink.min.js +374 -374
- package/dist/core/redisConversationMemoryManager.js +6 -16
- package/dist/telemetry/attributes.d.ts +1 -1
- package/dist/telemetry/attributes.js +62 -18
- package/dist/utils/redis.d.ts +17 -0
- package/dist/utils/redis.js +31 -0
- package/package.json +2 -1
|
@@ -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
|
|
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
|
-
|
|
424
|
-
|
|
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
|
|
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
|
|
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`,
|
|
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
|
-
|
|
233
|
-
|
|
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
|
|
243
|
-
//
|
|
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
|
-
|
|
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
|
-
//
|
|
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`,
|
|
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 {
|
package/dist/utils/redis.d.ts
CHANGED
|
@@ -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
|
package/dist/utils/redis.js
CHANGED
|
@@ -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.
|
|
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",
|