nexus-ai-pro 1.21.0 → 1.22.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.
Files changed (96) hide show
  1. package/API_STABILITY.md +16 -0
  2. package/CHANGELOG.md +35 -1
  3. package/README.md +3 -1
  4. package/dist/evaluate/evaluators.d.ts +18 -0
  5. package/dist/evaluate/evaluators.js +35 -0
  6. package/dist/evaluate/index.d.ts +1 -1
  7. package/dist/evaluate/index.js +1 -1
  8. package/dist/loaders/csv.d.ts +29 -0
  9. package/dist/loaders/csv.js +87 -0
  10. package/dist/loaders/git.d.ts +40 -0
  11. package/dist/loaders/git.js +83 -0
  12. package/dist/loaders/html.d.ts +35 -0
  13. package/dist/loaders/html.js +96 -0
  14. package/dist/loaders/index.d.ts +52 -0
  15. package/dist/loaders/index.js +46 -0
  16. package/dist/loaders/input.d.ts +20 -0
  17. package/dist/loaders/input.js +32 -0
  18. package/dist/loaders/json.d.ts +26 -0
  19. package/dist/loaders/json.js +49 -0
  20. package/dist/loaders/markdown.d.ts +33 -0
  21. package/dist/loaders/markdown.js +62 -0
  22. package/dist/loaders/pdf.d.ts +31 -0
  23. package/dist/loaders/pdf.js +38 -0
  24. package/dist/loaders/text.d.ts +35 -0
  25. package/dist/loaders/text.js +33 -0
  26. package/dist/loaders/web.d.ts +63 -0
  27. package/dist/loaders/web.js +137 -0
  28. package/dist/mcp/registry.d.ts +127 -0
  29. package/dist/mcp/registry.js +210 -0
  30. package/dist/rag/chroma.d.ts +63 -0
  31. package/dist/rag/chroma.js +103 -0
  32. package/dist/rag/ingestion.js +1 -1
  33. package/dist/rag/pinecone.d.ts +60 -0
  34. package/dist/rag/pinecone.js +95 -0
  35. package/dist/rag/qdrant.js +1 -7
  36. package/dist/rag/redis.d.ts +58 -0
  37. package/dist/rag/redis.js +158 -0
  38. package/dist/rag/retrievers.d.ts +214 -0
  39. package/dist/rag/retrievers.js +307 -0
  40. package/dist/rag/vector-helpers.d.ts +36 -0
  41. package/dist/rag/vector-helpers.js +70 -0
  42. package/dist/rag/weaviate.d.ts +67 -0
  43. package/dist/rag/weaviate.js +148 -0
  44. package/dist/sqlite/client.d.ts +5 -2
  45. package/dist/sqlite/index.d.ts +1 -0
  46. package/dist/sqlite/index.js +1 -0
  47. package/dist/sqlite/vectors.d.ts +47 -0
  48. package/dist/sqlite/vectors.js +105 -0
  49. package/dist/utils/safe-fetch.d.ts +6 -0
  50. package/dist-cjs/evaluate/evaluators.d.ts +18 -0
  51. package/dist-cjs/evaluate/evaluators.js +37 -0
  52. package/dist-cjs/evaluate/index.d.ts +1 -1
  53. package/dist-cjs/evaluate/index.js +3 -1
  54. package/dist-cjs/loaders/csv.d.ts +29 -0
  55. package/dist-cjs/loaders/csv.js +91 -0
  56. package/dist-cjs/loaders/git.d.ts +40 -0
  57. package/dist-cjs/loaders/git.js +121 -0
  58. package/dist-cjs/loaders/html.d.ts +35 -0
  59. package/dist-cjs/loaders/html.js +100 -0
  60. package/dist-cjs/loaders/index.d.ts +52 -0
  61. package/dist-cjs/loaders/index.js +50 -0
  62. package/dist-cjs/loaders/input.d.ts +20 -0
  63. package/dist-cjs/loaders/input.js +71 -0
  64. package/dist-cjs/loaders/json.d.ts +26 -0
  65. package/dist-cjs/loaders/json.js +52 -0
  66. package/dist-cjs/loaders/markdown.d.ts +33 -0
  67. package/dist-cjs/loaders/markdown.js +67 -0
  68. package/dist-cjs/loaders/pdf.d.ts +31 -0
  69. package/dist-cjs/loaders/pdf.js +43 -0
  70. package/dist-cjs/loaders/text.d.ts +35 -0
  71. package/dist-cjs/loaders/text.js +70 -0
  72. package/dist-cjs/loaders/web.d.ts +63 -0
  73. package/dist-cjs/loaders/web.js +177 -0
  74. package/dist-cjs/mcp/registry.d.ts +127 -0
  75. package/dist-cjs/mcp/registry.js +249 -0
  76. package/dist-cjs/rag/chroma.d.ts +63 -0
  77. package/dist-cjs/rag/chroma.js +108 -0
  78. package/dist-cjs/rag/ingestion.js +1 -1
  79. package/dist-cjs/rag/pinecone.d.ts +60 -0
  80. package/dist-cjs/rag/pinecone.js +100 -0
  81. package/dist-cjs/rag/qdrant.js +3 -9
  82. package/dist-cjs/rag/redis.d.ts +58 -0
  83. package/dist-cjs/rag/redis.js +162 -0
  84. package/dist-cjs/rag/retrievers.d.ts +214 -0
  85. package/dist-cjs/rag/retrievers.js +323 -0
  86. package/dist-cjs/rag/vector-helpers.d.ts +36 -0
  87. package/dist-cjs/rag/vector-helpers.js +81 -0
  88. package/dist-cjs/rag/weaviate.d.ts +67 -0
  89. package/dist-cjs/rag/weaviate.js +153 -0
  90. package/dist-cjs/sqlite/client.d.ts +5 -2
  91. package/dist-cjs/sqlite/index.d.ts +1 -0
  92. package/dist-cjs/sqlite/index.js +4 -1
  93. package/dist-cjs/sqlite/vectors.d.ts +47 -0
  94. package/dist-cjs/sqlite/vectors.js +110 -0
  95. package/dist-cjs/utils/safe-fetch.d.ts +6 -0
  96. package/package.json +210 -1
