@juspay/neurolink 12.9.6 → 12.10.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/dist/index.d.ts CHANGED
@@ -38,6 +38,9 @@ export { PROVIDER_DESCRIPTORS, PROVIDER_DESCRIPTORS_BY_NAME, PROVIDER_ALIAS_INDE
38
38
  export { NeuroLinkConfigManager as ConfigManager } from "./config/configManager.js";
39
39
  export { BaseFactory, BaseRegistry, NeuroLinkFeatureError, createErrorFactory, withRetry, TypedEventEmitter, } from "./core/infrastructure/index.js";
40
40
  export { NeuroLinkClient, createClient, NeuroLinkApiError, } from "./client/httpClient.js";
41
+ export { LocalTempArtifactStore } from "./artifacts/artifactStore.js";
42
+ export { RedisArtifactStore } from "./artifacts/redisArtifactStore.js";
43
+ export { createArtifactStore, resolveArtifactStorageType, } from "./artifacts/artifactStoreFactory.js";
41
44
  export { NeuroLinkLanguageModel, NeuroLinkProvider as NeuroLinkAIProvider, createNeuroLinkProvider, createNeuroLinkModel, createStreamingResponse, neurolink as neuroLinkAIInstance, } from "./client/aiSdkAdapter.js";
42
45
  export { createApiKeyAuthInterceptor, createBearerAuthInterceptor, createDynamicAuthInterceptor, createLoggingInterceptor, createRetryInterceptor, createRateLimitInterceptor, createRequestTransformInterceptor, createResponseTransformInterceptor, createCacheInterceptor, createTimeoutInterceptor, createErrorHandlerInterceptor, composeMiddleware, conditionalMiddleware, } from "./client/interceptors.js";
43
46
  export { SSEClient, WebSocketStreamingClient, createStreamingClient, createAsyncStream, collectStream, } from "./client/streamingClient.js";
package/dist/index.js CHANGED
@@ -51,6 +51,13 @@ export { BaseFactory, BaseRegistry, NeuroLinkFeatureError, createErrorFactory, w
51
51
  export {
52
52
  // HTTP Client
53
53
  NeuroLinkClient, createClient, NeuroLinkApiError, } from "./client/httpClient.js";
54
+ // ============================================================================
55
+ // ARTIFACT STORAGE — backends for externalized tool outputs and banked payloads.
56
+ // Pick one with `artifacts.storage` / STORAGE_TYPE, or implement ArtifactStore.
57
+ // ============================================================================
58
+ export { LocalTempArtifactStore } from "./artifacts/artifactStore.js";
59
+ export { RedisArtifactStore } from "./artifacts/redisArtifactStore.js";
60
+ export { createArtifactStore, resolveArtifactStorageType, } from "./artifacts/artifactStoreFactory.js";
54
61
  export {
55
62
  // AI SDK Adapter
56
63
  NeuroLinkLanguageModel, NeuroLinkProvider as NeuroLinkAIProvider, createNeuroLinkProvider, createNeuroLinkModel, createStreamingResponse, neurolink as neuroLinkAIInstance, } from "./client/aiSdkAdapter.js";
@@ -6,12 +6,15 @@ import { SpanSerializer, SpanType, SpanStatus, getMetricsAggregator, } from "../
6
6
  import { withSpan } from "../telemetry/withSpan.js";
7
7
  import { tracers } from "../telemetry/tracers.js";
8
8
  import { tool } from "../utils/tool.js";
9
+ import { MAX_ARTIFACT_SEARCH_MATCHES, readArtifactWindow, searchArtifactContent, validateSearchPattern, } from "../artifacts/artifactReader.js";
9
10
  /** Maximum characters returned per retrieval request */
10
11
  const DEFAULT_RETRIEVAL_LIMIT = 50_000;
11
12
  /** Hard maximum for user/LLM-supplied limit to prevent massive tool outputs */
12
13
  const MAX_RETRIEVAL_LIMIT = 200_000;
13
14
  /** Maximum number of search matches returned */
14
15
  const MAX_SEARCH_MATCHES = 50;
16
+ /** Bound on one artifact backend round trip, so a stalled store never hangs the tool. */
17
+ const ARTIFACT_READ_TIMEOUT_MS = 10_000;
15
18
  /**
16
19
  * Factory function that creates memory retrieval tools bound to a memory manager.
17
20
  *
@@ -27,13 +30,16 @@ const MAX_SEARCH_MATCHES = 50;
27
30
  export function createMemoryRetrievalTools(memoryManager, artifactStore) {
28
31
  return {
29
32
  retrieve_context: tool({
30
- description: "Retrieve messages from conversation memory, or fetch the full payload of " +
31
- "an externalized MCP tool output by artifact ID. Use this to:\n" +
33
+ description: "Retrieve messages from conversation memory, or read an externalized " +
34
+ "tool output / banked payload by artifact ID. Use this to:\n" +
32
35
  "• Access full tool outputs when a result was truncated or externalized\n" +
33
36
  "• Review previous assistant responses\n" +
34
- "• Search through conversation history\n" +
35
- "Supports filtering by role, pagination for large content, and regex search.\n" +
36
- "To fetch an externalized artifact, provide `artifactId` (omit sessionId).",
37
+ "• Search a session's history, or an artifact, for literal text\n" +
38
+ "Supports filtering by role, offset/limit pagination for large content, " +
39
+ "and case-insensitive literal search (not regex).\n" +
40
+ "To read an artifact, provide `artifactId` (omit sessionId): pass " +
41
+ "`offset`/`limit` to page, or `search` to get match offsets and jump " +
42
+ "straight to them instead of paging.",
37
43
  inputSchema: z.object({
38
44
  sessionId: z
39
45
  .string()
@@ -44,8 +50,9 @@ export function createMemoryRetrievalTools(memoryManager, artifactStore) {
44
50
  .string()
45
51
  .optional()
46
52
  .describe("Artifact ID from an externalized MCP tool output " +
47
- "(visible in the tool output as neurolinkArtifactId=<id>). " +
48
- "When provided, returns the full stored payload directly."),
53
+ "(visible in the tool output as neurolinkArtifactId=<id>) or a " +
54
+ "banked payload. When provided, reads the stored payload: a " +
55
+ "window at `offset`/`limit`, or with `search`, the matches."),
49
56
  messageId: z
50
57
  .string()
51
58
  .optional()
@@ -75,8 +82,13 @@ export function createMemoryRetrievalTools(memoryManager, artifactStore) {
75
82
  search: z
76
83
  .string()
77
84
  .optional()
78
- .describe("Regex pattern to search within message content. " +
79
- "Returns matching lines with line numbers."),
85
+ .describe("Case-insensitive literal text to find (regex metacharacters are " +
86
+ "matched literally). Session history: returns matching lines " +
87
+ "with line numbers. Artifact: returns up to " +
88
+ `${MAX_ARTIFACT_SEARCH_MATCHES} matches, each with the character ` +
89
+ "`offset` of the hit and a short snippet — pass that offset back " +
90
+ "as `offset` to read around it. With `offset`, the artifact " +
91
+ "search starts there; use `nextSearchOffset` to continue."),
80
92
  }),
81
93
  execute: async (args) => withSpan({
82
94
  name: "neurolink.memory.retrieve_context",
@@ -108,35 +120,12 @@ async function executeRetrieveContext(args, memoryManager, artifactStore, otelSp
108
120
  message: "Artifact store not configured",
109
121
  });
110
122
  return {
111
- error: "Artifact store not configured — " +
112
- "mcp.outputLimits.strategy must be set to 'externalize' to use artifactId retrieval",
123
+ error: "Artifact store not configured — this instance has never banked " +
124
+ "or externalized anything, so there is no artifact to read",
113
125
  artifactId: args.artifactId,
114
126
  };
115
127
  }
116
- const content = await withTimeout(artifactStore.retrieve(args.artifactId), 10_000, new Error(`ArtifactStore.retrieve() timed out for artifact "${args.artifactId}"`));
117
- if (content === null) {
118
- otelSpan.setStatus({
119
- code: SpanStatusCode.ERROR,
120
- message: "Artifact not found or has expired",
121
- });
122
- return {
123
- error: "Artifact not found or has expired",
124
- artifactId: args.artifactId,
125
- };
126
- }
127
- const charLimit = Math.min(args.limit ?? DEFAULT_RETRIEVAL_LIMIT, MAX_RETRIEVAL_LIMIT);
128
- const start = args.offset ?? 0;
129
- const slice = content.slice(start, start + charLimit);
130
- otelSpan.setAttribute("memory.artifact_size", content.length);
131
- otelSpan.setAttribute("memory.returned_bytes", slice.length);
132
- return {
133
- artifactId: args.artifactId,
134
- content: slice,
135
- totalSize: content.length,
136
- hasMore: start + charLimit < content.length,
137
- offset: start,
138
- limit: charLimit,
139
- };
128
+ return executeArtifactRetrieval(args.artifactId, args, artifactStore, otelSpan);
140
129
  }
141
130
  // ── End artifact resolution ─────────────────────────────────────────
142
131
  if (!args.sessionId) {
@@ -272,3 +261,83 @@ async function executeRetrieveContext(args, memoryManager, artifactStore, otelSp
272
261
  return { error: "Failed to retrieve context" };
273
262
  }
274
263
  }
264
+ /**
265
+ * The artifact branch of `retrieve_context`.
266
+ *
267
+ * Two modes, chosen by `search`:
268
+ * - Paged read: one window through `readArtifactWindow`, which lets a backend
269
+ * with range reads move only the window. `hasMore` comes from the window's
270
+ * `totalLength`, so it never needs the payload either.
271
+ * - Search: the whole payload is read once and scanned for the literal
272
+ * pattern; the model gets match offsets and bounded snippets and can jump
273
+ * straight to the hit with `offset` instead of paging to it. Before this
274
+ * branch existed `search` was accepted and silently ignored here, and a
275
+ * model could not tell — an unfiltered window looks like "no matches".
276
+ *
277
+ * Backend failures (a Redis outage, a timeout) are reported as errors, never
278
+ * as "not found": those are different facts and the model acts differently on
279
+ * each.
280
+ */
281
+ async function executeArtifactRetrieval(artifactId, args, artifactStore, otelSpan) {
282
+ const notFound = () => {
283
+ otelSpan.setStatus({
284
+ code: SpanStatusCode.ERROR,
285
+ message: "Artifact not found or has expired",
286
+ });
287
+ return { error: "Artifact not found or has expired", artifactId };
288
+ };
289
+ try {
290
+ if (args.search !== undefined) {
291
+ const invalid = validateSearchPattern(args.search);
292
+ if (invalid) {
293
+ otelSpan.setStatus({ code: SpanStatusCode.ERROR, message: invalid });
294
+ return { error: invalid, artifactId };
295
+ }
296
+ // A search has to see the whole payload; the window contract is for reads.
297
+ const content = await withTimeout(artifactStore.retrieve(artifactId), ARTIFACT_READ_TIMEOUT_MS, new Error(`ArtifactStore.retrieve() timed out for artifact "${artifactId}"`));
298
+ if (content === null) {
299
+ return notFound();
300
+ }
301
+ const result = searchArtifactContent(content, args.search, {
302
+ from: args.offset,
303
+ });
304
+ otelSpan.setAttribute("memory.artifact_size", content.length);
305
+ otelSpan.setAttribute("memory.search_matches", result.totalMatches);
306
+ return {
307
+ artifactId,
308
+ search: args.search,
309
+ totalSize: content.length,
310
+ ...result,
311
+ };
312
+ }
313
+ const charLimit = Math.min(args.limit ?? DEFAULT_RETRIEVAL_LIMIT, MAX_RETRIEVAL_LIMIT);
314
+ const start = Math.max(0, args.offset ?? 0);
315
+ const window = await withTimeout(readArtifactWindow(artifactStore, artifactId, {
316
+ offset: start,
317
+ limit: charLimit,
318
+ }), ARTIFACT_READ_TIMEOUT_MS, new Error(`Artifact read timed out for artifact "${artifactId}"`));
319
+ if (window === null) {
320
+ return notFound();
321
+ }
322
+ otelSpan.setAttribute("memory.artifact_size", window.totalLength);
323
+ otelSpan.setAttribute("memory.returned_bytes", window.content.length);
324
+ otelSpan.setAttribute("memory.artifact_range_read", typeof artifactStore.retrieveRange === "function");
325
+ return {
326
+ artifactId,
327
+ content: window.content,
328
+ totalSize: window.totalLength,
329
+ hasMore: window.offset + window.content.length < window.totalLength,
330
+ offset: window.offset,
331
+ limit: charLimit,
332
+ };
333
+ }
334
+ catch (error) {
335
+ const message = error instanceof Error ? error.message : String(error);
336
+ logger.error("[MemoryRetrievalTools] Artifact read failed", {
337
+ artifactId,
338
+ error: message,
339
+ });
340
+ otelSpan.setStatus({ code: SpanStatusCode.ERROR, message });
341
+ return { error: `Artifact read failed: ${message}`, artifactId };
342
+ }
343
+ }
@@ -82,8 +82,19 @@ export declare class NeuroLink {
82
82
  private mcpToolBatcher?;
83
83
  private mcpEnhancedDiscovery?;
84
84
  private mcpToolMiddlewares;
85
- /** Artifact store for externalized MCP tool outputs (set when strategy=externalize). */
85
+ /** Artifact store for externalized MCP tool outputs and banked payloads. */
86
86
  private mcpArtifactStore?;
87
+ /** `artifacts` constructor config — consulted whenever a store is created. */
88
+ private artifactsConfig?;
89
+ /**
90
+ * True when this instance built `mcpArtifactStore` itself (local or Redis
91
+ * from config / STORAGE_TYPE). NeuroLink closes only stores it built — on
92
+ * replacement and on shutdown; a store handed in through `artifacts.store`
93
+ * or `setArtifactStore()` is the caller's to close.
94
+ */
95
+ private ownsArtifactStore;
96
+ /** Normalizer settings, kept so `setArtifactStore()` can rebuild it. */
97
+ private mcpOutputNormalizerConfig?;
87
98
  private _disableToolCacheForCurrentRequest;
88
99
  /**
89
100
  * (toolName + args) keys already served during the CURRENT request.
@@ -2735,6 +2746,39 @@ export declare class NeuroLink {
2735
2746
  * @returns The artifact store backing {@link bankArtifact} / {@link readArtifact}
2736
2747
  */
2737
2748
  getArtifactStore(): ArtifactStore;
2749
+ /**
2750
+ * Replace this instance's artifact store.
2751
+ *
2752
+ * Everything that writes or reads artifacts follows the swap: banking,
2753
+ * `retrieve_context`, host-side `readArtifact`, and the MCP output
2754
+ * normalizer — which is rebuilt here because it captured the previous store
2755
+ * at construction. Assigning the field alone would miss it, and
2756
+ * externalized tool outputs would keep landing in the old backend while
2757
+ * read-backs looked in the new one.
2758
+ *
2759
+ * Call it before the first bank or externalized tool output: artifacts
2760
+ * already in the previous store are not migrated, and their ids stop
2761
+ * resolving through this instance. `artifacts.store` in the constructor
2762
+ * config is the same thing without the ordering concern.
2763
+ *
2764
+ * Ownership: a store you hand in — here or via `artifacts.store` — stays
2765
+ * yours to close. NeuroLink closes only the stores it built itself, when
2766
+ * they are replaced here and on `shutdown()`.
2767
+ *
2768
+ * @param store - Any {@link ArtifactStore}
2769
+ */
2770
+ setArtifactStore(store: ArtifactStore): void;
2771
+ /**
2772
+ * Build the store the `artifacts` config (or `STORAGE_TYPE`) asks for. The
2773
+ * Redis connection falls back to conversation memory's, so one
2774
+ * `STORAGE_TYPE=redis` moves sessions and artifacts together.
2775
+ */
2776
+ private createConfiguredArtifactStore;
2777
+ /**
2778
+ * (Re)build the MCP output normalizer over `artifactStore`. A no-op unless
2779
+ * `mcp.outputLimits` was configured — without it nothing is externalized.
2780
+ */
2781
+ private installOutputNormalizer;
2738
2782
  /**
2739
2783
  * Bank a payload to a file and get back a pointer to it.
2740
2784
  *
package/dist/neurolink.js CHANGED
@@ -47,7 +47,7 @@ import { ToolResultCache } from "./mcp/caching/index.js";
47
47
  import { EnhancedToolDiscovery } from "./mcp/enhancedToolDiscovery.js";
48
48
  import { ExternalServerManager } from "./mcp/externalServerManager.js";
49
49
  import { McpOutputNormalizer, DEFAULT_MAX_MCP_OUTPUT_BYTES, DEFAULT_WARN_MCP_OUTPUT_BYTES, } from "./mcp/mcpOutputNormalizer.js";
50
- import { LocalTempArtifactStore } from "./artifacts/artifactStore.js";
50
+ import { createArtifactStore, resolveArtifactStorageType, } from "./artifacts/artifactStoreFactory.js";
51
51
  import { bankArtifact as bankArtifactPayload, readArtifact as readBankedArtifact, } from "./artifacts/artifactBanking.js";
52
52
  import { ToolRouter } from "./mcp/routing/index.js";
53
53
  // Import direct tools server for automatic registration
@@ -380,8 +380,19 @@ export class NeuroLink {
380
380
  mcpToolBatcher;
381
381
  mcpEnhancedDiscovery;
382
382
  mcpToolMiddlewares = [];
383
- /** Artifact store for externalized MCP tool outputs (set when strategy=externalize). */
383
+ /** Artifact store for externalized MCP tool outputs and banked payloads. */
384
384
  mcpArtifactStore;
385
+ /** `artifacts` constructor config — consulted whenever a store is created. */
386
+ artifactsConfig;
387
+ /**
388
+ * True when this instance built `mcpArtifactStore` itself (local or Redis
389
+ * from config / STORAGE_TYPE). NeuroLink closes only stores it built — on
390
+ * replacement and on shutdown; a store handed in through `artifacts.store`
391
+ * or `setArtifactStore()` is the caller's to close.
392
+ */
393
+ ownsArtifactStore = false;
394
+ /** Normalizer settings, kept so `setArtifactStore()` can rebuild it. */
395
+ mcpOutputNormalizerConfig;
385
396
  _disableToolCacheForCurrentRequest = false;
386
397
  /**
387
398
  * (toolName + args) keys already served during the CURRENT request.
@@ -1198,24 +1209,22 @@ export class NeuroLink {
1198
1209
  });
1199
1210
  }
1200
1211
  // ToolRouter — lazy-initialized when 2+ external servers exist (see addExternalMCPServer)
1212
+ // Artifact storage — where externalized MCP outputs and banked payloads
1213
+ // live. Kept so both creation sites (externalize here, and
1214
+ // getArtifactStore() on demand) resolve the same backend.
1215
+ this.artifactsConfig = config?.artifacts;
1201
1216
  // McpOutputNormalizer — active when mcp.outputLimits is configured
1202
1217
  if (mcpConfig?.outputLimits) {
1203
1218
  const strategy = mcpConfig.outputLimits.strategy ?? "externalize";
1204
1219
  const maxBytes = mcpConfig.outputLimits.maxBytes ?? DEFAULT_MAX_MCP_OUTPUT_BYTES;
1205
1220
  const warnBytes = mcpConfig.outputLimits.warnBytes ?? DEFAULT_WARN_MCP_OUTPUT_BYTES;
1221
+ this.mcpOutputNormalizerConfig = { strategy, maxBytes, warnBytes };
1206
1222
  let artifactStore;
1207
1223
  if (strategy === "externalize") {
1208
- artifactStore = new LocalTempArtifactStore();
1224
+ artifactStore = this.createConfiguredArtifactStore();
1209
1225
  this.mcpArtifactStore = artifactStore;
1210
- logger.debug("[NeuroLink] MCP artifact store initialized (local-temp)");
1211
1226
  }
1212
- const normalizer = new McpOutputNormalizer({ strategy, maxBytes, warnBytes }, artifactStore);
1213
- this.externalServerManager.setOutputNormalizer(normalizer);
1214
- logger.debug("[NeuroLink] MCP output normalizer initialized", {
1215
- strategy,
1216
- maxBytes,
1217
- warnBytes,
1218
- });
1227
+ this.installOutputNormalizer(artifactStore);
1219
1228
  }
1220
1229
  }
1221
1230
  /**
@@ -2806,6 +2815,17 @@ Current user's request: ${currentInput}`;
2806
2815
  this._taskManager = undefined;
2807
2816
  }
2808
2817
  }
2818
+ // Release the artifact store this instance built (a pooled Redis
2819
+ // connection, for one). An injected store is the caller's to close.
2820
+ if (this.ownsArtifactStore && this.mcpArtifactStore?.close) {
2821
+ try {
2822
+ await this.mcpArtifactStore.close();
2823
+ logger.debug("[NeuroLink] Artifact store closed");
2824
+ }
2825
+ catch (error) {
2826
+ logger.warn("[NeuroLink] Artifact store close failed:", error);
2827
+ }
2828
+ }
2809
2829
  // Close conversation memory manager (release Redis connections, etc.)
2810
2830
  if (this.conversationMemory?.close) {
2811
2831
  try {
@@ -13010,13 +13030,86 @@ Current user's request: ${currentInput}`;
13010
13030
  */
13011
13031
  getArtifactStore() {
13012
13032
  if (!this.mcpArtifactStore) {
13013
- this.mcpArtifactStore = new LocalTempArtifactStore();
13014
- logger.debug("[NeuroLink] Artifact store created on demand (local-temp) for banking");
13033
+ this.mcpArtifactStore = this.createConfiguredArtifactStore();
13015
13034
  }
13016
13035
  // A no-op when the constructor already registered it.
13017
13036
  this.registerMemoryRetrievalTools();
13018
13037
  return this.mcpArtifactStore;
13019
13038
  }
13039
+ /**
13040
+ * Replace this instance's artifact store.
13041
+ *
13042
+ * Everything that writes or reads artifacts follows the swap: banking,
13043
+ * `retrieve_context`, host-side `readArtifact`, and the MCP output
13044
+ * normalizer — which is rebuilt here because it captured the previous store
13045
+ * at construction. Assigning the field alone would miss it, and
13046
+ * externalized tool outputs would keep landing in the old backend while
13047
+ * read-backs looked in the new one.
13048
+ *
13049
+ * Call it before the first bank or externalized tool output: artifacts
13050
+ * already in the previous store are not migrated, and their ids stop
13051
+ * resolving through this instance. `artifacts.store` in the constructor
13052
+ * config is the same thing without the ordering concern.
13053
+ *
13054
+ * Ownership: a store you hand in — here or via `artifacts.store` — stays
13055
+ * yours to close. NeuroLink closes only the stores it built itself, when
13056
+ * they are replaced here and on `shutdown()`.
13057
+ *
13058
+ * @param store - Any {@link ArtifactStore}
13059
+ */
13060
+ setArtifactStore(store) {
13061
+ const previous = this.mcpArtifactStore;
13062
+ if (previous === store) {
13063
+ return;
13064
+ }
13065
+ if (previous) {
13066
+ logger.warn("[NeuroLink] Artifact store replaced — artifacts already written to " +
13067
+ "the previous store will not resolve through this instance");
13068
+ if (this.ownsArtifactStore && previous.close) {
13069
+ void previous.close().catch((error) => {
13070
+ logger.warn("[NeuroLink] Previous artifact store close failed", {
13071
+ error: error instanceof Error ? error.message : String(error),
13072
+ });
13073
+ });
13074
+ }
13075
+ }
13076
+ this.mcpArtifactStore = store;
13077
+ this.ownsArtifactStore = false;
13078
+ this.installOutputNormalizer(store);
13079
+ this.registerMemoryRetrievalTools();
13080
+ }
13081
+ /**
13082
+ * Build the store the `artifacts` config (or `STORAGE_TYPE`) asks for. The
13083
+ * Redis connection falls back to conversation memory's, so one
13084
+ * `STORAGE_TYPE=redis` moves sessions and artifacts together.
13085
+ */
13086
+ createConfiguredArtifactStore() {
13087
+ const injected = this.artifactsConfig?.store !== undefined;
13088
+ const store = createArtifactStore(this.artifactsConfig, this.conversationMemoryConfig?.conversationMemory?.redisConfig);
13089
+ // An injected store is returned as-is by the factory and stays the
13090
+ // caller's; anything else was built here and is this instance's to close.
13091
+ this.ownsArtifactStore = !injected;
13092
+ logger.debug("[NeuroLink] Artifact store initialized", {
13093
+ backend: injected
13094
+ ? "custom"
13095
+ : resolveArtifactStorageType(this.artifactsConfig),
13096
+ });
13097
+ return store;
13098
+ }
13099
+ /**
13100
+ * (Re)build the MCP output normalizer over `artifactStore`. A no-op unless
13101
+ * `mcp.outputLimits` was configured — without it nothing is externalized.
13102
+ */
13103
+ installOutputNormalizer(artifactStore) {
13104
+ if (!this.mcpOutputNormalizerConfig) {
13105
+ return;
13106
+ }
13107
+ this.externalServerManager.setOutputNormalizer(new McpOutputNormalizer(this.mcpOutputNormalizerConfig, artifactStore));
13108
+ logger.debug("[NeuroLink] MCP output normalizer initialized", {
13109
+ ...this.mcpOutputNormalizerConfig,
13110
+ hasArtifactStore: artifactStore !== undefined,
13111
+ });
13112
+ }
13020
13113
  /**
13021
13114
  * Bank a payload to a file and get back a pointer to it.
13022
13115
  *
@@ -13355,7 +13448,24 @@ Current user's request: ${currentInput}`;
13355
13448
  logger.warn("[NeuroLink] Error shutting down external MCP servers:", error);
13356
13449
  }
13357
13450
  }
13358
- // 3. Clear all event listeners to prevent memory leaks
13451
+ // 3. Release the artifact store this instance built (a pooled Redis
13452
+ // connection, for one). Same ownership rule as shutdown(): an injected
13453
+ // store is the caller's to close. Without this step every
13454
+ // construct / use / dispose cycle left the pooled reference acquired.
13455
+ if (this.ownsArtifactStore && this.mcpArtifactStore?.close) {
13456
+ try {
13457
+ await this.mcpArtifactStore.close();
13458
+ logger.debug("[NeuroLink] Artifact store closed");
13459
+ }
13460
+ catch (error) {
13461
+ const err = error instanceof Error
13462
+ ? error
13463
+ : new Error(`Artifact store close error: ${String(error)}`);
13464
+ cleanupErrors.push(err);
13465
+ logger.warn("[NeuroLink] Error closing artifact store:", error);
13466
+ }
13467
+ }
13468
+ // 4. Clear all event listeners to prevent memory leaks
13359
13469
  if (this.emitter) {
13360
13470
  try {
13361
13471
  logger.debug("[NeuroLink] Removing all event listeners...");
@@ -13373,7 +13483,7 @@ Current user's request: ${currentInput}`;
13373
13483
  logger.warn("[NeuroLink] Error removing event listeners:", error);
13374
13484
  }
13375
13485
  }
13376
- // 4. Clear all circuit breakers
13486
+ // 5. Clear all circuit breakers
13377
13487
  if (this.toolCircuitBreakers && this.toolCircuitBreakers.size > 0) {
13378
13488
  try {
13379
13489
  logger.debug(`[NeuroLink] Clearing ${this.toolCircuitBreakers.size} circuit breakers...`);
@@ -13388,7 +13498,7 @@ Current user's request: ${currentInput}`;
13388
13498
  logger.warn("[NeuroLink] Error clearing circuit breakers:", error);
13389
13499
  }
13390
13500
  }
13391
- // 5. Clear all Maps and caches
13501
+ // 6. Clear all Maps and caches
13392
13502
  try {
13393
13503
  logger.debug("[NeuroLink] Clearing maps and caches...");
13394
13504
  if (this.toolExecutionMetrics) {
@@ -13439,7 +13549,7 @@ Current user's request: ${currentInput}`;
13439
13549
  this._taskManager = undefined;
13440
13550
  }
13441
13551
  }
13442
- // 6. Reset initialization flags
13552
+ // 7. Reset initialization flags
13443
13553
  try {
13444
13554
  logger.debug("[NeuroLink] Resetting initialization state...");
13445
13555
  this.mcpInitialized = false;
@@ -13455,7 +13565,7 @@ Current user's request: ${currentInput}`;
13455
13565
  cleanupErrors.push(err);
13456
13566
  logger.warn("[NeuroLink] Error resetting state:", error);
13457
13567
  }
13458
- // 6. Log completion
13568
+ // 7. Log completion
13459
13569
  if (cleanupErrors.length === 0) {
13460
13570
  logger.debug("[NeuroLink] ✅ Resource disposal completed successfully");
13461
13571
  }
@@ -7,6 +7,7 @@
7
7
  *
8
8
  * @module types/artifactTypes
9
9
  */
10
+ import type { RedisStorageConfig } from "./conversation.js";
10
11
  /** Metadata recorded alongside a stored artifact. */
11
12
  export type ArtifactMeta = {
12
13
  /** Tool name that produced the output. */
@@ -83,10 +84,63 @@ export type ArtifactPageRequest = {
83
84
  limit?: number;
84
85
  };
85
86
  /**
86
- * Pluggable storage contract for externalized MCP tool outputs.
87
+ * One window of an artifact, as returned by `ArtifactStore.retrieveRange` or
88
+ * by the shared reader when the store only supports whole-payload reads.
87
89
  *
88
- * Default backend: LocalTempArtifactStore (filesystem, single-process).
89
- * Future backends can implement this interface for S3, Redis blobs, etc.
90
+ * Offsets and lengths are CHARACTERS (UTF-16 code units, the unit
91
+ * `String.prototype.slice` and `retrieve_context`'s `offset` / `limit` use),
92
+ * never bytes — so a model advancing `offset` by the characters it received
93
+ * lands exactly where the previous window ended.
94
+ */
95
+ export type ArtifactWindow = {
96
+ /** The characters in `[offset, offset + content.length)`. */
97
+ content: string;
98
+ /** Character offset this window starts at. */
99
+ offset: number;
100
+ /** Total character length of the whole payload. */
101
+ totalLength: number;
102
+ };
103
+ /** One hit from a literal search over an artifact. */
104
+ export type ArtifactSearchMatch = {
105
+ /** Character offset of the match — pass it back as `offset` to read there. */
106
+ offset: number;
107
+ /** 1-based line number the match sits on. */
108
+ line: number;
109
+ /** Character offset the snippet starts at (≤ `offset`). */
110
+ snippetOffset: number;
111
+ /**
112
+ * Bounded context around the match. Bounded on purpose: an MCP artifact is
113
+ * usually one compact JSON line, so "the matching line" would be the whole
114
+ * payload.
115
+ */
116
+ snippet: string;
117
+ };
118
+ /** Result of a literal search over an artifact. */
119
+ export type ArtifactSearchResult = {
120
+ /** Matches returned, in payload order. */
121
+ matches: ArtifactSearchMatch[];
122
+ /** `matches.length`. */
123
+ matchCount: number;
124
+ /** Every match in the payload, including the ones not returned. */
125
+ totalMatches: number;
126
+ /** True when `totalMatches > matchCount`. */
127
+ truncated: boolean;
128
+ /** Character offset to pass as `offset` to search for the next matches. */
129
+ nextSearchOffset?: number;
130
+ };
131
+ /**
132
+ * Pluggable storage contract for externalized MCP tool outputs and banked
133
+ * payloads.
134
+ *
135
+ * Shipped backends: `LocalTempArtifactStore` (filesystem, per-process index
136
+ * with a cross-process sidecar) and `RedisArtifactStore` (TTL-expired, shared
137
+ * across replicas, range reads). Pick one with `artifacts.storage` or the
138
+ * `STORAGE_TYPE` environment variable, or inject any implementation via
139
+ * `artifacts.store` / `setArtifactStore()`.
140
+ *
141
+ * Only `store`, `retrieve`, `delete`, `cleanup` and `generatePreview` are
142
+ * required. `retrieveRange` and `close` are optional capabilities: NeuroLink
143
+ * uses them when present and falls back cleanly when absent.
90
144
  */
91
145
  export type ArtifactStore = {
92
146
  /**
@@ -100,6 +154,22 @@ export type ArtifactStore = {
100
154
  * Returns `null` if the artifact is not found or has been cleaned up.
101
155
  */
102
156
  retrieve(id: string): Promise<string | null>;
157
+ /**
158
+ * Retrieve one character window without materialising the whole payload.
159
+ *
160
+ * Optional. When present, `retrieve_context` and `readArtifact` call it for
161
+ * every paged read instead of `retrieve()` + slice, so a backend with native
162
+ * range reads (Redis `GETRANGE`, S3 `Range`) moves only the window. The
163
+ * result carries `totalLength` so `hasMore` never needs the payload.
164
+ *
165
+ * `offset` and `limit` are characters. A backend that can only address
166
+ * bytes must either know the payload is single-byte (ASCII) or fall back to
167
+ * a full read and slice — it must never return a window that starts at the
168
+ * wrong character. `limit` omitted means "to the end".
169
+ *
170
+ * Returns `null` if the artifact is not found or has expired.
171
+ */
172
+ retrieveRange?(id: string, range: ArtifactPageRequest): Promise<ArtifactWindow | null>;
103
173
  /** Delete a single artifact. No-op if the ID does not exist. */
104
174
  delete(id: string): Promise<void>;
105
175
  /**
@@ -109,6 +179,13 @@ export type ArtifactStore = {
109
179
  cleanup(olderThanMs: number): Promise<number>;
110
180
  /** Generate a short preview string from a serialized payload. */
111
181
  generatePreview(payload: string): string;
182
+ /**
183
+ * Release whatever the store holds open (a pooled connection, a file
184
+ * handle). Optional. NeuroLink calls it — from `shutdown()`, and when
185
+ * `setArtifactStore()` replaces the store — only for stores it built
186
+ * itself; a store you inject is yours to close.
187
+ */
188
+ close?(): Promise<void>;
112
189
  };
113
190
  /**
114
191
  * In-memory index row tracked by LocalTempArtifactStore.
@@ -122,3 +199,47 @@ export type IndexEntry = ArtifactMeta & {
122
199
  */
123
200
  rehydrated?: boolean;
124
201
  };
202
+ /**
203
+ * Where artifacts live. Mirrors conversation memory's `STORAGE_TYPE`:
204
+ * - "local" OS temp directory, per-process index with a cross-process
205
+ * sidecar. Fine for one machine; artifacts do not survive a pod.
206
+ * - "redis" Shared across replicas, expired by TTL, range reads via
207
+ * `GETRANGE`. Uses the same connection pool as Redis conversation
208
+ * memory.
209
+ */
210
+ export type ArtifactStorageType = "local" | "redis";
211
+ /**
212
+ * Artifact storage configuration (`new NeuroLink({ artifacts })`).
213
+ *
214
+ * Resolution order for the backend: `store` → `storage` → `STORAGE_TYPE`
215
+ * environment variable → `"local"`. Resolution order for the Redis connection:
216
+ * `redisConfig` → `conversationMemory.redisConfig` → `REDIS_URL` / `REDIS_HOST`
217
+ * environment variables — so a deployment already running conversation memory
218
+ * on Redis keeps its artifacts on the same Redis without new settings.
219
+ */
220
+ export type ArtifactStorageConfig = {
221
+ /** Backend to use. Default: `STORAGE_TYPE` env var, else `"local"`. */
222
+ storage?: ArtifactStorageType;
223
+ /**
224
+ * Redis connection for `storage: "redis"`. `keyPrefix` defaults to
225
+ * `neurolink:artifact:` (NOT the conversation prefix). `ttl` is seconds,
226
+ * must be positive, and defaults to 86400; zero or negative is replaced by
227
+ * the default with a warning — artifacts in Redis always expire.
228
+ * `userSessionsKeyPrefix` is ignored.
229
+ */
230
+ redisConfig?: RedisStorageConfig;
231
+ /**
232
+ * A ready-made backend. Wins over `storage`. Use this for S3, a database,
233
+ * or a wrapped store; `setArtifactStore()` does the same after construction.
234
+ */
235
+ store?: ArtifactStore;
236
+ };
237
+ /**
238
+ * What `RedisArtifactStore` keeps beside each payload. `charLength` is what
239
+ * makes range reads honest: when it equals `sizeBytes` the payload is pure
240
+ * ASCII and a byte range IS a character range.
241
+ */
242
+ export type RedisArtifactRecord = ArtifactMeta & {
243
+ /** `payload.length` at store time — characters, not bytes. */
244
+ charLength: number;
245
+ };