@zenera/rag 1.1.8 → 1.1.10

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 (42) hide show
  1. package/README.md +154 -10
  2. package/dist/command.js +2 -1
  3. package/dist/docs/assemble.d.ts +52 -0
  4. package/dist/docs/assemble.js +127 -0
  5. package/dist/docs/build.d.ts +34 -0
  6. package/dist/docs/build.js +108 -0
  7. package/dist/docs/chunk.d.ts +73 -0
  8. package/dist/docs/chunk.js +586 -0
  9. package/dist/docs/command.d.ts +3 -0
  10. package/dist/docs/command.js +529 -0
  11. package/dist/docs/files.d.ts +94 -0
  12. package/dist/docs/files.js +80 -0
  13. package/dist/docs/index.d.ts +13 -0
  14. package/dist/docs/index.js +13 -0
  15. package/dist/docs/load.d.ts +28 -0
  16. package/dist/docs/load.js +212 -0
  17. package/dist/docs/lookup.d.ts +80 -0
  18. package/dist/docs/lookup.js +147 -0
  19. package/dist/docs/parse.d.ts +95 -0
  20. package/dist/docs/parse.js +372 -0
  21. package/dist/docs/readme.d.ts +6 -0
  22. package/dist/docs/readme.js +122 -0
  23. package/dist/docs/render.d.ts +13 -0
  24. package/dist/docs/render.js +46 -0
  25. package/dist/docs/repl.d.ts +7 -0
  26. package/dist/docs/repl.js +130 -0
  27. package/dist/docs/search.d.ts +92 -0
  28. package/dist/docs/search.js +251 -0
  29. package/dist/docs/store.d.ts +55 -0
  30. package/dist/docs/store.js +171 -0
  31. package/dist/docs/tools.d.ts +10 -0
  32. package/dist/docs/tools.js +300 -0
  33. package/dist/index.d.ts +1 -0
  34. package/dist/index.js +3 -0
  35. package/dist/schema/command.js +3 -0
  36. package/dist/schema/query.js +1 -0
  37. package/dist/schema/search.d.ts +2 -0
  38. package/dist/schema/search.js +18 -2
  39. package/dist/schema/store.d.ts +4 -2
  40. package/dist/schema/store.js +16 -9
  41. package/dist/schema/tools.js +21 -2
  42. package/package.json +17 -4
