@jcode.labs/ragmir 2.17.1 → 2.18.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 (203) hide show
  1. package/README.md +53 -6
  2. package/dist/access-log.d.ts +10 -0
  3. package/dist/access-log.d.ts.map +1 -1
  4. package/dist/access-log.js +119 -49
  5. package/dist/access-log.js.map +1 -1
  6. package/dist/chunking.d.ts +5 -1
  7. package/dist/chunking.d.ts.map +1 -1
  8. package/dist/chunking.js +102 -16
  9. package/dist/chunking.js.map +1 -1
  10. package/dist/citation.d.ts +18 -0
  11. package/dist/citation.d.ts.map +1 -0
  12. package/dist/citation.js +54 -0
  13. package/dist/citation.js.map +1 -0
  14. package/dist/cli-entry.d.ts +3 -0
  15. package/dist/cli-entry.d.ts.map +1 -0
  16. package/dist/cli-entry.js +16 -0
  17. package/dist/cli-entry.js.map +1 -0
  18. package/dist/cli-fast.d.ts +13 -0
  19. package/dist/cli-fast.d.ts.map +1 -0
  20. package/dist/cli-fast.js +145 -0
  21. package/dist/cli-fast.js.map +1 -0
  22. package/dist/cli-options.d.ts +3 -0
  23. package/dist/cli-options.d.ts.map +1 -1
  24. package/dist/cli-options.js +7 -0
  25. package/dist/cli-options.js.map +1 -1
  26. package/dist/cli-stdin.d.ts +3 -0
  27. package/dist/cli-stdin.d.ts.map +1 -0
  28. package/dist/cli-stdin.js +15 -0
  29. package/dist/cli-stdin.js.map +1 -0
  30. package/dist/cli.js +254 -47
  31. package/dist/cli.js.map +1 -1
  32. package/dist/client.d.ts +13 -2
  33. package/dist/client.d.ts.map +1 -1
  34. package/dist/client.js +155 -10
  35. package/dist/client.js.map +1 -1
  36. package/dist/config.d.ts +6 -0
  37. package/dist/config.d.ts.map +1 -1
  38. package/dist/config.js +227 -49
  39. package/dist/config.js.map +1 -1
  40. package/dist/context-resources.d.ts +4 -2
  41. package/dist/context-resources.d.ts.map +1 -1
  42. package/dist/context-resources.js +55 -24
  43. package/dist/context-resources.js.map +1 -1
  44. package/dist/defaults.d.ts +27 -0
  45. package/dist/defaults.d.ts.map +1 -1
  46. package/dist/defaults.js +42 -2
  47. package/dist/defaults.js.map +1 -1
  48. package/dist/destroy.d.ts +2 -2
  49. package/dist/destroy.d.ts.map +1 -1
  50. package/dist/destroy.js +34 -20
  51. package/dist/destroy.js.map +1 -1
  52. package/dist/doctor.d.ts +3 -2
  53. package/dist/doctor.d.ts.map +1 -1
  54. package/dist/doctor.js +86 -41
  55. package/dist/doctor.js.map +1 -1
  56. package/dist/durable-file.d.ts +13 -0
  57. package/dist/durable-file.d.ts.map +1 -0
  58. package/dist/durable-file.js +56 -0
  59. package/dist/durable-file.js.map +1 -0
  60. package/dist/embeddings.d.ts +14 -3
  61. package/dist/embeddings.d.ts.map +1 -1
  62. package/dist/embeddings.js +233 -95
  63. package/dist/embeddings.js.map +1 -1
  64. package/dist/errors.d.ts +1 -1
  65. package/dist/errors.d.ts.map +1 -1
  66. package/dist/errors.js.map +1 -1
  67. package/dist/evaluate.d.ts +4 -3
  68. package/dist/evaluate.d.ts.map +1 -1
  69. package/dist/evaluate.js +611 -110
  70. package/dist/evaluate.js.map +1 -1
  71. package/dist/files.d.ts +4 -1
  72. package/dist/files.d.ts.map +1 -1
  73. package/dist/files.js +119 -33
  74. package/dist/files.js.map +1 -1
  75. package/dist/generation-retention.d.ts +52 -0
  76. package/dist/generation-retention.d.ts.map +1 -0
  77. package/dist/generation-retention.js +318 -0
  78. package/dist/generation-retention.js.map +1 -0
  79. package/dist/index-diagnostics.d.ts +3 -2
  80. package/dist/index-diagnostics.d.ts.map +1 -1
  81. package/dist/index-diagnostics.js +32 -3
  82. package/dist/index-diagnostics.js.map +1 -1
  83. package/dist/index-policy.d.ts.map +1 -1
  84. package/dist/index-policy.js +3 -0
  85. package/dist/index-policy.js.map +1 -1
  86. package/dist/index-write-lock.d.ts +16 -1
  87. package/dist/index-write-lock.d.ts.map +1 -1
  88. package/dist/index-write-lock.js +392 -13
  89. package/dist/index-write-lock.js.map +1 -1
  90. package/dist/index.d.ts +12 -4
  91. package/dist/index.d.ts.map +1 -1
  92. package/dist/index.js +5 -2
  93. package/dist/index.js.map +1 -1
  94. package/dist/ingest.d.ts +11 -2
  95. package/dist/ingest.d.ts.map +1 -1
  96. package/dist/ingest.js +680 -165
  97. package/dist/ingest.js.map +1 -1
  98. package/dist/ingestion-metrics.d.ts +58 -0
  99. package/dist/ingestion-metrics.d.ts.map +1 -0
  100. package/dist/ingestion-metrics.js +216 -0
  101. package/dist/ingestion-metrics.js.map +1 -0
  102. package/dist/ingestion-state.d.ts +11 -2
  103. package/dist/ingestion-state.d.ts.map +1 -1
  104. package/dist/ingestion-state.js +480 -49
  105. package/dist/ingestion-state.js.map +1 -1
  106. package/dist/limits.d.ts.map +1 -1
  107. package/dist/limits.js +11 -1
  108. package/dist/limits.js.map +1 -1
  109. package/dist/mcp-output.d.ts +44 -4
  110. package/dist/mcp-output.d.ts.map +1 -1
  111. package/dist/mcp-output.js +132 -159
  112. package/dist/mcp-output.js.map +1 -1
  113. package/dist/mcp-summaries.d.ts +32 -0
  114. package/dist/mcp-summaries.d.ts.map +1 -0
  115. package/dist/mcp-summaries.js +227 -0
  116. package/dist/mcp-summaries.js.map +1 -0
  117. package/dist/mcp.d.ts +1 -1
  118. package/dist/mcp.d.ts.map +1 -1
  119. package/dist/mcp.js +85 -46
  120. package/dist/mcp.js.map +1 -1
  121. package/dist/ocr-cache.d.ts +15 -0
  122. package/dist/ocr-cache.d.ts.map +1 -0
  123. package/dist/ocr-cache.js +90 -0
  124. package/dist/ocr-cache.js.map +1 -0
  125. package/dist/ocr.d.ts +31 -0
  126. package/dist/ocr.d.ts.map +1 -1
  127. package/dist/ocr.js +193 -17
  128. package/dist/ocr.js.map +1 -1
  129. package/dist/parsing.d.ts.map +1 -1
  130. package/dist/parsing.js +482 -85
  131. package/dist/parsing.js.map +1 -1
  132. package/dist/permissions.js +2 -2
  133. package/dist/permissions.js.map +1 -1
  134. package/dist/preview.d.ts.map +1 -1
  135. package/dist/preview.js +10 -11
  136. package/dist/preview.js.map +1 -1
  137. package/dist/quality-report.d.ts +12 -0
  138. package/dist/quality-report.d.ts.map +1 -0
  139. package/dist/quality-report.js +146 -0
  140. package/dist/quality-report.js.map +1 -0
  141. package/dist/query.d.ts +7 -4
  142. package/dist/query.d.ts.map +1 -1
  143. package/dist/query.js +371 -284
  144. package/dist/query.js.map +1 -1
  145. package/dist/ranking.d.ts +38 -0
  146. package/dist/ranking.d.ts.map +1 -0
  147. package/dist/ranking.js +278 -0
  148. package/dist/ranking.js.map +1 -0
  149. package/dist/redaction.d.ts +6 -1
  150. package/dist/redaction.d.ts.map +1 -1
  151. package/dist/redaction.js +118 -4
  152. package/dist/redaction.js.map +1 -1
  153. package/dist/research.d.ts +6 -1
  154. package/dist/research.d.ts.map +1 -1
  155. package/dist/research.js +330 -130
  156. package/dist/research.js.map +1 -1
  157. package/dist/runtime-info.d.ts +3 -0
  158. package/dist/runtime-info.d.ts.map +1 -0
  159. package/dist/runtime-info.js +81 -0
  160. package/dist/runtime-info.js.map +1 -0
  161. package/dist/security.d.ts +3 -2
  162. package/dist/security.d.ts.map +1 -1
  163. package/dist/security.js +242 -45
  164. package/dist/security.js.map +1 -1
  165. package/dist/semantic-config.d.ts +7 -1
  166. package/dist/semantic-config.d.ts.map +1 -1
  167. package/dist/semantic-config.js +30 -4
  168. package/dist/semantic-config.js.map +1 -1
  169. package/dist/setup.js +6 -3
  170. package/dist/setup.js.map +1 -1
  171. package/dist/skill.js +34 -12
  172. package/dist/skill.js.map +1 -1
  173. package/dist/source-fingerprint-cache.d.ts +20 -0
  174. package/dist/source-fingerprint-cache.d.ts.map +1 -0
  175. package/dist/source-fingerprint-cache.js +133 -0
  176. package/dist/source-fingerprint-cache.js.map +1 -0
  177. package/dist/storage-maintenance.d.ts +46 -0
  178. package/dist/storage-maintenance.d.ts.map +1 -0
  179. package/dist/storage-maintenance.js +329 -0
  180. package/dist/storage-maintenance.js.map +1 -0
  181. package/dist/store.d.ts +39 -2
  182. package/dist/store.d.ts.map +1 -1
  183. package/dist/store.js +559 -24
  184. package/dist/store.js.map +1 -1
  185. package/dist/text.d.ts +1 -1
  186. package/dist/text.d.ts.map +1 -1
  187. package/dist/text.js +12 -2
  188. package/dist/text.js.map +1 -1
  189. package/dist/types.d.ts +414 -5
  190. package/dist/types.d.ts.map +1 -1
  191. package/dist/vector-index.d.ts +42 -0
  192. package/dist/vector-index.d.ts.map +1 -0
  193. package/dist/vector-index.js +249 -0
  194. package/dist/vector-index.js.map +1 -0
  195. package/dist/version.d.ts +1 -1
  196. package/dist/version.js +1 -1
  197. package/dist/workload.d.ts +22 -0
  198. package/dist/workload.d.ts.map +1 -0
  199. package/dist/workload.js +181 -0
  200. package/dist/workload.js.map +1 -0
  201. package/examples/document-evidence-benchmark/README.md +3 -2
  202. package/examples/document-evidence-benchmark/golden-queries.json +1 -1
  203. package/package.json +36 -11
