@zenera/rag 1.1.9 → 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 +195 -10
- package/dist/command.js +2 -1
- 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/assemble.d.ts +52 -0
- package/dist/docs/assemble.js +127 -0
- package/dist/docs/build.d.ts +54 -0
- package/dist/docs/build.js +134 -0
- package/dist/docs/chunk.d.ts +73 -0
- package/dist/docs/chunk.js +586 -0
- package/dist/docs/command.d.ts +3 -0
- package/dist/docs/command.js +575 -0
- package/dist/docs/files.d.ts +94 -0
- package/dist/docs/files.js +80 -0
- package/dist/docs/index.d.ts +13 -0
- package/dist/docs/index.js +13 -0
- package/dist/docs/load.d.ts +42 -0
- package/dist/docs/load.js +203 -0
- package/dist/docs/lookup.d.ts +80 -0
- package/dist/docs/lookup.js +147 -0
- 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/parse.d.ts +95 -0
- package/dist/docs/parse.js +372 -0
- package/dist/docs/pool.d.ts +27 -0
- package/dist/docs/pool.js +133 -0
- package/dist/docs/readme.d.ts +6 -0
- package/dist/docs/readme.js +142 -0
- package/dist/docs/render.d.ts +13 -0
- package/dist/docs/render.js +46 -0
- package/dist/docs/repl.d.ts +7 -0
- package/dist/docs/repl.js +130 -0
- package/dist/docs/search.d.ts +92 -0
- package/dist/docs/search.js +251 -0
- package/dist/docs/store.d.ts +79 -0
- package/dist/docs/store.js +214 -0
- package/dist/docs/tools.d.ts +10 -0
- package/dist/docs/tools.js +300 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +3 -0
- package/dist/schema/build.d.ts +16 -2
- package/dist/schema/build.js +37 -25
- package/dist/schema/command.js +39 -10
- package/dist/schema/query.js +1 -0
- package/dist/schema/readme.js +6 -2
- package/dist/schema/search.d.ts +2 -0
- package/dist/schema/search.js +18 -2
- package/dist/schema/store.d.ts +13 -3
- package/dist/schema/store.js +73 -24
- package/dist/schema/tools.js +21 -2
- package/package.json +17 -4
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { availableParallelism } from 'node:os';
|
|
2
|
+
import { Worker } from 'node:worker_threads';
|
|
3
|
+
import { chunkDocument } from "./chunk.js";
|
|
4
|
+
import { outlineOf } from "./outline.js";
|
|
5
|
+
import { parseDocument } from "./parse.js";
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// Parsing several documents at once
|
|
8
|
+
//
|
|
9
|
+
// Remark is synchronous and holds the thread for as long as a document takes,
|
|
10
|
+
// so a corpus is parsed strictly one file at a time no matter how many cores
|
|
11
|
+
// are idle. A pool fixes that and nothing else: the work is pure, the inputs are
|
|
12
|
+
// independent, and the answers are placed by index, so the result is the same
|
|
13
|
+
// list in the same order that one thread would have produced.
|
|
14
|
+
//
|
|
15
|
+
// It is not always the faster choice. Starting a thread costs more than parsing
|
|
16
|
+
// a small document, so a handful of files are done here; and a caller that
|
|
17
|
+
// supplied its own `tokenCount` cannot be helped at all, because a function is
|
|
18
|
+
// not something `postMessage` can clone. Every one of those paths runs the same
|
|
19
|
+
// three functions in this thread instead, which is why the fallback is safe
|
|
20
|
+
// rather than merely convenient.
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
/** Below this, starting threads costs more than the parsing they would save. */
|
|
23
|
+
const MIN_DOCUMENTS = 8;
|
|
24
|
+
/** More than this and the threads contend for memory bandwidth, not for work. */
|
|
25
|
+
const MAX_WORKERS = 8;
|
|
26
|
+
/** One short of the machine, so the thread doing everything else keeps a core. */
|
|
27
|
+
export const poolSize = () => Math.max(1, Math.min(MAX_WORKERS, availableParallelism() - 1));
|
|
28
|
+
const EXTENSION = import.meta.url.endsWith('.ts') ? '.ts' : '.js';
|
|
29
|
+
// Built from `import.meta.url` because the extension changes on the way to
|
|
30
|
+
// `dist/`, and `rewriteRelativeImportExtensions` does not touch string literals.
|
|
31
|
+
const WORKER = new URL(`./parse-worker${EXTENSION}`, import.meta.url);
|
|
32
|
+
/** How many documents run between yields, so a long parse still narrates. */
|
|
33
|
+
const YIELD_EVERY = 32;
|
|
34
|
+
export async function parseAll(inputs, options = {}) {
|
|
35
|
+
const chunk = options.chunk ?? {};
|
|
36
|
+
const workers = Math.min(options.workers ?? poolSize(), inputs.length);
|
|
37
|
+
const out = new Array(inputs.length);
|
|
38
|
+
if (workers > 1 && inputs.length >= MIN_DOCUMENTS && typeof chunk.tokenCount !== 'function') {
|
|
39
|
+
try {
|
|
40
|
+
await threaded(out, inputs, chunk, workers, options.onProgress);
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
// A thread that would not start, or died. Whatever it did finish is
|
|
44
|
+
// in `out` already and is not parsed again; `here` fills the rest.
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return here(out, inputs, chunk, options.onProgress);
|
|
48
|
+
}
|
|
49
|
+
async function here(out, inputs, chunk, onProgress) {
|
|
50
|
+
let done = out.reduce((n, parsed) => (parsed ? n + 1 : n), 0);
|
|
51
|
+
for (const [at, input] of inputs.entries()) {
|
|
52
|
+
if (out[at]) {
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
const parsed = parseDocument(input.text, input.name, input.format);
|
|
56
|
+
const chunks = chunkDocument(parsed, chunk);
|
|
57
|
+
out[at] = { chunks, outline: outlineOf(parsed, chunks.length) };
|
|
58
|
+
onProgress?.(++done, inputs.length, [input.name]);
|
|
59
|
+
if (done % YIELD_EVERY === 0) {
|
|
60
|
+
// Remark is synchronous, so a corpus parsed here would hold the loop
|
|
61
|
+
// for minutes and freeze the very report saying it is still working.
|
|
62
|
+
await new Promise((resume) => setImmediate(resume));
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return out;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* The threaded path on its own. `parseAll` falls back to this thread when the
|
|
69
|
+
* workers cannot run, which is right for a build and useless for a test: a
|
|
70
|
+
* worker that never starts would look exactly like a fast one. Tests call this.
|
|
71
|
+
*/
|
|
72
|
+
export async function parseThreaded(inputs, chunk, size, onProgress) {
|
|
73
|
+
const out = new Array(inputs.length);
|
|
74
|
+
await threaded(out, inputs, chunk, size, onProgress);
|
|
75
|
+
return out;
|
|
76
|
+
}
|
|
77
|
+
async function threaded(out, inputs, chunk, size, onProgress) {
|
|
78
|
+
const workers = Array.from({ length: size }, () => new Worker(WORKER, { workerData: chunk }));
|
|
79
|
+
// What each thread is on, so a corpus with two pathological documents in it
|
|
80
|
+
// can name them instead of looking stalled a hair short of the total.
|
|
81
|
+
const busy = new Map();
|
|
82
|
+
let next = 0;
|
|
83
|
+
let done = 0;
|
|
84
|
+
const pump = async (worker) => {
|
|
85
|
+
while (next < inputs.length) {
|
|
86
|
+
const at = next++;
|
|
87
|
+
busy.set(worker, inputs[at].name);
|
|
88
|
+
out[at] = await ask(worker, { at, ...inputs[at] });
|
|
89
|
+
busy.delete(worker);
|
|
90
|
+
onProgress?.(++done, inputs.length, [...busy.values()]);
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
try {
|
|
94
|
+
await Promise.all(workers.map(pump));
|
|
95
|
+
}
|
|
96
|
+
finally {
|
|
97
|
+
await Promise.all(workers.map((worker) => worker.terminate()));
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
function ask(worker, job) {
|
|
101
|
+
return new Promise((resolve, reject) => {
|
|
102
|
+
const done = () => {
|
|
103
|
+
worker.off('message', onMessage);
|
|
104
|
+
worker.off('error', onError);
|
|
105
|
+
worker.off('exit', onExit);
|
|
106
|
+
};
|
|
107
|
+
const onMessage = (reply) => {
|
|
108
|
+
if (reply.at !== job.at) {
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
done();
|
|
112
|
+
if (reply.error !== undefined) {
|
|
113
|
+
reject(new Error(`${job.name}: ${reply.error}`));
|
|
114
|
+
}
|
|
115
|
+
else {
|
|
116
|
+
resolve({ chunks: reply.chunks, outline: reply.outline });
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
const onError = (err) => {
|
|
120
|
+
done();
|
|
121
|
+
reject(err);
|
|
122
|
+
};
|
|
123
|
+
const onExit = (code) => {
|
|
124
|
+
done();
|
|
125
|
+
reject(new Error(`parse worker stopped with code ${code}`));
|
|
126
|
+
};
|
|
127
|
+
worker.on('message', onMessage);
|
|
128
|
+
worker.on('error', onError);
|
|
129
|
+
worker.on('exit', onExit);
|
|
130
|
+
worker.postMessage(job);
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
//# sourceMappingURL=pool.js.map
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { type Report } from '../common/progress.ts';
|
|
2
|
+
import type { Counts, Manifest } from './files.ts';
|
|
3
|
+
export type Phase = 'reading' | 'embedding' | 'writing';
|
|
4
|
+
export declare const PHASES: Record<Phase, string>;
|
|
5
|
+
export declare const DOCS_REPORT: Report<Counts, Manifest>;
|
|
6
|
+
//# sourceMappingURL=readme.d.ts.map
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { basename } from 'node:path';
|
|
2
|
+
import { INTERVAL_MS, } from "../common/progress.js";
|
|
3
|
+
import { breakdown, duration, fields, grid, message, plural, searched } from "../common/prose.js";
|
|
4
|
+
export const PHASES = {
|
|
5
|
+
reading: 'reading the documents and cutting them into chunks',
|
|
6
|
+
embedding: 'embedding',
|
|
7
|
+
writing: 'writing the store',
|
|
8
|
+
};
|
|
9
|
+
/** What the running count is counting, which is not the same in every phase. */
|
|
10
|
+
const COUNTING = {
|
|
11
|
+
reading: 'parsed',
|
|
12
|
+
embedding: 'embedded',
|
|
13
|
+
};
|
|
14
|
+
export const DOCS_REPORT = { building, complete, failed };
|
|
15
|
+
function building(state) {
|
|
16
|
+
const rows = [
|
|
17
|
+
['documents', summary(state.documents)],
|
|
18
|
+
['embedding', state.embedding],
|
|
19
|
+
[
|
|
20
|
+
'started',
|
|
21
|
+
`${new Date(state.started).toISOString()} (${duration(state.now - state.started)} ago)`,
|
|
22
|
+
],
|
|
23
|
+
['step', state.step],
|
|
24
|
+
];
|
|
25
|
+
if (state.summary) {
|
|
26
|
+
rows.push(['found', counted(state.summary)]);
|
|
27
|
+
}
|
|
28
|
+
if (state.total > 0) {
|
|
29
|
+
// Rounded down: 13619 of 13621 is not 100%, and a report that says it is
|
|
30
|
+
// turns two slow documents into a build that looks hung.
|
|
31
|
+
const percent = Math.floor((state.done / state.total) * 100);
|
|
32
|
+
rows.push([
|
|
33
|
+
COUNTING[state.phase] ?? 'done',
|
|
34
|
+
`${state.done} of ${state.total} · ${percent}%`,
|
|
35
|
+
]);
|
|
36
|
+
}
|
|
37
|
+
if (state.pending.length > 0) {
|
|
38
|
+
rows.push(['still on', waiting(state.pending)]);
|
|
39
|
+
}
|
|
40
|
+
rows.push(['timing', breakdown(state.timings)]);
|
|
41
|
+
rows.push(['updated', new Date(state.now).toISOString()]);
|
|
42
|
+
return [
|
|
43
|
+
'# Document index — being built',
|
|
44
|
+
'',
|
|
45
|
+
'A searchable index of the documents named below, written by `zen rag docs index`.',
|
|
46
|
+
'**It is incomplete. Nothing should read it yet.**',
|
|
47
|
+
'',
|
|
48
|
+
...fields(rows),
|
|
49
|
+
'',
|
|
50
|
+
`These lines are refreshed at most every ${INTERVAL_MS / 1000} seconds while the build runs, and`,
|
|
51
|
+
'the whole file is replaced by a description of the index when it finishes. If it still says',
|
|
52
|
+
'"being built" and `.lock` names no living process, the build died part way.',
|
|
53
|
+
'',
|
|
54
|
+
].join('\n');
|
|
55
|
+
}
|
|
56
|
+
function complete(state) {
|
|
57
|
+
const { manifest } = state;
|
|
58
|
+
return [
|
|
59
|
+
`# Document index — ${basename(state.dir)}`,
|
|
60
|
+
'',
|
|
61
|
+
`A searchable index of ${plural(manifest.sources.length, 'document')}, built with`,
|
|
62
|
+
`${manifest.embedding.ref} (${manifest.embedding.dimensions}d) in ${duration(state.ms)}.`,
|
|
63
|
+
'Ask it a question and it answers with the passages that matched, quoted verbatim from',
|
|
64
|
+
'the copies kept in `sources/` — line numbers included, and with a marker wherever',
|
|
65
|
+
'something between two of them was left out.',
|
|
66
|
+
'',
|
|
67
|
+
'## What it covers',
|
|
68
|
+
'',
|
|
69
|
+
...documentTable(manifest.sources),
|
|
70
|
+
'',
|
|
71
|
+
`${counted(manifest.counts)},`,
|
|
72
|
+
`${searched(manifest.indexes)}.`,
|
|
73
|
+
'',
|
|
74
|
+
`Time: ${breakdown(state.timings)}.`,
|
|
75
|
+
'',
|
|
76
|
+
'## Files',
|
|
77
|
+
'',
|
|
78
|
+
...fields([
|
|
79
|
+
['manifest.json', 'what this index is and what built it — read this first'],
|
|
80
|
+
['outline.json', 'every heading and table, with the lines they cover'],
|
|
81
|
+
['sources/', 'the documents themselves, verbatim: where the quotes come from'],
|
|
82
|
+
['lance/', 'the chunks: the search text, the vectors, the filter columns'],
|
|
83
|
+
]),
|
|
84
|
+
'',
|
|
85
|
+
'## Asking it something',
|
|
86
|
+
'',
|
|
87
|
+
'From this directory:',
|
|
88
|
+
'',
|
|
89
|
+
'```',
|
|
90
|
+
'zen rag docs search --dir . "what you are after"',
|
|
91
|
+
'zen rag docs search --dir . --file "guides/**" --kind table "pressure rating"',
|
|
92
|
+
'zen rag docs list files --dir .',
|
|
93
|
+
'```',
|
|
94
|
+
'',
|
|
95
|
+
`Built by ${manifest.indexer} on ${manifest.createdAt}.`,
|
|
96
|
+
'',
|
|
97
|
+
].join('\n');
|
|
98
|
+
}
|
|
99
|
+
function failed(state) {
|
|
100
|
+
return [
|
|
101
|
+
'# Document index — failed',
|
|
102
|
+
'',
|
|
103
|
+
'This index was not finished and what is here is incomplete. Nothing should read it;',
|
|
104
|
+
'build it again with `zen rag docs index`.',
|
|
105
|
+
'',
|
|
106
|
+
...fields([
|
|
107
|
+
['documents', summary(state.documents)],
|
|
108
|
+
['step', state.step],
|
|
109
|
+
['reason', message(state.reason)],
|
|
110
|
+
['started', new Date(state.started).toISOString()],
|
|
111
|
+
['timing', breakdown(state.timings)],
|
|
112
|
+
[
|
|
113
|
+
'failed',
|
|
114
|
+
`${new Date().toISOString()} (after ${duration(Date.now() - state.started)})`,
|
|
115
|
+
],
|
|
116
|
+
]),
|
|
117
|
+
'',
|
|
118
|
+
].join('\n');
|
|
119
|
+
}
|
|
120
|
+
// ---------------------------------------------------------------------------
|
|
121
|
+
/** Names are only worth reading once there are few enough to act on. */
|
|
122
|
+
const MOST_NAMES = 3;
|
|
123
|
+
const waiting = (pending) => pending.length <= MOST_NAMES ? pending.join(', ') : `${pending.length} documents`;
|
|
124
|
+
const HEADERS = ['document', 'format', 'lines', 'sections', 'tables', 'chunks'];
|
|
125
|
+
const documentTable = (sources) => grid(HEADERS, sources.map((s) => [
|
|
126
|
+
s.name,
|
|
127
|
+
s.format,
|
|
128
|
+
String(s.lines),
|
|
129
|
+
String(s.sections),
|
|
130
|
+
String(s.tables),
|
|
131
|
+
String(s.chunks),
|
|
132
|
+
]));
|
|
133
|
+
function counted(counts) {
|
|
134
|
+
return (`${plural(counts.chunks, 'chunk')} over ${plural(counts.documents, 'document')}: ` +
|
|
135
|
+
`${counts.lines} lines, ${counts.sections} sections, ${counts.tables} tables`);
|
|
136
|
+
}
|
|
137
|
+
/** A corpus can be thousands of files; a README listing them all is a wall. */
|
|
138
|
+
function summary(documents) {
|
|
139
|
+
const shown = documents.slice(0, 12).join(', ');
|
|
140
|
+
return documents.length > 12 ? `${shown}, and ${documents.length - 12} more` : shown;
|
|
141
|
+
}
|
|
142
|
+
//# sourceMappingURL=readme.js.map
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { Assembly, Excerpt } from './assemble.ts';
|
|
2
|
+
import type { Match } from './search.ts';
|
|
3
|
+
export interface RenderOptions {
|
|
4
|
+
/** the line-number gutter; on unless something else is going to eat this */
|
|
5
|
+
numbers?: boolean;
|
|
6
|
+
colour?: boolean;
|
|
7
|
+
}
|
|
8
|
+
export declare function renderAssembly(assembly: Assembly, options?: RenderOptions): string;
|
|
9
|
+
export declare function renderExcerpt(file: Excerpt, options?: RenderOptions): string[];
|
|
10
|
+
/** The one-line-per-match view, for `--quiet` and for the prompt loop. */
|
|
11
|
+
export declare const matchRows: (matches: readonly Match[]) => string[][];
|
|
12
|
+
export declare const MATCH_HEADERS: readonly ["id", "kind", "lines", "score", "vec / txt", "heading"];
|
|
13
|
+
//# sourceMappingURL=render.d.ts.map
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { bold, dim } from '@zenera/cli/lib';
|
|
2
|
+
export function renderAssembly(assembly, options = {}) {
|
|
3
|
+
const out = [];
|
|
4
|
+
for (const file of assembly.files) {
|
|
5
|
+
out.push(...renderExcerpt(file, options), '');
|
|
6
|
+
}
|
|
7
|
+
if (assembly.truncated) {
|
|
8
|
+
out.push('(cut short by the line budget — raise it with --max-lines)');
|
|
9
|
+
}
|
|
10
|
+
return out.join('\n').trimEnd();
|
|
11
|
+
}
|
|
12
|
+
export function renderExcerpt(file, options = {}) {
|
|
13
|
+
const paint = options.colour === false ? (s) => s : undefined;
|
|
14
|
+
const strong = paint ?? bold;
|
|
15
|
+
const faint = paint ?? dim;
|
|
16
|
+
const width = String(file.lines).length;
|
|
17
|
+
return [
|
|
18
|
+
`## ${strong(file.path)} ${faint(`— ${file.shown} of ${file.lines} lines`)}`,
|
|
19
|
+
'',
|
|
20
|
+
...file.pieces.flatMap((piece) => renderPiece(piece, width, options, faint)),
|
|
21
|
+
];
|
|
22
|
+
}
|
|
23
|
+
function renderPiece(piece, width, options, faint) {
|
|
24
|
+
if (piece.type === 'omission') {
|
|
25
|
+
const named = piece.sections.length > 0 ? ` (${piece.sections.join(', ')})` : '';
|
|
26
|
+
return [faint(`... ${piece.count} lines omitted${named} ...`), ''];
|
|
27
|
+
}
|
|
28
|
+
const lines = piece.lines.map((line, at) => options.numbers === false
|
|
29
|
+
? line
|
|
30
|
+
: `${faint(String(piece.start + at).padStart(width))} ${faint('|')} ${line}`);
|
|
31
|
+
return [...lines, ''];
|
|
32
|
+
}
|
|
33
|
+
/** The one-line-per-match view, for `--quiet` and for the prompt loop. */
|
|
34
|
+
export const matchRows = (matches) => matches.map((m) => [
|
|
35
|
+
m.id,
|
|
36
|
+
m.kind,
|
|
37
|
+
`${m.bodyStart}-${m.bodyEnd}`,
|
|
38
|
+
m.score.toFixed(4),
|
|
39
|
+
// Fusion ranks; these say whether the rank was worth anything.
|
|
40
|
+
[m.relevance.vector?.toFixed(2), m.relevance.text?.toFixed(1)]
|
|
41
|
+
.map((v) => v ?? '·')
|
|
42
|
+
.join(' / '),
|
|
43
|
+
m.headings.split('\n')[0] ?? '',
|
|
44
|
+
]);
|
|
45
|
+
export const MATCH_HEADERS = ['id', 'kind', 'lines', 'score', 'vec / txt', 'heading'];
|
|
46
|
+
//# sourceMappingURL=render.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { type AssembleOptions } from './assemble.ts';
|
|
2
|
+
import { type DocsIndex, type DocsQuery } from './search.ts';
|
|
3
|
+
export interface ReplSettings extends AssembleOptions {
|
|
4
|
+
quiet?: boolean;
|
|
5
|
+
}
|
|
6
|
+
export declare function repl(index: DocsIndex, initial: DocsQuery, settings?: ReplSettings): Promise<void>;
|
|
7
|
+
//# sourceMappingURL=repl.d.ts.map
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { bold, cyan, dim, note, write } from '@zenera/cli/lib';
|
|
2
|
+
import { createInterface } from 'node:readline/promises';
|
|
3
|
+
import { assemble } from "./assemble.js";
|
|
4
|
+
import { CHUNK_KINDS } from "./chunk.js";
|
|
5
|
+
import { renderAssembly } from "./render.js";
|
|
6
|
+
import { SEARCH_MODES } from "./search.js";
|
|
7
|
+
export async function repl(index, initial, settings = {}) {
|
|
8
|
+
const rl = createInterface({ input: process.stdin, output: process.stderr });
|
|
9
|
+
let query = { ...initial };
|
|
10
|
+
note(bold(`${index.manifest.counts.documents} document(s), ${index.manifest.counts.chunks} chunks`));
|
|
11
|
+
note(dim(' a bare line searches; `help` lists the rest.'));
|
|
12
|
+
try {
|
|
13
|
+
if (query.query) {
|
|
14
|
+
query = await run(index, query, settings);
|
|
15
|
+
}
|
|
16
|
+
for (;;) {
|
|
17
|
+
const line = (await rl.question(cyan('docs> '))).trim();
|
|
18
|
+
if (line === '') {
|
|
19
|
+
continue;
|
|
20
|
+
}
|
|
21
|
+
if (line === 'quit' || line === 'exit') {
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
if (line === 'help') {
|
|
25
|
+
help();
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
if (line === 'reset') {
|
|
29
|
+
query = {};
|
|
30
|
+
note(dim(' cleared, narrowings and exclusions alike'));
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
if (line === 'show') {
|
|
34
|
+
note(dim(` ${JSON.stringify(query)}`));
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
if (line === 'files') {
|
|
38
|
+
for (const name of index.resolveFiles(query.files, query.exclude_files)) {
|
|
39
|
+
note(dim(` ${name}`));
|
|
40
|
+
}
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
const [head, ...rest] = line.split(' ');
|
|
44
|
+
const tail = rest.join(' ').trim();
|
|
45
|
+
const narrowed = narrow(query, head ?? '', tail);
|
|
46
|
+
if (narrowed) {
|
|
47
|
+
query = narrowed;
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
query = await run(index, { ...query, query: line }, settings);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
finally {
|
|
54
|
+
rl.close();
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* The narrowing verbs. Each one replaces its own field rather than adding to
|
|
59
|
+
* it — `file guides/**` twice in a row means the second pattern, which is what
|
|
60
|
+
* anyone typing it meant. An empty argument clears the field.
|
|
61
|
+
*/
|
|
62
|
+
function narrow(query, head, tail) {
|
|
63
|
+
const set = (key, value) => {
|
|
64
|
+
note(dim(` ${head} is ${tail || 'anything'}`));
|
|
65
|
+
return { ...query, [key]: value };
|
|
66
|
+
};
|
|
67
|
+
switch (head) {
|
|
68
|
+
case 'file':
|
|
69
|
+
return set('files', tail ? [tail] : undefined);
|
|
70
|
+
case 'not':
|
|
71
|
+
return set('exclude_files', tail ? [tail] : undefined);
|
|
72
|
+
case 'section':
|
|
73
|
+
return set('section', tail ? [tail] : undefined);
|
|
74
|
+
case 'kind':
|
|
75
|
+
if (tail && !CHUNK_KINDS.includes(tail)) {
|
|
76
|
+
note(dim(` kind is one of ${CHUNK_KINDS.join(', ')}`));
|
|
77
|
+
return query;
|
|
78
|
+
}
|
|
79
|
+
return set('kinds', tail ? [tail] : undefined);
|
|
80
|
+
case 'mode':
|
|
81
|
+
if (tail && !SEARCH_MODES.includes(tail)) {
|
|
82
|
+
note(dim(` mode is one of ${SEARCH_MODES.join(', ')}`));
|
|
83
|
+
return query;
|
|
84
|
+
}
|
|
85
|
+
return set('mode', (tail || undefined));
|
|
86
|
+
case 'limit': {
|
|
87
|
+
const limit = Number(tail);
|
|
88
|
+
if (!Number.isInteger(limit) || limit < 1) {
|
|
89
|
+
note(dim(' limit is a whole number of at least 1'));
|
|
90
|
+
return query;
|
|
91
|
+
}
|
|
92
|
+
return set('limit', limit);
|
|
93
|
+
}
|
|
94
|
+
default:
|
|
95
|
+
return undefined;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
async function run(index, query, settings) {
|
|
99
|
+
const result = await index.search(query);
|
|
100
|
+
const excerpt = await assemble(index, result.matches, settings);
|
|
101
|
+
if (excerpt.files.length > 0) {
|
|
102
|
+
write(renderAssembly(excerpt, {}));
|
|
103
|
+
}
|
|
104
|
+
note(dim(` ${result.matches.length} passage(s) in ${excerpt.files.length} document(s)` +
|
|
105
|
+
` · ${excerpt.shown} line(s) · ${result.files.length} document(s) in scope`));
|
|
106
|
+
if (result.matches.length === 0) {
|
|
107
|
+
return query;
|
|
108
|
+
}
|
|
109
|
+
const seen = new Set([...(query.exclude_ids ?? []), ...result.matches.map((m) => m.id)]);
|
|
110
|
+
note(dim(` ${seen.size} passage(s) now excluded — \`reset\` to see them again`));
|
|
111
|
+
return { ...query, exclude_ids: [...seen] };
|
|
112
|
+
}
|
|
113
|
+
function help() {
|
|
114
|
+
for (const line of [
|
|
115
|
+
' <text> search',
|
|
116
|
+
' file <pattern> only documents matching it; blank for all',
|
|
117
|
+
' not <pattern> drop documents matching it',
|
|
118
|
+
' section <name> only under this heading, by title, id or path',
|
|
119
|
+
` kind <k> ${CHUNK_KINDS.join(' | ')}`,
|
|
120
|
+
` mode <m> ${SEARCH_MODES.join(' | ')}`,
|
|
121
|
+
' limit <n> passages per search',
|
|
122
|
+
' files the documents currently in scope',
|
|
123
|
+
' show the query as it stands',
|
|
124
|
+
' reset forget it, narrowings and exclusions alike',
|
|
125
|
+
' quit',
|
|
126
|
+
]) {
|
|
127
|
+
note(dim(line));
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
//# sourceMappingURL=repl.js.map
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import type { Embedder } from '@zenera/neo';
|
|
2
|
+
import { type FileOutline, type HeadingRecord, type Manifest, type OpenDocs, type Outline } from './files.ts';
|
|
3
|
+
import { ChunkStore } from './store.ts';
|
|
4
|
+
export type SearchMode = 'hybrid' | 'vector' | 'text';
|
|
5
|
+
export declare const SEARCH_MODES: readonly SearchMode[];
|
|
6
|
+
export interface DocsQuery {
|
|
7
|
+
query?: string;
|
|
8
|
+
mode?: SearchMode;
|
|
9
|
+
/** patterns over the document name: a glob, a substring, or a regex */
|
|
10
|
+
files?: readonly string[];
|
|
11
|
+
exclude_files?: readonly string[];
|
|
12
|
+
/** a heading title, a structure id, or a structure path */
|
|
13
|
+
section?: readonly string[];
|
|
14
|
+
/** one of `CHUNK_KINDS` */
|
|
15
|
+
kinds?: readonly string[];
|
|
16
|
+
/** chunk ids already seen, so asking again moves on */
|
|
17
|
+
exclude_ids?: readonly string[];
|
|
18
|
+
limit?: number;
|
|
19
|
+
}
|
|
20
|
+
export interface Match {
|
|
21
|
+
id: string;
|
|
22
|
+
path: string;
|
|
23
|
+
kind: string;
|
|
24
|
+
headings: string;
|
|
25
|
+
structureId: string;
|
|
26
|
+
structurePath: string;
|
|
27
|
+
/** every line this chunk wants shown, headings and table header included */
|
|
28
|
+
lineNumbers: number[];
|
|
29
|
+
bodyStart: number;
|
|
30
|
+
bodyEnd: number;
|
|
31
|
+
text: string;
|
|
32
|
+
score: number;
|
|
33
|
+
/** where it placed in each leg, when it placed at all */
|
|
34
|
+
ranks: {
|
|
35
|
+
vector?: number;
|
|
36
|
+
text?: number;
|
|
37
|
+
};
|
|
38
|
+
/**
|
|
39
|
+
* And how well it did there. Fusion scores a rank, so its numbers say only
|
|
40
|
+
* that something placed first — a question the corpus cannot answer at all
|
|
41
|
+
* still returns a confident-looking `1 / (RRF_K + 0)`. These are what tell
|
|
42
|
+
* a caller whether first place was worth having.
|
|
43
|
+
*/
|
|
44
|
+
relevance: {
|
|
45
|
+
vector?: number;
|
|
46
|
+
text?: number;
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
export interface DocsResult {
|
|
50
|
+
matches: Match[];
|
|
51
|
+
mode: SearchMode;
|
|
52
|
+
/** the documents the narrowings left in play */
|
|
53
|
+
files: string[];
|
|
54
|
+
/** the sections they left in play, as structure paths */
|
|
55
|
+
sections: string[];
|
|
56
|
+
/** distinct chunks either leg returned, before fusion trimmed them */
|
|
57
|
+
considered: number;
|
|
58
|
+
}
|
|
59
|
+
export declare const DEFAULT_LIMIT = 8;
|
|
60
|
+
export declare class DocsIndex {
|
|
61
|
+
#private;
|
|
62
|
+
readonly manifest: Manifest;
|
|
63
|
+
readonly outline: Outline;
|
|
64
|
+
constructor(index: OpenDocs, store: ChunkStore, embedder?: Embedder);
|
|
65
|
+
/**
|
|
66
|
+
* An embedder is optional because half of what this index is for needs no
|
|
67
|
+
* model at all: listing, grepping and reading are exact, and asking for a
|
|
68
|
+
* credential to do them would be asking for nothing.
|
|
69
|
+
*/
|
|
70
|
+
static open(dir: string, embedder?: Embedder): Promise<DocsIndex>;
|
|
71
|
+
/** A document, split the way every line number in this index counts it. */
|
|
72
|
+
lines(name: string): Promise<string[]>;
|
|
73
|
+
file(name: string): FileOutline | undefined;
|
|
74
|
+
/** Document names matching the patterns, or all of them when there are none. */
|
|
75
|
+
resolveFiles(patterns?: readonly string[], exclude?: readonly string[]): string[];
|
|
76
|
+
/**
|
|
77
|
+
* Headings the terms name, over the documents still in play. A term is tried
|
|
78
|
+
* as a structure id, then a structure path, then as a pattern over the
|
|
79
|
+
* title — so `--section overview` and `--section sec:3` are the same
|
|
80
|
+
* argument, and neither has to be explained.
|
|
81
|
+
*/
|
|
82
|
+
resolveSections(terms: readonly string[], files: readonly string[]): HeadingRecord[];
|
|
83
|
+
close(): void;
|
|
84
|
+
search(query: DocsQuery, signal?: AbortSignal): Promise<DocsResult>;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Inside one of these sections, or anywhere when none were asked for. The
|
|
88
|
+
* boundary is a path separator and not a prefix, because `doc/sec:1` is a
|
|
89
|
+
* prefix of `doc/sec:10` and they are different sections.
|
|
90
|
+
*/
|
|
91
|
+
export declare const under: (path: string, sections: readonly string[]) => boolean;
|
|
92
|
+
//# sourceMappingURL=search.d.ts.map
|