@zenera/rag 1.1.10 → 1.1.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +41 -0
- package/dist/common/cache.d.ts +66 -0
- package/dist/common/cache.js +172 -0
- package/dist/common/embedder.d.ts +11 -1
- package/dist/common/embedder.js +6 -17
- package/dist/common/manifest.d.ts +7 -1
- package/dist/common/progress.d.ts +16 -1
- package/dist/common/progress.js +35 -3
- package/dist/common/prose.d.ts +7 -0
- package/dist/common/prose.js +13 -0
- package/dist/docs/build.d.ts +22 -2
- package/dist/docs/build.js +52 -26
- package/dist/docs/command.js +56 -10
- package/dist/docs/load.d.ts +18 -4
- package/dist/docs/load.js +54 -63
- package/dist/docs/outline.d.ts +11 -0
- package/dist/docs/outline.js +55 -0
- package/dist/docs/parse-cache.d.ts +20 -0
- package/dist/docs/parse-cache.js +60 -0
- package/dist/docs/parse-worker.d.ts +13 -0
- package/dist/docs/parse-worker.js +21 -0
- package/dist/docs/pool.d.ts +27 -0
- package/dist/docs/pool.js +133 -0
- package/dist/docs/readme.js +23 -3
- package/dist/docs/store.d.ts +25 -1
- package/dist/docs/store.js +59 -16
- package/dist/schema/build.d.ts +16 -2
- package/dist/schema/build.js +37 -25
- package/dist/schema/command.js +36 -10
- package/dist/schema/readme.js +6 -2
- package/dist/schema/store.d.ts +9 -1
- package/dist/schema/store.js +57 -15
- package/package.json +3 -3
package/dist/docs/build.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
|
+
import { embedStream, NO_CACHE, openCache } from "../common/cache.js";
|
|
1
2
|
import { beginBuild } from "../common/progress.js";
|
|
2
3
|
import { formatLines } from "./chunk.js";
|
|
3
4
|
import { INDEX_VERSION, SOURCES_DIR, writeIndex, } from "./files.js";
|
|
4
5
|
import { loadDocuments } from "./load.js";
|
|
5
6
|
import { DOCS_REPORT, PHASES } from "./readme.js";
|
|
6
|
-
import {
|
|
7
|
-
const DEFAULT_BATCH = 96;
|
|
7
|
+
import { openChunks } from "./store.js";
|
|
8
8
|
export async function buildIndex(options) {
|
|
9
9
|
const journal = beginBuild({
|
|
10
10
|
dir: options.out,
|
|
@@ -14,8 +14,25 @@ export async function buildIndex(options) {
|
|
|
14
14
|
phases: PHASES,
|
|
15
15
|
report: DOCS_REPORT,
|
|
16
16
|
});
|
|
17
|
+
const ref = options.embeddingRef ?? options.embedder.id;
|
|
18
|
+
const cache = options.cache === false
|
|
19
|
+
? NO_CACHE
|
|
20
|
+
: openCache(options.embedder, {
|
|
21
|
+
ref,
|
|
22
|
+
dir: options.cacheDir,
|
|
23
|
+
dimensions: options.dimensions,
|
|
24
|
+
});
|
|
25
|
+
let writer;
|
|
17
26
|
try {
|
|
18
|
-
const corpus = await loadDocuments(options.files, options.cwd,
|
|
27
|
+
const corpus = await loadDocuments(options.files, options.cwd, {
|
|
28
|
+
chunk: options.chunk,
|
|
29
|
+
cache: options.cache !== false,
|
|
30
|
+
cacheDir: options.cacheDir,
|
|
31
|
+
onProgress: (done, total, pending) => {
|
|
32
|
+
journal.progress(done, total, pending);
|
|
33
|
+
options.onReading?.(done, total, pending);
|
|
34
|
+
},
|
|
35
|
+
});
|
|
19
36
|
const chunks = recordsOf(corpus);
|
|
20
37
|
const sources = corpus.docs.map((doc) => ({
|
|
21
38
|
name: doc.name,
|
|
@@ -40,18 +57,20 @@ export async function buildIndex(options) {
|
|
|
40
57
|
journal.read(counts, chunks.length);
|
|
41
58
|
options.onRead?.({ sources, counts, skipped: corpus.skipped });
|
|
42
59
|
journal.phase('embedding');
|
|
43
|
-
|
|
60
|
+
writer = await openChunks(options.out);
|
|
61
|
+
const dimensions = await embedAll(chunks, options, journal, cache, writer);
|
|
44
62
|
journal.phase('writing');
|
|
45
|
-
const written = await
|
|
63
|
+
const written = await writer.finish();
|
|
46
64
|
const manifest = {
|
|
47
65
|
version: INDEX_VERSION,
|
|
48
66
|
kind: 'docs',
|
|
49
67
|
createdAt: new Date().toISOString(),
|
|
50
68
|
indexer: options.indexer,
|
|
51
69
|
embedding: {
|
|
52
|
-
ref
|
|
70
|
+
ref,
|
|
53
71
|
id: options.embedder.id,
|
|
54
|
-
dimensions
|
|
72
|
+
dimensions,
|
|
73
|
+
requested: options.dimensions,
|
|
55
74
|
},
|
|
56
75
|
sources,
|
|
57
76
|
counts,
|
|
@@ -60,10 +79,18 @@ export async function buildIndex(options) {
|
|
|
60
79
|
const outline = { files: corpus.docs.map((doc) => doc.outline) };
|
|
61
80
|
const documents = Object.fromEntries(corpus.docs.map((doc) => [doc.name, doc.text]));
|
|
62
81
|
await writeIndex(options.out, { manifest, outline, documents });
|
|
82
|
+
cache.commit();
|
|
63
83
|
journal.finish(manifest);
|
|
64
|
-
return {
|
|
84
|
+
return {
|
|
85
|
+
manifest,
|
|
86
|
+
chunks,
|
|
87
|
+
timings: journal.timings,
|
|
88
|
+
reused: { parses: corpus.cached, vectors: cache.hits },
|
|
89
|
+
};
|
|
65
90
|
}
|
|
66
91
|
catch (err) {
|
|
92
|
+
writer?.close();
|
|
93
|
+
cache.abandon();
|
|
67
94
|
journal.fail(err);
|
|
68
95
|
throw err;
|
|
69
96
|
}
|
|
@@ -86,23 +113,22 @@ function recordsOf(corpus) {
|
|
|
86
113
|
tokens: chunk.tokens,
|
|
87
114
|
})));
|
|
88
115
|
}
|
|
89
|
-
async function embedAll(chunks, options, journal) {
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
}
|
|
106
|
-
return out;
|
|
116
|
+
async function embedAll(chunks, options, journal, cache, writer) {
|
|
117
|
+
// A window at a time, rather than the whole corpus in one call. How many
|
|
118
|
+
// texts fit in a request, and how many requests may be in flight, are still
|
|
119
|
+
// the embedder's to answer — it knows the model's caps and it is the one
|
|
120
|
+
// that sees the 429s. What the window decides is only how much is resident.
|
|
121
|
+
return embedStream({
|
|
122
|
+
embedder: options.embedder,
|
|
123
|
+
cache,
|
|
124
|
+
records: chunks,
|
|
125
|
+
textOf: (chunk) => chunk.embedText,
|
|
126
|
+
signal: options.signal,
|
|
127
|
+
onProgress: (done, total) => {
|
|
128
|
+
journal.progress(done, total);
|
|
129
|
+
options.onProgress?.(done, total);
|
|
130
|
+
},
|
|
131
|
+
onWindow: (window, vectors) => writer.add(window, vectors),
|
|
132
|
+
});
|
|
107
133
|
}
|
|
108
134
|
//# sourceMappingURL=build.js.map
|
package/dist/docs/command.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
import { bold, CliError, cyan, dim, EXIT, isInteractive, json, note, parse, table, usageError, write, } from '@zenera/cli/lib';
|
|
1
|
+
import { bold, CliError, cyan, dim, EXIT, isInteractive, json, note, parse, paths, table, usageError, write, } from '@zenera/cli/lib';
|
|
2
2
|
import { relative, resolve } from 'node:path';
|
|
3
3
|
import { resolveEmbedder } from "../common/embedder.js";
|
|
4
4
|
import { locateIndex, outputDir } from "../common/locate.js";
|
|
5
5
|
import { assertSameEmbedding } from "../common/manifest.js";
|
|
6
6
|
import { PatternError } from "../common/match.js";
|
|
7
|
-
import {
|
|
7
|
+
import { INTERVAL_MS } from "../common/progress.js";
|
|
8
|
+
import { breakdown, grid } from "../common/prose.js";
|
|
8
9
|
import { assemble, DEFAULT_MAX_LINES } from "./assemble.js";
|
|
9
10
|
import { buildIndex } from "./build.js";
|
|
10
11
|
import { CHUNK_KINDS } from "./chunk.js";
|
|
@@ -69,8 +70,17 @@ export const command = {
|
|
|
69
70
|
' -o, --out <dir>',
|
|
70
71
|
dim(`Where the index goes. Default ${DEFAULT_DIR}, or ${DIR_ENV}.`),
|
|
71
72
|
],
|
|
72
|
-
[' --batch <n>', dim(
|
|
73
|
+
[' --batch <n>', dim("Texts per embedding request. Default: the model's own cap.")],
|
|
74
|
+
[
|
|
75
|
+
' --dimensions <n>',
|
|
76
|
+
dim("Narrower vectors, if the model allows it. Default: the model's own width."),
|
|
77
|
+
],
|
|
73
78
|
[' --chunk-tokens <n>', dim('Target chunk size. Default 384.')],
|
|
79
|
+
[
|
|
80
|
+
' --no-cache',
|
|
81
|
+
dim('Parse and embed everything again, ignoring what is already kept.'),
|
|
82
|
+
],
|
|
83
|
+
[' --cache-dir <dir>', dim('Keep the work somewhere other than the shared cache.')],
|
|
74
84
|
]),
|
|
75
85
|
'',
|
|
76
86
|
dim(' Every document is copied into the index, so it stays portable and'),
|
|
@@ -155,40 +165,58 @@ async function index(args, ctx) {
|
|
|
155
165
|
out: { type: 'string', short: 'o' },
|
|
156
166
|
embedding: { type: 'string' },
|
|
157
167
|
batch: { type: 'string' },
|
|
168
|
+
dimensions: { type: 'string' },
|
|
158
169
|
'chunk-tokens': { type: 'string' },
|
|
170
|
+
'no-cache': { type: 'boolean' },
|
|
171
|
+
'cache-dir': { type: 'string' },
|
|
159
172
|
quiet: { type: 'boolean' },
|
|
160
173
|
}, INDEX_USAGE);
|
|
161
174
|
if (positionals.length === 0) {
|
|
162
175
|
throw usageError('no document, directory or pattern given', INDEX_USAGE);
|
|
163
176
|
}
|
|
164
177
|
const out = outputDir(ctx.cwd, values.out, DOCS_INDEX);
|
|
178
|
+
const cacheDir = values['cache-dir'] ? resolve(ctx.cwd, values['cache-dir']) : paths.cache();
|
|
165
179
|
const loud = !values.quiet && !ctx.json;
|
|
166
|
-
|
|
180
|
+
// Undefined when unasked, all the way to the cache key: a width nobody
|
|
181
|
+
// named is not the same key as the width the model happens to default to,
|
|
182
|
+
// and resolving it here would miss every vector already paid for.
|
|
183
|
+
const dimensions = values.dimensions ? count(values.dimensions, '--dimensions') : undefined;
|
|
184
|
+
const chosen = await resolveEmbedder(values.embedding, {
|
|
185
|
+
maxBatch: values.batch ? count(values.batch, '--batch') : undefined,
|
|
186
|
+
dimensions,
|
|
187
|
+
});
|
|
167
188
|
const started = Date.now();
|
|
168
|
-
const { manifest } = await buildIndex({
|
|
189
|
+
const { manifest, timings, reused } = await buildIndex({
|
|
169
190
|
files: positionals,
|
|
170
191
|
cwd: ctx.cwd,
|
|
171
192
|
out,
|
|
172
193
|
embedder: chosen,
|
|
173
194
|
embeddingRef: values.embedding,
|
|
174
195
|
indexer: 'zenera-rag',
|
|
175
|
-
batch: values.batch ? count(values.batch, '--batch') : undefined,
|
|
176
196
|
chunk: values['chunk-tokens']
|
|
177
197
|
? { chunkTokens: count(values['chunk-tokens'], '--chunk-tokens') }
|
|
178
198
|
: undefined,
|
|
199
|
+
dimensions,
|
|
200
|
+
cache: !values['no-cache'],
|
|
201
|
+
cacheDir: values['cache-dir'] ? cacheDir : undefined,
|
|
202
|
+
onReading: loud
|
|
203
|
+
? throttled((done, total, pending) => note(dim(` parsed ${done}/${total} · ${Math.floor((done / total) * 100)}% · ` +
|
|
204
|
+
`${elapsed(started)}${still(pending)}`)))
|
|
205
|
+
: undefined,
|
|
179
206
|
onRead: loud
|
|
180
207
|
? (summary) => {
|
|
181
208
|
printSources(summary.sources);
|
|
182
209
|
for (const skip of summary.skipped) {
|
|
183
210
|
note(dim(` skipped ${skip.name}: ${skip.reason}`));
|
|
184
211
|
}
|
|
185
|
-
//
|
|
186
|
-
//
|
|
212
|
+
// Embedding is one call now, and a long one; this is the line
|
|
213
|
+
// that makes the wait before the first progress report a wait
|
|
214
|
+
// rather than a hang.
|
|
187
215
|
note(dim(` embedding ${summary.counts.chunks} chunks with ${chosen.id} …`));
|
|
188
216
|
}
|
|
189
217
|
: undefined,
|
|
190
218
|
onProgress: loud
|
|
191
|
-
? (done, total) => note(dim(` embedded ${done}/${total} · ${Math.
|
|
219
|
+
? (done, total) => note(dim(` embedded ${done}/${total} · ${Math.floor((done / total) * 100)}% · ${elapsed(started)}`))
|
|
192
220
|
: undefined,
|
|
193
221
|
});
|
|
194
222
|
if (ctx.json) {
|
|
@@ -202,6 +230,10 @@ async function index(args, ctx) {
|
|
|
202
230
|
note(` wrote ${bold(String(manifest.counts.chunks))} chunks from ` +
|
|
203
231
|
`${bold(String(manifest.counts.documents))} document(s) to ${bold(out)}, ` +
|
|
204
232
|
`embedded with ${manifest.embedding.ref} (${manifest.embedding.dimensions}d)`);
|
|
233
|
+
note(dim(` ${breakdown(timings)}`));
|
|
234
|
+
note(dim(` reused ${reused.parses}/${manifest.counts.documents} parses, ` +
|
|
235
|
+
`${reused.vectors}/${manifest.counts.chunks} vectors` +
|
|
236
|
+
`${values['no-cache'] ? ' (--no-cache)' : ` from ${cacheDir}`}`));
|
|
205
237
|
const where = out === resolve(ctx.cwd, DEFAULT_DIR) ? '' : ` --dir ${relative(ctx.cwd, out) || out}`;
|
|
206
238
|
note(dim(` search it: ${cyan(`zen rag docs search${where} "what you are after"`)}`));
|
|
207
239
|
}
|
|
@@ -233,6 +265,18 @@ function elapsed(since) {
|
|
|
233
265
|
const seconds = Math.round((Date.now() - since) / 1000);
|
|
234
266
|
return seconds < 60 ? `${seconds}s` : `${Math.floor(seconds / 60)}m${seconds % 60}s`;
|
|
235
267
|
}
|
|
268
|
+
/** Reading fires per document, and a line per document is not a narration. */
|
|
269
|
+
function throttled(say) {
|
|
270
|
+
let saidAt = 0;
|
|
271
|
+
return (...args) => {
|
|
272
|
+
if (Date.now() - saidAt >= INTERVAL_MS) {
|
|
273
|
+
saidAt = Date.now();
|
|
274
|
+
say(...args);
|
|
275
|
+
}
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
/** Only worth naming when there are few enough to go and look at. */
|
|
279
|
+
const still = (pending) => pending.length > 0 && pending.length <= 2 ? ` · still on ${pending.join(', ')}` : '';
|
|
236
280
|
const MANY = { type: 'string', multiple: true };
|
|
237
281
|
async function search(args, ctx) {
|
|
238
282
|
const { values, positionals } = parse(args, {
|
|
@@ -281,7 +325,9 @@ async function search(args, ctx) {
|
|
|
281
325
|
const manifest = await readManifest(dir);
|
|
282
326
|
const ref = values.embedding ?? manifest.embedding.ref;
|
|
283
327
|
assertSameEmbedding(manifest, ref);
|
|
284
|
-
|
|
328
|
+
// A query has to be asked at the width the passages were written at, and
|
|
329
|
+
// the ref alone does not say what that was.
|
|
330
|
+
const found = await DocsIndex.open(dir, await resolveEmbedder(ref, { dimensions: manifest.embedding.requested }));
|
|
285
331
|
try {
|
|
286
332
|
if (values.interactive) {
|
|
287
333
|
await repl(found, query, shape);
|
package/dist/docs/load.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { Chunk, ChunkOptions } from './chunk.ts';
|
|
2
2
|
import type { FileOutline } from './files.ts';
|
|
3
|
-
import { type DocFormat
|
|
3
|
+
import { type DocFormat } from './parse.ts';
|
|
4
4
|
/** Markdown, and plain text read as paragraphs. Anything else is not a document. */
|
|
5
5
|
export declare const DOC_EXTENSIONS: readonly [".md", ".markdown", ".txt", ".text"];
|
|
6
6
|
export interface LoadedDoc {
|
|
@@ -11,7 +11,6 @@ export interface LoadedDoc {
|
|
|
11
11
|
format: DocFormat;
|
|
12
12
|
/** the document verbatim, CRLF normalized: what goes into `sources/` */
|
|
13
13
|
text: string;
|
|
14
|
-
parsed: ParsedDoc;
|
|
15
14
|
chunks: Chunk[];
|
|
16
15
|
outline: FileOutline;
|
|
17
16
|
}
|
|
@@ -22,7 +21,22 @@ export interface Corpus {
|
|
|
22
21
|
name: string;
|
|
23
22
|
reason: string;
|
|
24
23
|
}[];
|
|
24
|
+
/** documents whose chunks came from a previous build rather than the parser */
|
|
25
|
+
cached: number;
|
|
25
26
|
}
|
|
26
|
-
export
|
|
27
|
+
export interface LoadOptions {
|
|
28
|
+
chunk?: ChunkOptions;
|
|
29
|
+
/** remember what documents parse to; on by default */
|
|
30
|
+
cache?: boolean;
|
|
31
|
+
/** keep the parses somewhere other than the shared store */
|
|
32
|
+
cacheDir?: string;
|
|
33
|
+
onProgress?: (done: number, total: number, pending: readonly string[]) => void;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Reading is cheap and parsing is not, so the two are split: every file is read
|
|
37
|
+
* and hashed here, and only the documents the cache has never seen are handed
|
|
38
|
+
* to the pool. On a rebuild of an unchanged corpus that is none of them.
|
|
39
|
+
*/
|
|
40
|
+
export declare function loadDocuments(inputs: readonly string[], cwd: string, options?: LoadOptions): Promise<Corpus>;
|
|
27
41
|
export declare const formatOf: (path: string) => DocFormat;
|
|
28
42
|
//# sourceMappingURL=load.d.ts.map
|
package/dist/docs/load.js
CHANGED
|
@@ -3,8 +3,9 @@ import { createHash } from 'node:crypto';
|
|
|
3
3
|
import { readdir, readFile, stat } from 'node:fs/promises';
|
|
4
4
|
import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
5
5
|
import { isGlob, wildcard } from "../common/match.js";
|
|
6
|
-
import {
|
|
7
|
-
import { normalize
|
|
6
|
+
import { NO_PARSE_CACHE, openParseCache, parseKey } from "./parse-cache.js";
|
|
7
|
+
import { normalize } from "./parse.js";
|
|
8
|
+
import { parseAll } from "./pool.js";
|
|
8
9
|
// ---------------------------------------------------------------------------
|
|
9
10
|
// Finding the documents, and reading them
|
|
10
11
|
//
|
|
@@ -28,6 +29,11 @@ export const DOC_EXTENSIONS = ['.md', '.markdown', '.txt', '.text'];
|
|
|
28
29
|
const MAX_BYTES = 16 * 1024 * 1024;
|
|
29
30
|
/** Enough for a documentation tree; far short of a filesystem. */
|
|
30
31
|
const MAX_FILES = 20_000;
|
|
32
|
+
/**
|
|
33
|
+
* Reading is cheap and parsing is not, so the two are split: every file is read
|
|
34
|
+
* and hashed here, and only the documents the cache has never seen are handed
|
|
35
|
+
* to the pool. On a rebuild of an unchanged corpus that is none of them.
|
|
36
|
+
*/
|
|
31
37
|
export async function loadDocuments(inputs, cwd, options = {}) {
|
|
32
38
|
const found = await discover(inputs, cwd);
|
|
33
39
|
if (found.length === 0) {
|
|
@@ -35,7 +41,9 @@ export async function loadDocuments(inputs, cwd, options = {}) {
|
|
|
35
41
|
}
|
|
36
42
|
const root = commonRoot(found);
|
|
37
43
|
const taken = new Set();
|
|
38
|
-
const
|
|
44
|
+
const chunk = options.chunk ?? {};
|
|
45
|
+
const cache = options.cache === false ? NO_PARSE_CACHE : openParseCache(options.cacheDir);
|
|
46
|
+
const read = [];
|
|
39
47
|
const skipped = [];
|
|
40
48
|
for (const absolute of found) {
|
|
41
49
|
const name = distinct(nameOf(root, absolute), taken);
|
|
@@ -45,23 +53,55 @@ export async function loadDocuments(inputs, cwd, options = {}) {
|
|
|
45
53
|
continue;
|
|
46
54
|
}
|
|
47
55
|
const raw = await readFile(absolute);
|
|
48
|
-
|
|
49
|
-
const format = formatOf(absolute);
|
|
50
|
-
const parsed = parseDocument(text, name, format);
|
|
51
|
-
const chunks = chunkDocument(parsed, options);
|
|
52
|
-
docs.push({
|
|
56
|
+
read.push({
|
|
53
57
|
name,
|
|
54
58
|
file: basename(absolute),
|
|
55
59
|
sha256: createHash('sha256').update(raw).digest('hex'),
|
|
56
60
|
bytes: raw.byteLength,
|
|
57
|
-
format,
|
|
58
|
-
text,
|
|
59
|
-
|
|
60
|
-
chunks,
|
|
61
|
-
outline: outlineOf(parsed, chunks.length),
|
|
61
|
+
format: formatOf(absolute),
|
|
62
|
+
text: normalize(raw.toString('utf8')),
|
|
63
|
+
key: parseKey(raw, name, chunk),
|
|
62
64
|
});
|
|
63
65
|
}
|
|
64
|
-
|
|
66
|
+
const parsed = new Array(read.length);
|
|
67
|
+
const misses = [];
|
|
68
|
+
const missAt = [];
|
|
69
|
+
for (const [at, doc] of read.entries()) {
|
|
70
|
+
const hit = doc.key === undefined ? undefined : cache.get(doc.key);
|
|
71
|
+
if (hit) {
|
|
72
|
+
parsed[at] = hit;
|
|
73
|
+
}
|
|
74
|
+
else {
|
|
75
|
+
missAt.push(at);
|
|
76
|
+
misses.push({ name: doc.name, text: doc.text, format: doc.format });
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
const done = read.length - misses.length;
|
|
80
|
+
options.onProgress?.(done, read.length, []);
|
|
81
|
+
const fresh = await parseAll(misses, {
|
|
82
|
+
chunk,
|
|
83
|
+
onProgress: (at, _of, pending) => options.onProgress?.(done + at, read.length, pending),
|
|
84
|
+
});
|
|
85
|
+
for (const [i, result] of fresh.entries()) {
|
|
86
|
+
const at = missAt[i];
|
|
87
|
+
parsed[at] = result;
|
|
88
|
+
const key = read[at].key;
|
|
89
|
+
if (key !== undefined) {
|
|
90
|
+
cache.put(key, result);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
cache.commit();
|
|
94
|
+
const docs = read.map((doc, at) => ({
|
|
95
|
+
name: doc.name,
|
|
96
|
+
file: doc.file,
|
|
97
|
+
sha256: doc.sha256,
|
|
98
|
+
bytes: doc.bytes,
|
|
99
|
+
format: doc.format,
|
|
100
|
+
text: doc.text,
|
|
101
|
+
chunks: parsed[at].chunks,
|
|
102
|
+
outline: parsed[at].outline,
|
|
103
|
+
}));
|
|
104
|
+
return { docs, skipped, cached: cache.hits };
|
|
65
105
|
}
|
|
66
106
|
export const formatOf = (path) => ['.txt', '.text'].includes(extname(path).toLowerCase()) ? 'text' : 'markdown';
|
|
67
107
|
// ---------------------------------------------------------------------------
|
|
@@ -160,53 +200,4 @@ function distinct(name, taken) {
|
|
|
160
200
|
taken.add(candidate);
|
|
161
201
|
return candidate;
|
|
162
202
|
}
|
|
163
|
-
// ---------------------------------------------------------------------------
|
|
164
|
-
// the outline
|
|
165
|
-
// ---------------------------------------------------------------------------
|
|
166
|
-
/**
|
|
167
|
-
* Headings and tables, with the line each ends on. That end is what makes the
|
|
168
|
-
* outline enough on its own: a section runs from its heading to the line before
|
|
169
|
-
* the next heading at the same depth or shallower, so scoping a search to a
|
|
170
|
-
* section, listing what is in one, or naming the sections a skipped range
|
|
171
|
-
* covered are all answerable without reading the document.
|
|
172
|
-
*/
|
|
173
|
-
function outlineOf(doc, chunks) {
|
|
174
|
-
const sections = doc.sections.filter((s) => s.line !== undefined);
|
|
175
|
-
const headings = sections.map((section, at) => {
|
|
176
|
-
const next = sections.findIndex((other, i) => i > at && other.level <= section.level);
|
|
177
|
-
const end = next === -1 ? doc.lines.length : sections[next].line - 1;
|
|
178
|
-
return {
|
|
179
|
-
line: section.line,
|
|
180
|
-
end,
|
|
181
|
-
level: section.level,
|
|
182
|
-
title: section.title,
|
|
183
|
-
id: section.id,
|
|
184
|
-
path: section.path,
|
|
185
|
-
};
|
|
186
|
-
});
|
|
187
|
-
const tables = doc.blocks
|
|
188
|
-
.filter((block) => block.table)
|
|
189
|
-
.map((block) => {
|
|
190
|
-
const table = block.table;
|
|
191
|
-
return {
|
|
192
|
-
id: block.id,
|
|
193
|
-
path: block.path,
|
|
194
|
-
section: block.section.path,
|
|
195
|
-
line: block.start,
|
|
196
|
-
end: block.end,
|
|
197
|
-
columns: table.columns,
|
|
198
|
-
rows: table.rows.length,
|
|
199
|
-
caption: table.caption,
|
|
200
|
-
};
|
|
201
|
-
});
|
|
202
|
-
return {
|
|
203
|
-
name: doc.name,
|
|
204
|
-
title: doc.title,
|
|
205
|
-
format: doc.format,
|
|
206
|
-
lines: doc.lines.length,
|
|
207
|
-
chunks,
|
|
208
|
-
headings,
|
|
209
|
-
tables,
|
|
210
|
-
};
|
|
211
|
-
}
|
|
212
203
|
//# sourceMappingURL=load.js.map
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { FileOutline } from './files.ts';
|
|
2
|
+
import type { ParsedDoc } from './parse.ts';
|
|
3
|
+
/**
|
|
4
|
+
* Headings and tables, with the line each ends on. That end is what makes the
|
|
5
|
+
* outline enough on its own: a section runs from its heading to the line before
|
|
6
|
+
* the next heading at the same depth or shallower, so scoping a search to a
|
|
7
|
+
* section, listing what is in one, or naming the sections a skipped range
|
|
8
|
+
* covered are all answerable without reading the document.
|
|
9
|
+
*/
|
|
10
|
+
export declare function outlineOf(doc: ParsedDoc, chunks: number): FileOutline;
|
|
11
|
+
//# sourceMappingURL=outline.d.ts.map
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// What a document holds, without the document
|
|
3
|
+
//
|
|
4
|
+
// This lives apart from `load.ts` for one reason: a parse worker needs it, and
|
|
5
|
+
// `load.ts` imports the CLI. A worker that reached for it there would load the
|
|
6
|
+
// whole command layer into every thread to call one pure function. Nothing here
|
|
7
|
+
// may import anything that is not pure.
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
/**
|
|
10
|
+
* Headings and tables, with the line each ends on. That end is what makes the
|
|
11
|
+
* outline enough on its own: a section runs from its heading to the line before
|
|
12
|
+
* the next heading at the same depth or shallower, so scoping a search to a
|
|
13
|
+
* section, listing what is in one, or naming the sections a skipped range
|
|
14
|
+
* covered are all answerable without reading the document.
|
|
15
|
+
*/
|
|
16
|
+
export function outlineOf(doc, chunks) {
|
|
17
|
+
const sections = doc.sections.filter((s) => s.line !== undefined);
|
|
18
|
+
const headings = sections.map((section, at) => {
|
|
19
|
+
const next = sections.findIndex((other, i) => i > at && other.level <= section.level);
|
|
20
|
+
const end = next === -1 ? doc.lines.length : sections[next].line - 1;
|
|
21
|
+
return {
|
|
22
|
+
line: section.line,
|
|
23
|
+
end,
|
|
24
|
+
level: section.level,
|
|
25
|
+
title: section.title,
|
|
26
|
+
id: section.id,
|
|
27
|
+
path: section.path,
|
|
28
|
+
};
|
|
29
|
+
});
|
|
30
|
+
const tables = doc.blocks
|
|
31
|
+
.filter((block) => block.table)
|
|
32
|
+
.map((block) => {
|
|
33
|
+
const table = block.table;
|
|
34
|
+
return {
|
|
35
|
+
id: block.id,
|
|
36
|
+
path: block.path,
|
|
37
|
+
section: block.section.path,
|
|
38
|
+
line: block.start,
|
|
39
|
+
end: block.end,
|
|
40
|
+
columns: table.columns,
|
|
41
|
+
rows: table.rows.length,
|
|
42
|
+
caption: table.caption,
|
|
43
|
+
};
|
|
44
|
+
});
|
|
45
|
+
return {
|
|
46
|
+
name: doc.name,
|
|
47
|
+
title: doc.title,
|
|
48
|
+
format: doc.format,
|
|
49
|
+
lines: doc.lines.length,
|
|
50
|
+
chunks,
|
|
51
|
+
headings,
|
|
52
|
+
tables,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
//# sourceMappingURL=outline.js.map
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { ChunkOptions } from './chunk.ts';
|
|
2
|
+
import type { Parsed } from './pool.ts';
|
|
3
|
+
export declare const PARSE_KIND = "docs-parse";
|
|
4
|
+
/** Bumped when chunking changes shape, which invalidates every entry. */
|
|
5
|
+
export declare const PARSE_VERSION = 1;
|
|
6
|
+
export interface ParseCache {
|
|
7
|
+
get(key: string): Parsed | undefined;
|
|
8
|
+
put(key: string, parsed: Parsed): void;
|
|
9
|
+
commit(): void;
|
|
10
|
+
readonly hits: number;
|
|
11
|
+
}
|
|
12
|
+
export declare const NO_PARSE_CACHE: ParseCache;
|
|
13
|
+
/**
|
|
14
|
+
* What the chunks depend on, and nothing else. `tokenCount` is a function and
|
|
15
|
+
* cannot be hashed; a caller that supplies one gets no cache rather than a key
|
|
16
|
+
* that quietly ignores it.
|
|
17
|
+
*/
|
|
18
|
+
export declare function parseKey(bytes: Buffer, name: string, options: ChunkOptions): string | undefined;
|
|
19
|
+
export declare function openParseCache(dir?: string): ParseCache;
|
|
20
|
+
//# sourceMappingURL=parse-cache.d.ts.map
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { Cache, cacheKey } from '@zenera/cli/lib';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// Not parsing the same document twice
|
|
5
|
+
//
|
|
6
|
+
// Once vectors are cached, parsing is what a rebuild of an unchanged corpus
|
|
7
|
+
// spends all of its time on — the threads make it several times faster, and
|
|
8
|
+
// this makes it nothing at all. A document that has not changed produces
|
|
9
|
+
// exactly the chunks it produced last time, so the chunks are what is kept.
|
|
10
|
+
//
|
|
11
|
+
// The key is the file's bytes together with everything that decides how they
|
|
12
|
+
// are cut: change the document, the chunk settings or this module, and the
|
|
13
|
+
// entry is a miss rather than a wrong answer. It sits in the machine's shared
|
|
14
|
+
// cache next to the vectors, so a corpus indexed into two directories is read
|
|
15
|
+
// once, and it follows the same rule everything there does — every error is a
|
|
16
|
+
// miss and nothing else.
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
export const PARSE_KIND = 'docs-parse';
|
|
19
|
+
/** Bumped when chunking changes shape, which invalidates every entry. */
|
|
20
|
+
export const PARSE_VERSION = 1;
|
|
21
|
+
export const NO_PARSE_CACHE = {
|
|
22
|
+
get: () => undefined,
|
|
23
|
+
put: () => { },
|
|
24
|
+
commit: () => { },
|
|
25
|
+
hits: 0,
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* What the chunks depend on, and nothing else. `tokenCount` is a function and
|
|
29
|
+
* cannot be hashed; a caller that supplies one gets no cache rather than a key
|
|
30
|
+
* that quietly ignores it.
|
|
31
|
+
*/
|
|
32
|
+
export function parseKey(bytes, name, options) {
|
|
33
|
+
if (typeof options.tokenCount === 'function') {
|
|
34
|
+
return undefined;
|
|
35
|
+
}
|
|
36
|
+
return cacheKey(PARSE_VERSION, name, options.chunkTokens, options.minChunkTokens, options.maxChunkTokens, options.tableSliceTokens, createHash('sha256').update(bytes).digest('hex'));
|
|
37
|
+
}
|
|
38
|
+
export function openParseCache(dir) {
|
|
39
|
+
return new StoredParses(new Cache(PARSE_KIND, { dir }));
|
|
40
|
+
}
|
|
41
|
+
class StoredParses {
|
|
42
|
+
#store;
|
|
43
|
+
constructor(store) {
|
|
44
|
+
this.#store = store;
|
|
45
|
+
}
|
|
46
|
+
get hits() {
|
|
47
|
+
return this.#store.hits;
|
|
48
|
+
}
|
|
49
|
+
get(key) {
|
|
50
|
+
const found = this.#store.get(key);
|
|
51
|
+
return found?.chunks && found.outline ? found : undefined;
|
|
52
|
+
}
|
|
53
|
+
put(key, parsed) {
|
|
54
|
+
this.#store.put(key, { chunks: parsed.chunks, outline: parsed.outline });
|
|
55
|
+
}
|
|
56
|
+
commit() {
|
|
57
|
+
this.#store.commit();
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
//# sourceMappingURL=parse-cache.js.map
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export interface ParseJob {
|
|
2
|
+
at: number;
|
|
3
|
+
name: string;
|
|
4
|
+
text: string;
|
|
5
|
+
format: 'markdown' | 'text';
|
|
6
|
+
}
|
|
7
|
+
export interface ParseDone {
|
|
8
|
+
at: number;
|
|
9
|
+
chunks?: unknown;
|
|
10
|
+
outline?: unknown;
|
|
11
|
+
error?: string;
|
|
12
|
+
}
|
|
13
|
+
//# sourceMappingURL=parse-worker.d.ts.map
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { parentPort, workerData } from 'node:worker_threads';
|
|
2
|
+
import { chunkDocument } from "./chunk.js";
|
|
3
|
+
import { outlineOf } from "./outline.js";
|
|
4
|
+
import { parseDocument } from "./parse.js";
|
|
5
|
+
const options = (workerData ?? {});
|
|
6
|
+
parentPort?.on('message', (job) => {
|
|
7
|
+
try {
|
|
8
|
+
const parsed = parseDocument(job.text, job.name, job.format);
|
|
9
|
+
const chunks = chunkDocument(parsed, options);
|
|
10
|
+
parentPort.postMessage({ at: job.at, chunks, outline: outlineOf(parsed, chunks.length) });
|
|
11
|
+
}
|
|
12
|
+
catch (err) {
|
|
13
|
+
// Sent back rather than thrown: an uncaught throw here kills the thread,
|
|
14
|
+
// and the pool would lose the other documents queued behind this one.
|
|
15
|
+
parentPort.postMessage({
|
|
16
|
+
at: job.at,
|
|
17
|
+
error: err instanceof Error ? err.message : String(err),
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
});
|
|
21
|
+
//# sourceMappingURL=parse-worker.js.map
|