package/dist/ingest.js CHANGED
@@ -2,19 +2,26 @@ import { recordAccess } from "./access-log.js";
2
2
  import { summarizeChunkStats } from "./chunk-stats.js";
3
3
  import { chunkDocument, chunkSearchText } from "./chunking.js";
4
4
  import { loadConfig } from "./config.js";
5
- import { VECTOR_DISTANCE_METRIC } from "./defaults.js";
6
- import { embedTexts } from "./embeddings.js";
5
+ import { MAX_INGEST_CHUNK_WINDOW, MAX_INGEST_CHUNKS_PER_FILE, MAX_INGEST_FILE_BATCH_SIZE, MAX_INGEST_SOURCE_WINDOW_BYTES, MAX_INGEST_VECTOR_BYTES_PER_FILE, VECTOR_DISTANCE_METRIC, } from "./defaults.js";
6
+ import { embeddingModelState, embedTexts } from "./embeddings.js";
7
7
  import { countSkippedByReason, inventorySourceFiles, summarizeUnsupportedExtensions, } from "./files.js";
8
+ import { collectGenerationGarbageUnlocked } from "./generation-retention.js";
8
9
  import { INDEX_SCHEMA_VERSION } from "./index-diagnostics.js";
9
10
  import { indexPolicyFingerprint } from "./index-policy.js";
10
11
  import { withIndexWriteLock } from "./index-write-lock.js";
11
- import { canResumeIngestion, createIngestionRunState, finishIngestionState, generationTableName, ingestionProgress, readIngestionState, removeStagedIndexManifest, resumeIngestionState, updateIngestionFile, writeIngestionState, writeStagedIndexManifest, } from "./ingestion-state.js";
12
+ import { activeIngestionMetrics, createIngestionMetricsCollector, runWithIngestionMetrics, } from "./ingestion-metrics.js";
13
+ import { canResumeIngestion, compactIngestionState, createIngestionRunState, finishIngestionState, generationTableName, ingestionProgress, readIngestionState, reconcileIngestionFile, removeStagedIndexManifest, resumeIngestionState, updateIngestionFile, writeIngestionState, } from "./ingestion-state.js";
12
14
  import { operationSignal, throwIfAborted } from "./operation.js";
13
15
  import { parseFile } from "./parsing.js";
14
- import { redactText, totalRedactions } from "./redaction.js";
15
- import { activeIndexTableName, dropRowsTable, openRowsTable, openRowsTableByName, readEmptyTextFiles, readIndexManifest, updateRowsInTable, writeEmptyTextFiles, writeIndexManifest, } from "./store.js";
16
+ import { summarizeIndexedCorpus } from "./quality-report.js";
17
+ import { redactDocument, totalRedactions } from "./redaction.js";
18
+ import { securityAuditWithConfig } from "./security.js";
19
+ import { maintainStorageTable } from "./storage-maintenance.js";
20
+ import { activeIndexTableName, closeRowsTable, dropRowsTable, openRowsTable, openRowsTableByName, readEmptyTextFiles, readIndexManifest, updateRowsInTable, writeEmptyTextFiles, writeIndexManifest, } from "./store.js";
16
21
  import { VERSION } from "./version.js";
22
+ import { runWorkload } from "./workload.js";
17
23
  const MAX_SOURCE_DIAGNOSTIC_ITEMS = 20;
24
+ const MAX_HEALTH_PREVIEW_ITEMS = 50;
18
25
  const DEFAULT_INGEST_FILE_BATCH_SIZE = 25;