package/API_STABILITY.md CHANGED
@@ -456,6 +456,22 @@ with nexus-ai-pro and declares the core versions it works with as a peer depende
456
456
  - The access rules are a guarantee: loopback binding by default, a token on every request, the token
457
457
  in a header for every change, and loopback hosts only unless others are allowed.
458
458
 
459
+ ## Retrieval breadth stage (1.22.0)
460
+
461
+ The loaders (`nexus-ai-pro/loaders/*`), the Redis, Pinecone, Weaviate, Chroma, and SQLite vector
462
+ stores, the retrievers (`nexus-ai-pro/rag/retrievers`), and the MCP registry
463
+ (`nexus-ai-pro/mcp/registry`) are experimental in production readiness. Their contracts follow the
464
+ 1.x rules.
465
+
466
+ - Every vector store keeps the `VectorStore` contract and its contract test, which checks that a
467
+ metadata filter matches type as well as value.
468
+ - A loader's document ids are stable: the path relative to a directory or repository, the final URL
469
+ of a page, and the file name plus key or position of a row or record. Chunk ids built from them do
470
+ not change in a minor release, so a reload keeps replacing the same chunks.
471
+ - The REST stores' payload layout — `content`, `source`, `metadata` as JSON, and `meta_` filter fields
472
+ — is stable across 1.x, so an index written by one release is read by the next.
473
+ - The registry reads `servers` and `mcpServers`; new configuration fields are optional.
474
+
459
475
  ## Durable execution stage (1.21.0)
460
476
 
461
477
  Functional workflows (`nexus-ai-pro/graph/functional`) and the SQLite adapters (`nexus-ai-pro/sqlite`)
