@juspay/neurolink 13.8.15 → 13.8.17
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 +2 -2
- package/dist/browser/neurolink.min.js +412 -412
- package/dist/core/conversationMemoryFactory.d.ts +2 -2
- package/dist/core/conversationMemoryFactory.js +2 -2
- package/dist/core/conversationMemoryInitializer.d.ts +2 -2
- package/dist/core/conversationMemoryInitializer.js +2 -2
- package/dist/core/redisConversationMemoryManager.d.ts +13 -2
- package/dist/core/redisConversationMemoryManager.js +31 -1
- package/dist/neurolink.js +1 -1
- package/dist/server/adapters/expressAdapter.js +5 -2
- package/dist/server/utils/expressBody.d.ts +33 -0
- package/dist/server/utils/expressBody.js +353 -0
- package/dist/types/common.d.ts +2 -1
- package/dist/types/conversation.d.ts +12 -0
- package/dist/types/server.d.ts +49 -0
- package/docs-site/static/search-index.json +8 -5
- package/package.json +1 -1
|
@@ -2,13 +2,13 @@
|
|
|
2
2
|
* Conversation Memory Factory for NeuroLink
|
|
3
3
|
* Creates appropriate conversation memory manager based on configuration
|
|
4
4
|
*/
|
|
5
|
-
import type { StorageType, ConversationMemoryConfig, RedisStorageConfig } from "../types/index.js";
|
|
5
|
+
import type { StorageType, ConversationMemoryConfig, NeuroLinkEvents, RedisStorageConfig, TypedEventEmitter } from "../types/index.js";
|
|
6
6
|
import { ConversationMemoryManager } from "./conversationMemoryManager.js";
|
|
7
7
|
import { RedisConversationMemoryManager } from "./redisConversationMemoryManager.js";
|
|
8
8
|
/**
|
|
9
9
|
* Creates a conversation memory manager based on configuration
|
|
10
10
|
*/
|
|
11
|
-
export declare function createConversationMemoryManager(config: ConversationMemoryConfig, storageType?: StorageType, redisConfig?: RedisStorageConfig): ConversationMemoryManager | RedisConversationMemoryManager;
|
|
11
|
+
export declare function createConversationMemoryManager(config: ConversationMemoryConfig, storageType?: StorageType, redisConfig?: RedisStorageConfig, eventEmitter?: TypedEventEmitter<NeuroLinkEvents>): ConversationMemoryManager | RedisConversationMemoryManager;
|
|
12
12
|
/**
|
|
13
13
|
* Get storage type from environment variable or configuration
|
|
14
14
|
*/
|
|
@@ -8,7 +8,7 @@ import { RedisConversationMemoryManager } from "./redisConversationMemoryManager
|
|
|
8
8
|
/**
|
|
9
9
|
* Creates a conversation memory manager based on configuration
|
|
10
10
|
*/
|
|
11
|
-
export function createConversationMemoryManager(config, storageType = "memory", redisConfig) {
|
|
11
|
+
export function createConversationMemoryManager(config, storageType = "memory", redisConfig, eventEmitter) {
|
|
12
12
|
logger.debug("[conversationMemoryFactory] Creating conversation memory manager", {
|
|
13
13
|
storageType,
|
|
14
14
|
config: {
|
|
@@ -38,7 +38,7 @@ export function createConversationMemoryManager(config, storageType = "memory",
|
|
|
38
38
|
ttl: redisConfig?.ttl || 86400,
|
|
39
39
|
hasConnectionOptions: !!redisConfig?.connectionOptions,
|
|
40
40
|
});
|
|
41
|
-
const redisManager = new RedisConversationMemoryManager(config, redisConfig);
|
|
41
|
+
const redisManager = new RedisConversationMemoryManager(config, redisConfig, eventEmitter);
|
|
42
42
|
logger.debug("[conversationMemoryFactory] Redis conversation manager created successfully", {
|
|
43
43
|
managerType: redisManager.constructor.name,
|
|
44
44
|
config: {
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Conversation Memory Initializer
|
|
3
3
|
* Provides integration with Redis storage for conversation memory
|
|
4
4
|
*/
|
|
5
|
-
import type { ConversationMemoryConfig } from "../types/index.js";
|
|
5
|
+
import type { ConversationMemoryConfig, NeuroLinkEvents, TypedEventEmitter } from "../types/index.js";
|
|
6
6
|
import type { ConversationMemoryManager } from "./conversationMemoryManager.js";
|
|
7
7
|
import type { RedisConversationMemoryManager } from "./redisConversationMemoryManager.js";
|
|
8
8
|
/**
|
|
@@ -11,4 +11,4 @@ import type { RedisConversationMemoryManager } from "./redisConversationMemoryMa
|
|
|
11
11
|
*/
|
|
12
12
|
export declare function initializeConversationMemory(config?: {
|
|
13
13
|
conversationMemory?: Partial<ConversationMemoryConfig>;
|
|
14
|
-
}): Promise<ConversationMemoryManager | RedisConversationMemoryManager | null>;
|
|
14
|
+
}, eventEmitter?: TypedEventEmitter<NeuroLinkEvents>): Promise<ConversationMemoryManager | RedisConversationMemoryManager | null>;
|
|
@@ -9,7 +9,7 @@ import { createConversationMemoryManager, getRedisConfigFromEnv, getStorageType,
|
|
|
9
9
|
* Initialize conversation memory for NeuroLink
|
|
10
10
|
* This function decides whether to use in-memory or Redis storage
|
|
11
11
|
*/
|
|
12
|
-
export async function initializeConversationMemory(config) {
|
|
12
|
+
export async function initializeConversationMemory(config, eventEmitter) {
|
|
13
13
|
logger.debug("[conversationMemoryInitializer] Initialize conversation memory called", {
|
|
14
14
|
hasConfig: !!config,
|
|
15
15
|
hasMemoryConfig: !!config?.conversationMemory,
|
|
@@ -58,7 +58,7 @@ export async function initializeConversationMemory(config) {
|
|
|
58
58
|
});
|
|
59
59
|
// Create Redis-based conversation memory manager
|
|
60
60
|
logger.debug("[conversationMemoryInitializer] Creating Redis conversation memory manager");
|
|
61
|
-
const redisMemoryManager = createConversationMemoryManager(memoryConfig, "redis", redisConfig);
|
|
61
|
+
const redisMemoryManager = createConversationMemoryManager(memoryConfig, "redis", redisConfig, eventEmitter);
|
|
62
62
|
logger.debug("[conversationMemoryInitializer] Checking Redis manager creation result", {
|
|
63
63
|
managerType: redisMemoryManager?.constructor?.name || "unknown",
|
|
64
64
|
isRedisType: redisMemoryManager?.constructor?.name ===
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Redis Conversation Memory Manager for NeuroLink
|
|
3
3
|
* Redis-based implementation of conversation storage with same interface as ConversationMemoryManager
|
|
4
4
|
*/
|
|
5
|
-
import type { ChatMessage, ConversationMemoryConfig, ConversationMemoryStats, RedisConversationObject, RedisStorageConfig, SessionListItem, SessionMemory, SessionMetadata, StoreConversationTurnOptions, AgenticLoopReportMetadata, IConversationMemoryManager } from "../types/index.js";
|
|
5
|
+
import type { ChatMessage, ConversationMemoryConfig, ConversationMemoryStats, RedisConversationObject, RedisStorageConfig, SessionListItem, SessionMemory, SessionMetadata, StoreConversationTurnOptions, AgenticLoopReportMetadata, IConversationMemoryManager, NeuroLinkEvents, TypedEventEmitter } from "../types/index.js";
|
|
6
6
|
/**
|
|
7
7
|
* Redis-based implementation of the ConversationMemoryManager
|
|
8
8
|
* Uses the same interface but stores data in Redis
|
|
@@ -13,6 +13,11 @@ export declare class RedisConversationMemoryManager implements IConversationMemo
|
|
|
13
13
|
private summarizationEngine;
|
|
14
14
|
private redisConfig;
|
|
15
15
|
private redisClient;
|
|
16
|
+
/**
|
|
17
|
+
* Receives `conversation:titleGenerated` once a new conversation's title is
|
|
18
|
+
* stored. Optional because the manager can be constructed standalone.
|
|
19
|
+
*/
|
|
20
|
+
private readonly eventEmitter?;
|
|
16
21
|
/**
|
|
17
22
|
* Temporary storage for tool execution data to prevent race conditions
|
|
18
23
|
* Key format: "${sessionId}:${userId}"
|
|
@@ -28,7 +33,7 @@ export declare class RedisConversationMemoryManager implements IConversationMemo
|
|
|
28
33
|
* Key format: "${sessionId}:${userId}"
|
|
29
34
|
*/
|
|
30
35
|
private summarizationInProgress;
|
|
31
|
-
constructor(config: ConversationMemoryConfig, redisConfig?: RedisStorageConfig);
|
|
36
|
+
constructor(config: ConversationMemoryConfig, redisConfig?: RedisStorageConfig, eventEmitter?: TypedEventEmitter<NeuroLinkEvents>);
|
|
32
37
|
/**
|
|
33
38
|
* Initialize the memory manager with Redis connection
|
|
34
39
|
*/
|
|
@@ -155,6 +160,12 @@ export declare class RedisConversationMemoryManager implements IConversationMemo
|
|
|
155
160
|
* @returns Complete conversation object with all data, or null if session doesn't exist
|
|
156
161
|
*/
|
|
157
162
|
getUserSessionObject(userId: string, sessionId: string): Promise<RedisConversationObject | null>;
|
|
163
|
+
/**
|
|
164
|
+
* Announce a stored title. A throwing listener is logged rather than
|
|
165
|
+
* propagated: the title is already persisted, and the caller's catch block
|
|
166
|
+
* reports title-generation failures.
|
|
167
|
+
*/
|
|
168
|
+
private emitTitleGenerated;
|
|
158
169
|
/**
|
|
159
170
|
* Generate a conversation title from the first user message
|
|
160
171
|
* Uses AI to create a concise, descriptive title (5-8 words)
|
|
@@ -29,6 +29,11 @@ export class RedisConversationMemoryManager {
|
|
|
29
29
|
summarizationEngine = new SummarizationEngine();
|
|
30
30
|
redisConfig;
|
|
31
31
|
redisClient = null;
|
|
32
|
+
/**
|
|
33
|
+
* Receives `conversation:titleGenerated` once a new conversation's title is
|
|
34
|
+
* stored. Optional because the manager can be constructed standalone.
|
|
35
|
+
*/
|
|
36
|
+
eventEmitter;
|
|
32
37
|
/**
|
|
33
38
|
* Temporary storage for tool execution data to prevent race conditions
|
|
34
39
|
* Key format: "${sessionId}:${userId}"
|
|
@@ -44,9 +49,10 @@ export class RedisConversationMemoryManager {
|
|
|
44
49
|
* Key format: "${sessionId}:${userId}"
|
|
45
50
|
*/
|
|
46
51
|
summarizationInProgress = new Set();
|
|
47
|
-
constructor(config, redisConfig = {}) {
|
|
52
|
+
constructor(config, redisConfig = {}, eventEmitter) {
|
|
48
53
|
this.config = config;
|
|
49
54
|
this.redisConfig = getNormalizedConfig(redisConfig);
|
|
55
|
+
this.eventEmitter = eventEmitter;
|
|
50
56
|
}
|
|
51
57
|
/**
|
|
52
58
|
* Initialize the memory manager with Redis connection
|
|
@@ -419,6 +425,12 @@ export class RedisConversationMemoryManager {
|
|
|
419
425
|
const serializedData = serializeConversation(updatedConversation);
|
|
420
426
|
if (this.redisClient) {
|
|
421
427
|
await setWithTtl(this.redisClient, updatedRedisKey, serializedData, this.redisConfig.ttl);
|
|
428
|
+
this.emitTitleGenerated({
|
|
429
|
+
sessionId: options.sessionId,
|
|
430
|
+
userId: normalizedUserId,
|
|
431
|
+
title,
|
|
432
|
+
timestamp: Date.now(),
|
|
433
|
+
});
|
|
422
434
|
}
|
|
423
435
|
}
|
|
424
436
|
}
|
|
@@ -1023,6 +1035,24 @@ export class RedisConversationMemoryManager {
|
|
|
1023
1035
|
return null;
|
|
1024
1036
|
}
|
|
1025
1037
|
}
|
|
1038
|
+
/**
|
|
1039
|
+
* Announce a stored title. A throwing listener is logged rather than
|
|
1040
|
+
* propagated: the title is already persisted, and the caller's catch block
|
|
1041
|
+
* reports title-generation failures.
|
|
1042
|
+
*/
|
|
1043
|
+
emitTitleGenerated(event) {
|
|
1044
|
+
try {
|
|
1045
|
+
this.eventEmitter?.emit("conversation:titleGenerated", event);
|
|
1046
|
+
}
|
|
1047
|
+
catch (listenerError) {
|
|
1048
|
+
logger.warn("[RedisConversationMemoryManager] conversation:titleGenerated listener threw", {
|
|
1049
|
+
sessionId: event.sessionId,
|
|
1050
|
+
error: listenerError instanceof Error
|
|
1051
|
+
? listenerError.message
|
|
1052
|
+
: String(listenerError),
|
|
1053
|
+
});
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1026
1056
|
/**
|
|
1027
1057
|
* Generate a conversation title from the first user message
|
|
1028
1058
|
* Uses AI to create a concise, descriptive title (5-8 words)
|
package/dist/neurolink.js
CHANGED
|
@@ -13851,7 +13851,7 @@ Current user's request: ${currentInput}`;
|
|
|
13851
13851
|
// Import the integration module
|
|
13852
13852
|
const { initializeConversationMemory } = await import("./core/conversationMemoryInitializer.js");
|
|
13853
13853
|
// Use the integration module to create the appropriate memory manager
|
|
13854
|
-
const memoryManager = await initializeConversationMemory(this.conversationMemoryConfig);
|
|
13854
|
+
const memoryManager = await initializeConversationMemory(this.conversationMemoryConfig, this.emitter);
|
|
13855
13855
|
// Assign to conversationMemory with proper type to handle both memory manager types
|
|
13856
13856
|
this.conversationMemory = memoryManager;
|
|
13857
13857
|
// Reset the lazy init flag since we've now initialized
|
|
@@ -6,6 +6,7 @@ import { logger } from "../../utils/logger.js";
|
|
|
6
6
|
import { AlreadyRunningError, ServerAdapterError, ServerStartError, ServerStopError, wrapError, } from "../errors.js";
|
|
7
7
|
import { BaseServerAdapter } from "../abstract/baseServerAdapter.js";
|
|
8
8
|
import { isErrorResponse } from "../utils/validation.js";
|
|
9
|
+
import { createBoundedBodyParser } from "../utils/expressBody.js";
|
|
9
10
|
import { isRawBodyContentType, isWebResponse, readWebResponse, } from "../utils/rawBody.js";
|
|
10
11
|
/**
|
|
11
12
|
* Express-specific server adapter
|
|
@@ -41,8 +42,10 @@ export class ExpressServerAdapter extends BaseServerAdapter {
|
|
|
41
42
|
const bodyLimit = this.config.bodyParser.maxSize ||
|
|
42
43
|
this.config.bodyParser.jsonLimit ||
|
|
43
44
|
"10mb";
|
|
44
|
-
|
|
45
|
-
|
|
45
|
+
const jsonLimit = this.config.bodyParser.jsonLimit || bodyLimit;
|
|
46
|
+
// body-parser drains a body it refuses, so each is read through a bound.
|
|
47
|
+
this.app.use(createBoundedBodyParser(express.json({ limit: jsonLimit }), jsonLimit));
|
|
48
|
+
this.app.use(createBoundedBodyParser(express.urlencoded({ extended: true, limit: bodyLimit }), bodyLimit));
|
|
46
49
|
// Uploads (multipart, octet-stream, audio/*) reach the route as bytes.
|
|
47
50
|
this.app.use(express.raw({
|
|
48
51
|
type: (req) => isRawBodyContentType(req.headers["content-type"]),
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bounded JSON and form body reads for the Express adapter.
|
|
3
|
+
*
|
|
4
|
+
* `express.json()` and `express.urlencoded()` (body-parser) refuse a body over
|
|
5
|
+
* their limit with a 413, but only after the whole request has been taken off
|
|
6
|
+
* the socket: a declared Content-Length over the limit is seen at once and the
|
|
7
|
+
* body is still drained, and a chunked body is read to its end. Nothing has
|
|
8
|
+
* authenticated the request at that point, so a client could make the server
|
|
9
|
+
* receive any amount of data for the price of one 413.
|
|
10
|
+
*
|
|
11
|
+
* `createBoundedBodyParser` wraps a body-parser middleware so the bytes are
|
|
12
|
+
* read here, up to the limit, and handed to that same middleware to parse. Which
|
|
13
|
+
* requests are parsed, charset handling, strict JSON, the qs options and the
|
|
14
|
+
* error each failure carries all stay body-parser's, because body-parser still
|
|
15
|
+
* makes those decisions: it is asked first, from the headers alone, whether it
|
|
16
|
+
* would read the request, and then parses the bytes it is given. What changes is
|
|
17
|
+
* who reads the socket and what happens at the limit. A body over the limit is
|
|
18
|
+
* refused without reading it (declared Content-Length) or at the limit (chunked,
|
|
19
|
+
* or a compressed body that inflates past it), and the connection is closed so
|
|
20
|
+
* the client stops sending.
|
|
21
|
+
*
|
|
22
|
+
* `express.raw()` (uploads) is not wrapped.
|
|
23
|
+
*
|
|
24
|
+
* @module server/utils/expressBody
|
|
25
|
+
*/
|
|
26
|
+
import type { ServerNodeMiddleware } from "../../types/index.js";
|
|
27
|
+
/**
|
|
28
|
+
* Wrap a body-parser middleware (`express.json()`, `express.urlencoded()`) so a
|
|
29
|
+
* body is read only as far as `limit`. `limit` is the one the middleware was
|
|
30
|
+
* built with. A limit that cannot be read as a byte count here is left to the
|
|
31
|
+
* middleware alone.
|
|
32
|
+
*/
|
|
33
|
+
export declare function createBoundedBodyParser(parser: ServerNodeMiddleware, limit: string | number | undefined): ServerNodeMiddleware;
|
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bounded JSON and form body reads for the Express adapter.
|
|
3
|
+
*
|
|
4
|
+
* `express.json()` and `express.urlencoded()` (body-parser) refuse a body over
|
|
5
|
+
* their limit with a 413, but only after the whole request has been taken off
|
|
6
|
+
* the socket: a declared Content-Length over the limit is seen at once and the
|
|
7
|
+
* body is still drained, and a chunked body is read to its end. Nothing has
|
|
8
|
+
* authenticated the request at that point, so a client could make the server
|
|
9
|
+
* receive any amount of data for the price of one 413.
|
|
10
|
+
*
|
|
11
|
+
* `createBoundedBodyParser` wraps a body-parser middleware so the bytes are
|
|
12
|
+
* read here, up to the limit, and handed to that same middleware to parse. Which
|
|
13
|
+
* requests are parsed, charset handling, strict JSON, the qs options and the
|
|
14
|
+
* error each failure carries all stay body-parser's, because body-parser still
|
|
15
|
+
* makes those decisions: it is asked first, from the headers alone, whether it
|
|
16
|
+
* would read the request, and then parses the bytes it is given. What changes is
|
|
17
|
+
* who reads the socket and what happens at the limit. A body over the limit is
|
|
18
|
+
* refused without reading it (declared Content-Length) or at the limit (chunked,
|
|
19
|
+
* or a compressed body that inflates past it), and the connection is closed so
|
|
20
|
+
* the client stops sending.
|
|
21
|
+
*
|
|
22
|
+
* `express.raw()` (uploads) is not wrapped.
|
|
23
|
+
*
|
|
24
|
+
* @module server/utils/expressBody
|
|
25
|
+
*/
|
|
26
|
+
import { Readable } from "node:stream";
|
|
27
|
+
import * as zlib from "node:zlib";
|
|
28
|
+
import { tryParseByteLimit } from "./rawBody.js";
|
|
29
|
+
const noop = () => undefined;
|
|
30
|
+
/** The error body-parser raises for an over-limit body, which the adapter's error handler answers 413. */
|
|
31
|
+
function bodyParserError(status, message, type, details = {}) {
|
|
32
|
+
return Object.assign(new Error(message), {
|
|
33
|
+
status,
|
|
34
|
+
statusCode: status,
|
|
35
|
+
expose: status < 500,
|
|
36
|
+
type,
|
|
37
|
+
...details,
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
const entityTooLarge = (limit, received) => bodyParserError(413, "request entity too large", "entity.too.large", {
|
|
41
|
+
limit,
|
|
42
|
+
...(received === undefined ? {} : { received }),
|
|
43
|
+
});
|
|
44
|
+
/** A Content-Encoding body-parser inflates, and nothing else. */
|
|
45
|
+
function decompressorFor(encoding) {
|
|
46
|
+
switch (encoding) {
|
|
47
|
+
case "gzip":
|
|
48
|
+
return zlib.createGunzip;
|
|
49
|
+
case "deflate":
|
|
50
|
+
return zlib.createInflate;
|
|
51
|
+
case "br":
|
|
52
|
+
return zlib.createBrotliDecompress;
|
|
53
|
+
case "zstd":
|
|
54
|
+
// Node 22.15 and later.
|
|
55
|
+
return typeof zlib.createZstdDecompress === "function"
|
|
56
|
+
? zlib.createZstdDecompress
|
|
57
|
+
: undefined;
|
|
58
|
+
default:
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
/** body-parser's own "already handled" test: the request has been read off, or cannot be. */
|
|
63
|
+
const isRequestFinished = (req) => ("upgrade" in req && req.upgrade === true) ||
|
|
64
|
+
!req.socket?.readable ||
|
|
65
|
+
(req.complete && !req.readable);
|
|
66
|
+
/** A request that is already in memory, to hand a body-parser middleware in place of the socket. */
|
|
67
|
+
function bufferedRequest(headers, bytes, body) {
|
|
68
|
+
let pushed = false;
|
|
69
|
+
const stream = new Readable({
|
|
70
|
+
read() {
|
|
71
|
+
if (!pushed) {
|
|
72
|
+
pushed = true;
|
|
73
|
+
if (bytes.length > 0) {
|
|
74
|
+
this.push(bytes);
|
|
75
|
+
}
|
|
76
|
+
this.push(null);
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
});
|
|
80
|
+
return Object.assign(stream, { headers, body });
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Ask a body-parser middleware what it would do with this request, without
|
|
84
|
+
* giving it a body. It is run on an empty one: what it decides from the headers
|
|
85
|
+
* (not a body, not its type, a charset it refuses) comes back as it would
|
|
86
|
+
* have, and so does what it only finds out when it starts to read (a charset
|
|
87
|
+
* the decoder lacks). The Content-Encoding is left off because inflating is
|
|
88
|
+
* done here, and the Content-Length is zeroed so the declared size, which is
|
|
89
|
+
* checked here, does not stand in the way.
|
|
90
|
+
*/
|
|
91
|
+
function askParser(parser, req, res) {
|
|
92
|
+
const headers = { ...req.headers };
|
|
93
|
+
delete headers["content-encoding"];
|
|
94
|
+
delete headers["transfer-encoding"];
|
|
95
|
+
if (req.headers["transfer-encoding"] !== undefined ||
|
|
96
|
+
req.headers["content-length"] !== undefined) {
|
|
97
|
+
headers["content-length"] = "0";
|
|
98
|
+
}
|
|
99
|
+
const before = "body" in req ? req.body : undefined;
|
|
100
|
+
const probe = bufferedRequest(headers, Buffer.alloc(0), before);
|
|
101
|
+
return new Promise((resolve) => {
|
|
102
|
+
let synchronous = true;
|
|
103
|
+
parser(probe, res, (error) => {
|
|
104
|
+
if (error !== undefined) {
|
|
105
|
+
resolve({ kind: "reject", error, beforeRead: synchronous });
|
|
106
|
+
}
|
|
107
|
+
else if (probe.body !== before) {
|
|
108
|
+
resolve({ kind: "parse" });
|
|
109
|
+
}
|
|
110
|
+
else {
|
|
111
|
+
resolve({ kind: "skip", body: probe.body });
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
synchronous = false;
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* How far past the limit the raw bytes of a compressed body may run. The limit
|
|
119
|
+
* is on what a body inflates to, but the bytes on the wire are capped too:
|
|
120
|
+
* empty gzip members inflate to nothing, and so do the zeros Node lets follow a
|
|
121
|
+
* complete gzip stream, so without a cap a stream of either is read until the
|
|
122
|
+
* client stops. The slack covers the framing a real encoder adds (deflate's
|
|
123
|
+
* stored blocks carry 5 bytes per 64 KiB).
|
|
124
|
+
*/
|
|
125
|
+
const COMPRESSED_WIRE_SLACK = 64 * 1024;
|
|
126
|
+
/**
|
|
127
|
+
* Read a request body, refusing more than `maxBytes` (of what is inflated, when
|
|
128
|
+
* the request is compressed, with the raw bytes capped at `maxBytes` plus
|
|
129
|
+
* {@link COMPRESSED_WIRE_SLACK}). Reading stops at the limit and the request is
|
|
130
|
+
* left paused, not drained. Events rather than `for await`: leaving an async
|
|
131
|
+
* iterator early destroys the request, and its socket with it, before an answer
|
|
132
|
+
* can be sent.
|
|
133
|
+
*/
|
|
134
|
+
function readBounded(req, maxBytes, decompressor) {
|
|
135
|
+
return new Promise((resolve, reject) => {
|
|
136
|
+
const source = decompressor ?? req;
|
|
137
|
+
const chunks = [];
|
|
138
|
+
const wireCap = maxBytes + COMPRESSED_WIRE_SLACK;
|
|
139
|
+
let received = 0;
|
|
140
|
+
let wire = 0;
|
|
141
|
+
// A compressed body is complete; the rest of the request is being read off.
|
|
142
|
+
let draining = false;
|
|
143
|
+
let settled = false;
|
|
144
|
+
const release = () => {
|
|
145
|
+
source.off("data", onData);
|
|
146
|
+
source.off("end", onEnd);
|
|
147
|
+
source.off("error", onFail);
|
|
148
|
+
req.off("data", onWire);
|
|
149
|
+
req.off("end", onRequestEnd);
|
|
150
|
+
req.off("error", onFail);
|
|
151
|
+
req.off("close", onClose);
|
|
152
|
+
};
|
|
153
|
+
const detachDecompressor = () => {
|
|
154
|
+
if (decompressor) {
|
|
155
|
+
req.unpipe(decompressor);
|
|
156
|
+
decompressor.on("error", noop);
|
|
157
|
+
decompressor.destroy();
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
const stop = () => {
|
|
161
|
+
detachDecompressor();
|
|
162
|
+
req.pause();
|
|
163
|
+
};
|
|
164
|
+
const settle = (outcome) => {
|
|
165
|
+
if (!settled) {
|
|
166
|
+
settled = true;
|
|
167
|
+
release();
|
|
168
|
+
outcome();
|
|
169
|
+
}
|
|
170
|
+
};
|
|
171
|
+
const finish = (closeConnection) => {
|
|
172
|
+
settle(() => resolve({ bytes: Buffer.concat(chunks), closeConnection }));
|
|
173
|
+
};
|
|
174
|
+
const aborted = () => bodyParserError(400, "request aborted", "request.aborted", {
|
|
175
|
+
code: "ECONNABORTED",
|
|
176
|
+
received,
|
|
177
|
+
});
|
|
178
|
+
function onData(chunk) {
|
|
179
|
+
const buffer = typeof chunk === "string" ? Buffer.from(chunk) : chunk;
|
|
180
|
+
received += buffer.length;
|
|
181
|
+
if (received > maxBytes) {
|
|
182
|
+
settle(() => {
|
|
183
|
+
stop();
|
|
184
|
+
reject(entityTooLarge(maxBytes, received));
|
|
185
|
+
});
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
chunks.push(buffer);
|
|
189
|
+
}
|
|
190
|
+
function onWire(chunk) {
|
|
191
|
+
wire += chunk.length;
|
|
192
|
+
if (wire <= wireCap) {
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
if (draining) {
|
|
196
|
+
// The body is whole; what keeps arriving is not part of it.
|
|
197
|
+
req.pause();
|
|
198
|
+
finish(true);
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
settle(() => {
|
|
202
|
+
stop();
|
|
203
|
+
reject(entityTooLarge(maxBytes, wire));
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
function onEnd() {
|
|
207
|
+
if (!decompressor) {
|
|
208
|
+
finish(false);
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
// The rest of the request, if any, is not part of the body: read it off
|
|
212
|
+
// up to the cap, so the connection can be kept.
|
|
213
|
+
detachDecompressor();
|
|
214
|
+
if (req.complete) {
|
|
215
|
+
req.resume();
|
|
216
|
+
finish(false);
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
draining = true;
|
|
220
|
+
source.off("data", onData);
|
|
221
|
+
source.off("end", onEnd);
|
|
222
|
+
source.off("error", onFail);
|
|
223
|
+
req.on("end", onRequestEnd);
|
|
224
|
+
req.resume();
|
|
225
|
+
}
|
|
226
|
+
function onRequestEnd() {
|
|
227
|
+
finish(false);
|
|
228
|
+
}
|
|
229
|
+
function onFail(error) {
|
|
230
|
+
if (draining) {
|
|
231
|
+
finish(true);
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
settle(() => {
|
|
235
|
+
stop();
|
|
236
|
+
reject(error);
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
function onClose() {
|
|
240
|
+
// A request that arrived whole closes once it has been read, which for a
|
|
241
|
+
// compressed one can be before the inflating has finished.
|
|
242
|
+
if (req.complete) {
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
if (draining) {
|
|
246
|
+
finish(true);
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
settle(() => {
|
|
250
|
+
stop();
|
|
251
|
+
reject(aborted());
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
if (req.destroyed && !req.complete) {
|
|
255
|
+
settle(() => reject(aborted()));
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
source.on("data", onData);
|
|
259
|
+
source.on("end", onEnd);
|
|
260
|
+
source.on("error", onFail);
|
|
261
|
+
req.on("close", onClose);
|
|
262
|
+
if (decompressor) {
|
|
263
|
+
req.on("error", onFail);
|
|
264
|
+
req.on("data", onWire);
|
|
265
|
+
req.pipe(decompressor);
|
|
266
|
+
}
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
/** Answer the request with `error` and close the connection, so a client still sending stops. */
|
|
270
|
+
function refuse(res, next, error) {
|
|
271
|
+
if (!res.headersSent) {
|
|
272
|
+
res.setHeader("Connection", "close");
|
|
273
|
+
}
|
|
274
|
+
next(error);
|
|
275
|
+
}
|
|
276
|
+
async function parseBounded(parser, maxBytes, req, res, next) {
|
|
277
|
+
try {
|
|
278
|
+
if (isRequestFinished(req)) {
|
|
279
|
+
next();
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
const verdict = await askParser(parser, req, res);
|
|
283
|
+
if (verdict.kind === "skip") {
|
|
284
|
+
req.body = verdict.body;
|
|
285
|
+
next();
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
if (verdict.kind === "reject" && verdict.beforeRead) {
|
|
289
|
+
refuse(res, next, verdict.error);
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
const encoding = (req.headers["content-encoding"] || "identity").toLowerCase();
|
|
293
|
+
const inflate = encoding === "identity" ? undefined : decompressorFor(encoding);
|
|
294
|
+
if (encoding !== "identity" && !inflate) {
|
|
295
|
+
refuse(res, next, bodyParserError(415, `unsupported content encoding "${encoding}"`, "encoding.unsupported", { encoding }));
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
// Compressed, the declared length is the compressed size, which says
|
|
299
|
+
// nothing about the body.
|
|
300
|
+
const declared = Number.parseInt(String(req.headers["content-length"]), 10);
|
|
301
|
+
if (!inflate && declared > maxBytes) {
|
|
302
|
+
refuse(res, next, entityTooLarge(maxBytes));
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
if (verdict.kind === "reject") {
|
|
306
|
+
refuse(res, next, verdict.error);
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
const before = "body" in req ? req.body : undefined;
|
|
310
|
+
let read;
|
|
311
|
+
try {
|
|
312
|
+
read = await readBounded(req, maxBytes, inflate?.());
|
|
313
|
+
}
|
|
314
|
+
catch (error) {
|
|
315
|
+
refuse(res, next, error);
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
if (read.closeConnection && !res.headersSent) {
|
|
319
|
+
res.setHeader("Connection", "close");
|
|
320
|
+
}
|
|
321
|
+
const { bytes } = read;
|
|
322
|
+
const headers = { ...req.headers, "content-length": String(bytes.length) };
|
|
323
|
+
delete headers["content-encoding"];
|
|
324
|
+
delete headers["transfer-encoding"];
|
|
325
|
+
const replay = bufferedRequest(headers, bytes, before);
|
|
326
|
+
parser(replay, res, (error) => {
|
|
327
|
+
if (error !== undefined) {
|
|
328
|
+
next(error);
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
req.body = replay.body;
|
|
332
|
+
next();
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
catch (error) {
|
|
336
|
+
next(error);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
/**
|
|
340
|
+
* Wrap a body-parser middleware (`express.json()`, `express.urlencoded()`) so a
|
|
341
|
+
* body is read only as far as `limit`. `limit` is the one the middleware was
|
|
342
|
+
* built with. A limit that cannot be read as a byte count here is left to the
|
|
343
|
+
* middleware alone.
|
|
344
|
+
*/
|
|
345
|
+
export function createBoundedBodyParser(parser, limit) {
|
|
346
|
+
const maxBytes = tryParseByteLimit(limit);
|
|
347
|
+
if (maxBytes === undefined) {
|
|
348
|
+
return parser;
|
|
349
|
+
}
|
|
350
|
+
return (req, res, next) => {
|
|
351
|
+
void parseBounded(parser, maxBytes, req, res, next);
|
|
352
|
+
};
|
|
353
|
+
}
|
package/dist/types/common.d.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Common utility types for NeuroLink
|
|
3
3
|
*/
|
|
4
4
|
import type { NeuroLink } from "../neurolink.js";
|
|
5
|
-
import type { ConversationMemoryConfig } from "./conversation.js";
|
|
5
|
+
import type { ConversationMemoryConfig, ConversationTitleGeneratedEvent } from "./conversation.js";
|
|
6
6
|
import type { AutoresearchErrorEvent, AutoresearchExperimentCompletedEvent, AutoresearchExperimentStartedEvent, AutoresearchInitializedEvent, AutoresearchMetricImprovedEvent, AutoresearchPhaseChangedEvent, AutoresearchResumedEvent, AutoresearchRevertEvent, AutoresearchRevertFailedEvent, AutoresearchStateUpdatedEvent } from "./autoresearch.js";
|
|
7
7
|
/**
|
|
8
8
|
* Type-safe unknown value - use when type is truly unknown
|
|
@@ -120,6 +120,7 @@ export type NeuroLinkEvents = {
|
|
|
120
120
|
"externalMCP:serverRemoved": unknown;
|
|
121
121
|
"tools-register:start": unknown;
|
|
122
122
|
"tools-register:end": unknown;
|
|
123
|
+
"conversation:titleGenerated": ConversationTitleGeneratedEvent;
|
|
123
124
|
connected: unknown;
|
|
124
125
|
message: unknown;
|
|
125
126
|
error: unknown;
|
|
@@ -752,6 +752,18 @@ export type RedisConversationObject = ConversationBase & {
|
|
|
752
752
|
/** Array of conversation messages */
|
|
753
753
|
messages: ChatMessage[];
|
|
754
754
|
};
|
|
755
|
+
/**
|
|
756
|
+
* Payload of the `conversation:titleGenerated` event, emitted once the title
|
|
757
|
+
* of a new Redis-backed conversation has been stored.
|
|
758
|
+
*/
|
|
759
|
+
export type ConversationTitleGeneratedEvent = {
|
|
760
|
+
sessionId: string;
|
|
761
|
+
userId: string;
|
|
762
|
+
/** The title that was stored, which is what `listSessions()` reports. */
|
|
763
|
+
title: string;
|
|
764
|
+
/** Unix epoch milliseconds when the title was stored. */
|
|
765
|
+
timestamp: number;
|
|
766
|
+
};
|
|
755
767
|
/**
|
|
756
768
|
* Full conversation data for session restoration and manipulation
|
|
757
769
|
* Extends Redis storage object with additional loop mode metadata
|