@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.
Files changed (62) hide show
  1. package/README.md +195 -10
  2. package/dist/command.js +2 -1
  3. package/dist/common/cache.d.ts +66 -0
  4. package/dist/common/cache.js +172 -0
  5. package/dist/common/embedder.d.ts +11 -1
  6. package/dist/common/embedder.js +6 -17
  7. package/dist/common/manifest.d.ts +7 -1
  8. package/dist/common/progress.d.ts +16 -1
  9. package/dist/common/progress.js +35 -3
  10. package/dist/common/prose.d.ts +7 -0
  11. package/dist/common/prose.js +13 -0
  12. package/dist/docs/assemble.d.ts +52 -0
  13. package/dist/docs/assemble.js +127 -0
  14. package/dist/docs/build.d.ts +54 -0
  15. package/dist/docs/build.js +134 -0
  16. package/dist/docs/chunk.d.ts +73 -0
  17. package/dist/docs/chunk.js +586 -0
  18. package/dist/docs/command.d.ts +3 -0
  19. package/dist/docs/command.js +575 -0
  20. package/dist/docs/files.d.ts +94 -0
  21. package/dist/docs/files.js +80 -0
  22. package/dist/docs/index.d.ts +13 -0
  23. package/dist/docs/index.js +13 -0
  24. package/dist/docs/load.d.ts +42 -0
  25. package/dist/docs/load.js +203 -0
  26. package/dist/docs/lookup.d.ts +80 -0
  27. package/dist/docs/lookup.js +147 -0
  28. package/dist/docs/outline.d.ts +11 -0
  29. package/dist/docs/outline.js +55 -0
  30. package/dist/docs/parse-cache.d.ts +20 -0
  31. package/dist/docs/parse-cache.js +60 -0
  32. package/dist/docs/parse-worker.d.ts +13 -0
  33. package/dist/docs/parse-worker.js +21 -0
  34. package/dist/docs/parse.d.ts +95 -0
  35. package/dist/docs/parse.js +372 -0
  36. package/dist/docs/pool.d.ts +27 -0
  37. package/dist/docs/pool.js +133 -0
  38. package/dist/docs/readme.d.ts +6 -0
  39. package/dist/docs/readme.js +142 -0
  40. package/dist/docs/render.d.ts +13 -0
  41. package/dist/docs/render.js +46 -0
  42. package/dist/docs/repl.d.ts +7 -0
  43. package/dist/docs/repl.js +130 -0
  44. package/dist/docs/search.d.ts +92 -0
  45. package/dist/docs/search.js +251 -0
  46. package/dist/docs/store.d.ts +79 -0
  47. package/dist/docs/store.js +214 -0
  48. package/dist/docs/tools.d.ts +10 -0
  49. package/dist/docs/tools.js +300 -0
  50. package/dist/index.d.ts +1 -0
  51. package/dist/index.js +3 -0
  52. package/dist/schema/build.d.ts +16 -2
  53. package/dist/schema/build.js +37 -25
  54. package/dist/schema/command.js +39 -10
  55. package/dist/schema/query.js +1 -0
  56. package/dist/schema/readme.js +6 -2
  57. package/dist/schema/search.d.ts +2 -0
  58. package/dist/schema/search.js +18 -2
  59. package/dist/schema/store.d.ts +13 -3
  60. package/dist/schema/store.js +73 -24
  61. package/dist/schema/tools.js +21 -2
  62. package/package.json +17 -4