package/CHANGELOG.md CHANGED
@@ -4,6 +4,39 @@ Notable changes to this project are recorded here. The format follows [Keep a Ch
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [1.22.0] - 2026-09-29
8
+
9
+ Retrieval and integration breadth. Getting documents in, finding the right ones, and reaching the
10
+ tools a team already runs, without a catalogue of dependencies.
11
+
12
+ ### Added
13
+
14
+ - **Document loaders** on `nexus-ai-pro/loaders/*`, one entry point per format: text files and
15
+ directories, Markdown with front matter, HTML, CSV, JSON and JSON Lines, PDF through a parser you
16
+ inject, web pages and sitemaps through the SSRF-safe fetch, and Git repositories. Each streams
17
+ documents, and `loadIntoStore()` splits them into chunks and adds them to one or more stores in
18
+ batches.
19
+ - **Four more vector stores** behind the `VectorStore` contract, each through REST or an injected
20
+ client: Redis with RediSearch (`nexus-ai-pro/rag/redis`), Pinecone, Weaviate, and Chroma
21
+ (`nexus-ai-pro/rag/pinecone`, `/weaviate`, `/chroma`), plus `SqliteVectorStore` on
22
+ `nexus-ai-pro/sqlite/vectors`, ranked in JavaScript or by sqlite-vec. All pass the same contract
23
+ test, which now also checks that a filter matches a value's type.
24
+ - **Composable retrievers** on `nexus-ai-pro/rag/retrievers`: BM25 keyword search (`KeywordIndex`),
25
+ hybrid search fused by reciprocal rank, reranking through any scorer or a chat model, maximal
26
+ marginal relevance, parent-document retrieval, and multi-query retrieval.
27
+ - **Retrieval evaluators**: `recallAtK()` and `reciprocalRank()` on `nexus-ai-pro/evaluate`.
28
+ - **An MCP registry** on `nexus-ai-pro/mcp/registry`: many servers from one configuration file —
29
+ including the file desktop MCP clients use — with per-server allow and deny lists, credentials from
30
+ the environment by placeholder, health checks, and named tool bundles.
31
+
32
+ ### Changed
33
+
34
+ - `SqliteValue` includes bytes, which the drivers bind as blobs.
35
+
36
+ ### Fixed
37
+
38
+ - `ingestDocuments()` honours `overlap: 0`. It used the default overlap of 150 characters instead.
39
+
7
40
  ## [1.21.0] - 2026-09-29
8
41
 
9
42
  Durable execution everywhere. Durability no longer requires drawing a graph, and a crash costs a
@@ -1085,7 +1118,8 @@ shared policy vocabulary is in place for a future release that revisits this.
1085
1118
  - URL fetching rejects unsafe private-network targets and limits response reads.
1086
1119
  - CLI and audit findings no longer disclose detected secret values.
1087
1120
 
1088
- [Unreleased]: https://github.com/mkhitar-abrahamyan/nexus-ai/compare/v1.21.0...HEAD
1121
+ [Unreleased]: https://github.com/mkhitar-abrahamyan/nexus-ai/compare/v1.22.0...HEAD
1122
+ [1.22.0]: https://github.com/mkhitar-abrahamyan/nexus-ai/compare/v1.21.0...v1.22.0
1089
1123
  [1.21.0]: https://github.com/mkhitar-abrahamyan/nexus-ai/compare/v1.20.0...v1.21.0
1090
1124
  [1.20.0]: https://github.com/mkhitar-abrahamyan/nexus-ai/compare/v1.19.0...v1.20.0
1091
1125
  [1.19.0]: https://github.com/mkhitar-abrahamyan/nexus-ai/compare/v1.18.0...v1.19.0
package/README.md CHANGED
@@ -109,7 +109,7 @@ from the doc comments. The guides live in the repository, so these links go to G
109
109
  | [Graphs](https://github.com/mkhitar-abrahamyan/nexus-ai/blob/main/docs/graphs.md) | Parallel branches, interrupts, checkpoints, time travel, subgraphs, caching, diagrams |
110
110
  | [Agents and tools](https://github.com/mkhitar-abrahamyan/nexus-ai/blob/main/docs/agents.md) | Graph-based agents with approvals and middleware, and the simple tool loop |
111
111
  | [Long-term memory](https://github.com/mkhitar-abrahamyan/nexus-ai/blob/main/docs/memory.md) | Namespaced memory with semantic search, in memory or Redis |
112
- | [MCP](https://github.com/mkhitar-abrahamyan/nexus-ai/blob/main/docs/mcp.md) | Borrowing tools from MCP servers and serving your own |
112
+ | [MCP](https://github.com/mkhitar-abrahamyan/nexus-ai/blob/main/docs/mcp.md) | Borrowing tools from MCP servers, a registry of many, and serving your own |
113
113
  | [Traces](https://github.com/mkhitar-abrahamyan/nexus-ai/blob/main/docs/tracing.md) | Run trees, queries, feedback, comparison, and alerts |
114
114
  | [Evaluation](https://github.com/mkhitar-abrahamyan/nexus-ai/blob/main/docs/evaluation.md) | Datasets, evaluators, experiments, comparisons with a verdict, review queues, LLM judges |
115
115
  | [Prompts](https://github.com/mkhitar-abrahamyan/nexus-ai/blob/main/docs/prompts.md) | Typed templates, content versions, gated promotion, rollback, A/B splits, serving through outages |
@@ -120,6 +120,8 @@ from the doc comments. The guides live in the repository, so these links go to G
120
120
  | [Batch tiers](https://github.com/mkhitar-abrahamyan/nexus-ai/blob/main/docs/batch.md) | The providers' discounted batch APIs, resumable from any process |
121
121
  | [Caching](https://github.com/mkhitar-abrahamyan/nexus-ai/blob/main/docs/caching.md) | Exact and semantic response caches, with memory, Redis, and SQLite adapters |
122
122
  | [Grounding](https://github.com/mkhitar-abrahamyan/nexus-ai/blob/main/docs/grounding.md) | Retrieval, citations, verification, self-consistency, and knowledge graphs |
123
+ | [Loaders](https://github.com/mkhitar-abrahamyan/nexus-ai/blob/main/docs/loaders.md) | Files, Markdown, HTML, CSV, JSON, PDF, web pages, sitemaps, and Git repositories into retrieval |
124
+ | [Retrieval](https://github.com/mkhitar-abrahamyan/nexus-ai/blob/main/docs/retrieval.md) | Hybrid search, reranking, MMR, parent documents, and Redis, Pinecone, Weaviate, and Chroma stores |
123
125
  | [Embeddings and retrieval](https://github.com/mkhitar-abrahamyan/nexus-ai/blob/main/docs/embeddings.md) | Embeddings as a routed operation family, and RAG helpers |
124
126
  | [Images (experimental)](https://github.com/mkhitar-abrahamyan/nexus-ai/blob/main/docs/images.md) | Generation and masked edits over three backends, input safety, moderation, asset stores |
125
127
  | [Voice](https://github.com/mkhitar-abrahamyan/nexus-ai/blob/main/docs/voice.md) | Transcription, speech, voice turns, and voice sessions |
@@ -80,3 +80,21 @@ export declare function pairwise(options: {
80
80
  export declare function passRate(key?: string): SummaryEvaluator;
81
81
  /** Total cost of the experiment, so a quality gain that tripled the bill is visible. */
82
82
  export declare function totalCost(key?: string): SummaryEvaluator;
83
+ /** Options for the retrieval evaluators. */
84
+ export interface RetrievalEvaluatorOptions {
85
+ /** Results considered, from the top. Defaults to every result. */
86
+ k?: number;
87
+ /** The metric's name. */
88
+ key?: string;
89
+ }
90
+ /**
91
+ * The share of an example's relevant ids found in the output's top `k`. The output is a ranked list
92
+ * of ids or of chunks with an `id`, such as a retriever's results; the example's `expected` is the
93
+ * relevant id, or a list of them.
94
+ */
95
+ export declare function recallAtK(options?: RetrievalEvaluatorOptions): Evaluator;
96
+ /**
97
+ * One over the rank of the first relevant id in the output's top `k`, or 0 when none is there. Its
98
+ * mean over a dataset is the mean reciprocal rank, which rewards putting the right chunk first.
99
+ */
100
+ export declare function reciprocalRank(options?: RetrievalEvaluatorOptions): Evaluator;
@@ -135,6 +135,41 @@ export function totalCost(key = 'total-cost') {
135
135
  { key, score: results.reduce((total, result) => total + (result.cost ?? 0), 0) },
136
136
  ];
137
137
  }
138
+ export function recallAtK(options = {}) {
139
+ return (context) => {
140
+ const relevant = relevantIds(context.example.expected);
141
+ const found = rankedIds(context.output).slice(0, options.k);
142
+ const hits = relevant.filter((id) => found.includes(id));
143
+ const score = relevant.length === 0 ? 1 : hits.length / relevant.length;
144
+ return {
145
+ key: options.key ?? (options.k ? `recall@${options.k}` : 'recall'),
146
+ score,
147
+ passed: hits.length === relevant.length,
148
+ ...(hits.length === relevant.length
149
+ ? {}
150
+ : { comment: `missing: ${relevant.filter((id) => !hits.includes(id)).join(', ')}` }),
151
+ };
152
+ };
153
+ }
154
+ export function reciprocalRank(options = {}) {
155
+ return (context) => {
156
+ const relevant = new Set(relevantIds(context.example.expected));
157
+ const rank = rankedIds(context.output)
158
+ .slice(0, options.k)
159
+ .findIndex((id) => relevant.has(id));
160
+ return { key: options.key ?? 'reciprocal-rank', score: rank < 0 ? 0 : 1 / (rank + 1), passed: rank === 0 };
161
+ };
162
+ }
163
+ function relevantIds(expected) {
164
+ if (Array.isArray(expected))
165
+ return expected.map(String);
166
+ return expected === undefined || expected === null ? [] : [String(expected)];
167
+ }
168
+ function rankedIds(output) {
169
+ if (!Array.isArray(output))
170
+ return [];
171
+ return output.map((item) => item !== null && typeof item === 'object' && 'id' in item ? String(item.id) : String(item));
172
+ }
138
173
  function asText(value) {
139
174
  if (value === undefined || value === null)
140
175
  return '';
@@ -1,6 +1,6 @@
1
1
  export { type CompareOptions, compareExperiments, type ExampleComparison, type ExperimentComparison, formatComparison, type MetricComparison, scoreMap, } from './compare.js';
2
2
  export { contentVersion, createDataset, type CreateDatasetOptions, datasetFromTraces, FileDatasetStore, FileExperimentStore, type FromTracesOptions, MemoryDatasetStore, MemoryExperimentStore, readExperiment, splitOf, } from './datasets.js';
3
- export { completed, contains, embeddingSimilarity, exactMatch, mustNotMatch, pairwise, passRate, totalCost, trajectory, type TrajectoryOptions, underCost, underLatency, } from './evaluators.js';
3
+ export { completed, contains, embeddingSimilarity, exactMatch, mustNotMatch, pairwise, passRate, recallAtK, reciprocalRank, type RetrievalEvaluatorOptions, totalCost, trajectory, type TrajectoryOptions, underCost, underLatency, } from './evaluators.js';
4
4
  export { AnnotationQueue, type AnnotationQueueOptions, evaluateOnline, type OnlineEvaluationOptions, type OnlineEvaluationReport, type ReviewAnswer, type ReviewItem, type ReviewQuestion, } from './review.js';
5
5
  export { evaluate, type EvaluateOptions, type EvaluationTarget, stats, summarize } from './run.js';
6
6
  export type { Dataset, DatasetExample, DatasetStore, EvaluationContext, EvaluationScore, Evaluator, ExampleResult, Experiment, ExperimentStore, MetricSummary, SummaryEvaluator, } from '../types/evaluate.js';
@@ -1,5 +1,5 @@
1
1
  export { compareExperiments, formatComparison, scoreMap, } from './compare.js';
2
2
  export { contentVersion, createDataset, datasetFromTraces, FileDatasetStore, FileExperimentStore, MemoryDatasetStore, MemoryExperimentStore, readExperiment, splitOf, } from './datasets.js';
3
- export { completed, contains, embeddingSimilarity, exactMatch, mustNotMatch, pairwise, passRate, totalCost, trajectory, underCost, underLatency, } from './evaluators.js';
3
+ export { completed, contains, embeddingSimilarity, exactMatch, mustNotMatch, pairwise, passRate, recallAtK, reciprocalRank, totalCost, trajectory, underCost, underLatency, } from './evaluators.js';
4
4
  export { AnnotationQueue, evaluateOnline, } from './review.js';
5
5
  export { evaluate, stats, summarize } from './run.js';
@@ -0,0 +1,29 @@
1
+ import type { DocumentSource, FileInput } from './index.js';
2
+ import type { FileLoaderOptions } from './text.js';
3
+ /** Options for `parseCsv()`. */
4
+ export interface CsvParseOptions {
5
+ /** The field separator. Defaults to a comma; use `'\t'` for TSV. */
6
+ delimiter?: string;
7
+ }
8
+ /**
9
+ * Parses CSV into rows of fields, following RFC 4180: quoted fields may hold the delimiter, line
10
+ * breaks, and doubled quotes; lines may end in CRLF or LF; and a trailing line break adds no row.
11
+ */
12
+ export declare function parseCsv(text: string, options?: CsvParseOptions): string[][];
13
+ /** Options for `loadCsv()`. */
14
+ export interface CsvLoaderOptions extends FileLoaderOptions, CsvParseOptions {
15
+ /**
16
+ * Columns that make up a row's text, written as `column: value` lines. Defaults to every column not
17
+ * named in `idColumn` or `metadataColumns`.
18
+ */
19
+ textColumns?: readonly string[];
20
+ /** The column whose value becomes the row's id, after the file name. Defaults to the row number. */
21
+ idColumn?: string;
22
+ /** Columns copied into the metadata, where a filter can select on them. */
23
+ metadataColumns?: readonly string[];
24
+ }
25
+ /**
26
+ * CSV files, one document per row. The first row names the columns. Each row's text lists its columns
27
+ * by name, so a model reading a chunk knows what each value is.
28
+ */
29
+ export declare function loadCsv(inputs: FileInput | Iterable<FileInput>, options?: CsvLoaderOptions): AsyncGenerator<DocumentSource>;
@@ -0,0 +1,87 @@
1
+ import { inputsOf, readText } from './input.js';
2
+ export function parseCsv(text, options = {}) {
3
+ const delimiter = options.delimiter ?? ',';
4
+ if (delimiter.length !== 1 || delimiter === '"' || delimiter === '\n' || delimiter === '\r') {
5
+ throw new RangeError('delimiter must be one character other than a quote or a line break');
6
+ }
7
+ const rows = [];
8
+ let row = [];
9
+ let field = '';
10
+ let quoted = false;
11
+ let index = text.charCodeAt(0) === 0xfeff ? 1 : 0;
12
+ for (; index < text.length; index++) {
13
+ const char = text[index];
14
+ if (quoted) {
15
+ if (char !== '"')
16
+ field += char;
17
+ else if (text[index + 1] === '"') {
18
+ field += '"';
19
+ index++;
20
+ }
21
+ else
22
+ quoted = false;
23
+ }
24
+ else if (char === '"' && field === '')
25
+ quoted = true;
26
+ else if (char === delimiter) {
27
+ row.push(field);
28
+ field = '';
29
+ }
30
+ else if (char === '\n' || char === '\r') {
31
+ if (char === '\r' && text[index + 1] === '\n')
32
+ index++;
33
+ row.push(field);
34
+ rows.push(row);
35
+ row = [];
36
+ field = '';
37
+ }
38
+ else
39
+ field += char;
40
+ }
41
+ if (quoted)
42
+ throw new SyntaxError('The CSV ends inside a quoted field');
43
+ if (field !== '' || row.length > 0) {
44
+ row.push(field);
45
+ rows.push(row);
46
+ }
47
+ return rows;
48
+ }
49
+ export async function* loadCsv(inputs, options = {}) {
50
+ for (const input of inputsOf(inputs)) {
51
+ const { source, text } = await readText(input);
52
+ const [header, ...rows] = parseCsv(text, options);
53
+ if (!header)
54
+ continue;
55
+ const column = (name) => {
56
+ const index = header.indexOf(name);
57
+ if (index < 0)
58
+ throw new RangeError(`${source} has no column "${name}"`);
59
+ return index;
60
+ };
61
+ const idIndex = options.idColumn === undefined ? -1 : column(options.idColumn);
62
+ const metadataIndexes = (options.metadataColumns ?? []).map((name) => [name, column(name)]);
63
+ const textIndexes = options.textColumns
64
+ ? options.textColumns.map(column)
65
+ : header
66
+ .map((_, index) => index)
67
+ .filter((index) => index !== idIndex && !metadataIndexes.some(([, metadata]) => metadata === index));
68
+ for (const [rowIndex, fields] of rows.entries()) {
69
+ if (fields.length === 1 && fields[0] === '')
70
+ continue;
71
+ const key = idIndex >= 0 ? fields[idIndex] : String(rowIndex + 1);
72
+ yield {
73
+ id: `${source}#${key}`,
74
+ text: textIndexes
75
+ .map((index) => `${header[index]}: ${fields[index] ?? ''}`)
76
+ .filter((line) => !line.endsWith(': '))
77
+ .join('\n'),
78
+ source,
79
+ metadata: {
80
+ ...options.metadata,
81
+ ...Object.fromEntries(metadataIndexes.map(([name, index]) => [name, fields[index] ?? ''])),
82
+ row: rowIndex + 1,
83
+ },
84
+ };
85
+ }
86
+ }
87
+ }
@@ -0,0 +1,40 @@
1
+ import type { DocumentSource } from './index.js';
2
+ import type { FileParser } from './text.js';
3
+ /** Options for `loadGitRepository()`. */
4
+ export interface GitLoaderOptions {
5
+ /** A branch or tag to read. Cloned at that ref; a local repository without it is read as checked out. */
6
+ ref?: string;
7
+ /** File extensions read, lower case with the dot, such as `['.md', '.ts']`. Defaults to every text file. */
8
+ extensions?: readonly string[];
9
+ /** Skips a path, relative to the repository root with `/` separators. */
10
+ ignore?: (path: string) => boolean;
11
+ /** A parser per extension, as for `loadDirectory()`. Other files load as plain text. */
12
+ parsers?: Record<string, FileParser>;
13
+ /** Files larger than this are skipped, in bytes. Defaults to 1 MB. */
14
+ maxFileBytes?: number;
15
+ /** The `git` executable. Defaults to `git` on the path. */
16
+ git?: string;
17
+ /** Time allowed for the clone, in milliseconds. Defaults to 2 minutes. */
18
+ timeoutMs?: number;
19
+ /** Application data added to every document. */
20
+ metadata?: Record<string, unknown>;
21
+ }
22
+ /** Raised when `git` fails, with what it printed. */
23
+ export declare class GitLoaderError extends Error {
24
+ /** What `git` wrote to stderr. */
25
+ readonly stderr: string;
26
+ constructor(message: string,
27
+ /** What `git` wrote to stderr. */
28
+ stderr: string);
29
+ }
30
+ /**
31
+ * The files a Git repository tracks, one document each. A remote URL — `https://`, `ssh://`,
32
+ * `git://`, `file://`, or `user@host:path` — is shallow-cloned into a temporary directory that is
33
+ * removed afterwards; a local path is read in place. Only tracked files are read, binary files and
34
+ * files over `maxFileBytes` are skipped, and each document carries the repository and commit in its
35
+ * metadata, so a citation can link to the exact revision.
36
+ *
37
+ * Runs the `git` executable, without a shell, with credential prompts and the `ext::` transport
38
+ * disabled. A clone keeps the committed line endings, so a commit loads the same on every platform.
39
+ */
40
+ export declare function loadGitRepository(repository: string, options?: GitLoaderOptions): AsyncGenerator<DocumentSource>;
@@ -0,0 +1,83 @@
1
+ export class GitLoaderError extends Error {
2
+ stderr;
3
+ constructor(message, stderr) {
4
+ super(message);
5
+ this.stderr = stderr;
6
+ this.name = 'GitLoaderError';
7
+ }
8
+ }
9
+ const REMOTE = /^(?:https?|ssh|git|file):\/\/|^[\w.-]+@[\w.-]+:/;
10
+ export async function* loadGitRepository(repository, options = {}) {
11
+ if (repository.startsWith('-'))
12
+ throw new RangeError('A repository must not start with "-"');
13
+ const { mkdtemp, readFile, rm } = await import('node:fs/promises');
14
+ const { tmpdir } = await import('node:os');
15
+ const path = await import('node:path');
16
+ const remote = REMOTE.test(repository);
17
+ const clone = remote || options.ref !== undefined;
18
+ const directory = clone ? await mkdtemp(path.join(tmpdir(), 'nexus-git-')) : repository;
19
+ try {
20
+ if (clone) {
21
+ await git([
22
+ 'clone',
23
+ ...(remote ? ['--depth', '1'] : []),
24
+ '--no-tags',
25
+ ...(options.ref ? ['--branch', options.ref] : []),
26
+ '--',
27
+ remote ? repository : path.resolve(repository),
28
+ directory,
29
+ ], undefined, options);
30
+ }
31
+ const commit = (await git(['rev-parse', 'HEAD'], directory, options)).trim();
32
+ const files = (await git(['ls-files', '-z'], directory, options)).split('\0').filter(Boolean).sort();
33
+ const extensions = options.extensions && new Set(options.extensions.map((extension) => extension.toLowerCase()));
34
+ const maxFileBytes = options.maxFileBytes ?? 1_000_000;
35
+ for (const file of files) {
36
+ const extension = path.extname(file).toLowerCase();
37
+ if (extensions && !extensions.has(extension))
38
+ continue;
39
+ if (options.ignore?.(file))
40
+ continue;
41
+ let bytes;
42
+ try {
43
+ bytes = new Uint8Array(await readFile(path.join(directory, file)));
44
+ }
45
+ catch {
46
+ continue;
47
+ }
48
+ if (bytes.length > maxFileBytes || bytes.subarray(0, 8000).includes(0))
49
+ continue;
50
+ const metadata = { ...options.metadata, repository, commit, path: file };
51
+ const parse = options.parsers?.[extension];
52
+ if (parse) {
53
+ for await (const document of parse({ source: file, content: bytes })) {
54
+ yield { ...document, metadata: { ...metadata, ...document.metadata } };
55
+ }
56
+ }
57
+ else {
58
+ yield { id: file, text: new TextDecoder().decode(bytes), source: file, metadata };
59
+ }
60
+ }
61
+ }
62
+ finally {
63
+ if (clone)
64
+ await rm(directory, { recursive: true, force: true });
65
+ }
66
+ }
67
+ async function git(args, cwd, options) {
68
+ const { execFile } = await import('node:child_process');
69
+ return new Promise((resolve, reject) => {
70
+ execFile(options.git ?? 'git', ['-c', 'protocol.ext.allow=never', '-c', 'core.quotePath=false', '-c', 'core.autocrlf=false', ...args], {
71
+ cwd,
72
+ timeout: options.timeoutMs ?? 120_000,
73
+ maxBuffer: 64 * 1024 * 1024,
74
+ env: { ...process.env, GIT_TERMINAL_PROMPT: '0', GIT_ASKPASS: '', SSH_ASKPASS: '' },
75
+ windowsHide: true,
76
+ }, (error, stdout, stderr) => {
77
+ if (error)
78
+ reject(new GitLoaderError(`git ${args[0]} failed: ${stderr.trim() || error.message}`, stderr));
79
+ else
80
+ resolve(stdout);
81
+ });
82
+ });
83
+ }
@@ -0,0 +1,35 @@
1
+ import type { DocumentSource, FileInput } from './index.js';
2
+ import type { FileLoaderOptions } from './text.js';
3
+ /** An HTML page as text, with what a loader needs from its head and its links. */
4
+ export interface HtmlText {
5
+ /** The readable text. Headings become Markdown `#` lines and list items `- ` lines. */
6
+ text: string;
7
+ /** The `<title>`. */
8
+ title?: string;
9
+ /** The `<meta name="description">`. */
10
+ description?: string;
11
+ /** Every `href` on an `<a>`, resolved against `baseUrl` when one is given. */
12
+ links: string[];
13
+ }
14
+ /** Options for `htmlToText()`. */
15
+ export interface HtmlToTextOptions {
16
+ /**
17
+ * Keeps only `<main>`, or else `<article>`, when the page has one, dropping navigation, headers,
18
+ * and footers. On by default.
19
+ */
20
+ mainContent?: boolean;
21
+ /** Resolves relative links. */
22
+ baseUrl?: string | URL;
23
+ }
24
+ /**
25
+ * Turns HTML into readable text without a DOM: scripts, styles, and the head are dropped, block
26
+ * elements become line breaks, headings become Markdown headings so `splitOnMarkdownHeadings` splits
27
+ * a page at its sections, and entities are decoded. A heuristic, not a browser: it is built for pages
28
+ * whose content is in the HTML, not rendered by script.
29
+ */
30
+ export declare function htmlToText(html: string, options?: HtmlToTextOptions): HtmlText;
31
+ /** Options for `loadHtml()`. */
32
+ export interface HtmlLoaderOptions extends FileLoaderOptions, HtmlToTextOptions {
33
+ }
34
+ /** HTML files, one document each, with the page title and description in the metadata. */
35
+ export declare function loadHtml(inputs: FileInput | Iterable<FileInput>, options?: HtmlLoaderOptions): AsyncGenerator<DocumentSource>;
@@ -0,0 +1,96 @@
1
+ import { inputsOf, readText } from './input.js';
2
+ const DROPPED = /<(script|style|noscript|template|svg|iframe|canvas|head)\b[^>]*>[\s\S]*?<\/\1\s*>/gi;
3
+ const BLOCK = /<\/?(p|div|section|article|main|header|footer|nav|aside|blockquote|pre|table|thead|tbody|tfoot|tr|ul|ol|dl|dt|dd|figure|figcaption|form|fieldset|address|hr)\b[^>]*>/gi;
4
+ const ENTITIES = {
5
+ amp: '&',
6
+ lt: '<',
7
+ gt: '>',
8
+ quot: '"',
9
+ apos: "'",
10
+ nbsp: ' ',
11
+ mdash: '—',
12
+ ndash: '–',
13
+ hellip: '…',
14
+ copy: '©',
15
+ reg: '®',
16
+ trade: '™',
17
+ lsquo: '‘',
18
+ rsquo: '’',
19
+ ldquo: '“',
20
+ rdquo: '”',
21
+ laquo: '«',
22
+ raquo: '»',
23
+ middot: '·',
24
+ bull: '•',
25
+ euro: '€',
26
+ pound: '£',
27
+ deg: '°',
28
+ };
29
+ export function htmlToText(html, options = {}) {
30
+ const title = /<title\b[^>]*>([\s\S]*?)<\/title\s*>/i.exec(html)?.[1];
31
+ const description = /<meta\b[^>]*name\s*=\s*["']description["'][^>]*content\s*=\s*["']([^"']*)["']/i.exec(html)?.[1] ??
32
+ /<meta\b[^>]*content\s*=\s*["']([^"']*)["'][^>]*name\s*=\s*["']description["']/i.exec(html)?.[1];
33
+ let body = html.replace(/<!--[\s\S]*?-->/g, '').replace(DROPPED, '');
34
+ if (options.mainContent ?? true) {
35
+ const region = /<main\b[^>]*>([\s\S]*?)<\/main\s*>/i.exec(body) ?? /<article\b[^>]*>([\s\S]*?)<\/article\s*>/i.exec(body);
36
+ if (region)
37
+ body = region[1];
38
+ }
39
+ const links = [];
40
+ for (const match of body.matchAll(/<a\b[^>]*href\s*=\s*["']([^"'#][^"']*)["']/gi)) {
41
+ const href = decodeEntities(match[1]);
42
+ try {
43
+ links.push(options.baseUrl ? new URL(href, options.baseUrl).href : href);
44
+ }
45
+ catch {
46
+ }
47
+ }
48
+ const text = body
49
+ .replace(/<h([1-6])\b[^>]*>([\s\S]*?)<\/h\1\s*>/gi, (_, level, inner) => {
50
+ return `\n\n${'#'.repeat(Number(level))} ${inner.replace(/<[^>]+>/g, '').trim()}\n\n`;
51
+ })
52
+ .replace(/<li\b[^>]*>/gi, '\n- ')
53
+ .replace(/<br\s*\/?>/gi, '\n')
54
+ .replace(/<\/(td|th)\s*>/gi, '\t')
55
+ .replace(BLOCK, '\n')
56
+ .replace(/<[^>]+>/g, '');
57
+ return {
58
+ text: tidy(decodeEntities(text)),
59
+ ...(title ? { title: tidy(decodeEntities(title)) } : {}),
60
+ ...(description ? { description: decodeEntities(description).trim() } : {}),
61
+ links: [...new Set(links)],
62
+ };
63
+ }
64
+ export async function* loadHtml(inputs, options = {}) {
65
+ for (const input of inputsOf(inputs)) {
66
+ const { source, text } = await readText(input);
67
+ const page = htmlToText(text, options);
68
+ yield {
69
+ id: source,
70
+ text: page.text,
71
+ source,
72
+ metadata: {
73
+ ...options.metadata,
74
+ ...(page.title ? { title: page.title } : {}),
75
+ ...(page.description ? { description: page.description } : {}),
76
+ },
77
+ };
78
+ }
79
+ }
80
+ function decodeEntities(text) {
81
+ return text.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (entity, name) => {
82
+ if (name[0] === '#') {
83
+ const code = name[1] === 'x' || name[1] === 'X' ? Number.parseInt(name.slice(2), 16) : Number(name.slice(1));
84
+ return Number.isFinite(code) && code > 0 && code <= 0x10ffff ? String.fromCodePoint(code) : entity;
85
+ }
86
+ return ENTITIES[name.toLowerCase()] ?? entity;
87
+ });
88
+ }
89
+ function tidy(text) {
90
+ return text
91
+ .split('\n')
92
+ .map((line) => line.replace(/[ \t\f\v ]+/g, ' ').trim())
93
+ .join('\n')
94
+ .replace(/\n{3,}/g, '\n\n')
95
+ .trim();
96
+ }
@@ -0,0 +1,52 @@
1
+ import type { RagChunk } from '../hallucination/rag.js';
2
+ import { type DocumentSource, type IngestionOptions } from '../rag/ingestion.js';
3
+ export type { DocumentSource } from '../rag/ingestion.js';
4
+ /**
5
+ * Documents produced one at a time. Every loader returns one, so a corpus larger than memory streams
6
+ * through ingestion instead of being read whole, and an array of documents works as well.
7
+ */
8
+ export type DocumentLoader = AsyncIterable<DocumentSource> | Iterable<DocumentSource>;
9
+ /**
10
+ * A file to load: a path, a `file:` URL, or content already in memory with a name for it. In-memory
11
+ * content is how a loader reads an upload, a database blob, or a file on a runtime without a disk.
12
+ */
13
+ export type FileInput = string | URL | {
14
+ source: string;
15
+ content: string | Uint8Array;
16
+ };
17
+ /** Reads every document from one or more loaders, in order, into an array. */
18
+ export declare function collectDocuments(...loaders: DocumentLoader[]): Promise<DocumentSource[]>;
19
+ /**
20
+ * Where loaded chunks go: a vector store, a `KeywordIndex`, or anything else with an `add()` for
21
+ * chunks. Loading into several at once fills a vector store and the keyword index hybrid search reads
22
+ * in one pass over the corpus.
23
+ */
24
+ export interface ChunkSink {
25
+ /** Adds chunks, replacing those whose id is already held. */
26
+ add(chunks: RagChunk[]): unknown;
27
+ }
28
+ /** Options for `loadIntoStore()`: how documents are split, and how many are embedded at once. */
29
+ export interface LoadIntoStoreOptions extends IngestionOptions {
30
+ /** Documents split and added to the store together. Defaults to 32. */
31
+ batchSize?: number;
32
+ /** Stops loading between batches. */
33
+ signal?: AbortSignal;
34
+ /** Called after each batch is stored, for progress reporting. */
35
+ onBatch?: (progress: LoadIntoStoreResult) => void;
36
+ }
37
+ /** What `loadIntoStore()` stored. */
38
+ export interface LoadIntoStoreResult {
39
+ /** Documents read from the loaders. */
40
+ documents: number;
41
+ /** Chunks added to the store. */
42
+ chunks: number;
43
+ /** Characters across every document. */
44
+ totalCharacters: number;
45
+ }
46
+ /**
47
+ * Streams documents from loaders into one or more stores: each batch is split into chunks and added to
48
+ * every store at once, so memory holds one batch at a time however large the corpus is. Chunk ids
49
+ * derive from document ids, so loading the same corpus again replaces its chunks rather than
50
+ * duplicating them. A document with no id is numbered in load order.
51
+ */
52
+ export declare function loadIntoStore(stores: ChunkSink | readonly ChunkSink[], loaders: DocumentLoader | readonly DocumentLoader[], options?: LoadIntoStoreOptions): Promise<LoadIntoStoreResult>;
@@ -0,0 +1,46 @@
1
+ import { ingestDocuments } from '../rag/ingestion.js';
2
+ export async function collectDocuments(...loaders) {
3
+ const documents = [];
4
+ for (const loader of loaders) {
5
+ for await (const document of loader)
6
+ documents.push(document);
7
+ }
8
+ return documents;
9
+ }
10
+ export async function loadIntoStore(stores, loaders, options = {}) {
11
+ const batchSize = options.batchSize ?? 32;
12
+ if (!(Number.isInteger(batchSize) && batchSize > 0))
13
+ throw new RangeError('batchSize must be a positive integer');
14
+ const result = { documents: 0, chunks: 0, totalCharacters: 0 };
15
+ let batch = [];
16
+ const flush = async () => {
17
+ if (batch.length === 0)
18
+ return;
19
+ const ingested = ingestDocuments(batch, options);
20
+ const targets = 'add' in stores ? [stores] : stores;
21
+ await Promise.all(targets.map((store) => store.add(ingested.chunks)));
22
+ result.chunks += ingested.chunks.length;
23
+ result.totalCharacters += ingested.totalCharacters;
24
+ batch = [];
25
+ options.onBatch?.({ ...result });
26
+ };
27
+ const sources = isLoaderList(loaders) ? loaders : [loaders];
28
+ for (const loader of sources) {
29
+ for await (const document of loader) {
30
+ options.signal?.throwIfAborted();
31
+ result.documents++;
32
+ batch.push(document.id ? document : { ...document, id: `doc-${result.documents}` });
33
+ if (batch.length >= batchSize)
34
+ await flush();
35
+ }
36
+ }
37
+ options.signal?.throwIfAborted();
38
+ await flush();
39
+ return result;
40
+ }
41
+ function isLoaderList(value) {
42
+ if (!Array.isArray(value))
43
+ return false;
44
+ const first = value[0];
45
+ return first !== undefined && typeof first === 'object' && first !== null && !('text' in first);
46
+ }