@juspay/neurolink 12.51.2 → 12.51.4

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.
@@ -3,6 +3,7 @@
3
3
  *
4
4
  * Splits HTML content by semantic tags.
5
5
  */
6
+ import { removeHtmlMarkup } from "../../utils/htmlText.js";
6
7
  import { BaseChunker, DEFAULT_CHUNKER_CONFIG } from "./BaseChunker.js";
7
8
  /**
8
9
  * HTML Chunker
@@ -28,10 +29,12 @@ export class HTMLChunker extends BaseChunker {
28
29
  * Strip HTML tags from content
29
30
  */
30
31
  stripHtml(html) {
31
- return html
32
- .replace(/<script[^>]*>[\s\S]*?<\/script>/gi, "")
33
- .replace(/<style[^>]*>[\s\S]*?<\/style>/gi, "")
34
- .replace(/<[^>]+>/g, " ")
32
+ return removeHtmlMarkup(html, {
33
+ tagReplacement: " ",
34
+ commentReplacement: " ",
35
+ lineBreakEndTags: [],
36
+ lineBreakVoidTags: [],
37
+ })
35
38
  .replace(/\s+/g, " ")
36
39
  .trim();
37
40
  }
@@ -6,6 +6,7 @@
6
6
  */
7
7
  import { randomUUID } from "crypto";
8
8
  import { decodeHtmlTextEntitiesOnce } from "../../utils/htmlEntities.js";
9
+ import { removeHtmlMarkup } from "../../utils/htmlText.js";
9
10
  /**
10
11
  * HTML-aware chunker implementation
11
12
  * Splits based on HTML structure (tags, elements)
@@ -171,16 +172,24 @@ export class HTMLChunker {
171
172
  * Extract plain text from HTML
172
173
  */
173
174
  extractText(html) {
174
- return decodeHtmlTextEntitiesOnce(html
175
- // Remove script and style elements
176
- .replace(/<script[\s\S]*?<\/script>/gi, "")
177
- .replace(/<style[\s\S]*?<\/style>/gi, "")
178
- // Remove HTML comments
179
- .replace(/<!--[\s\S]*?-->/g, "")
180
- // Replace block elements with newlines
181
- .replace(/<\/(p|div|br|h[1-6]|li|tr)>/gi, "\n")
182
- // Remove remaining tags
183
- .replace(/<[^>]+>/g, ""), { nbsp: " ", amp: "&", lt: "<", gt: ">", quot: '"', "#039": "'" })
175
+ return decodeHtmlTextEntitiesOnce(removeHtmlMarkup(html, {
176
+ tagReplacement: "",
177
+ commentReplacement: "",
178
+ lineBreakEndTags: [
179
+ "p",
180
+ "div",
181
+ "br",
182
+ "h1",
183
+ "h2",
184
+ "h3",
185
+ "h4",
186
+ "h5",
187
+ "h6",
188
+ "li",
189
+ "tr",
190
+ ],
191
+ lineBreakVoidTags: [],
192
+ }), { nbsp: " ", amp: "&", lt: "<", gt: ">", quot: '"', "#039": "'" })
184
193
  .replace(/\s+/g, " ")
185
194
  .trim();
186
195
  }
@@ -28,6 +28,7 @@ import { readFile } from "fs/promises";
28
28
  import { basename, extname } from "path";
29
29
  import { logger } from "../../utils/logger.js";
30
30
  import { decodeHtmlTextEntitiesOnce } from "../../utils/htmlEntities.js";
31
+ import { removeHtmlMarkup } from "../../utils/htmlText.js";
31
32
  import { CSVProcessor, stripBom, stripMetadataLine, splitCsvLines, dedupeColumnNames, sanitizeColumnName, } from "../../utils/csvProcessor.js";
32
33
  import { splitCSVFields } from "../../utils/csvUtils.js";
33
34
  import { MDocument } from "./MDocument.js";