@@ -0,0 +1,529 @@
1
+ import { bold, CliError, cyan, dim, EXIT, isInteractive, json, note, parse, table, usageError, write, } from '@zenera/cli/lib';
2
+ import { relative, resolve } from 'node:path';
3
+ import { resolveEmbedder } from "../common/embedder.js";
4
+ import { locateIndex, outputDir } from "../common/locate.js";
5
+ import { assertSameEmbedding } from "../common/manifest.js";
6
+ import { PatternError } from "../common/match.js";
7
+ import { grid } from "../common/prose.js";
8
+ import { assemble, DEFAULT_MAX_LINES } from "./assemble.js";
9
+ import { buildIndex } from "./build.js";
10
+ import { CHUNK_KINDS } from "./chunk.js";
11
+ import { DOCS_INDEX, readManifest } from "./files.js";
12
+ import { DOC_EXTENSIONS } from "./load.js";
13
+ import { grepLines, listFiles, listSections, listTables, readRange, readSection, } from "./lookup.js";
14
+ import { MATCH_HEADERS, matchRows, renderAssembly } from "./render.js";
15
+ import { repl } from "./repl.js";
16
+ import { DEFAULT_LIMIT, DocsIndex, SEARCH_MODES, } from "./search.js";
17
+ const { defaultDir: DEFAULT_DIR, envName: DIR_ENV } = DOCS_INDEX;
18
+ // ---------------------------------------------------------------------------
19
+ // zen rag docs — a pile of documents, as something to search
20
+ //
21
+ // The subject word is gone by the time this runs: the frame in `../command.ts`
22
+ // strips it and hands over the rest, so everything below is about markdown and
23
+ // nothing below knows there is a second subject.
24
+ //
25
+ // `search` is written for two callers who want the same thing differently. A
26
+ // person gets a prompt and refines by typing; a model gets flags, a stable
27
+ // `--json` shape, no terminal, and exit 0 when nothing matched — because an
28
+ // empty answer is an answer, and a caller made to tell "no results" from "no
29
+ // index" by reading stderr will get it wrong.
30
+ //
31
+ // The narrowings are the point. Nobody finds the paragraph they want on the
32
+ // first ask: they search, see it is the wrong release, and search again inside
33
+ // one — which is why --file takes a glob, --section takes a heading, and --kind
34
+ // takes `table` when the answer is a table and not the prose around it.
35
+ //
36
+ // `list`, `grep` and `show` are the other half, and they are deliberately not
37
+ // searches. A ranking can only ever hand back the top of a list, so it cannot
38
+ // answer "does the word `deprecated` appear anywhere" — the honest answer is
39
+ // every match or none, and these give it with no model and no credential.
40
+ // ---------------------------------------------------------------------------
41
+ const USAGE = 'zen rag docs <index|search|list|grep|show|stats> [args...]';
42
+ const INDEX_USAGE = 'zen rag docs index --embedding <ref> [--out <dir>] <path...>';
43
+ const SEARCH_USAGE = 'zen rag docs search [--dir <dir>] [query...]';
44
+ const LIST_USAGE = 'zen rag docs list <files|sections|tables> [--dir <dir>]';
45
+ const GREP_USAGE = 'zen rag docs grep <pattern> [--dir <dir>]';
46
+ const SHOW_USAGE = 'zen rag docs show <file> [--section <name>] [--lines <from-to>]';
47
+ export const command = {
48
+ summary: 'Search a pile of markdown and text documents.',
49
+ usage: USAGE,
50
+ details: [
51
+ 'Commands',
52
+ ...table([
53
+ [' index <path...>', dim('Read the documents and write a searchable index.')],
54
+ [' search [text]', dim('Ask it something. --interactive for a prompt.')],
55
+ [' list <what>', dim('Every document, section or table. No ranking.')],
56
+ [' grep <pattern>', dim('Every matching line, with the section it sits in.')],
57
+ [' show <file>', dim('A document, a section of one, or a line range.')],
58
+ [' stats', dim('What is in an index, and what built it.')],
59
+ ]),
60
+ '',
61
+ 'Index',
62
+ ...table([
63
+ [' <path...>', dim(`Files, directories or globs. ${DOC_EXTENSIONS.join(', ')}.`)],
64
+ [
65
+ ' --embedding <ref>',
66
+ dim('Which embedder makes the vectors. Omit it to be shown the choices.'),
67
+ ],
68
+ [
69
+ ' -o, --out <dir>',
70
+ dim(`Where the index goes. Default ${DEFAULT_DIR}, or ${DIR_ENV}.`),
71
+ ],
72
+ [' --batch <n>', dim('Texts per embedding request. Default 96.')],
73
+ [' --chunk-tokens <n>', dim('Target chunk size. Default 384.')],
74
+ ]),
75
+ '',
76
+ dim(' Every document is copied into the index, so it stays portable and'),
77
+ dim(' every quoted line comes from the document rather than a rebuild of it.'),
78
+ '',
79
+ 'Search',
80
+ ...table([
81
+ [' <text>', dim('What to look for. One question, not a list of terms.')],
82
+ [' -d, --dir <dir>', dim(`Which index. Found from here if unset; see ${DIR_ENV}.`)],
83
+ [' --embedding <ref>', dim('Must be the one the index was built with.')],
84
+ [' -f, --file <pattern>', dim('Only these documents. Repeatable.')],
85
+ [' --exclude-file <pattern>', dim('Drop these documents. Repeatable.')],
86
+ [' -s, --section <name>', dim('Only under this heading, and what nests in it.')],
87
+ [` --kind <k>`, dim(`${CHUNK_KINDS.join(' | ')}. Repeatable.`)],
88
+ [' --mode <m>', dim(`${SEARCH_MODES.join(' | ')}. Default hybrid.`)],
89
+ [' --exclude-id <id>', dim('Drop a passage already seen. Repeatable.')],
90
+ [' --limit <n>', dim(`Passages kept. Default ${DEFAULT_LIMIT}.`)],
91
+ [' -B, --before <n>', dim('Extra lines quoted before each passage.')],
92
+ [' -A, --after <n>', dim('Extra lines quoted after each passage.')],
93
+ [
94
+ ' --max-lines <n>',
95
+ dim(`A ceiling on the whole answer. Default ${DEFAULT_MAX_LINES}.`),
96
+ ],
97
+ [' --no-numbers', dim('Quote the lines without their numbers.')],
98
+ [' --hits', dim('One line per passage instead of the text.')],
99
+ [' --interactive', dim('Prompt, search, narrow, search again. Needs a terminal.')],
100
+ [' --quiet', dim('No narration.')],
101
+ ]),
102
+ '',
103
+ dim(' A --file pattern with * or ? is a glob over the whole document name,'),
104
+ dim(' otherwise a substring. Names are relative to what was indexed, so'),
105
+ dim(' --file "nsx_4.2*/api/**" is one release and --file routing is a word.'),
106
+ '',
107
+ 'Exact listing — no embedder, no credential',
108
+ ...table([
109
+ [' list files', dim('Every document, with its size and what it holds.')],
110
+ [' list sections', dim('Every heading. --depth to stop at a level.')],
111
+ [' list tables', dim('Every table, with its columns and row count.')],
112
+ [' grep <pattern>', dim('Every matching line. --regex for a regex.')],
113
+ [' --file <pattern>', dim('Narrow to documents. Repeatable, as everywhere.')],
114
+ [' --section <name>', dim('Narrow to a heading and what nests in it.')],
115
+ [' --regex', dim('Read every pattern as a regex.')],
116
+ [' --case-sensitive', dim('Match the capitals too.')],
117
+ [' --limit <n>', dim('Keep at most n; the count still reports them all.')],
118
+ ]),
119
+ '',
120
+ 'Show',
121
+ ...table([
122
+ [' <file>', dim('A document name, as `list files` prints it.')],
123
+ [' --section <name>', dim('Just that heading and what nests under it.')],
124
+ [' --lines <from-to>', dim('Just those lines, e.g. --lines 40-80.')],
125
+ [' --no-numbers', dim('Without the line-number gutter.')],
126
+ ]),
127
+ '',
128
+ dim(`Without --dir, the index is the nearest one at or above the working`),
129
+ dim(`directory; ${cyan(DIR_ENV)} names it outright.`),
130
+ '',
131
+ dim(`Credentials come from the ${cyan('zen')} keyring — try ${cyan('zen key ls')}.`),
132
+ ],
133
+ async run(ctx) {
134
+ const [name, ...tail] = ctx.args;
135
+ switch (name) {
136
+ case 'index':
137
+ return await index(tail, ctx);
138
+ case 'search':
139
+ return await search(tail, ctx);
140
+ case 'list':
141
+ return await list(tail, ctx);
142
+ case 'grep':
143
+ return await grep(tail, ctx);
144
+ case 'show':
145
+ return await show(tail, ctx);
146
+ case 'stats':
147
+ return await stats(tail, ctx);
148
+ default:
149
+ throw usageError(name ? `unknown command "${name}"` : 'no command given', USAGE);
150
+ }
151
+ },
152
+ };
153
+ async function index(args, ctx) {
154
+ const { values, positionals } = parse(args, {
155
+ out: { type: 'string', short: 'o' },
156
+ embedding: { type: 'string' },
157
+ batch: { type: 'string' },
158
+ 'chunk-tokens': { type: 'string' },
159
+ quiet: { type: 'boolean' },
160
+ }, INDEX_USAGE);
161
+ if (positionals.length === 0) {
162
+ throw usageError('no document, directory or pattern given', INDEX_USAGE);
163
+ }
164
+ const out = outputDir(ctx.cwd, values.out, DOCS_INDEX);
165
+ const loud = !values.quiet && !ctx.json;
166
+ const chosen = await resolveEmbedder(values.embedding);
167
+ const started = Date.now();
168
+ const { manifest } = await buildIndex({
169
+ files: positionals,
170
+ cwd: ctx.cwd,
171
+ out,
172
+ embedder: chosen,
173
+ embeddingRef: values.embedding,
174
+ indexer: 'zenera-rag',
175
+ batch: values.batch ? count(values.batch, '--batch') : undefined,
176
+ chunk: values['chunk-tokens']
177
+ ? { chunkTokens: count(values['chunk-tokens'], '--chunk-tokens') }
178
+ : undefined,
179
+ onRead: loud
180
+ ? (summary) => {
181
+ printSources(summary.sources);
182
+ for (const skip of summary.skipped) {
183
+ note(dim(` skipped ${skip.name}: ${skip.reason}`));
184
+ }
185
+ // The first batch takes a while and says nothing while it
186
+ // does; this is the line that makes that a wait, not a hang.
187
+ note(dim(` embedding ${summary.counts.chunks} chunks with ${chosen.id} …`));
188
+ }
189
+ : undefined,
190
+ onProgress: loud
191
+ ? (done, total) => note(dim(` embedded ${done}/${total} · ${Math.round((done / total) * 100)}% · ${elapsed(started)}`))
192
+ : undefined,
193
+ });
194
+ if (ctx.json) {
195
+ json({ out, manifest });
196
+ return;
197
+ }
198
+ // stdout is the path and nothing else, so `DIR=$(zen rag docs index …)`
199
+ // works; what it means goes to stderr, where the narration lives.
200
+ note();
201
+ write(out);
202
+ note(` wrote ${bold(String(manifest.counts.chunks))} chunks from ` +
203
+ `${bold(String(manifest.counts.documents))} document(s) to ${bold(out)}, ` +
204
+ `embedded with ${manifest.embedding.ref} (${manifest.embedding.dimensions}d)`);
205
+ const where = out === resolve(ctx.cwd, DEFAULT_DIR) ? '' : ` --dir ${relative(ctx.cwd, out) || out}`;
206
+ note(dim(` search it: ${cyan(`zen rag docs search${where} "what you are after"`)}`));
207
+ }
208
+ const SOURCE_HEADERS = ['LINES', 'SECTIONS', 'TABLES', 'CHUNKS'];
209
+ function printSources(sources) {
210
+ // A corpus can be thousands of documents and a wall of them is not a report.
211
+ const shownRows = sources.slice(0, 20);
212
+ const cells = shownRows.map((s) => [s.lines, s.sections, s.tables, s.chunks]);
213
+ const total = SOURCE_HEADERS.map((_, i) => sources.reduce((n, s) => n + column(s, i), 0));
214
+ // Numbers are padded before they are styled: a colour code has no width,
215
+ // and `table` cannot know that.
216
+ const widths = SOURCE_HEADERS.map((h, i) => Math.max(h.length, ...[...cells, total].map((row) => String(row[i]).length)));
217
+ note();
218
+ notes(table([
219
+ [bold('DOCUMENT'), ...SOURCE_HEADERS.map((h, i) => bold(h.padStart(widths[i])))],
220
+ ...shownRows.map((s, at) => [
221
+ s.name,
222
+ ...cells[at].map((c, i) => String(c).padStart(widths[i])),
223
+ ]),
224
+ ...(sources.length > shownRows.length
225
+ ? [[dim(`… and ${sources.length - shownRows.length} more`)]]
226
+ : []),
227
+ [dim('total'), ...total.map((c, i) => dim(String(c).padStart(widths[i])))],
228
+ ]).map((line) => ` ${line}`));
229
+ note();
230
+ }
231
+ const column = (source, at) => [source.lines, source.sections, source.tables, source.chunks][at] ?? 0;
232
+ function elapsed(since) {
233
+ const seconds = Math.round((Date.now() - since) / 1000);
234
+ return seconds < 60 ? `${seconds}s` : `${Math.floor(seconds / 60)}m${seconds % 60}s`;
235
+ }
236
+ const MANY = { type: 'string', multiple: true };
237
+ async function search(args, ctx) {
238
+ const { values, positionals } = parse(args, {
239
+ dir: { type: 'string', short: 'd' },
240
+ embedding: { type: 'string' },
241
+ file: { ...MANY, short: 'f' },
242
+ 'exclude-file': MANY,
243
+ section: { ...MANY, short: 's' },
244
+ kind: MANY,
245
+ mode: { type: 'string' },
246
+ 'exclude-id': MANY,
247
+ limit: { type: 'string' },
248
+ before: { type: 'string', short: 'B' },
249
+ after: { type: 'string', short: 'A' },
250
+ 'max-lines': { type: 'string' },
251
+ 'no-numbers': { type: 'boolean' },
252
+ hits: { type: 'boolean' },
253
+ interactive: { type: 'boolean' },
254
+ quiet: { type: 'boolean' },
255
+ }, SEARCH_USAGE);
256
+ const text = positionals.join(' ').trim();
257
+ const query = {
258
+ query: text || undefined,
259
+ mode: modeOf(values.mode),
260
+ files: values.file,
261
+ exclude_files: values['exclude-file'],
262
+ section: values.section,
263
+ kinds: kindsOf(values.kind),
264
+ exclude_ids: values['exclude-id'],
265
+ limit: values.limit ? count(values.limit, '--limit') : undefined,
266
+ };
267
+ const shape = {
268
+ before: values.before ? count(values.before, '--before', 0) : undefined,
269
+ after: values.after ? count(values.after, '--after', 0) : undefined,
270
+ maxLines: values['max-lines'] ? count(values['max-lines'], '--max-lines') : undefined,
271
+ };
272
+ // Everything that can be wrong about the invocation is settled before a
273
+ // credential is asked for, so a typo is a usage error and not a login.
274
+ if (values.interactive && !isInteractive()) {
275
+ throw usageError('--interactive needs a terminal', SEARCH_USAGE);
276
+ }
277
+ if (!values.interactive && !text) {
278
+ throw usageError('nothing to search for', SEARCH_USAGE);
279
+ }
280
+ const dir = indexDir(ctx, values.dir);
281
+ const manifest = await readManifest(dir);
282
+ const ref = values.embedding ?? manifest.embedding.ref;
283
+ assertSameEmbedding(manifest, ref);
284
+ const found = await DocsIndex.open(dir, await resolveEmbedder(ref));
285
+ try {
286
+ if (values.interactive) {
287
+ await repl(found, query, shape);
288
+ return;
289
+ }
290
+ const result = await patterned(() => found.search(query));
291
+ const excerpt = await assemble(found, result.matches, shape);
292
+ if (ctx.json) {
293
+ json({
294
+ matches: result.matches,
295
+ files: excerpt.files,
296
+ scope: { files: result.files, sections: result.sections },
297
+ mode: result.mode,
298
+ considered: result.considered,
299
+ truncated: excerpt.truncated,
300
+ });
301
+ return;
302
+ }
303
+ if (values.hits) {
304
+ notes(grid(MATCH_HEADERS, matchRows(result.matches)));
305
+ }
306
+ else if (excerpt.files.length > 0) {
307
+ write(renderAssembly(excerpt, { numbers: !values['no-numbers'] }));
308
+ }
309
+ if (!values.quiet) {
310
+ note(dim(` ${result.matches.length} passage(s) in ${excerpt.files.length} document(s)` +
311
+ ` · ${excerpt.shown} line(s) · ${result.files.length} document(s) in scope`));
312
+ }
313
+ }
314
+ finally {
315
+ found.close();
316
+ }
317
+ }
318
+ const LIST_OPTIONS = {
319
+ dir: { type: 'string', short: 'd' },
320
+ file: { ...MANY, short: 'f' },
321
+ 'exclude-file': MANY,
322
+ section: { ...MANY, short: 's' },
323
+ depth: { type: 'string' },
324
+ regex: { type: 'boolean' },
325
+ 'case-sensitive': { type: 'boolean' },
326
+ limit: { type: 'string' },
327
+ quiet: { type: 'boolean' },
328
+ };
329
+ async function list(args, ctx) {
330
+ const { values, positionals } = parse(args, LIST_OPTIONS, LIST_USAGE);
331
+ const [what] = positionals;
332
+ if (!what || !['files', 'sections', 'tables'].includes(what)) {
333
+ throw usageError(what ? `cannot list "${what}"` : 'nothing named to list', 'expected files, sections or tables');
334
+ }
335
+ const options = {
336
+ files: values.file,
337
+ exclude_files: values['exclude-file'],
338
+ section: values.section,
339
+ depth: values.depth ? count(values.depth, '--depth') : undefined,
340
+ limit: values.limit ? count(values.limit, '--limit') : undefined,
341
+ };
342
+ const found = await open(ctx, values.dir);
343
+ try {
344
+ const result = await patterned(async () => what === 'files'
345
+ ? listFiles(found, options)
346
+ : what === 'sections'
347
+ ? listSections(found, options)
348
+ : listTables(found, options));
349
+ report(ctx, result, values.quiet, what);
350
+ }
351
+ finally {
352
+ found.close();
353
+ }
354
+ }
355
+ async function grep(args, ctx) {
356
+ const { values, positionals } = parse(args, LIST_OPTIONS, GREP_USAGE);
357
+ const [text] = positionals;
358
+ if (!text) {
359
+ throw usageError('no pattern given', GREP_USAGE);
360
+ }
361
+ const found = await open(ctx, values.dir);
362
+ try {
363
+ const result = await patterned(() => grepLines(found, text, {
364
+ files: values.file,
365
+ exclude_files: values['exclude-file'],
366
+ section: values.section,
367
+ regex: values.regex,
368
+ caseSensitive: values['case-sensitive'],
369
+ limit: values.limit ? count(values.limit, '--limit') : undefined,
370
+ }));
371
+ report(ctx, result, values.quiet, 'line');
372
+ }
373
+ finally {
374
+ found.close();
375
+ }
376
+ }
377
+ function report(ctx, result, quiet, what) {
378
+ if (ctx.json) {
379
+ json(result);
380
+ return;
381
+ }
382
+ const headers = Object.keys(result.rows[0] ?? {});
383
+ if (result.rows.length > 0) {
384
+ write(grid(headers, result.rows.map((row) => headers.map((h) => String(row[h] ?? '')))).join('\n'));
385
+ }
386
+ if (!quiet) {
387
+ note(dim(` ${result.found} ${what}(s)${shown(result.found, result.rows.length)}`));
388
+ }
389
+ }
390
+ const shown = (found, kept) => (kept < found ? `, showing ${kept}` : '');
391
+ /**
392
+ * A document, verbatim. No embedder and no store: the copy in `sources/` is the
393
+ * document, and asking for a credential to print something already on disk
394
+ * would be theatre.
395
+ */
396
+ async function show(args, ctx) {
397
+ const { values, positionals } = parse(args, {
398
+ dir: { type: 'string', short: 'd' },
399
+ section: { type: 'string', short: 's' },
400
+ lines: { type: 'string' },
401
+ 'no-numbers': { type: 'boolean' },
402
+ quiet: { type: 'boolean' },
403
+ }, SHOW_USAGE);
404
+ const [name] = positionals;
405
+ if (!name) {
406
+ throw usageError('no document named', SHOW_USAGE);
407
+ }
408
+ const found = await open(ctx, values.dir);
409
+ try {
410
+ const file = found.resolveFiles([name])[0];
411
+ if (!file) {
412
+ throw new CliError(`no document called ${name}`, EXIT.failed, 'list them with `zen rag docs list files`');
413
+ }
414
+ const span = values.lines ? range(values.lines) : undefined;
415
+ const excerpt = values.section
416
+ ? await readSection(found, file, values.section)
417
+ : await readRange(found, file, span?.[0] ?? 1, span?.[1] ?? Infinity);
418
+ if (ctx.json) {
419
+ json(excerpt);
420
+ return;
421
+ }
422
+ const width = String(excerpt.end).length;
423
+ write(excerpt.lines
424
+ .map((line, at) => values['no-numbers']
425
+ ? line
426
+ : `${dim(String(excerpt.start + at).padStart(width))} ${dim('|')} ${line}`)
427
+ .join('\n'));
428
+ if (!values.quiet) {
429
+ note(dim(` ${file} · lines ${excerpt.start}-${excerpt.end} of ${excerpt.total}`));
430
+ }
431
+ }
432
+ finally {
433
+ found.close();
434
+ }
435
+ }
436
+ async function stats(args, ctx) {
437
+ const { values } = parse(args, { dir: { type: 'string', short: 'd' } }, 'zen rag docs stats [--dir <dir>]');
438
+ const dir = indexDir(ctx, values.dir);
439
+ const manifest = await readManifest(dir);
440
+ if (ctx.json) {
441
+ json(manifest);
442
+ return;
443
+ }
444
+ note(bold(dir));
445
+ notes(table([
446
+ [' built', manifest.createdAt],
447
+ [' by', manifest.indexer],
448
+ [' embedder', `${manifest.embedding.ref} (${manifest.embedding.dimensions}d)`],
449
+ [
450
+ ' indexes',
451
+ `fts ${yes(manifest.indexes.fts)} · vector ${yes(manifest.indexes.vector)}`,
452
+ ],
453
+ ]));
454
+ printSources(manifest.sources);
455
+ notes(table([
456
+ [' documents', String(manifest.counts.documents)],
457
+ [' chunks', String(manifest.counts.chunks)],
458
+ [' lines', String(manifest.counts.lines)],
459
+ [' sections', String(manifest.counts.sections)],
460
+ [' tables', String(manifest.counts.tables)],
461
+ ]).map(dim));
462
+ }
463
+ // ---------------------------------------------------------------------------
464
+ /**
465
+ * Where the index is, said out loud when nobody named it. Finding one and not
466
+ * saying which would make every answer here unattributable.
467
+ */
468
+ function indexDir(ctx, flag) {
469
+ const { dir, from } = locateIndex(ctx.cwd, flag, DOCS_INDEX);
470
+ if (from === 'found' && !ctx.json) {
471
+ note(dim(` using ${relative(ctx.cwd, dir) || dir}`));
472
+ }
473
+ return dir;
474
+ }
475
+ /** The exact half opens the index without an embedder, and so without a key. */
476
+ const open = async (ctx, flag) => await DocsIndex.open(indexDir(ctx, flag));
477
+ /** A bad pattern is a bad invocation, not a failure of the index. */
478
+ async function patterned(run) {
479
+ try {
480
+ return await run();
481
+ }
482
+ catch (err) {
483
+ if (err instanceof PatternError) {
484
+ throw usageError(err.message, USAGE);
485
+ }
486
+ throw err;
487
+ }
488
+ }
489
+ function modeOf(value) {
490
+ if (value === undefined) {
491
+ return undefined;
492
+ }
493
+ if (!SEARCH_MODES.includes(value)) {
494
+ throw usageError(`--mode cannot be "${value}"`, `expected ${SEARCH_MODES.join(', ')}`);
495
+ }
496
+ return value;
497
+ }
498
+ function kindsOf(values) {
499
+ for (const value of values ?? []) {
500
+ if (!CHUNK_KINDS.includes(value)) {
501
+ throw usageError(`--kind cannot be "${value}"`, `expected ${CHUNK_KINDS.join(', ')}`);
502
+ }
503
+ }
504
+ return values;
505
+ }
506
+ /** `40-80`, or `40` for a single line. */
507
+ function range(value) {
508
+ const [from, to] = value.split('-');
509
+ const start = Number(from);
510
+ const end = to === undefined ? start : Number(to);
511
+ if (!Number.isInteger(start) || !Number.isInteger(end) || start < 1 || end < start) {
512
+ throw usageError(`--lines cannot be "${value}"`, 'expected something like 40-80');
513
+ }
514
+ return [start, end];
515
+ }
516
+ function count(value, flag, min = 1) {
517
+ const number = Number(value);
518
+ if (!Number.isInteger(number) || number < min) {
519
+ throw usageError(`${flag} must be a whole number of at least ${min}`, USAGE);
520
+ }
521
+ return number;
522
+ }
523
+ function notes(lines) {
524
+ for (const line of lines) {
525
+ note(line);
526
+ }
527
+ }
528
+ const yes = (value) => (value ? 'yes' : 'no');
529
+ //# sourceMappingURL=command.js.map
@@ -0,0 +1,94 @@
1
+ import { type IndexHead, type IndexSpec } from '../common/manifest.ts';
2
+ import type { DocFormat } from './parse.ts';
3
+ export declare const INDEX_VERSION = 1;
4
+ /** How a document index is found, read, and refused. */
5
+ export declare const DOCS_INDEX: IndexSpec;
6
+ export declare const OUTLINE_FILE = "outline.json";
7
+ export declare const LANCE_DIR = "lance";
8
+ export declare const SOURCES_DIR = "sources";
9
+ export interface HeadingRecord {
10
+ /** the heading's own line, 1-based */
11
+ line: number;
12
+ /** where the section ends, which is the line before the next one at this depth */
13
+ end: number;
14
+ level: number;
15
+ title: string;
16
+ id: string;
17
+ path: string;
18
+ }
19
+ export interface TableRecord {
20
+ id: string;
21
+ path: string;
22
+ /** the structure path of the section it sits in */
23
+ section: string;
24
+ line: number;
25
+ end: number;
26
+ columns: string[];
27
+ rows: number;
28
+ caption: string;
29
+ }
30
+ /** One document's shape, without a word of its text. */
31
+ export interface FileOutline {
32
+ name: string;
33
+ title: string;
34
+ format: DocFormat;
35
+ lines: number;
36
+ chunks: number;
37
+ headings: HeadingRecord[];
38
+ tables: TableRecord[];
39
+ }
40
+ export interface Outline {
41
+ files: FileOutline[];
42
+ }
43
+ export interface DocRecord {
44
+ /** the document's name within the index: a relative path, and its identity */
45
+ name: string;
46
+ /** what the file was called on the machine that built this */
47
+ file: string;
48
+ /** the copy kept beside the index, relative to it */
49
+ path: string;
50
+ sha256: string;
51
+ format: DocFormat;
52
+ title: string;
53
+ bytes: number;
54
+ lines: number;
55
+ sections: number;
56
+ tables: number;
57
+ chunks: number;
58
+ }
59
+ export interface Counts {
60
+ documents: number;
61
+ chunks: number;
62
+ lines: number;
63
+ sections: number;
64
+ tables: number;
65
+ }
66
+ export interface Manifest extends IndexHead {
67
+ sources: DocRecord[];
68
+ counts: Counts;
69
+ }
70
+ export interface WrittenIndex {
71
+ manifest: Manifest;
72
+ outline: Outline;
73
+ /** the documents to keep beside the index, verbatim, by name */
74
+ documents: Readonly<Record<string, string>>;
75
+ }
76
+ export interface OpenDocs {
77
+ dir: string;
78
+ manifest: Manifest;
79
+ outline: Outline;
80
+ }
81
+ export declare const lancePath: (dir: string) => string;
82
+ export declare function writeIndex(dir: string, index: WrittenIndex): Promise<void>;
83
+ export declare function openIndex(dir: string): Promise<OpenDocs>;
84
+ export declare const readManifest: (dir: string) => Promise<Manifest>;
85
+ /**
86
+ * A document, verbatim, as it was indexed. The name is looked up in the
87
+ * manifest rather than joined onto the directory, so nothing a caller types
88
+ * ever reaches the filesystem — and a name that is not in the index is told so
89
+ * instead of becoming a path that happens not to exist.
90
+ */
91
+ export declare function readSource(index: OpenDocs, name: string): Promise<string>;
92
+ /** The same document, split the way every line number in the index counts it. */
93
+ export declare function readLines(index: OpenDocs, name: string): Promise<string[]>;
94
+ //# sourceMappingURL=files.d.ts.map