@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.
- package/CHANGELOG.md +6 -2
- package/dist/auth/sessionManager.js +8 -3
- package/dist/browser/neurolink.min.js +391 -391
- package/dist/core/redisConversationMemoryManager.js +6 -16
- package/dist/telemetry/attributes.d.ts +1 -1
- package/dist/telemetry/attributes.js +84 -8
- 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`, 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
|
-
|
|
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
|
-
|
|
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[
|
|
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[
|
|
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 {
|
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",
|