@@ -429,17 +430,25 @@ export class WebLoader {
429
430
  * Convert HTML to plain text
430
431
  */
431
432
  htmlToText(html) {
432
- return decodeHtmlTextEntitiesOnce(html
433
- // Remove script and style elements
434
- .replace(/<script[\s\S]*?<\/script>/gi, "")
435
- .replace(/<style[\s\S]*?<\/style>/gi, "")
436
- // Remove HTML comments
437
- .replace(/<!--[\s\S]*?-->/g, "")
438
- // Replace common block elements with newlines
439
- .replace(/<\/(p|div|h[1-6]|br|li|tr|blockquote)>/gi, "\n")
440
- .replace(/<(br|hr)\s*\/?>/gi, "\n")
441
- // Remove remaining tags
442
- .replace(/<[^>]+>/g, ""), {
433
+ return decodeHtmlTextEntitiesOnce(removeHtmlMarkup(html, {
434
+ tagReplacement: "",
435
+ commentReplacement: "",
436
+ lineBreakEndTags: [
437
+ "p",
438
+ "div",
439
+ "h1",
440
+ "h2",
441
+ "h3",
442
+ "h4",
443
+ "h5",
444
+ "h6",
445
+ "br",
446
+ "li",
447
+ "tr",
448
+ "blockquote",
449
+ ],
450
+ lineBreakVoidTags: ["br", "hr"],
451
+ }), {
443
452
  nbsp: " ",
444
453
  amp: "&",
445
454
  lt: "<",
@@ -135,13 +135,17 @@ function extractToken(ctx, type, headerName, customExtractor) {
135
135
  }
136
136
  switch (type) {
137
137
  case "bearer": {
138
- // Extract token from "Bearer <token>"
139
- const match = headerValue.match(/^Bearer\s+(.+)$/i);
138
+ // Extract token from "Bearer <token>". The token must open with a
139
+ // non-whitespace character. With `\s+(.+)` the whitespace run and the
140
+ // token could both claim the same tabs, so a header that failed to match
141
+ // (a line terminator after a long run of tabs) was retried at every
142
+ // split, which is quadratic in the header length.
143
+ const match = headerValue.match(/^Bearer\s+(\S.*)$/i);
140
144
  return match ? match[1] : null;
141
145
  }
142
146
  case "basic": {
143
- // Extract credentials from "Basic <base64>"
144
- const match = headerValue.match(/^Basic\s+(.+)$/i);
147
+ // Extract credentials from "Basic <base64>" (same shape as above).
148
+ const match = headerValue.match(/^Basic\s+(\S.*)$/i);
145
149
  return match ? match[1] : null;
146
150
  }
147
151
  case "api-key":
@@ -1,4 +1,5 @@
1
1
  import express from "express";
2
+ import rateLimit from "express-rate-limit";
2
3
  import fs from "fs";
3
4
  import http from "http";
4
5
  import path from "path";
@@ -29,6 +30,24 @@ function resolvePublicPath() {
29
30
  }
30
31
  return compiled; // let express.static handle the 404
31
32
  }
33
+ // Per-client request budget for the HTTP surface (the page and its static
34
+ // assets). One page load is a handful of requests, so the default leaves normal
35
+ // use untouched and only bounds a client that is hammering the file-serving
36
+ // routes. Both values are operator settings, read once at startup.
37
+ const DEFAULT_RATE_LIMIT_WINDOW_MS = 60_000;
38
+ const DEFAULT_RATE_LIMIT_MAX = 300;
39
+ function positiveIntegerFromEnv(name, fallback) {
40
+ const raw = process.env[name];
41
+ if (raw === undefined || raw.trim() === "") {
42
+ return fallback;
43
+ }
44
+ const parsed = Number(raw);
45
+ if (!Number.isSafeInteger(parsed) || parsed < 1) {
46
+ logger.warn(`[SERVER] Ignoring ${name}: expected a positive integer, using ${fallback}`);
47
+ return fallback;
48
+ }
49
+ return parsed;
50
+ }
32
51
  export async function startVoiceServer(port = 3000) {
33
52
  const app = express();
34
53
  // NEW11: refuse to bind to non-loopback interfaces unless the operator
@@ -46,6 +65,22 @@ export async function startVoiceServer(port = 3000) {
46
65
  // set custom headers — see voiceWebSocketHandler.verifyClient. HTTP routes
47
66
  // intentionally reject `?token=` (would leak via Referer + access logs).
48
67
  const authToken = process.env.VOICE_SERVER_AUTH_TOKEN;
68
+ /* ---------- RATE LIMIT ---------- */
69
+ // Registered first so it also bounds failed-auth attempts and oversized
70
+ // bodies. /health stays exempt for the same reason the auth middleware skips
71
+ // it: a load balancer probes it on a fixed cadence with no credentials.
72
+ const rateLimitWindowMs = positiveIntegerFromEnv("VOICE_SERVER_RATE_LIMIT_WINDOW_MS", DEFAULT_RATE_LIMIT_WINDOW_MS);
73
+ const rateLimitMax = positiveIntegerFromEnv("VOICE_SERVER_RATE_LIMIT_MAX", DEFAULT_RATE_LIMIT_MAX);
74
+ app.use(rateLimit({
75
+ windowMs: rateLimitWindowMs,
76
+ limit: rateLimitMax,
77
+ standardHeaders: "draft-6",
78
+ legacyHeaders: false,
79
+ skip: (req) => req.path === "/health",
80
+ handler: (_req, res) => {
81
+ res.status(429).json({ error: "Too many requests" });
82
+ },
83
+ }));
49
84
  /* ---------- BODY LIMITS + AUTH ---------- */
50
85
  // NEW11: cap JSON / urlencoded body to 100kb. Express's default is 100kb
51
86
  // for json() but only when explicitly registered; without this any future
@@ -108,7 +143,7 @@ export async function startVoiceServer(port = 3000) {
108
143
  const exposure = allowPublic
109
144
  ? `bound publicly on ${host}:${port} (VOICE_SERVER_ALLOW_PUBLIC=1)`
110
145
  : `bound to loopback ${host}:${port} (set VOICE_SERVER_ALLOW_PUBLIC=1 to expose externally)`;
111
- logger.info(`[SERVER] Voice server running — ${exposure}${authToken ? " (auth required)" : " (no auth — token via VOICE_SERVER_AUTH_TOKEN recommended)"}`);
146
+ logger.info(`[SERVER] Voice server running — ${exposure}${authToken ? " (auth required)" : " (no auth — token via VOICE_SERVER_AUTH_TOKEN recommended)"}; rate limit ${rateLimitMax} requests per ${rateLimitWindowMs}ms per client`);
112
147
  resolve();
113
148
  });
114
149
  });
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Single-pass markup removal for turning HTML into indexable text.
3
+ *
4
+ * This is text conversion, not output sanitization. The result is plain text
5
+ * for retrieval and chunking; it is not meant to be written back into a page,
6
+ * and the caller's entity decoding can legitimately turn `&lt;b&gt;` into
7
+ * `<b>`.
8
+ *
9
+ * Why one pass: the previous implementation chained several global regex
10
+ * replacements (script, style, comment, block end, generic tag). Each removal
11
+ * could join the text on either side of it into a new tag for the next
12
+ * replacement (`<scr<!---->ipt>`), and the lazy `[\s\S]*?` bodies rescanned
13
+ * the rest of the input once per unclosed `<script`, which is quadratic.
14
+ * Here the input is read left to right exactly once. Every character of the
15
+ * output is copied from an unremoved stretch of the input, a removed stretch
16
+ * is never re-read, and every search either consumes what it scans or is
17
+ * answered in constant time from an index computed up front, so the work is
18
+ * linear in the input length.
19
+ *
20
+ * Invariant: the output never matches `<[^>]+>`. A `<` survives only when no
21
+ * `>` follows it in the input, or when the next character is the `>` itself
22
+ * (`<>`, which is not a tag). A removed span therefore cannot leave behind,
23
+ * or assemble, a tag.
24
+ */
25
+ /**
26
+ * Remove HTML markup from `html` in one linear pass.
27
+ *
28
+ * - `<script>` / `<style>` elements are removed with their bodies. The end
29
+ * tag may carry whitespace or attributes (`</script >`). An element with
30
+ * no usable end tag loses only its start tag, as before.
31
+ * - `<!-- ... -->` is replaced with `commentReplacement`. An unterminated
32
+ * comment is treated like any other tag and ends at the next `>`.
33
+ * - A closing tag named in `lineBreakEndTags` and a void tag named in
34
+ * `lineBreakVoidTags` (`<br>`, `<hr />`) become a newline.
35
+ * - Any other `<...>` becomes `tagReplacement`. A `<` with no `>` after it,
36
+ * and the empty `<>`, are literal text and are kept.
37
+ *
38
+ * Tag-name lists are lower case.
39
+ */
40
+ export declare function removeHtmlMarkup(html: string, options: {
41
+ readonly tagReplacement: string;
42
+ readonly commentReplacement: string;
43
+ readonly lineBreakEndTags: readonly string[];
44
+ readonly lineBreakVoidTags: readonly string[];
45
+ }): string;
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Single-pass markup removal for turning HTML into indexable text.
3
+ *
4
+ * This is text conversion, not output sanitization. The result is plain text
5
+ * for retrieval and chunking; it is not meant to be written back into a page,
6
+ * and the caller's entity decoding can legitimately turn `&lt;b&gt;` into
7
+ * `<b>`.
8
+ *
9
+ * Why one pass: the previous implementation chained several global regex
10
+ * replacements (script, style, comment, block end, generic tag). Each removal
11
+ * could join the text on either side of it into a new tag for the next
12
+ * replacement (`<scr<!---->ipt>`), and the lazy `[\s\S]*?` bodies rescanned
13
+ * the rest of the input once per unclosed `<script`, which is quadratic.
14
+ * Here the input is read left to right exactly once. Every character of the
15
+ * output is copied from an unremoved stretch of the input, a removed stretch
16
+ * is never re-read, and every search either consumes what it scans or is
17
+ * answered in constant time from an index computed up front, so the work is
18
+ * linear in the input length.
19
+ *
20
+ * Invariant: the output never matches `<[^>]+>`. A `<` survives only when no
21
+ * `>` follows it in the input, or when the next character is the `>` itself
22
+ * (`<>`, which is not a tag). A removed span therefore cannot leave behind,
23
+ * or assemble, a tag.
24
+ */
25
+ const SLASH = 0x2f;
26
+ const GREATER_THAN = 0x3e;
27
+ const WHITESPACE = /\s/;
28
+ function isAsciiAlphanumeric(code) {
29
+ return ((code >= 0x30 && code <= 0x39) ||
30
+ (code >= 0x41 && code <= 0x5a) ||
31
+ (code >= 0x61 && code <= 0x7a));
32
+ }
33
+ // The characters after which an HTML tokenizer ends a tag name.
34
+ function endsTagName(code) {
35
+ return (code === 0x09 ||
36
+ code === 0x0a ||
37
+ code === 0x0c ||
38
+ code === 0x0d ||
39
+ code === 0x20 ||
40
+ code === SLASH ||
41
+ code === GREATER_THAN);
42
+ }
43
+ function scanTagName(html, from) {
44
+ let end = from;
45
+ while (end < html.length && isAsciiAlphanumeric(html.charCodeAt(end))) {
46
+ end++;
47
+ }
48
+ return end;
49
+ }
50
+ /**
51
+ * Index of the next `</name` that is followed by whitespace, `/` or `>`
52
+ * (so `</script >` and `</SCRIPT foo>` count), or -1. Case-insensitive.
53
+ */
54
+ function findEndTag(html, name, from) {
55
+ let at = html.indexOf("</", from);
56
+ while (at !== -1) {
57
+ const nameEnd = at + 2 + name.length;
58
+ if (html.slice(at + 2, nameEnd).toLowerCase() === name &&
59
+ endsTagName(html.charCodeAt(nameEnd))) {
60
+ return at;
61
+ }
62
+ at = html.indexOf("</", at + 2);
63
+ }
64
+ return -1;
65
+ }
66
+ /**
67
+ * Remove HTML markup from `html` in one linear pass.
68
+ *
69
+ * - `<script>` / `<style>` elements are removed with their bodies. The end
70
+ * tag may carry whitespace or attributes (`</script >`). An element with
71
+ * no usable end tag loses only its start tag, as before.
72
+ * - `<!-- ... -->` is replaced with `commentReplacement`. An unterminated
73
+ * comment is treated like any other tag and ends at the next `>`.
74
+ * - A closing tag named in `lineBreakEndTags` and a void tag named in
75
+ * `lineBreakVoidTags` (`<br>`, `<hr />`) become a newline.
76
+ * - Any other `<...>` becomes `tagReplacement`. A `<` with no `>` after it,
77
+ * and the empty `<>`, are literal text and are kept.
78
+ *
79
+ * Tag-name lists are lower case.
80
+ */
81
+ export function removeHtmlMarkup(html, options) {
82
+ const { tagReplacement, commentReplacement } = options;
83
+ const lineBreakEndTags = new Set(options.lineBreakEndTags);
84
+ const lineBreakVoidTags = new Set(options.lineBreakVoidTags);
85
+ // Answers "is there any `>` / `-->` after here" without a scan. Once an
86
+ // element finds no usable end tag, no later element of that name can.
87
+ const lastGreaterThan = html.lastIndexOf(">");
88
+ const lastCommentEnd = html.lastIndexOf("-->");
89
+ const withoutEndTag = new Set();
90
+ const markupAt = (at) => {
91
+ if (html.startsWith("<!--", at) && lastCommentEnd >= at + 4) {
92
+ return {
93
+ end: html.indexOf("-->", at + 4) + 3,
94
+ replacement: commentReplacement,
95
+ };
96
+ }
97
+ const closing = html.charCodeAt(at + 1) === SLASH;
98
+ const nameStart = closing ? at + 2 : at + 1;
99
+ const nameEnd = scanTagName(html, nameStart);
100
+ const name = html.slice(nameStart, nameEnd).toLowerCase();
101
+ if (!closing &&
102
+ (name === "script" || name === "style") &&
103
+ endsTagName(html.charCodeAt(nameEnd)) &&
104
+ !withoutEndTag.has(name)) {
105
+ const startTagEnd = html.indexOf(">", nameEnd);
106
+ const endTag = findEndTag(html, name, startTagEnd + 1);
107
+ if (endTag !== -1 && endTag < lastGreaterThan) {
108
+ return { end: html.indexOf(">", endTag) + 1, replacement: "" };
109
+ }
110
+ withoutEndTag.add(name);
111
+ }
112
+ if (closing &&
113
+ html.charCodeAt(nameEnd) === GREATER_THAN &&
114
+ lineBreakEndTags.has(name)) {
115
+ return { end: nameEnd + 1, replacement: "\n" };
116
+ }
117
+ if (!closing && lineBreakVoidTags.has(name)) {
118
+ let probe = nameEnd;
119
+ while (WHITESPACE.test(html.charAt(probe))) {
120
+ probe++;
121
+ }
122
+ if (html.charCodeAt(probe) === SLASH) {
123
+ probe++;
124
+ }
125
+ if (html.charCodeAt(probe) === GREATER_THAN) {
126
+ return { end: probe + 1, replacement: "\n" };
127
+ }
128
+ }
129
+ const end = html.indexOf(">", at + 1);
130
+ return end === at + 1
131
+ ? null
132
+ : { end: end + 1, replacement: tagReplacement };
133
+ };
134
+ let output = "";
135
+ let copied = 0;
136
+ let open = html.indexOf("<");
137
+ while (open !== -1 && open < lastGreaterThan) {
138
+ const markup = markupAt(open);
139
+ if (markup === null) {
140
+ open = html.indexOf("<", open + 1);
141
+ continue;
142
+ }
143
+ output += html.slice(copied, open) + markup.replacement;
144
+ copied = markup.end;
145
+ open = html.indexOf("<", copied);
146
+ }
147
+ return output + html.slice(copied);
148
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@juspay/neurolink",
3
- "version": "12.51.2",
3
+ "version": "12.51.4",
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": {
@@ -164,11 +164,13 @@
164
164
  "test:embedding-response-validation": "pnpm exec tsx test/continuous-test-suite-embedding-response-validation.ts",
165
165
  "test:pdf-image-streaming": "pnpm exec tsx test/continuous-test-suite-pdf-image-streaming.ts",
166
166
  "test:rag": "pnpm exec tsx test/continuous-test-suite-rag.ts",
167
+ "test:rag-html-markup": "pnpm exec tsx test/continuous-test-suite-rag-html-markup.ts",
167
168
  "test:skills": "pnpm exec tsx test/continuous-test-suite-skills.ts",
168
169
  "test:decide": "pnpm exec tsx test/continuous-test-suite-decide.ts",
169
170
  "test:decide-catalog": "pnpm exec tsx test/continuous-test-suite-decide-catalog.ts",
170
171
  "test:agent-mode": "pnpm exec tsx test/continuous-test-suite-agent-mode.ts",
171
172
  "test:servers": "pnpm exec tsx test/continuous-test-suite-servers.ts",
173
+ "test:server-boundaries": "pnpm exec tsx test/continuous-test-suite-server-boundaries.ts",
172
174
  "test:websocket-agent-handler": "pnpm exec tsx test/continuous-test-suite-websocket-agent-handler.ts",
173
175
  "test:tool-reliability": "pnpm exec tsx test/continuous-test-suite-tool-reliability.ts",
174
176
  "test:video-abort": "pnpm exec tsx test/continuous-test-suite-video-abort.ts",