@juspay/neurolink 12.28.0 → 12.28.2

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.
@@ -32,6 +32,45 @@ export type VisionImageConversion = {
32
32
  /** True when the bytes were re-encoded; false when they were left alone. */
33
33
  readonly converted: boolean;
34
34
  };
35
+ /**
36
+ * Knobs for one vision-compatibility pass.
37
+ *
38
+ * `autoOrient` exists so a caller that also wants EXIF orientation applied
39
+ * gets it inside the same decode. Rotating first and transcoding second means
40
+ * two full decode/re-encode cycles, and for the lossy formats in both sets
41
+ * (TIFF, which sharp writes JPEG-compressed by default) the intermediate
42
+ * re-encode costs a generation of image quality that the model then reads.
43
+ *
44
+ * `outputFormat` picks the transcode target; see {@link VisionImageOutputFormat}
45
+ * for the default and why it stays PNG unless a caller opts into JPEG.
46
+ */
47
+ export type VisionTranscodeOptions = {
48
+ readonly autoOrient?: boolean;
49
+ readonly outputFormat?: VisionImageOutputFormat;
50
+ };
51
+ /**
52
+ * What a bounded header probe could establish about an image's EXIF
53
+ * orientation tag, without decoding the image or reading all of its bytes.
54
+ *
55
+ * `"inconclusive"` is not a failure — it is the honest answer whenever the
56
+ * prefix ran out before the parse reached a verdict, or the container is one
57
+ * this probe does not parse. Callers must treat it as "find out the expensive
58
+ * way", never as "no tag".
59
+ */
60
+ export type ExifOrientationProbe = "absent" | "present" | "inconclusive";
61
+ /**
62
+ * Outcome of an EXIF-orientation pass over one image.
63
+ *
64
+ * See `adapters/imageFormatSupport.ts` — `normalized` is false both when the
65
+ * image carried no orientation tag (or an already-upright `1`) and when the
66
+ * pass could not run (sharp unavailable, decode failure), so callers must not
67
+ * treat it as a success flag; either way `buffer` is safe to send as-is.
68
+ */
69
+ export type ImageOrientationNormalization = {
70
+ readonly buffer: Buffer;
71
+ /** True when the bytes were re-encoded to apply and strip EXIF orientation. */
72
+ readonly normalized: boolean;
73
+ };
35
74
  /**
36
75
  * Outcome of an audio-compatibility pass over one file.
37
76
  *
@@ -1,5 +1,5 @@
1
1
  import { existsSync, readFileSync, statSync } from "fs";
2
- import { readFile as readFileAsync, stat as statAsync } from "fs/promises";
2
+ import { open as openAsync, readFile as readFileAsync, stat as statAsync, } from "fs/promises";
3
3
  import pLimit from "p-limit";
4
4
  import { request } from "undici";
5
5
  import { redirectFollowingDispatcher } from "./redirectDispatcher.js";
@@ -8,9 +8,9 @@ import { CONVERSATION_INSTRUCTIONS, STRUCTURED_OUTPUT_INSTRUCTIONS, } from "../c
8
8
  import { getAvailableInputTokens } from "../constants/contextWindows.js";
9
9
  import { PDF_LIMITS } from "../core/constants.js";
10
10
  import { enforceAggregateFileBudget, FILE_READ_BUDGET_PERCENT, } from "../context/fileTokenBudget.js";
11
- import { isCSVContent, SIZE_TIER_THRESHOLDS } from "../types/index.js";
11
+ import { isCSVContent, isTextContent, SIZE_TIER_THRESHOLDS, } from "../types/index.js";
12
12
  import { tracers, ATTR, withSpan } from "../telemetry/index.js";
13
- import { needsVisionTranscode, toVisionCompatibleImage, } from "../adapters/imageFormatSupport.js";
13
+ import { EXIF_PROBE_PREFIX_BYTES, mayCarryExifOrientation, needsVisionTranscode, normalizeImageOrientation, probeExifOrientation, toVisionCompatibleImage, } from "../adapters/imageFormatSupport.js";
14
14
  import { FILE_TYPE_REGISTRY, lookupByExtension, } from "../processors/config/fileTypeRegistry.js";
15
15
  import { ErrorFactory, NeuroLinkError, withTimeout } from "./errorHandling.js";
16
16
  import { FileDetector } from "./fileDetector.js";
@@ -1405,6 +1405,20 @@ export async function buildMultimodalMessagesArray(options, provider, model) {
1405
1405
  supportsNativeVideo(provider, model);
1406
1406
  // If no images, PDFs, audio or video, use standard message building and convert to MultimodalChatMessage[]
1407
1407
  if (!hasImages && !hasPDFs && !hasNativeAudio && !hasNativeVideo) {
1408
+ // A `content: [{type: "text", ...}]` item never reaches
1409
+ // convertContentToProviderFormat on this branch (that only runs for the
1410
+ // multimodal path below), and buildMessagesArray only ever reads
1411
+ // inp.text/options.prompt — so without this fold, every TextContent item
1412
+ // in `content[]` was silently dropped whenever the request carried no
1413
+ // image, PDF, native audio or native video. Fold them into inp.text first
1414
+ // (same append-and-preserve-order shape as the CSV fold immediately
1415
+ // below), so ordinary text-only requests behave the same as the
1416
+ // multimodal path, which already reads its (first) text item out of
1417
+ // `content[]`.
1418
+ const textContentItems = inp.content?.filter(isTextContent) ?? [];
1419
+ if (textContentItems.length > 0) {
1420
+ inp.text = appendTextContentToText(textContentItems, inp.text ?? "");
1421
+ }
1408
1422
  // #289: CSV content[] items don't need vision, so they never reach the
1409
1423
  // multimodal converter below — process them into the prompt text here
1410
1424
  // (otherwise a `content: [{type:"csv"}]`-only request silently drops it).
@@ -1562,6 +1576,23 @@ export async function buildMultimodalMessagesArray(options, provider, model) {
1562
1576
  throw error;
1563
1577
  }
1564
1578
  }
1579
+ /**
1580
+ * Fold TextContent `content[]` items into the prompt text, in the order they
1581
+ * appear, on the text-only path (no image/PDF/native-audio, so
1582
+ * convertContentToProviderFormat's own text handling never runs). Mirrors
1583
+ * appendCsvContentToText's append-after-base-text shape; unlike CSV this
1584
+ * needs no async decoding, just string concatenation.
1585
+ */
1586
+ function appendTextContentToText(textItems, baseText) {
1587
+ let text = baseText;
1588
+ for (const item of textItems) {
1589
+ if (!item.text) {
1590
+ continue;
1591
+ }
1592
+ text += `${text ? "\n\n" : ""}${item.text}`;
1593
+ }
1594
+ return text;
1595
+ }
1565
1596
  /**
1566
1597
  * Timeout for detecting/parsing an in-memory CSV `content[]` buffer (#325
1567
1598
  * review, round 2). Bounds a stalled detector rather than blocking the
@@ -1976,12 +2007,17 @@ async function processImageToBase64(image, index, outputFormat) {
1976
2007
  ImageProcessor.validateBufferSize(image, `image input at index ${index}`);
1977
2008
  imageData = image.toString("base64");
1978
2009
  }
1979
- // Last line of defence for vision-format compatibility. `normalizeVisionImageFormats`
1980
- // handles `input.images` eagerly so the providers that read that array
1981
- // directly (Google AI Studio, Bedrock) see converted bytes, but images that
1982
- // arrive as URLs are downloaded further downstream and only become bytes
1983
- // here. A no-op for the universal formats, so the common path is unaffected.
1984
- if (needsVisionTranscode(mimeType)) {
2010
+ // Last line of defence for vision-format compatibility AND orientation.
2011
+ // `normalizeVisionImageFormats` handles `input.images` eagerly so the
2012
+ // providers that read that array directly (Google AI Studio, Bedrock) see
2013
+ // converted/oriented bytes, but images that arrive as URLs are downloaded
2014
+ // further downstream and only become bytes here. A no-op for PNG/GIF (which
2015
+ // cannot carry an orientation tag) already sent as-is, so the common
2016
+ // no-EXIF case is unaffected.
2017
+ const needsOrientationCheck = mayCarryExifOrientation(mimeType);
2018
+ const needsTranscode = needsVisionTranscode(mimeType);
2019
+ const context = `image input at index ${index}`;
2020
+ if (needsTranscode) {
1985
2021
  // Guard the decoded bytes, not just the Buffer input. The buffer branch
1986
2022
  // above is already checked, but a data: URI reaches here having only been
1987
2023
  // regex-matched — so an oversized one was handed straight to sharp/ffmpeg,
@@ -1989,15 +2025,41 @@ async function processImageToBase64(image, index, outputFormat) {
1989
2025
  //
1990
2026
  // Sized before the decode rather than after: checking the Buffer would mean
1991
2027
  // allocating the very thing the limit exists to refuse.
1992
- const context = `image input at index ${index}`;
1993
2028
  ImageProcessor.validateSize(base64DecodedByteLength(imageData), context);
1994
2029
  const rawImage = Buffer.from(imageData, "base64");
1995
- const compatible = await toVisionCompatibleImage(rawImage, mimeType, outputFormat);
2030
+ // Both passes need the same decode, so a format in both sets (HEIC,
2031
+ // HEIF, TIFF, AVIF) gets one pipeline rather than two — see
2032
+ // `toVisionCompatibleImage`'s `autoOrient`.
2033
+ const compatible = await toVisionCompatibleImage(rawImage, mimeType, {
2034
+ autoOrient: needsOrientationCheck,
2035
+ outputFormat,
2036
+ });
1996
2037
  if (compatible.converted) {
1997
2038
  imageData = compatible.buffer.toString("base64");
1998
2039
  mimeType = compatible.mimeType;
1999
2040
  }
2000
2041
  }
2042
+ else if (needsOrientationCheck) {
2043
+ // Ask a bounded header prefix whether this image needs touching at all
2044
+ // before paying for the size guard or a full decode. PNG/JPEG/WebP were
2045
+ // previously gated on `needsTranscode` alone — false for all three — so
2046
+ // this MIME type reached the provider with no local size check
2047
+ // whatsoever. A large image with no orientation tag is never going to be
2048
+ // modified by this pass, and must keep reaching the provider exactly as
2049
+ // it did before rather than being rejected locally for a limit that
2050
+ // exists to bound a decode nothing here needs.
2051
+ if (probeBase64ExifOrientation(imageData, mimeType) !== "absent") {
2052
+ ImageProcessor.validateSize(base64DecodedByteLength(imageData), context);
2053
+ const rawImage = Buffer.from(imageData, "base64");
2054
+ const oriented = await normalizeImageOrientation(rawImage, mimeType);
2055
+ // Re-encoding changes byte length. An oversized result falls back to
2056
+ // the already-validated original, matching normalizeVisionImageFormats.
2057
+ if (oriented.normalized &&
2058
+ withinConversionLimit(oriented.buffer, `oriented ${context}`)) {
2059
+ imageData = oriented.buffer.toString("base64");
2060
+ }
2061
+ }
2062
+ }
2001
2063
  return { imageData, mimeType };
2002
2064
  }
2003
2065
  /**
@@ -2027,14 +2089,46 @@ export async function normalizeVisionImageFormats(input, outputFormat) {
2027
2089
  ? entry.data
2028
2090
  : entry;
2029
2091
  const source = await readImageSourceForConversion(payload);
2030
- if (!source || !needsVisionTranscode(source.mimeType)) {
2092
+ if (!source ||
2093
+ (!needsVisionTranscode(source.mimeType) &&
2094
+ !mayCarryExifOrientation(source.mimeType))) {
2031
2095
  continue;
2032
2096
  }
2033
- const compatible = await toVisionCompatibleImage(source.buffer, source.mimeType, outputFormat);
2034
- if (!compatible.converted) {
2097
+ let buffer = source.buffer;
2098
+ let mimeType = source.mimeType;
2099
+ let changed = false;
2100
+ const orientable = mayCarryExifOrientation(mimeType);
2101
+ if (needsVisionTranscode(mimeType)) {
2102
+ // A format in both sets (HEIC, HEIF, TIFF, AVIF) is oriented inside the
2103
+ // transcode's own decode instead of ahead of it. Two sequential passes
2104
+ // cost two decodes and two encodes, and the intermediate re-encode is
2105
+ // lossy for some of these formats — so the model would read an image a
2106
+ // generation worse than the one it could have been sent.
2107
+ const compatible = await toVisionCompatibleImage(buffer, mimeType, {
2108
+ autoOrient: orientable,
2109
+ outputFormat,
2110
+ });
2111
+ if (compatible.converted) {
2112
+ buffer = compatible.buffer;
2113
+ mimeType = compatible.mimeType;
2114
+ changed = true;
2115
+ }
2116
+ }
2117
+ else if (orientable) {
2118
+ const oriented = await normalizeImageOrientation(buffer, mimeType);
2119
+ // Re-encoding changes byte length, so the size guard that gated the
2120
+ // read above must run again on the result — a re-encode that grew past
2121
+ // the limit must not silently replace an under-limit original.
2122
+ if (oriented.normalized &&
2123
+ withinConversionLimit(oriented.buffer, `oriented image at index ${index}`)) {
2124
+ buffer = oriented.buffer;
2125
+ changed = true;
2126
+ }
2127
+ }
2128
+ if (!changed) {
2035
2129
  continue;
2036
2130
  }
2037
- const dataUri = `data:${compatible.mimeType};base64,${compatible.buffer.toString("base64")}`;
2131
+ const dataUri = `data:${mimeType};base64,${buffer.toString("base64")}`;
2038
2132
  images[index] = isWrapped
2039
2133
  ? { ...entry, data: dataUri }
2040
2134
  : dataUri;
@@ -2081,6 +2175,34 @@ function base64DecodedByteLength(base64) {
2081
2175
  }
2082
2176
  return Math.max(0, Math.floor(significant / 4) * 3 - padding);
2083
2177
  }
2178
+ /**
2179
+ * Base64 characters sufficient to decode at least
2180
+ * {@link EXIF_PROBE_PREFIX_BYTES} bytes, rounded up to a 4-character group so
2181
+ * the slice below always starts at the beginning of the encoded stream.
2182
+ */
2183
+ const EXIF_PROBE_BASE64_CHARS = Math.ceil(EXIF_PROBE_PREFIX_BYTES / 3) * 4;
2184
+ /**
2185
+ * `probeExifOrientation`, but for a still-encoded data-URI payload: decodes
2186
+ * only its leading {@link EXIF_PROBE_BASE64_CHARS} characters rather than the
2187
+ * whole string, the same bounded-prefix trade `probeFileExifOrientation`
2188
+ * makes for a local file. This is what lets `processImageToBase64` ask "does
2189
+ * this image need touching at all?" before it pays for the size guard or a
2190
+ * full decode — see the call site for why that ordering is the point.
2191
+ *
2192
+ * Reports `"inconclusive"` on any decode failure rather than propagating it:
2193
+ * the caller's full-size path handles that verdict correctly already.
2194
+ */
2195
+ function probeBase64ExifOrientation(base64, mimeType) {
2196
+ try {
2197
+ const window = base64.length <= EXIF_PROBE_BASE64_CHARS
2198
+ ? base64
2199
+ : base64.slice(0, EXIF_PROBE_BASE64_CHARS);
2200
+ return probeExifOrientation(Buffer.from(window, "base64"), mimeType);
2201
+ }
2202
+ catch {
2203
+ return "inconclusive";
2204
+ }
2205
+ }
2084
2206
  /**
2085
2207
  * Whether a payload is small enough to hand to an image decoder, reported
2086
2208
  * rather than thrown. See {@link readImageSourceForConversion} for why this
@@ -2100,6 +2222,42 @@ function withinConversionByteLimit(bytes, context) {
2100
2222
  function withinConversionLimit(buffer, context) {
2101
2223
  return withinConversionByteLimit(buffer.length, context);
2102
2224
  }
2225
+ /**
2226
+ * Ask a file's own header bytes whether it carries an EXIF orientation,
2227
+ * reading at most {@link EXIF_PROBE_PREFIX_BYTES} to find out.
2228
+ *
2229
+ * This is what keeps the orientation pass off the JPEG hot path. Without it,
2230
+ * widening the conversion gate to cover orientation means every local `.jpg`
2231
+ * and `.webp` is stat'd, read into memory in full and handed to a decoder,
2232
+ * purely to discover that the overwhelming majority carry no tag — new I/O
2233
+ * on the single most common image input there is.
2234
+ *
2235
+ * Reports `"inconclusive"` on any read failure rather than propagating it:
2236
+ * the full read that follows will hit the same problem a few lines later,
2237
+ * where the existing handler already turns it into a warning and a skipped
2238
+ * conversion. Failing here instead would only duplicate that.
2239
+ */
2240
+ async function probeFileExifOrientation(path, mimeType, size) {
2241
+ // The caller has already stat'd, so a small file costs a small buffer
2242
+ // rather than the window's full width.
2243
+ const window = Math.min(Math.max(size, 0), EXIF_PROBE_PREFIX_BYTES);
2244
+ if (window === 0) {
2245
+ return "inconclusive";
2246
+ }
2247
+ let handle;
2248
+ try {
2249
+ handle = await openAsync(path, "r");
2250
+ const prefix = Buffer.alloc(window);
2251
+ const { bytesRead } = await handle.read(prefix, 0, prefix.length, 0);
2252
+ return probeExifOrientation(prefix.subarray(0, bytesRead), mimeType);
2253
+ }
2254
+ catch {
2255
+ return "inconclusive";
2256
+ }
2257
+ finally {
2258
+ await handle?.close().catch(() => undefined);
2259
+ }
2260
+ }
2103
2261
  async function readImageSourceForConversion(payload) {
2104
2262
  // Both in-memory shapes are size-checked before they can reach a decoder,
2105
2263
  // the same way the file-path branch below is. `withinConversionLimit`
@@ -2131,16 +2289,27 @@ async function readImageSourceForConversion(payload) {
2131
2289
  return undefined;
2132
2290
  }
2133
2291
  const mimeType = getMimeTypeFromExtension(payload);
2134
- if (!needsVisionTranscode(mimeType)) {
2292
+ const transcodable = needsVisionTranscode(mimeType);
2293
+ if (!transcodable && !mayCarryExifOrientation(mimeType)) {
2135
2294
  return undefined;
2136
2295
  }
2137
2296
  try {
2138
2297
  // Preflight the size before reading. Conversion replaces the entry with a
2139
2298
  // data URI, which bypasses processImageToBase64's buffer-size guard — so
2140
2299
  // without this a large local HEIC/TIFF/BMP was read fully into memory and
2141
- // then re-encoded, with no limit applied at either step.
2300
+ // then re-encoded, with no limit applied at either step. The same applies
2301
+ // to a local JPEG read only for its EXIF tag: still bounded before the read.
2142
2302
  const { size } = await statAsync(payload);
2143
2303
  ImageProcessor.validateSize(size, `image at ${safeBasename(payload)}`);
2304
+ // A file whose only reason to be here is the orientation question gets
2305
+ // that question answered from its header, and is not read at all when the
2306
+ // answer is no. A transcodable format is exempt: its bytes are needed
2307
+ // whatever the orientation turns out to be, so a probe would be pure
2308
+ // overhead.
2309
+ if (!transcodable &&
2310
+ (await probeFileExifOrientation(payload, mimeType, size)) === "absent") {
2311
+ return undefined;
2312
+ }
2144
2313
  const buffer = await readFileAsync(payload);
2145
2314
  ImageProcessor.validateBufferSize(buffer, `image at ${safeBasename(payload)}`);
2146
2315
  return { buffer, mimeType };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@juspay/neurolink",
3
- "version": "12.28.0",
3
+ "version": "12.28.2",
4
4
  "packageManager": "pnpm@10.15.1",
5
5
  "description": "The pipe layer of an AI nervous system: one interface connecting provider neurons to your application, across three inference types — generate, stream and decide. `decide` returns typed, calibrated judgements from a non-generative model (~400ms, ~$0.00002/call) for routing, tool selection and context budgeting. MCP-native (4 transports), voice TTS/STT/realtime, RAG, agents, memory, compaction, 9 observability exporters. OpenAI · Anthropic · Gemini · Bedrock · Azure · Ollama · TypeSafe Jev and more.",
6
6
  "author": {
@@ -96,6 +96,7 @@
96
96
  "test:media-registry-collisions": "pnpm exec tsx test/continuous-test-suite-media-registry-collisions.ts",
97
97
  "test:memory": "pnpm exec tsx test/continuous-test-suite-memory.ts",
98
98
  "test:multimodal-rag": "pnpm exec tsx test/continuous-test-suite-multimodal-rag.ts",
99
+ "test:multimodal-text-content-fold": "pnpm exec tsx test/continuous-test-suite-multimodal-text-content-fold.ts",
99
100
  "test:proxy-sharing": "pnpm run build && pnpm exec tsx test/continuous-test-suite-proxy-sharing.ts",
100
101
  "test:openai-compat-streaming-retry": "pnpm exec tsx test/continuous-test-suite-openai-compat-streaming-retry.ts",
101
102
  "test:anthropic-streaming-retry": "pnpm exec tsx test/continuous-test-suite-anthropic-streaming-retry.ts",
@@ -183,7 +184,7 @@
183
184
  "// CI tier — fast, no live AI calls, safe for every commit (test:unit; also see the separate provider-safety-net CI job, which runs build + test:providers-mocked + test:provider-structure + test:error-classifier-contract on every PR)": "",
184
185
  "test:tool-routing": "pnpm exec tsx test/continuous-test-suite-tool-routing.ts",
185
186
  "test:tool-routing-semantic": "pnpm exec tsx test/continuous-test-suite-tool-routing-semantic.ts",
186
- "test:unit": "pnpm run test:bugfixes && pnpm run test:mcp:infra && pnpm run test:mcp:spans && pnpm run test:tool-routing && pnpm run test:tool-routing-cli && pnpm run test:tool-dedup && pnpm run test:model-pool && pnpm run test:classifier-router && pnpm run test:tool-routing-semantic && pnpm run test:mcp-result-cache && pnpm run test:mcp-direct-name-repair && pnpm run test:mcp-breaker-resolved-errors && pnpm run test:model-not-found-retryable && pnpm run test:archive:security && pnpm run test:office:security && pnpm run test:vector-chroma && pnpm run test:vector-pgvector && pnpm run test:vector-pinecone && pnpm run test:provider-wiring && pnpm run test:docs-mcp",
187
+ "test:unit": "pnpm run test:bugfixes && pnpm run test:mcp:infra && pnpm run test:mcp:spans && pnpm run test:tool-routing && pnpm run test:tool-routing-cli && pnpm run test:tool-dedup && pnpm run test:model-pool && pnpm run test:classifier-router && pnpm run test:tool-routing-semantic && pnpm run test:mcp-result-cache && pnpm run test:mcp-direct-name-repair && pnpm run test:mcp-breaker-resolved-errors && pnpm run test:model-not-found-retryable && pnpm run test:archive:security && pnpm run test:office:security && pnpm run test:image-exif && pnpm run test:vector-chroma && pnpm run test:vector-pgvector && pnpm run test:vector-pinecone && pnpm run test:provider-wiring && pnpm run test:docs-mcp",
187
188
  "// CI tier — live providers, runs only when API keys are present (test:credentials and test:dynamic make real provider calls when keys are set, so they live here, not in test:unit; test:matrix, a different suite covering the full provider capability matrix, runs nightly via .github/workflows/live-matrix.yml — test:providers itself is still only wired into test:live, not any GitHub Actions workflow)": "",
188
189
  "test:live": "pnpm run test:providers && pnpm run test:mcp:http && pnpm run test:mcp:sdk && pnpm run test:mcp:cli && pnpm run test:observability && pnpm run test:context && pnpm run test:memory && pnpm run test:tool-reliability && pnpm run test:evaluation && pnpm run test:autoresearch && pnpm run test:credentials && pnpm run test:dynamic",
189
190
  "// CI tier — product output (image/video/TTS/PPT) — costs $$ per run (not wired into any GitHub Actions workflow as of this comment; run manually or add to live-matrix.yml if nightly coverage is needed)": "",
@@ -250,6 +251,7 @@
250
251
  "test:archive:security": "pnpm exec tsx test/continuous-test-suite-archive-security.ts",
251
252
  "test:office:security": "pnpm exec tsx test/continuous-test-suite-office-security.ts",
252
253
  "test:vision-image-format": "pnpm exec tsx test/continuous-test-suite-vision-image-format.ts",
254
+ "test:image-exif": "pnpm exec tsx test/continuous-test-suite-image-exif.ts",
253
255
  "test:model-pool": "pnpm exec tsx test/continuous-test-suite-model-pool.ts",
254
256
  "test:agents": "pnpm exec tsx test/continuous-test-suite-agents.ts",
255
257
  "test:classifier-router": "pnpm exec tsx test/continuous-test-suite-classifier-router.ts",