@@ -0,0 +1,80 @@
1
+ import { CliError, EXIT } from '@zenera/cli/lib';
2
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
3
+ import { dirname, join } from 'node:path';
4
+ import { MANIFEST_FILE, readHead } from "../common/manifest.js";
5
+ // ---------------------------------------------------------------------------
6
+ // What a document index is, on disk
7
+ //
8
+ // A directory, written in an order that is the whole crash story: the manifest
9
+ // goes last, so a half-built index has no manifest and reads as "not indexed"
10
+ // rather than as a store that quietly lost half its documents.
11
+ //
12
+ // manifest.json what this index is and what built it
13
+ // outline.json the headings and tables of every document, read whole
14
+ // sources/ the documents themselves, verbatim
15
+ // lance/ the chunks: the search text, the vectors, the filters
16
+ //
17
+ // `sources/` is not a convenience here, the way it is for a schema index — it
18
+ // is where the answers come from. A search returns line ranges, and the lines
19
+ // are read back out of these copies, so what is quoted is the document and not
20
+ // a reconstruction of it. That also makes the index one portable thing: nothing
21
+ // in it names a path outside itself, so it can be moved, shipped, or mounted
22
+ // somewhere else in an agent's sandbox and still answer.
23
+ //
24
+ // It is also why there is no table of lines. The original design kept one row
25
+ // per physical line beside the chunks, carrying the text and its structure. With
26
+ // the document itself sitting in `sources/`, that table would be a second copy
27
+ // of the same bytes; the per-line structure it also held is derivable from the
28
+ // outline, since a section runs from its heading to the next heading at the same
29
+ // depth or shallower. One store, one copy, same answers.
30
+ //
31
+ // `outline.json` is deliberately thin — headings, tables, counts — because it is
32
+ // parsed whole every time anything is asked. It is what makes `list`, `show` and
33
+ // section scoping work with no store, no embedder and no credential.
34
+ // ---------------------------------------------------------------------------
35
+ export const INDEX_VERSION = 1;
36
+ /** How a document index is found, read, and refused. */
37
+ export const DOCS_INDEX = {
38
+ kind: 'docs',
39
+ version: INDEX_VERSION,
40
+ defaultDir: './docs-db',
41
+ envName: 'ZEN_DOCS_DB',
42
+ };
43
+ export const OUTLINE_FILE = 'outline.json';
44
+ export const LANCE_DIR = 'lance';
45
+ export const SOURCES_DIR = 'sources';
46
+ export const lancePath = (dir) => join(dir, LANCE_DIR);
47
+ export async function writeIndex(dir, index) {
48
+ await mkdir(dir, { recursive: true });
49
+ await writeFile(join(dir, OUTLINE_FILE), JSON.stringify(index.outline));
50
+ for (const [name, text] of Object.entries(index.documents)) {
51
+ const target = join(dir, SOURCES_DIR, name);
52
+ await mkdir(dirname(target), { recursive: true });
53
+ await writeFile(target, text);
54
+ }
55
+ await writeFile(join(dir, MANIFEST_FILE), `${JSON.stringify(index.manifest, null, 4)}\n`);
56
+ }
57
+ export async function openIndex(dir) {
58
+ const manifest = await readManifest(dir);
59
+ const outline = JSON.parse(await readFile(join(dir, OUTLINE_FILE), 'utf8'));
60
+ return { dir, manifest, outline };
61
+ }
62
+ export const readManifest = (dir) => readHead(dir, DOCS_INDEX);
63
+ /**
64
+ * A document, verbatim, as it was indexed. The name is looked up in the
65
+ * manifest rather than joined onto the directory, so nothing a caller types
66
+ * ever reaches the filesystem — and a name that is not in the index is told so
67
+ * instead of becoming a path that happens not to exist.
68
+ */
69
+ export async function readSource(index, name) {
70
+ const record = index.manifest.sources.find((s) => s.name === name);
71
+ if (!record) {
72
+ throw new CliError(`${index.dir} holds no document called ${name}`, EXIT.failed, `list them with \`zen rag docs list files\``);
73
+ }
74
+ return await readFile(join(index.dir, record.path), 'utf8');
75
+ }
76
+ /** The same document, split the way every line number in the index counts it. */
77
+ export async function readLines(index, name) {
78
+ return (await readSource(index, name)).split('\n');
79
+ }
80
+ //# sourceMappingURL=files.js.map
@@ -0,0 +1,13 @@
1
+ export * from './assemble.ts';
2
+ export * from './build.ts';
3
+ export * from './chunk.ts';
4
+ export * from './files.ts';
5
+ export * from './load.ts';
6
+ export * from './lookup.ts';
7
+ export * from './parse.ts';
8
+ export * from './readme.ts';
9
+ export * from './render.ts';
10
+ export * from './search.ts';
11
+ export * from './store.ts';
12
+ export * from './tools.ts';
13
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,13 @@
1
+ export * from "./assemble.js";
2
+ export * from "./build.js";
3
+ export * from "./chunk.js";
4
+ export * from "./files.js";
5
+ export * from "./load.js";
6
+ export * from "./lookup.js";
7
+ export * from "./parse.js";
8
+ export * from "./readme.js";
9
+ export * from "./render.js";
10
+ export * from "./search.js";
11
+ export * from "./store.js";
12
+ export * from "./tools.js";
13
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,42 @@
1
+ import type { Chunk, ChunkOptions } from './chunk.ts';
2
+ import type { FileOutline } from './files.ts';
3
+ import { type DocFormat } from './parse.ts';
4
+ /** Markdown, and plain text read as paragraphs. Anything else is not a document. */
5
+ export declare const DOC_EXTENSIONS: readonly [".md", ".markdown", ".txt", ".text"];
6
+ export interface LoadedDoc {
7
+ name: string;
8
+ file: string;
9
+ sha256: string;
10
+ bytes: number;
11
+ format: DocFormat;
12
+ /** the document verbatim, CRLF normalized: what goes into `sources/` */
13
+ text: string;
14
+ chunks: Chunk[];
15
+ outline: FileOutline;
16
+ }
17
+ export interface Corpus {
18
+ docs: LoadedDoc[];
19
+ /** files that were found and not read, with the reason */
20
+ skipped: {
21
+ name: string;
22
+ reason: string;
23
+ }[];
24
+ /** documents whose chunks came from a previous build rather than the parser */
25
+ cached: number;
26
+ }
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>;
41
+ export declare const formatOf: (path: string) => DocFormat;
42
+ //# sourceMappingURL=load.d.ts.map
@@ -0,0 +1,203 @@
1
+ import { CliError, EXIT } from '@zenera/cli/lib';
2
+ import { createHash } from 'node:crypto';
3
+ import { readdir, readFile, stat } from 'node:fs/promises';
4
+ import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep } from 'node:path';
5
+ import { isGlob, wildcard } from "../common/match.js";
6
+ import { NO_PARSE_CACHE, openParseCache, parseKey } from "./parse-cache.js";
7
+ import { normalize } from "./parse.js";
8
+ import { parseAll } from "./pool.js";
9
+ // ---------------------------------------------------------------------------
10
+ // Finding the documents, and reading them
11
+ //
12
+ // What can be named is a file, a directory, or a pattern. A directory is walked,
13
+ // because "index my notes" is the question people actually have and asking them
14
+ // to enumerate it would be answering a different one. Hidden directories and
15
+ // `node_modules` are skipped: a document nobody can see is not one anybody meant
16
+ // to index.
17
+ //
18
+ // Every document is given a NAME, which is its path relative to the common root
19
+ // of everything that was named — so indexing two release trees keeps
20
+ // `nsx_4.1.0/api/routing.md` and `nsx_4.2.0/api/routing.md` apart, and a search
21
+ // can be narrowed to one of them with a pattern over exactly that string. The
22
+ // name is the identity: it is stamped on every chunk, it is what `sources/`
23
+ // files are called, and nothing anywhere records where the file was on the
24
+ // machine that built the index.
25
+ // ---------------------------------------------------------------------------
26
+ /** Markdown, and plain text read as paragraphs. Anything else is not a document. */
27
+ export const DOC_EXTENSIONS = ['.md', '.markdown', '.txt', '.text'];
28
+ /** A file larger than this is a data dump, not something anyone wrote. */
29
+ const MAX_BYTES = 16 * 1024 * 1024;
30
+ /** Enough for a documentation tree; far short of a filesystem. */
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
+ */
37
+ export async function loadDocuments(inputs, cwd, options = {}) {
38
+ const found = await discover(inputs, cwd);
39
+ if (found.length === 0) {
40
+ throw new CliError('nothing to index', EXIT.invalid, `no ${DOC_EXTENSIONS.join(', ')} files were found under ${inputs.join(', ')}`);
41
+ }
42
+ const root = commonRoot(found);
43
+ const taken = new Set();
44
+ const chunk = options.chunk ?? {};
45
+ const cache = options.cache === false ? NO_PARSE_CACHE : openParseCache(options.cacheDir);
46
+ const read = [];
47
+ const skipped = [];
48
+ for (const absolute of found) {
49
+ const name = distinct(nameOf(root, absolute), taken);
50
+ const info = await stat(absolute);
51
+ if (info.size > MAX_BYTES) {
52
+ skipped.push({ name, reason: `larger than ${MAX_BYTES / 1024 / 1024} MB` });
53
+ continue;
54
+ }
55
+ const raw = await readFile(absolute);
56
+ read.push({
57
+ name,
58
+ file: basename(absolute),
59
+ sha256: createHash('sha256').update(raw).digest('hex'),
60
+ bytes: raw.byteLength,
61
+ format: formatOf(absolute),
62
+ text: normalize(raw.toString('utf8')),
63
+ key: parseKey(raw, name, chunk),
64
+ });
65
+ }
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 };
105
+ }
106
+ export const formatOf = (path) => ['.txt', '.text'].includes(extname(path).toLowerCase()) ? 'text' : 'markdown';
107
+ // ---------------------------------------------------------------------------
108
+ // discovery
109
+ // ---------------------------------------------------------------------------
110
+ async function discover(inputs, cwd) {
111
+ const out = new Set();
112
+ for (const input of inputs) {
113
+ const path = resolve(cwd, input);
114
+ if (isGlob(input)) {
115
+ // The pattern is anchored at the deepest directory it names outright,
116
+ // so a walk of the whole tree is never needed to answer one.
117
+ const anchor = staticPrefix(path);
118
+ const match = wildcard(path, { caseSensitive: true });
119
+ for (const file of await walk(anchor)) {
120
+ if (match(file)) {
121
+ out.add(file);
122
+ }
123
+ }
124
+ continue;
125
+ }
126
+ const info = await stat(path).catch(() => undefined);
127
+ if (!info) {
128
+ throw new CliError(`no such file or directory: ${input}`, EXIT.invalid);
129
+ }
130
+ if (info.isDirectory()) {
131
+ for (const file of await walk(path)) {
132
+ out.add(file);
133
+ }
134
+ }
135
+ else {
136
+ // A file named outright is indexed whatever it is called: someone
137
+ // who types a name has already decided it is a document.
138
+ out.add(path);
139
+ }
140
+ }
141
+ return [...out].sort();
142
+ }
143
+ async function walk(root) {
144
+ const out = [];
145
+ const queue = [root];
146
+ while (queue.length > 0 && out.length < MAX_FILES) {
147
+ const dir = queue.shift();
148
+ const entries = await readdir(dir, { withFileTypes: true }).catch(() => []);
149
+ for (const entry of entries) {
150
+ if (entry.name.startsWith('.') || entry.name === 'node_modules') {
151
+ continue;
152
+ }
153
+ const path = join(dir, entry.name);
154
+ if (entry.isDirectory()) {
155
+ queue.push(path);
156
+ }
157
+ else if (indexable(entry.name)) {
158
+ out.push(path);
159
+ }
160
+ }
161
+ }
162
+ return out;
163
+ }
164
+ const indexable = (name) => DOC_EXTENSIONS.includes(extname(name).toLowerCase());
165
+ /** The part of a pattern before the first wildcard, which is a real directory. */
166
+ function staticPrefix(pattern) {
167
+ const parts = pattern.split(sep);
168
+ const at = parts.findIndex((part) => isGlob(part));
169
+ const head = (at === -1 ? parts : parts.slice(0, at)).join(sep);
170
+ return head && isAbsolute(head) ? head : dirname(pattern.split('*')[0] ?? pattern);
171
+ }
172
+ /**
173
+ * The deepest directory holding every file, which is what names are taken
174
+ * relative to. One file is its own directory, so a single document is named by
175
+ * its basename rather than by an accident of where it was kept.
176
+ */
177
+ function commonRoot(files) {
178
+ const parts = files.map((file) => dirname(file).split(sep));
179
+ const first = parts[0] ?? [];
180
+ let at = 0;
181
+ while (at < first.length && parts.every((p) => p[at] === first[at])) {
182
+ at++;
183
+ }
184
+ return first.slice(0, at).join(sep) || sep;
185
+ }
186
+ function nameOf(root, absolute) {
187
+ const rel = relative(root, absolute);
188
+ // A file outside the common root cannot happen by construction; if it ever
189
+ // did, a name climbing out of the index directory must not.
190
+ return !rel || rel.startsWith('..') || isAbsolute(rel)
191
+ ? basename(absolute)
192
+ : rel.split(sep).join('/');
193
+ }
194
+ function distinct(name, taken) {
195
+ let candidate = name;
196
+ for (let n = 2; taken.has(candidate); n++) {
197
+ const dot = name.lastIndexOf('.');
198
+ candidate = dot === -1 ? `${name}_${n}` : `${name.slice(0, dot)}_${n}${name.slice(dot)}`;
199
+ }
200
+ taken.add(candidate);
201
+ return candidate;
202
+ }
203
+ //# sourceMappingURL=load.js.map
@@ -0,0 +1,80 @@
1
+ import { type MatchOptions } from '../common/match.ts';
2
+ import type { DocsIndex } from './search.ts';
3
+ export interface Listing<T> {
4
+ found: number;
5
+ rows: T[];
6
+ truncated: boolean;
7
+ }
8
+ export interface ListOptions {
9
+ /** patterns over the document name */
10
+ files?: readonly string[];
11
+ exclude_files?: readonly string[];
12
+ limit?: number;
13
+ }
14
+ export declare const DEFAULT_ROWS = 50;
15
+ export declare const MAX_ROWS = 500;
16
+ export interface FileRow {
17
+ name: string;
18
+ title: string;
19
+ format: string;
20
+ lines: number;
21
+ sections: number;
22
+ tables: number;
23
+ chunks: number;
24
+ }
25
+ export declare function listFiles(index: DocsIndex, options?: ListOptions): Listing<FileRow>;
26
+ export interface SectionRow {
27
+ file: string;
28
+ id: string;
29
+ path: string;
30
+ level: number;
31
+ title: string;
32
+ line: number;
33
+ end: number;
34
+ }
35
+ export interface SectionOptions extends ListOptions {
36
+ /** a heading title, a structure id, or a structure path */
37
+ section?: readonly string[];
38
+ /** deepest heading level to report; the default is everything */
39
+ depth?: number;
40
+ }
41
+ export declare function listSections(index: DocsIndex, options?: SectionOptions): Listing<SectionRow>;
42
+ export interface TableRow {
43
+ file: string;
44
+ id: string;
45
+ section: string;
46
+ caption: string;
47
+ columns: string;
48
+ rows: number;
49
+ line: number;
50
+ end: number;
51
+ }
52
+ export declare function listTables(index: DocsIndex, options?: SectionOptions): Listing<TableRow>;
53
+ export interface LineRow {
54
+ file: string;
55
+ line: number;
56
+ text: string;
57
+ /** the innermost heading the line sits under, so a hit has a place */
58
+ section: string;
59
+ }
60
+ export interface GrepOptions extends SectionOptions, MatchOptions {
61
+ }
62
+ /**
63
+ * Lines matching a pattern. Substring by default, glob when it has wildcards,
64
+ * a regular expression when asked — the same rule every other pattern in this
65
+ * package follows, so nobody has to remember which flavour a flag takes.
66
+ */
67
+ export declare function grepLines(index: DocsIndex, pattern: string, options?: GrepOptions): Promise<Listing<LineRow>>;
68
+ export interface Verbatim {
69
+ file: string;
70
+ title: string;
71
+ start: number;
72
+ end: number;
73
+ lines: string[];
74
+ /** the document's length, so a caller can tell what it did not get */
75
+ total: number;
76
+ }
77
+ /** A named section, verbatim: heading line to the line before the next peer. */
78
+ export declare function readSection(index: DocsIndex, file: string, section: string): Promise<Verbatim>;
79
+ export declare function readRange(index: DocsIndex, file: string, from: number, to: number): Promise<Verbatim>;
80
+ //# sourceMappingURL=lookup.d.ts.map
@@ -0,0 +1,147 @@
1
+ import { CliError, EXIT } from '@zenera/cli/lib';
2
+ import { loose } from "../common/match.js";
3
+ import { under } from "./search.js";
4
+ export const DEFAULT_ROWS = 50;
5
+ export const MAX_ROWS = 500;
6
+ export function listFiles(index, options = {}) {
7
+ const names = new Set(index.resolveFiles(options.files, options.exclude_files));
8
+ const rows = index.manifest.sources
9
+ .filter((source) => names.has(source.name))
10
+ .map((source) => ({
11
+ name: source.name,
12
+ title: source.title,
13
+ format: source.format,
14
+ lines: source.lines,
15
+ sections: source.sections,
16
+ tables: source.tables,
17
+ chunks: source.chunks,
18
+ }));
19
+ return cut(rows, options.limit);
20
+ }
21
+ export function listSections(index, options = {}) {
22
+ const files = index.resolveFiles(options.files, options.exclude_files);
23
+ const within = new Set(files);
24
+ const wanted = options.section?.length
25
+ ? index.resolveSections(options.section, files).map((h) => h.path)
26
+ : [];
27
+ const rows = [];
28
+ for (const file of index.outline.files) {
29
+ if (!within.has(file.name)) {
30
+ continue;
31
+ }
32
+ for (const heading of file.headings) {
33
+ if (under(heading.path, wanted) && heading.level <= (options.depth ?? Infinity)) {
34
+ rows.push({ file: file.name, ...record(heading) });
35
+ }
36
+ }
37
+ }
38
+ return cut(rows, options.limit);
39
+ }
40
+ export function listTables(index, options = {}) {
41
+ const files = index.resolveFiles(options.files, options.exclude_files);
42
+ const within = new Set(files);
43
+ const wanted = options.section?.length
44
+ ? index.resolveSections(options.section, files).map((h) => h.path)
45
+ : [];
46
+ const rows = [];
47
+ for (const file of index.outline.files) {
48
+ if (!within.has(file.name)) {
49
+ continue;
50
+ }
51
+ for (const table of file.tables) {
52
+ if (!under(table.path, wanted)) {
53
+ continue;
54
+ }
55
+ rows.push({
56
+ file: file.name,
57
+ id: table.id,
58
+ section: table.section,
59
+ caption: table.caption,
60
+ columns: table.columns.join(', '),
61
+ rows: table.rows,
62
+ line: table.line,
63
+ end: table.end,
64
+ });
65
+ }
66
+ }
67
+ return cut(rows, options.limit);
68
+ }
69
+ /**
70
+ * Lines matching a pattern. Substring by default, glob when it has wildcards,
71
+ * a regular expression when asked — the same rule every other pattern in this
72
+ * package follows, so nobody has to remember which flavour a flag takes.
73
+ */
74
+ export async function grepLines(index, pattern, options = {}) {
75
+ const files = index.resolveFiles(options.files, options.exclude_files);
76
+ const wanted = options.section?.length
77
+ ? index.resolveSections(options.section, files)
78
+ : undefined;
79
+ const match = loose(pattern, { regex: options.regex, caseSensitive: options.caseSensitive });
80
+ const rows = [];
81
+ for (const name of files) {
82
+ const spans = wanted?.filter((h) => index.file(name)?.headings.includes(h));
83
+ if (wanted && (!spans || spans.length === 0)) {
84
+ continue;
85
+ }
86
+ const lines = await index.lines(name);
87
+ const headings = index.file(name)?.headings ?? [];
88
+ for (const [at, text] of lines.entries()) {
89
+ const line = at + 1;
90
+ if (spans && !spans.some((span) => line >= span.line && line <= span.end)) {
91
+ continue;
92
+ }
93
+ if (match(text)) {
94
+ rows.push({ file: name, line, text, section: enclosing(headings, line) });
95
+ }
96
+ }
97
+ }
98
+ return cut(rows, options.limit);
99
+ }
100
+ /** A named section, verbatim: heading line to the line before the next peer. */
101
+ export async function readSection(index, file, section) {
102
+ const found = index.resolveSections([section], [file]);
103
+ const heading = found[0];
104
+ if (!heading) {
105
+ throw new CliError(`${file} has no section called ${section}`, EXIT.failed, 'list them with `zen rag docs list sections --file <name>`');
106
+ }
107
+ return await readRange(index, file, heading.line, heading.end);
108
+ }
109
+ export async function readRange(index, file, from, to) {
110
+ const lines = await index.lines(file);
111
+ const outline = index.file(file);
112
+ const start = Math.max(1, from);
113
+ const end = Math.min(lines.length, to);
114
+ return {
115
+ file,
116
+ title: outline?.title ?? file,
117
+ start,
118
+ end,
119
+ lines: lines.slice(start - 1, end),
120
+ total: lines.length,
121
+ };
122
+ }
123
+ // ---------------------------------------------------------------------------
124
+ function cut(rows, limit) {
125
+ const take = Math.min(Math.max(1, limit ?? DEFAULT_ROWS), MAX_ROWS);
126
+ return { found: rows.length, rows: rows.slice(0, take), truncated: rows.length > take };
127
+ }
128
+ const record = (heading) => ({
129
+ id: heading.id,
130
+ path: heading.path,
131
+ level: heading.level,
132
+ title: heading.title,
133
+ line: heading.line,
134
+ end: heading.end,
135
+ });
136
+ /** The last heading at or above this line, which is the section it is in. */
137
+ function enclosing(headings, line) {
138
+ let title = '';
139
+ for (const heading of headings) {
140
+ if (heading.line > line) {
141
+ break;
142
+ }
143
+ title = heading.title;
144
+ }
145
+ return title;
146
+ }
147
+ //# sourceMappingURL=lookup.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