19
26
  const ARCHIVE_PATH_PATTERNS = [
20
27
  /(^|[/_-])archive(s)?([/_-]|$)/iu,
@@ -36,10 +43,49 @@ export async function ingest(options = {}) {
36
43
  }
37
44
  export async function ingestWithConfig(config, options = {}, connection) {
38
45
  const signal = operationSignal(options);
39
- return withIndexWriteLock(config.storageDir, signal, () => ingestUnlocked(config, options, connection, signal));
46
+ return runWorkload(config, "ingestion", signal, async ({ queueTimeMs }) => {
47
+ const collector = createIngestionMetricsCollector(options.collectMetrics === true, queueTimeMs, config.embeddingProvider);
48
+ if (!collector) {
49
+ return withIndexWriteLock(config.storageDir, signal, () => ingestUnlocked(config, options, connection, signal));
50
+ }
51
+ return runWithIngestionMetrics(collector, async () => {
52
+ collector.beginPhase("writeLockWait");
53
+ try {
54
+ const result = await withIndexWriteLock(config.storageDir, signal, () => {
55
+ collector.endPhase("writeLockWait");
56
+ return ingestUnlocked(config, options, connection, signal);
57
+ });
58
+ const metrics = collector.complete({
59
+ runId: result.runId,
60
+ status: result.errors.length > 0 ? "completed_with_errors" : "completed",
61
+ candidateFiles: result.supportedFiles,
62
+ processedFiles: result.rebuiltFiles + result.emptyTextFiles.length + result.errors.length,
63
+ fallbackFiles: result.staleLastKnownGood.length,
64
+ errorCount: result.errors.length,
65
+ ocrSubprocesses: result.ocr.subprocesses,
66
+ });
67
+ return options.collectMetrics === true ? { ...result, metrics } : result;
68
+ }
69
+ catch (error) {
70
+ collector.endPhase("writeLockWait");
71
+ collector.recordFailure(error);
72
+ collector.complete({
73
+ runId: null,
74
+ status: signal?.aborted ? "interrupted" : "failed",
75
+ candidateFiles: 0,
76
+ processedFiles: 0,
77
+ fallbackFiles: 0,
78
+ errorCount: 1,
79
+ ocrSubprocesses: 0,
80
+ });
81
+ throw error;
82
+ }
83
+ });
84
+ });
40
85
  }
41
86
  async function ingestUnlocked(config, options, connection, signal) {
42
87
  let state = null;
88
+ const metrics = activeIngestionMetrics();
43
89
  try {
44
90
  throwIfAborted(signal);
45
91
  const requestedBatchSize = ingestFileBatchSize(options.batchSize);
@@ -47,7 +93,10 @@ async function ingestUnlocked(config, options, connection, signal) {
47
93
  const existingManifest = await readIndexManifest(config);
48
94
  const storedState = await readIngestionState(config);
49
95
  const storedEmptyFiles = await readEmptyTextFiles(config);
50
- const inventory = await inventorySourceFiles(config, signal ? { signal } : {});
96
+ const inventory = metrics
97
+ ? await metrics.measureAsync("inventory", () => inventorySourceFiles(config, signal ? { signal } : {}))
98
+ : await inventorySourceFiles(config, signal ? { signal } : {});
99
+ metrics?.recordSourceBytes(inventory.contentBytesRead);
51
100
  const files = inventory.supportedFiles;
52
101
  const inventoryMetrics = sourceInventoryMetrics(files);
53
102
  const currentFiles = new Map(files.map((file) => [file.relativePath, file]));
@@ -63,6 +112,10 @@ async function ingestUnlocked(config, options, connection, signal) {
63
112
  const canReuse = manifestCompatible && existingTable !== null;
64
113
  const previousIndexedFiles = canReuse ? (existingManifest.indexedFiles ?? []) : [];
65
114
  const previousEmptyFiles = canReuse ? storedEmptyFiles : [];
115
+ const previousFiles = new Map([
116
+ ...previousIndexedFiles.map((file) => [file.relativePath, file]),
117
+ ...previousEmptyFiles.map((file) => [file.relativePath, { ...file, chunkCount: 0 }]),
118
+ ]);
66
119
  const reusableIndexedFiles = previousIndexedFiles.filter((file) => currentFiles.get(file.relativePath)?.checksum === file.checksum);
67
120
  const reusableEmptyFiles = previousEmptyFiles.filter((file) => currentFiles.get(file.relativePath)?.checksum === file.checksum);
68
121
  const reusablePaths = new Set([
@@ -104,22 +157,15 @@ async function ingestUnlocked(config, options, connection, signal) {
104
157
  files,
105
158
  reusablePaths: mode === "incremental" ? reusablePaths : new Set(),
106
159
  reusableChunkCounts,
160
+ ...(mode === "incremental" ? { previousFiles } : {}),
107
161
  });
108
162
  if (mode === "rebuild") {
109
- state = {
110
- ...state,
111
- tableName: generationTableName(config.tableName, state.runId),
112
- files: state.files.map((file) => ({
113
- ...file,
114
- state: "pending",
115
- chunkCount: 0,
116
- reused: false,
117
- })),
118
- };
163
+ state.tableName = generationTableName(config.tableName, state.runId);
119
164
  }
120
165
  }
121
166
  await persistIngestionProgress(state, config, options);
122
167
  throwIfAborted(signal);
168
+ const tableName = state.tableName;
123
169
  const previousPaths = new Set(previousIndexedFiles.map((file) => file.relativePath));
124
170
  const currentPaths = new Set(files.map((file) => file.relativePath));
125
171
  const removedPaths = state.mode === "incremental"
@@ -133,12 +179,17 @@ async function ingestUnlocked(config, options, connection, signal) {
133
179
  });
134
180
  let removalApplied = false;
135
181
  let lexicalIndexWarning = null;
136
- for (const fileBatch of valueBatches(pendingFiles, state.batchSize)) {
182
+ let storageWarning = null;
183
+ let storageMutations = 0;
184
+ const ocr = emptyPdfOcrMetrics();
185
+ const failurePolicy = incrementalFailurePolicy(options, config);
186
+ for (const fileWindow of ingestionWindows(pendingFiles, state.batchSize, config)) {
137
187
  throwIfAborted(signal);
138
- const parsedBatch = await mapLimit(fileBatch, config.ingestConcurrency, signal, async (file) => parseSourceFile(file, config, signal));
139
- for (const parsed of parsedBatch) {
188
+ const parsedWindow = await mapLimit(fileWindow, config.ingestConcurrency, signal, async (file) => parseSourceFile(file, config, signal));
189
+ for (const parsed of parsedWindow) {
190
+ mergePdfOcrMetrics(ocr, parsed.ocr);
140
191
  state = updateIngestionFile(state, parsed.file.relativePath, parsed.error
141
- ? { state: "error", chunkCount: 0, redactions: 0, error: parsed.error }
192
+ ? { state: "error", redactions: 0, error: parsed.error }
142
193
  : {
143
194
  state: "parsed",
144
195
  chunkCount: parsed.chunks.length,
@@ -148,41 +199,117 @@ async function ingestUnlocked(config, options, connection, signal) {
148
199
  }
149
200
  await persistIngestionProgress(state, config, options);
150
201
  throwIfAborted(signal);
151
- const successfulFiles = parsedBatch.filter((parsed) => parsed.error === null);
152
- const allChunks = successfulFiles.flatMap((parsed) => parsed.chunks);
153
- const rows = await vectorRowsForChunks(allChunks, config, signal);
154
- for (const parsed of successfulFiles) {
155
- state = updateIngestionFile(state, parsed.file.relativePath, { state: "embedded" });
156
- }
157
- await persistIngestionProgress(state, config, options);
158
- throwIfAborted(signal);
159
- const replacePaths = [
160
- ...fileBatch.map((file) => file.relativePath),
161
- ...(!removalApplied ? removedPaths : []),
162
- ];
163
- const writeResult = await updateRowsInTable(rows, replacePaths, state.tableName, config, connection);
164
- removalApplied = true;
165
- lexicalIndexWarning ??= writeResult.lexicalIndexWarning;
166
- for (const parsed of successfulFiles) {
167
- state = updateIngestionFile(state, parsed.file.relativePath, { state: "indexed" });
202
+ for (const parsed of parsedWindow) {
203
+ throwIfAborted(signal);
204
+ const successful = parsed.error === null;
205
+ const chunkCount = parsed.chunks.length;
206
+ const rows = successful ? await vectorRowsForChunks(parsed.chunks, config, signal) : [];
207
+ if (successful) {
208
+ state = updateIngestionFile(state, parsed.file.relativePath, { state: "embedded" });
209
+ await persistIngestionProgress(state, config, options);
210
+ throwIfAborted(signal);
211
+ }
212
+ const replacePaths = [
213
+ ...(successful || (state.mode === "incremental" && failurePolicy === "remove-stale")
214
+ ? [parsed.file.relativePath]
215
+ : []),
216
+ ...(!removalApplied ? removedPaths : []),
217
+ ];
218
+ if (rows.length > 0 || replacePaths.length > 0) {
219
+ const writeResult = metrics
220
+ ? await metrics.measureAsync("storageWrite", () => updateRowsInTable(rows, replacePaths, tableName, config, connection))
221
+ : await updateRowsInTable(rows, replacePaths, tableName, config, connection);
222
+ metrics?.recordStoragePayload(vectorRowsPayloadBytes(rows));
223
+ removalApplied = true;
224
+ storageMutations += 1;
225
+ lexicalIndexWarning ??= writeResult.lexicalIndexWarning;
226
+ }
227
+ if (successful) {
228
+ state = updateIngestionFile(state, parsed.file.relativePath, {
229
+ state: "indexed",
230
+ lastGoodChecksum: parsed.file.checksum,
231
+ lastGoodChunkCount: chunkCount,
232
+ lastGoodBytes: parsed.file.bytes,
233
+ lastGoodMtimeMs: parsed.file.mtimeMs,
234
+ staleLastKnownGood: false,
235
+ });
236
+ }
237
+ else if (state.mode === "incremental" && failurePolicy === "remove-stale") {
238
+ state = updateIngestionFile(state, parsed.file.relativePath, {
239
+ chunkCount: 0,
240
+ lastGoodChecksum: null,
241
+ lastGoodChunkCount: 0,
242
+ lastGoodBytes: null,
243
+ lastGoodMtimeMs: null,
244
+ staleLastKnownGood: false,
245
+ });
246
+ }
247
+ await persistIngestionProgress(state, config, options);
248
+ parsed.chunks.length = 0;
249
+ throwIfAborted(signal);
168
250
  }
169
- await writeProgressManifest(state, config, connection);
170
- await persistIngestionProgress(state, config, options);
171
- throwIfAborted(signal);
172
251
  }
173
252
  if (!removalApplied && removedPaths.length > 0) {
174
- const writeResult = await updateRowsInTable([], removedPaths, state.tableName, config, connection);
253
+ const writeResult = metrics
254
+ ? await metrics.measureAsync("storageWrite", () => updateRowsInTable([], removedPaths, tableName, config, connection))
255
+ : await updateRowsInTable([], removedPaths, tableName, config, connection);
256
+ storageMutations += 1;
175
257
  lexicalIndexWarning ??= writeResult.lexicalIndexWarning;
176
258
  }
177
- const manifest = await manifestForState(state, config, connection);
259
+ sortIngestionFilesForStorage(state);
260
+ let manifest = await manifestForState(state, config, connection);
261
+ const maintain = () => maintainStorageTable(tableName, config, connection, {
262
+ additionalMutations: storageMutations,
263
+ ...(manifest.vectorDimension === undefined
264
+ ? {}
265
+ : { vectorDimension: manifest.vectorDimension }),
266
+ });
267
+ metrics?.recordMaintenanceOperation();
268
+ const maintenance = metrics
269
+ ? await metrics.measureAsync("maintenance", maintain)
270
+ : await maintain();
271
+ if (manifest.chunkCount > 0 && !maintenance.adaptiveIndices) {
272
+ throw new Error("Cannot activate an index without adaptive vector index diagnostics.");
273
+ }
274
+ if (maintenance.adaptiveIndices) {
275
+ manifest = { ...manifest, vectorIndex: maintenance.adaptiveIndices.vectorIndex };
276
+ }
277
+ const securityReport = await securityAuditWithConfig(config, {
278
+ deep: false,
279
+ ...(signal ? { signal } : {}),
280
+ });
281
+ const checkedAt = new Date().toISOString();
282
+ manifest = {
283
+ ...manifest,
284
+ health: indexHealthSnapshot({
285
+ checkedAt,
286
+ inventory,
287
+ files,
288
+ state,
289
+ manifest,
290
+ securityWarnings: securityReport.warnings,
291
+ }),
292
+ maintenance: indexMaintenanceSnapshot(maintenance, checkedAt),
293
+ };
294
+ storageWarning = combineWarnings(storageWarning, maintenance.warning);
178
295
  await validateIngestionTable(state, manifest, config, connection);
179
296
  await writeEmptyTextFiles(emptyTextRecords(state), config);
180
- await writeIndexManifest(manifest, config);
297
+ await writeIndexManifest(manifest, config, indexedFilesFromState(state));
298
+ try {
299
+ const collectGarbage = () => collectGenerationGarbageUnlocked(config, connection, { state });
300
+ metrics?.recordMaintenanceOperation();
301
+ const garbageCollection = metrics
302
+ ? await metrics.measureAsync("maintenance", collectGarbage)
303
+ : await collectGarbage();
304
+ storageWarning = combineWarnings(storageWarning, garbageCollection.warning);
305
+ }
306
+ catch (error) {
307
+ storageWarning = combineWarnings(storageWarning, `Generation cleanup failed (${error instanceof Error ? error.message : String(error)}). The active index remains available.`);
308
+ }
181
309
  await removeStagedIndexManifest(state.runId, config);
182
310
  const errors = ingestionErrors(state);
183
311
  state = finishIngestionState(state, errors.length > 0 ? "completed_with_errors" : "completed");
184
- await persistIngestionProgress(state, config, options);
185
- const indexedFiles = manifest.indexedFiles ?? [];
312
+ await persistIngestionProgress(state, config, options, true);
186
313
  const emptyTextFiles = emptyTextRecords(state).map((file) => file.relativePath);
187
314
  const redactions = state.files.reduce((sum, file) => sum + file.redactions, 0);
188
315
  await recordAccess(config, {
@@ -194,9 +321,10 @@ async function ingestUnlocked(config, options, connection, signal) {
194
321
  runId: state.runId,
195
322
  resumed: state.resumed,
196
323
  batchSize: state.batchSize,
197
- indexedFiles: indexedFiles.length,
324
+ indexedFiles: manifest.fileCount,
198
325
  rebuiltFiles: state.files.filter((file) => file.state === "indexed" && file.chunkCount > 0 && !file.reused).length,
199
326
  reusedFiles: state.files.filter((file) => file.reused).length,
327
+ staleLastKnownGood: manifest.staleFiles?.map((file) => file.relativePath) ?? [],
200
328
  policyRebuild,
201
329
  chunks: manifest.chunkCount,
202
330
  discoveredFiles: inventory.discoveredFiles,
@@ -210,8 +338,10 @@ async function ingestUnlocked(config, options, connection, signal) {
210
338
  emptyTextFiles,
211
339
  unsupportedExtensions: summarizeUnsupportedExtensions(inventory.skippedFiles),
212
340
  redactions,
213
- vectorIndexWarning: null,
341
+ ocr,
342
+ vectorIndexWarning: maintenance.adaptiveIndices?.warning ?? null,
214
343
  lexicalIndexWarning,
344
+ storageWarning,
215
345
  errors,
216
346
  };
217
347
  }
@@ -228,68 +358,122 @@ async function ingestUnlocked(config, options, connection, signal) {
228
358
  throw error;
229
359
  }
230
360
  }
361
+ function combineWarnings(...warnings) {
362
+ const present = warnings.filter((warning) => warning !== null);
363
+ return present.length > 0 ? present.join(" ") : null;
364
+ }
365
+ function emptyPdfOcrMetrics() {
366
+ return {
367
+ pages: 0,
368
+ cacheHits: 0,
369
+ cacheMisses: 0,
370
+ batches: 0,
371
+ subprocesses: 0,
372
+ durationMs: 0,
373
+ };
374
+ }
375
+ function mergePdfOcrMetrics(target, source) {
376
+ if (!source) {
377
+ return;
378
+ }
379
+ target.pages += source.pages;
380
+ target.cacheHits += source.cacheHits;
381
+ target.cacheMisses += source.cacheMisses;
382
+ target.batches += source.batches;
383
+ target.subprocesses += source.subprocesses;
384
+ target.durationMs = Math.round((target.durationMs + source.durationMs) * 1_000) / 1_000;
385
+ }
231
386
  async function reconcileCommittedFiles(state, config, connection) {
232
387
  const filesToReconcile = state.files.filter((file) => file.state !== "indexed" && file.state !== "error");
233
388
  if (filesToReconcile.length === 0) {
234
389
  return state;
235
390
  }
236
391
  const table = await openRowsTableByName(state.tableName, config, connection);
237
- const rows = table
238
- ? (await table.query().select(["relativePath", "checksum"]).toArray())
239
- : [];
240
- const rowCounts = new Map();
241
- for (const row of rows) {
242
- const key = `${row.relativePath}\0${row.checksum}`;
243
- rowCounts.set(key, (rowCounts.get(key) ?? 0) + 1);
244
- }
245
392
  const committedEmptyFiles = state.mode === "incremental"
246
393
  ? new Set((await readEmptyTextFiles(config)).map((file) => `${file.relativePath}\0${file.checksum}`))
247
394
  : new Set();
248
- return {
249
- ...state,
250
- files: state.files.map((file) => {
251
- if (file.state === "indexed" || file.state === "error") {
252
- return file;
253
- }
254
- const key = `${file.relativePath}\0${file.checksum}`;
255
- const committed = file.chunkCount > 0 ? rowCounts.get(key) === file.chunkCount : committedEmptyFiles.has(key);
256
- return committed ? { ...file, state: "indexed", error: null } : file;
257
- }),
258
- };
395
+ for (const file of filesToReconcile) {
396
+ const key = `${file.relativePath}\0${file.checksum}`;
397
+ const committedRows = file.chunkCount > 0 && table
398
+ ? await table.countRows(`relativePath = ${sqlString(file.relativePath)} AND checksum = ${sqlString(file.checksum)}`)
399
+ : 0;
400
+ const committed = file.chunkCount > 0 ? committedRows === file.chunkCount : committedEmptyFiles.has(key);
401
+ if (committed) {
402
+ reconcileIngestionFile(state, file.relativePath, {
403
+ state: "indexed",
404
+ lastGoodChecksum: file.checksum,
405
+ lastGoodChunkCount: file.chunkCount,
406
+ lastGoodBytes: file.bytes,
407
+ lastGoodMtimeMs: file.mtimeMs,
408
+ staleLastKnownGood: false,
409
+ error: null,
410
+ });
411
+ }
412
+ }
413
+ return state;
259
414
  }
260
415
  async function parseSourceFile(file, config, signal) {
416
+ const metrics = activeIngestionMetrics();
417
+ metrics?.recordSourceBytes(file.bytes);
261
418
  try {
262
- const parsed = await parseFile(file, { ...config, ...(signal ? { signal } : {}) });
419
+ const parse = () => parseFile(file, { ...config, ...(signal ? { signal } : {}) });
420
+ const parsed = metrics ? await metrics.measureAsync("parsing", parse) : await parse();
263
421
  throwIfAborted(signal);
264
- const redacted = redactText(parsed.text, config);
422
+ const redacted = metrics
423
+ ? metrics.measure("redaction", () => redactDocument(parsed, config))
424
+ : redactDocument(parsed, config);
425
+ const chunks = metrics
426
+ ? metrics.measure("chunking", () => chunkDocument(redacted.document, config.chunkSize, config.chunkOverlap, {
427
+ maxChunks: MAX_INGEST_CHUNKS_PER_FILE,
428
+ ...(signal ? { signal } : {}),
429
+ }))
430
+ : chunkDocument(redacted.document, config.chunkSize, config.chunkOverlap, {
431
+ maxChunks: MAX_INGEST_CHUNKS_PER_FILE,
432
+ ...(signal ? { signal } : {}),
433
+ });
265
434
  return {
266
435
  file,
267
- chunks: chunkDocument({ ...parsed, text: redacted.text }, config.chunkSize, config.chunkOverlap),
436
+ chunks,
268
437
  redactions: totalRedactions(redacted.counts),
438
+ ocr: parsed.ocr ?? null,
269
439
  error: null,
270
440
  };
271
441
  }
272
442
  catch (error) {
273
443
  throwIfAborted(signal);
444
+ metrics?.recordFailure(error);
274
445
  return {
275
446
  file,
276
447
  chunks: [],
277
448
  redactions: 0,
449
+ ocr: null,
278
450
  error: error instanceof Error ? error.message : String(error),
279
451
  };
280
452
  }
281
453
  }
282
454
  async function vectorRowsForChunks(chunks, config, signal) {
455
+ const metrics = activeIngestionMetrics();
283
456
  const rows = [];
457
+ let vectorBytes = 0;
284
458
  for (const batch of valueBatches(chunks, config.embeddingBatchSize)) {
285
459
  throwIfAborted(signal);
286
- const embeddings = await embedTexts(batch.map(chunkSearchText), config);
460
+ metrics?.recordEmbeddingModelState(embeddingModelState(config));
461
+ const embed = () => embedTexts(batch.map(chunkSearchText), config, "document", signal, (admission) => {
462
+ metrics?.recordEmbeddingQueue(admission.queueTimeMs);
463
+ });
464
+ const embeddings = metrics ? await metrics.measureAsync("embedding", embed) : await embed();
465
+ metrics?.recordEmbeddings(embeddings.length);
287
466
  throwIfAborted(signal);
288
467
  for (const [index, chunk] of batch.entries()) {
289
468
  const vector = embeddings[index];
290
469
  if (!vector) {
291
470
  throw new Error(`Missing embedding for chunk ${chunk.relativePath}#${chunk.chunkIndex}.`);
292
471
  }
472
+ vectorBytes += vector.length * Float64Array.BYTES_PER_ELEMENT;
473
+ if (vectorBytes > MAX_INGEST_VECTOR_BYTES_PER_FILE) {
474
+ metrics?.recordTruncation();
475
+ throw new Error(`Vector memory limit of ${MAX_INGEST_VECTOR_BYTES_PER_FILE} bytes exceeded for ${chunk.relativePath}. Increase chunkSize, split the source file, or use a smaller embedding model.`);
476
+ }
293
477
  rows.push({
294
478
  ...chunk,
295
479
  searchText: chunkSearchText(chunk),
@@ -301,25 +485,45 @@ async function vectorRowsForChunks(chunks, config, signal) {
301
485
  }
302
486
  return rows;
303
487
  }
304
- async function persistIngestionProgress(state, config, options) {
305
- await writeIngestionState(state, config);
306
- await options.onProgress?.(ingestionProgress(state));
488
+ function vectorRowsPayloadBytes(rows) {
489
+ return rows.reduce((total, row) => total +
490
+ Buffer.byteLength(row.id, "utf8") +
491
+ Buffer.byteLength(row.source, "utf8") +
492
+ Buffer.byteLength(row.relativePath, "utf8") +
493
+ Buffer.byteLength(row.contextPath, "utf8") +
494
+ Buffer.byteLength(row.text, "utf8") +
495
+ Buffer.byteLength(row.searchText, "utf8") +
496
+ Buffer.byteLength(row.embeddingProvider, "utf8") +
497
+ Buffer.byteLength(row.embeddingModel, "utf8") +
498
+ row.vector.length * Float64Array.BYTES_PER_ELEMENT, 0);
307
499
  }
308
- async function writeProgressManifest(state, config, connection) {
309
- const manifest = await manifestForState(state, config, connection);
310
- if (state.mode === "rebuild") {
311
- await writeStagedIndexManifest(manifest, state.runId, config);
312
- return;
500
+ async function persistIngestionProgress(state, config, options, compact = false) {
501
+ if (compact) {
502
+ await compactIngestionState(state, config);
503
+ }
504
+ else {
505
+ await writeIngestionState(state, config);
313
506
  }
314
- await writeEmptyTextFiles(emptyTextRecords(state), config);
315
- await writeIndexManifest(manifest, config);
507
+ await options.onProgress?.(ingestionProgress(state));
316
508
  }
317
509
  async function manifestForState(state, config, connection) {
318
- const indexedFiles = indexedFilesFromState(state);
319
- const chunkCount = indexedFiles.reduce((sum, file) => sum + file.chunkCount, 0);
510
+ const corpus = summarizeIndexedCorpus(indexedFilesFromState(state));
511
+ const staleFiles = state.files
512
+ .flatMap((file) => file.staleLastKnownGood && file.lastGoodChecksum !== null && file.error !== null
513
+ ? [
514
+ {
515
+ relativePath: file.relativePath,
516
+ currentChecksum: file.checksum,
517
+ lastGoodChecksum: file.lastGoodChecksum,
518
+ chunkCount: file.lastGoodChunkCount,
519
+ error: file.error,
520
+ },
521
+ ]
522
+ : [])
523
+ .sort((left, right) => left.relativePath.localeCompare(right.relativePath));
320
524
  const table = await openRowsTableByName(state.tableName, config, connection);
321
525
  const [firstRow] = table ? (await table.query().limit(1).toArray()) : [];
322
- if (chunkCount > 0 && !firstRow) {
526
+ if (corpus.chunkCount > 0 && !firstRow) {
323
527
  throw new Error("Cannot write an index manifest without indexed rows.");
324
528
  }
325
529
  return {
@@ -328,38 +532,151 @@ async function manifestForState(state, config, connection) {
328
532
  ragmirVersion: VERSION,
329
533
  embeddingProvider: config.embeddingProvider,
330
534
  embeddingModel: config.embeddingModel,
535
+ embeddingModelRevision: config.embeddingModelRevision,
536
+ embeddingModelDigest: config.embeddingModelDigest,
331
537
  indexPolicyFingerprint: state.policyFingerprint,
332
538
  ...(firstRow ? { vectorDimension: firstRow.vector.length } : {}),
333
539
  vectorDistanceMetric: VECTOR_DISTANCE_METRIC,
334
540
  chunkSize: config.chunkSize,
335
541
  chunkOverlap: config.chunkOverlap,
336
- fileCount: indexedFiles.length,
337
- chunkCount,
542
+ fileCount: corpus.fileCount,
543
+ chunkCount: corpus.chunkCount,
544
+ corpusFingerprint: corpus.corpusFingerprint,
338
545
  tableName: state.tableName,
339
- indexedFiles,
546
+ ...(staleFiles.length > 0 ? { staleFiles } : {}),
340
547
  };
341
548
  }
342
- function indexedFilesFromState(state) {
343
- return state.files
344
- .filter((file) => file.state === "indexed" && file.chunkCount > 0)
345
- .map((file) => ({
346
- relativePath: file.relativePath,
347
- checksum: file.checksum,
348
- chunkCount: file.chunkCount,
349
- bytes: file.bytes,
350
- mtimeMs: file.mtimeMs,
351
- }))
352
- .sort((left, right) => left.relativePath.localeCompare(right.relativePath));
549
+ function indexHealthSnapshot(input) {
550
+ const inventoryMetrics = sourceInventoryMetrics(input.files);
551
+ const emptyTextFiles = emptyTextRecords(input.state).map((file) => file.relativePath);
552
+ const staleInIndex = (input.manifest.staleFiles ?? []).map((file) => file.relativePath);
553
+ const missingFromIndex = input.state.files
554
+ .filter((file) => file.state === "error" && !file.staleLastKnownGood)
555
+ .map((file) => file.relativePath)
556
+ .sort();
557
+ const previews = {
558
+ missingFromIndex: missingFromIndex.slice(0, MAX_HEALTH_PREVIEW_ITEMS),
559
+ staleInIndex: staleInIndex.slice(0, MAX_HEALTH_PREVIEW_ITEMS),
560
+ emptyTextFiles: emptyTextFiles.slice(0, MAX_HEALTH_PREVIEW_ITEMS),
561
+ };
562
+ return {
563
+ schemaVersion: 1,
564
+ checkedAt: input.checkedAt,
565
+ discoveredFiles: input.inventory.discoveredFiles,
566
+ supportedFiles: input.files.length,
567
+ supportedBytes: inventoryMetrics.supportedBytes,
568
+ largestFileBytes: inventoryMetrics.largestFileBytes,
569
+ skippedFiles: input.inventory.skippedFiles.length,
570
+ unsupportedFiles: countSkippedByReason(input.inventory.skippedFiles, "unsupported-extension"),
571
+ oversizedFiles: countSkippedByReason(input.inventory.skippedFiles, "oversized"),
572
+ sensitiveFiles: countSkippedByReason(input.inventory.skippedFiles, "sensitive-name"),
573
+ emptyTextFiles: emptyTextFiles.length,
574
+ missingFromIndex: missingFromIndex.length,
575
+ staleInIndex: staleInIndex.length,
576
+ previews,
577
+ previewOmitted: {
578
+ missingFromIndex: missingFromIndex.length - previews.missingFromIndex.length,
579
+ staleInIndex: staleInIndex.length - previews.staleInIndex.length,
580
+ emptyTextFiles: emptyTextFiles.length - previews.emptyTextFiles.length,
581
+ },
582
+ skippedByReason: skippedFileCounts(input.inventory.skippedFiles),
583
+ sourceDiagnostics: sourceDiagnostics(input.files, input.inventory.skippedFiles),
584
+ securityCheckedAt: input.checkedAt,
585
+ securityWarnings: [...input.securityWarnings].sort().slice(0, MAX_HEALTH_PREVIEW_ITEMS),
586
+ };
587
+ }
588
+ function indexMaintenanceSnapshot(report, checkedAt) {
589
+ return {
590
+ schemaVersion: 1,
591
+ checkedAt,
592
+ status: report.status,
593
+ tableVersion: report.tableVersion,
594
+ mutationsSinceOptimization: report.mutationsSinceOptimization,
595
+ fragments: report.fragments,
596
+ fullTextIndex: report.fullTextIndex,
597
+ warning: report.warning,
598
+ };
599
+ }
600
+ function skippedFileCounts(skippedFiles) {
601
+ const counts = new Map();
602
+ for (const file of skippedFiles) {
603
+ counts.set(file.reason, (counts.get(file.reason) ?? 0) + 1);
604
+ }
605
+ return Object.fromEntries([...counts.entries()].sort(([left], [right]) => left.localeCompare(right)));
606
+ }
607
+ function* indexedFilesFromState(state) {
608
+ for (const file of state.files) {
609
+ if (file.state === "indexed" && file.chunkCount > 0) {
610
+ yield {
611
+ relativePath: file.relativePath,
612
+ checksum: file.checksum,
613
+ chunkCount: file.chunkCount,
614
+ bytes: file.bytes,
615
+ mtimeMs: file.mtimeMs,
616
+ };
617
+ }
618
+ else if (file.staleLastKnownGood &&
619
+ file.lastGoodChecksum !== null &&
620
+ file.lastGoodChunkCount > 0) {
621
+ yield {
622
+ relativePath: file.relativePath,
623
+ checksum: file.lastGoodChecksum,
624
+ chunkCount: file.lastGoodChunkCount,
625
+ ...(file.lastGoodBytes === null ? {} : { bytes: file.lastGoodBytes }),
626
+ ...(file.lastGoodMtimeMs === null ? {} : { mtimeMs: file.lastGoodMtimeMs }),
627
+ };
628
+ }
629
+ }
630
+ }
631
+ function sortIngestionFilesForStorage(state) {
632
+ state.files.sort((left, right) => compareUnicodeScalarValues(left.relativePath, right.relativePath));
633
+ }
634
+ function compareUnicodeScalarValues(left, right) {
635
+ let leftIndex = 0;
636
+ let rightIndex = 0;
637
+ while (leftIndex < left.length && rightIndex < right.length) {
638
+ const leftCodePoint = left.codePointAt(leftIndex);
639
+ const rightCodePoint = right.codePointAt(rightIndex);
640
+ if (leftCodePoint === undefined || rightCodePoint === undefined) {
641
+ break;
642
+ }
643
+ if (leftCodePoint !== rightCodePoint) {
644
+ return leftCodePoint - rightCodePoint;
645
+ }
646
+ leftIndex += leftCodePoint > 0xffff ? 2 : 1;
647
+ rightIndex += rightCodePoint > 0xffff ? 2 : 1;
648
+ }
649
+ return left.length - right.length;
353
650
  }
354
651
  function emptyTextRecords(state) {
355
652
  return state.files
356
- .filter((file) => file.state === "indexed" && file.chunkCount === 0)
357
- .map((file) => ({
358
- relativePath: file.relativePath,
359
- checksum: file.checksum,
360
- bytes: file.bytes,
361
- mtimeMs: file.mtimeMs,
362
- }))
653
+ .flatMap((file) => {
654
+ if (file.state === "indexed" && file.chunkCount === 0) {
655
+ return [
656
+ {
657
+ relativePath: file.relativePath,
658
+ checksum: file.checksum,
659
+ bytes: file.bytes,
660
+ mtimeMs: file.mtimeMs,
661
+ },
662
+ ];
663
+ }
664
+ if (file.staleLastKnownGood &&
665
+ file.lastGoodChecksum !== null &&
666
+ file.lastGoodChunkCount === 0 &&
667
+ file.lastGoodBytes !== null &&
668
+ file.lastGoodMtimeMs !== null) {
669
+ return [
670
+ {
671
+ relativePath: file.relativePath,
672
+ checksum: file.lastGoodChecksum,
673
+ bytes: file.lastGoodBytes,
674
+ mtimeMs: file.lastGoodMtimeMs,
675
+ },
676
+ ];
677
+ }
678
+ return [];
679
+ })
363
680
  .sort((left, right) => left.relativePath.localeCompare(right.relativePath));
364
681
  }
365
682
  function ingestionErrors(state) {
@@ -373,34 +690,101 @@ async function validateIngestionTable(state, manifest, config, connection) {
373
690
  }
374
691
  throw new Error("Ingestion validation failed because the generated table is missing.");
375
692
  }
376
- const rows = (await table.query().select(["id", "relativePath", "checksum"]).toArray());
377
- if (rows.length !== manifest.chunkCount) {
378
- throw new Error(`Ingestion validation expected ${manifest.chunkCount} rows but found ${rows.length}.`);
693
+ const rowCount = await table.countRows();
694
+ await validateIngestionMetadata({
695
+ expectedChunkCount: manifest.chunkCount,
696
+ actualChunkCount: rowCount,
697
+ expectedFiles: indexedFilesFromState(state),
698
+ idRows: streamQueryRows(table.query().select(["id"]).orderBy({ columnName: "id", ascending: true })),
699
+ fileRows: streamQueryRows(table
700
+ .query()
701
+ .select(["relativePath", "checksum"])
702
+ .orderBy([
703
+ { columnName: "relativePath", ascending: true },
704
+ { columnName: "checksum", ascending: true },
705
+ ])),
706
+ });
707
+ }
708
+ export async function validateIngestionMetadata(options) {
709
+ if (options.actualChunkCount !== options.expectedChunkCount) {
710
+ throw new Error(`Ingestion validation expected ${options.expectedChunkCount} rows but found ${options.actualChunkCount}.`);
379
711
  }
380
- if (new Set(rows.map((row) => row.id)).size !== rows.length) {
381
- throw new Error("Ingestion validation found duplicate chunk identifiers.");
712
+ let previousId = null;
713
+ for await (const row of options.idRows) {
714
+ const id = requiredStringField(row, "id", "Ingestion validation found an invalid chunk id.");
715
+ if (id === previousId) {
716
+ throw new Error("Ingestion validation found duplicate chunk identifiers.");
717
+ }
718
+ previousId = id;
382
719
  }
383
- const expectedFiles = new Map((manifest.indexedFiles ?? []).map((file) => [
384
- `${file.relativePath}\0${file.checksum}`,
385
- file.chunkCount,
386
- ]));
387
- const actualFiles = new Map();
388
- for (const row of rows) {
389
- const key = `${row.relativePath}\0${row.checksum}`;
390
- actualFiles.set(key, (actualFiles.get(key) ?? 0) + 1);
720
+ const expectedFiles = options.expectedFiles[Symbol.iterator]();
721
+ let expectedFile = expectedFiles.next();
722
+ let actualKey = null;
723
+ let actualCount = 0;
724
+ const validateActualFile = () => {
725
+ if (actualKey === null) {
726
+ return;
727
+ }
728
+ if (expectedFile.done ||
729
+ `${expectedFile.value.relativePath}\0${expectedFile.value.checksum}` !== actualKey ||
730
+ expectedFile.value.chunkCount !== actualCount) {
731
+ throw new Error("Ingestion validation found rows that do not match the generated manifest.");
732
+ }
733
+ expectedFile = expectedFiles.next();
734
+ };
735
+ for await (const row of options.fileRows) {
736
+ const relativePath = requiredStringField(row, "relativePath", "Ingestion validation found an invalid source path.");
737
+ const checksum = requiredStringField(row, "checksum", "Ingestion validation found an invalid source checksum.");
738
+ const key = `${relativePath}\0${checksum}`;
739
+ if (actualKey !== key) {
740
+ validateActualFile();
741
+ actualKey = key;
742
+ actualCount = 0;
743
+ }
744
+ actualCount += 1;
391
745
  }
392
- if (expectedFiles.size !== actualFiles.size ||
393
- [...expectedFiles].some(([key, count]) => actualFiles.get(key) !== count)) {
746
+ validateActualFile();
747
+ if (!expectedFile.done) {
394
748
  throw new Error("Ingestion validation found rows that do not match the generated manifest.");
395
749
  }
396
750
  }
751
+ async function* streamQueryRows(query) {
752
+ for await (const batch of query) {
753
+ for (let index = 0; index < batch.numRows; index += 1) {
754
+ yield batch.get(index);
755
+ }
756
+ }
757
+ }
758
+ function requiredStringField(row, key, message, allowEmpty = false) {
759
+ const value = tableRowField(row, key);
760
+ if (typeof value !== "string" || (!allowEmpty && value.length === 0)) {
761
+ throw new Error(message);
762
+ }
763
+ return value;
764
+ }
765
+ function tableRowField(row, key) {
766
+ return typeof row === "object" && row !== null ? Reflect.get(row, key) : undefined;
767
+ }
397
768
  function ingestFileBatchSize(value) {
398
769
  const batchSize = value ?? DEFAULT_INGEST_FILE_BATCH_SIZE;
399
770
  if (!Number.isInteger(batchSize) || batchSize <= 0) {
400
771
  throw new Error("batchSize must be a positive integer.");
401
772
  }
773
+ if (batchSize > MAX_INGEST_FILE_BATCH_SIZE) {
774
+ throw new Error(`batchSize must be at most ${MAX_INGEST_FILE_BATCH_SIZE}.`);
775
+ }
402
776
  return batchSize;
403
777
  }
778
+ function sqlString(value) {
779
+ return `'${value.replaceAll("'", "''")}'`;
780
+ }
781
+ function incrementalFailurePolicy(options, config) {
782
+ const policy = options.incrementalFailurePolicy ?? config.incrementalFailurePolicy;
783
+ if (policy !== "preserve-last-good" && policy !== "remove-stale") {
784
+ throw new Error('incrementalFailurePolicy must be either "preserve-last-good" or "remove-stale".');
785
+ }
786
+ return policy;
787
+ }
404
788
  function valueBatches(values, batchSize) {
405
789
  const batches = [];
406
790
  for (let index = 0; index < values.length; index += batchSize) {
@@ -408,12 +792,49 @@ function valueBatches(values, batchSize) {
408
792
  }
409
793
  return batches;
410
794
  }
795
+ function ingestionWindows(files, maxFiles, config) {
796
+ const windows = [];
797
+ let current = [];
798
+ let currentBytes = 0;
799
+ let currentChunks = 0;
800
+ for (const file of files) {
801
+ const estimatedChunks = estimatedChunkCount(file, config);
802
+ const exceedsWindow = current.length > 0 &&
803
+ (current.length >= maxFiles ||
804
+ currentBytes + file.bytes > MAX_INGEST_SOURCE_WINDOW_BYTES ||
805
+ currentChunks + estimatedChunks > MAX_INGEST_CHUNK_WINDOW);
806
+ if (exceedsWindow) {
807
+ windows.push(current);
808
+ current = [];
809
+ currentBytes = 0;
810
+ currentChunks = 0;
811
+ }
812
+ current.push(file);
813
+ currentBytes += file.bytes;
814
+ currentChunks += estimatedChunks;
815
+ }
816
+ if (current.length > 0) {
817
+ windows.push(current);
818
+ }
819
+ return windows;
820
+ }
821
+ function estimatedChunkCount(file, config) {
822
+ const step = Math.max(1, config.chunkSize - config.chunkOverlap);
823
+ return Math.max(1, Math.ceil(file.bytes / step));
824
+ }
411
825
  export async function audit(cwd = process.cwd(), options = {}) {
412
826
  const signal = operationSignal(options);
413
827
  throwIfAborted(signal);
414
828
  const config = await loadConfig(cwd);
829
+ return auditWithConfig(config, options);
830
+ }
831
+ export async function auditWithConfig(config, options = {}) {
832
+ const signal = operationSignal(options);
415
833
  throwIfAborted(signal);
416
- const inventory = await inventorySourceFiles(config, signal ? { signal } : {});
834
+ const inventory = await inventorySourceFiles(config, {
835
+ ...(signal ? { signal } : {}),
836
+ writeFingerprintCache: false,
837
+ });
417
838
  throwIfAborted(signal);
418
839
  const files = inventory.supportedFiles;
419
840
  const inventoryMetrics = sourceInventoryMetrics(files);
@@ -423,7 +844,10 @@ export async function audit(cwd = process.cwd(), options = {}) {
423
844
  const emptyTextFiles = await currentEmptyTextFiles(config, files, signal);
424
845
  throwIfAborted(signal);
425
846
  if (!table) {
426
- return {
847
+ return applyAuditPreviewLimit({
848
+ mode: "deep",
849
+ inventoryVerified: true,
850
+ cost: "O(corpus)",
427
851
  discoveredFiles: inventory.discoveredFiles,
428
852
  supportedBytes: inventoryMetrics.supportedBytes,
429
853
  largestFileBytes: inventoryMetrics.largestFileBytes,
@@ -437,55 +861,146 @@ export async function audit(cwd = process.cwd(), options = {}) {
437
861
  staleInIndex: [],
438
862
  totalChunks: 0,
439
863
  chunkStats: summarizeChunkStats([]),
440
- };
864
+ }, options.previewLimit);
441
865
  }
442
- const rows = (await table
443
- .query()
444
- .select(["relativePath", "checksum", "contextPath", "text"])
445
- .toArray());
446
- throwIfAborted(signal);
447
- const counts = new Map();
448
- const checksums = new Map();
449
- for (const row of rows) {
450
- throwIfAborted(signal);
451
- counts.set(row.relativePath, (counts.get(row.relativePath) ?? 0) + 1);
452
- if (row.checksum) {
453
- const fileChecksums = checksums.get(row.relativePath) ?? new Set();
454
- fileChecksums.add(row.checksum);
455
- checksums.set(row.relativePath, fileChecksums);
866
+ try {
867
+ const counts = new Map();
868
+ const checksums = new Map();
869
+ const chunkStats = createAuditChunkStatsAccumulator();
870
+ for await (const row of streamQueryRows(table.query().select(["relativePath", "checksum", "contextPath", "text"]))) {
871
+ throwIfAborted(signal);
872
+ const relativePath = requiredStringField(row, "relativePath", "Audit found an invalid source path.");
873
+ const checksum = tableRowField(row, "checksum");
874
+ const contextPath = requiredStringField(row, "contextPath", "Audit found an invalid chunk context.", true);
875
+ const text = requiredStringField(row, "text", "Audit found invalid chunk text.");
876
+ counts.set(relativePath, (counts.get(relativePath) ?? 0) + 1);
877
+ if (typeof checksum === "string" && checksum.length > 0) {
878
+ const fileChecksums = checksums.get(relativePath) ?? new Set();
879
+ fileChecksums.add(checksum);
880
+ checksums.set(relativePath, fileChecksums);
881
+ }
882
+ recordAuditChunkStats(chunkStats, text.length, contextPath.trim().length > 0);
456
883
  }
884
+ const supportedSet = new Set(supportedFiles);
885
+ const indexedSet = new Set(counts.keys());
886
+ const currentChecksums = new Map(files.map((file) => [file.relativePath, file.checksum]));
887
+ throwIfAborted(signal);
888
+ return applyAuditPreviewLimit({
889
+ mode: "deep",
890
+ inventoryVerified: true,
891
+ cost: "O(corpus)",
892
+ discoveredFiles: inventory.discoveredFiles,
893
+ supportedBytes: inventoryMetrics.supportedBytes,
894
+ largestFileBytes: inventoryMetrics.largestFileBytes,
895
+ indexedFiles: [...counts.entries()]
896
+ .sort(([a], [b]) => a.localeCompare(b))
897
+ .map(([source, chunks]) => ({ source, chunks })),
898
+ supportedFiles,
899
+ skippedFiles: inventory.skippedFiles,
900
+ emptyTextFiles: [...emptyTextFiles].sort(),
901
+ unsupportedExtensions: summarizeUnsupportedExtensions(inventory.skippedFiles),
902
+ sourceDiagnostics: sourceDiagnostics(files, inventory.skippedFiles),
903
+ missingFromIndex: supportedFiles.filter((file) => !indexedSet.has(file) && !emptyTextFiles.has(file)),
904
+ staleInIndex: [...indexedSet]
905
+ .filter((file) => {
906
+ if (!supportedSet.has(file)) {
907
+ return true;
908
+ }
909
+ const currentChecksum = currentChecksums.get(file);
910
+ const indexedChecksums = checksums.get(file);
911
+ return !currentChecksum || !indexedChecksums?.has(currentChecksum);
912
+ })
913
+ .sort(),
914
+ totalChunks: chunkStats.count,
915
+ chunkStats: finishAuditChunkStats(chunkStats),
916
+ }, options.previewLimit);
457
917
  }
458
- const supportedSet = new Set(supportedFiles);
459
- const indexedSet = new Set(counts.keys());
460
- const currentChecksums = new Map(files.map((file) => [file.relativePath, file.checksum]));
461
- throwIfAborted(signal);
918
+ finally {
919
+ closeRowsTable(table, config);
920
+ }
921
+ }
922
+ function applyAuditPreviewLimit(report, previewLimit) {
923
+ if (previewLimit === undefined) {
924
+ return report;
925
+ }
926
+ if (!Number.isSafeInteger(previewLimit) || previewLimit < 0) {
927
+ throw new Error("Audit preview limit must be a non-negative integer.");
928
+ }
929
+ const limit = previewLimit;
462
930
  return {
463
- discoveredFiles: inventory.discoveredFiles,
464
- supportedBytes: inventoryMetrics.supportedBytes,
465
- largestFileBytes: inventoryMetrics.largestFileBytes,
466
- indexedFiles: [...counts.entries()]
467
- .sort(([a], [b]) => a.localeCompare(b))
468
- .map(([source, chunks]) => ({ source, chunks })),
469
- supportedFiles,
470
- skippedFiles: inventory.skippedFiles,
471
- emptyTextFiles: [...emptyTextFiles].sort(),
472
- unsupportedExtensions: summarizeUnsupportedExtensions(inventory.skippedFiles),
473
- sourceDiagnostics: sourceDiagnostics(files, inventory.skippedFiles),
474
- missingFromIndex: supportedFiles.filter((file) => !indexedSet.has(file) && !emptyTextFiles.has(file)),
475
- staleInIndex: [...indexedSet]
476
- .filter((file) => {
477
- if (!supportedSet.has(file)) {
478
- return true;
479
- }
480
- const currentChecksum = currentChecksums.get(file);
481
- const indexedChecksums = checksums.get(file);
482
- return !currentChecksum || !indexedChecksums?.has(currentChecksum);
483
- })
484
- .sort(),
485
- totalChunks: rows.length,
486
- chunkStats: summarizeChunkStats(rows),
931
+ ...report,
932
+ indexedFiles: report.indexedFiles.slice(0, limit),
933
+ supportedFiles: report.supportedFiles.slice(0, limit),
934
+ skippedFiles: report.skippedFiles.slice(0, limit),
935
+ emptyTextFiles: report.emptyTextFiles.slice(0, limit),
936
+ sourceDiagnostics: {
937
+ duplicateCandidates: report.sourceDiagnostics.duplicateCandidates.slice(0, limit),
938
+ archiveCandidates: report.sourceDiagnostics.archiveCandidates.slice(0, limit),
939
+ mirrorCandidates: report.sourceDiagnostics.mirrorCandidates.slice(0, limit),
940
+ },
941
+ missingFromIndex: report.missingFromIndex.slice(0, limit),
942
+ staleInIndex: report.staleInIndex.slice(0, limit),
943
+ omitted: {
944
+ indexedFiles: omittedCount(report.indexedFiles.length, limit),
945
+ supportedFiles: omittedCount(report.supportedFiles.length, limit),
946
+ skippedFiles: omittedCount(report.skippedFiles.length, limit),
947
+ emptyTextFiles: omittedCount(report.emptyTextFiles.length, limit),
948
+ duplicateCandidates: omittedCount(report.sourceDiagnostics.duplicateCandidates.length, limit),
949
+ archiveCandidates: omittedCount(report.sourceDiagnostics.archiveCandidates.length, limit),
950
+ mirrorCandidates: omittedCount(report.sourceDiagnostics.mirrorCandidates.length, limit),
951
+ missingFromIndex: omittedCount(report.missingFromIndex.length, limit),
952
+ staleInIndex: omittedCount(report.staleInIndex.length, limit),
953
+ },
954
+ };
955
+ }
956
+ function omittedCount(total, retained) {
957
+ return Math.max(0, total - retained);
958
+ }
959
+ function createAuditChunkStatsAccumulator() {
960
+ return {
961
+ count: 0,
962
+ totalChars: 0,
963
+ minChars: Number.POSITIVE_INFINITY,
964
+ maxChars: 0,
965
+ contextualChunks: 0,
966
+ lengthHistogram: new Map(),
487
967
  };
488
968
  }
969
+ function recordAuditChunkStats(stats, characters, contextual) {
970
+ stats.count += 1;
971
+ stats.totalChars += characters;
972
+ stats.minChars = Math.min(stats.minChars, characters);
973
+ stats.maxChars = Math.max(stats.maxChars, characters);
974
+ stats.contextualChunks += contextual ? 1 : 0;
975
+ stats.lengthHistogram.set(characters, (stats.lengthHistogram.get(characters) ?? 0) + 1);
976
+ }
977
+ function finishAuditChunkStats(stats) {
978
+ if (stats.count === 0) {
979
+ return summarizeChunkStats([]);
980
+ }
981
+ const sortedHistogram = [...stats.lengthHistogram].sort(([left], [right]) => left - right);
982
+ return {
983
+ count: stats.count,
984
+ minChars: stats.minChars,
985
+ averageChars: stats.totalChars / stats.count,
986
+ p50Chars: histogramPercentile(sortedHistogram, stats.count, 0.5),
987
+ p95Chars: histogramPercentile(sortedHistogram, stats.count, 0.95),
988
+ maxChars: stats.maxChars,
989
+ contextualChunks: stats.contextualChunks,
990
+ contextualRatio: stats.contextualChunks / stats.count,
991
+ };
992
+ }
993
+ function histogramPercentile(histogram, count, quantile) {
994
+ const target = Math.max(1, Math.ceil(count * quantile));
995
+ let cumulative = 0;
996
+ for (const [value, occurrences] of histogram) {
997
+ cumulative += occurrences;
998
+ if (cumulative >= target) {
999
+ return value;
1000
+ }
1001
+ }
1002
+ return histogram.at(-1)?.[0] ?? 0;
1003
+ }
489
1004
  function sourceInventoryMetrics(files) {
490
1005
  let supportedBytes = 0;
491
1006
  let largestFileBytes = 0;