@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.
@@ -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
- this.app.use(express.json({ limit: this.config.bodyParser.jsonLimit || bodyLimit }));
45
- this.app.use(express.urlencoded({ extended: true, limit: bodyLimit }));
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
+ }
@@ -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