@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.
- package/README.md +154 -10
- package/dist/command.js +2 -1
- package/dist/docs/assemble.d.ts +52 -0
- package/dist/docs/assemble.js +127 -0
- package/dist/docs/build.d.ts +34 -0
- package/dist/docs/build.js +108 -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 +529 -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 +28 -0
- package/dist/docs/load.js +212 -0
- package/dist/docs/lookup.d.ts +80 -0
- package/dist/docs/lookup.js +147 -0
- package/dist/docs/parse.d.ts +95 -0
- package/dist/docs/parse.js +372 -0
- package/dist/docs/readme.d.ts +6 -0
- package/dist/docs/readme.js +122 -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 +55 -0
- package/dist/docs/store.js +171 -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/command.js +3 -0
- package/dist/schema/query.js +1 -0
- package/dist/schema/search.d.ts +2 -0
- package/dist/schema/search.js +18 -2
- package/dist/schema/store.d.ts +4 -2
- package/dist/schema/store.js +16 -9
- package/dist/schema/tools.js +21 -2
- package/package.json +17 -4
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
import remarkFrontmatter from 'remark-frontmatter';
|
|
2
|
+
import remarkGfm from 'remark-gfm';
|
|
3
|
+
import remarkParse from 'remark-parse';
|
|
4
|
+
import { unified } from 'unified';
|
|
5
|
+
/** Path segments carry their kind, so heading ancestors stay recoverable. */
|
|
6
|
+
const SEGMENT = {
|
|
7
|
+
heading: 'sec',
|
|
8
|
+
paragraph: 'para',
|
|
9
|
+
blockquote: 'quote',
|
|
10
|
+
list: 'list',
|
|
11
|
+
list_item: 'item',
|
|
12
|
+
table: 'table',
|
|
13
|
+
table_header: 'header',
|
|
14
|
+
table_row: 'row',
|
|
15
|
+
code: 'code',
|
|
16
|
+
frontmatter: 'fm',
|
|
17
|
+
html: 'html',
|
|
18
|
+
hr: 'hr',
|
|
19
|
+
};
|
|
20
|
+
/** The root of every path: a document is the structure everything else sits in. */
|
|
21
|
+
export const ROOT_SEGMENT = 'doc';
|
|
22
|
+
const processor = unified()
|
|
23
|
+
.use(remarkParse)
|
|
24
|
+
.use(remarkGfm)
|
|
25
|
+
.use(remarkFrontmatter, ['yaml', 'toml']);
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
/** Every ancestor of a section, the document root first, itself last. */
|
|
28
|
+
export function ancestry(section) {
|
|
29
|
+
const out = [];
|
|
30
|
+
for (let at = section; at; at = at.parent) {
|
|
31
|
+
out.unshift(at);
|
|
32
|
+
}
|
|
33
|
+
return out;
|
|
34
|
+
}
|
|
35
|
+
/** The breadcrumb, which prefixes every chunk of text under this section. */
|
|
36
|
+
export const headingPath = (section) => ancestry(section)
|
|
37
|
+
.map((s) => s.title)
|
|
38
|
+
.filter(Boolean)
|
|
39
|
+
.join(' > ');
|
|
40
|
+
/** The heading lines a chunk under this section must be rendered with. */
|
|
41
|
+
export const headingLines = (section) => ancestry(section)
|
|
42
|
+
.map((s) => s.line)
|
|
43
|
+
.filter((line) => line !== undefined);
|
|
44
|
+
/**
|
|
45
|
+
* Everything downstream indexes `lines` by number, and mdast positions refer to
|
|
46
|
+
* the exact string handed to the parser — so the normalization happens once,
|
|
47
|
+
* here, and the normalized text is the only version anything ever sees.
|
|
48
|
+
*/
|
|
49
|
+
export const normalize = (text) => text.replace(/^\uFEFF/, '').replace(/\r\n?/g, '\n');
|
|
50
|
+
export function parseDocument(source, name, format) {
|
|
51
|
+
const text = normalize(source);
|
|
52
|
+
const lines = text.split('\n');
|
|
53
|
+
return format === 'text' ? plain(text, lines, name) : markdown(text, lines, name);
|
|
54
|
+
}
|
|
55
|
+
function markdown(text, lines, name) {
|
|
56
|
+
const tree = processor.parse(text);
|
|
57
|
+
const root = {
|
|
58
|
+
id: ROOT_SEGMENT,
|
|
59
|
+
path: ROOT_SEGMENT,
|
|
60
|
+
title: stripExtension(name),
|
|
61
|
+
level: 0,
|
|
62
|
+
line: undefined,
|
|
63
|
+
parent: undefined,
|
|
64
|
+
};
|
|
65
|
+
const walk = { lines, counters: new Map(), sections: [root], blocks: [] };
|
|
66
|
+
let section = root;
|
|
67
|
+
let title = '';
|
|
68
|
+
// Only ever holds the paragraph immediately before the current position,
|
|
69
|
+
// which is the whole of what a markdown table gets for a caption.
|
|
70
|
+
let lead = '';
|
|
71
|
+
for (const node of tree.children) {
|
|
72
|
+
if (node.type === 'heading') {
|
|
73
|
+
const heading = node;
|
|
74
|
+
const line = lineOf(heading);
|
|
75
|
+
const text = collapse(inline(heading.children));
|
|
76
|
+
while (section.parent && section.level >= heading.depth) {
|
|
77
|
+
section = section.parent;
|
|
78
|
+
}
|
|
79
|
+
section = {
|
|
80
|
+
id: id(walk, 'heading'),
|
|
81
|
+
path: '',
|
|
82
|
+
title: text,
|
|
83
|
+
level: heading.depth,
|
|
84
|
+
line,
|
|
85
|
+
parent: section,
|
|
86
|
+
};
|
|
87
|
+
section.path = `${section.parent.path}/${section.id}`;
|
|
88
|
+
walk.sections.push(section);
|
|
89
|
+
if (!title && heading.depth <= 2) {
|
|
90
|
+
title = text;
|
|
91
|
+
}
|
|
92
|
+
lead = '';
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
const block = blockOf(node, section, walk, lead);
|
|
96
|
+
if (block) {
|
|
97
|
+
walk.blocks.push(block);
|
|
98
|
+
lead = block.kind === 'paragraph' ? block.text : '';
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return {
|
|
102
|
+
name,
|
|
103
|
+
title: title || stripExtension(basenameOf(name)),
|
|
104
|
+
format: 'markdown',
|
|
105
|
+
lines,
|
|
106
|
+
sections: walk.sections,
|
|
107
|
+
blocks: walk.blocks,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
function blockOf(node, section, walk, lead) {
|
|
111
|
+
const start = lineOf(node);
|
|
112
|
+
const end = endLineOf(node);
|
|
113
|
+
switch (node.type) {
|
|
114
|
+
case 'paragraph':
|
|
115
|
+
return make('paragraph', collapse(inline(node.children)));
|
|
116
|
+
case 'blockquote':
|
|
117
|
+
return make('blockquote', collapse(blocks(node.children)));
|
|
118
|
+
case 'code': {
|
|
119
|
+
const code = node;
|
|
120
|
+
return make('code', code.value);
|
|
121
|
+
}
|
|
122
|
+
case 'html': {
|
|
123
|
+
const stripped = collapse(tags(node.value));
|
|
124
|
+
return stripped.length >= 3 ? make('html', stripped) : undefined;
|
|
125
|
+
}
|
|
126
|
+
case 'yaml':
|
|
127
|
+
return make('frontmatter', String(node.value ?? ''));
|
|
128
|
+
case 'thematicBreak':
|
|
129
|
+
return make('hr', '');
|
|
130
|
+
case 'list':
|
|
131
|
+
return list(node, section, walk, start, end);
|
|
132
|
+
case 'table':
|
|
133
|
+
return table(node, section, walk, start, end, lead);
|
|
134
|
+
default:
|
|
135
|
+
// A `+++` block. remark-frontmatter puts it in the tree; mdast's own
|
|
136
|
+
// node union names only the `---` one.
|
|
137
|
+
return node.type === 'toml'
|
|
138
|
+
? make('frontmatter', String(node.value ?? ''))
|
|
139
|
+
: undefined;
|
|
140
|
+
}
|
|
141
|
+
function make(kind, text) {
|
|
142
|
+
const own = id(walk, kind);
|
|
143
|
+
return { id: own, path: `${section.path}/${own}`, kind, start, end, section, text };
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
function list(node, section, walk, start, end) {
|
|
147
|
+
const own = id(walk, 'list');
|
|
148
|
+
const path = `${section.path}/${own}`;
|
|
149
|
+
const ordinal = node.start ?? 1;
|
|
150
|
+
const items = node.children.map((child, at) => {
|
|
151
|
+
const item = child;
|
|
152
|
+
const mine = id(walk, 'list_item');
|
|
153
|
+
const marker = node.ordered ? `${ordinal + at}.` : '-';
|
|
154
|
+
// The marker is kept: enumeration and step order are meaning, and a
|
|
155
|
+
// numbered step reads differently from a bullet.
|
|
156
|
+
return {
|
|
157
|
+
id: mine,
|
|
158
|
+
path: `${path}/${mine}`,
|
|
159
|
+
start: lineOf(item),
|
|
160
|
+
end: endLineOf(item),
|
|
161
|
+
text: `${marker} ${collapse(blocks(item.children))}`.trim(),
|
|
162
|
+
};
|
|
163
|
+
});
|
|
164
|
+
return {
|
|
165
|
+
id: own,
|
|
166
|
+
path,
|
|
167
|
+
kind: 'list',
|
|
168
|
+
start,
|
|
169
|
+
end,
|
|
170
|
+
section,
|
|
171
|
+
text: items.map((i) => i.text).join('\n'),
|
|
172
|
+
items,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
function table(node, section, walk, start, end, caption) {
|
|
176
|
+
const own = id(walk, 'table');
|
|
177
|
+
const path = `${section.path}/${own}`;
|
|
178
|
+
const [header, ...body] = node.children;
|
|
179
|
+
const columns = header ? cellsOf(header) : [];
|
|
180
|
+
const headerLine = header ? lineOf(header) : start;
|
|
181
|
+
const firstRow = body[0] ? lineOf(body[0]) : undefined;
|
|
182
|
+
// mdast has no node for the alignment row — alignment lives on the table —
|
|
183
|
+
// so it is the line after the header, when there is room for it.
|
|
184
|
+
const separatorLine = firstRow === undefined || firstRow > headerLine + 1 ? headerLine + 1 : undefined;
|
|
185
|
+
const rows = body.map((row) => {
|
|
186
|
+
const mine = id(walk, 'table_row');
|
|
187
|
+
const cells = cellsOf(row);
|
|
188
|
+
return {
|
|
189
|
+
id: mine,
|
|
190
|
+
path: `${path}/${mine}`,
|
|
191
|
+
line: lineOf(row),
|
|
192
|
+
cells,
|
|
193
|
+
text: rowText(columns, cells),
|
|
194
|
+
};
|
|
195
|
+
});
|
|
196
|
+
return {
|
|
197
|
+
id: own,
|
|
198
|
+
path,
|
|
199
|
+
kind: 'table',
|
|
200
|
+
start,
|
|
201
|
+
end,
|
|
202
|
+
section,
|
|
203
|
+
text: rows.map((r) => r.text).join('\n'),
|
|
204
|
+
table: {
|
|
205
|
+
columns,
|
|
206
|
+
headerLine,
|
|
207
|
+
separatorLine,
|
|
208
|
+
caption,
|
|
209
|
+
keyColumn: keyColumnOf(columns, rows),
|
|
210
|
+
rows,
|
|
211
|
+
},
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* A data row does not contain its own column names. Stripping the pipes leaves
|
|
216
|
+
* `V-200-30 3" WCC 98.2`, in which the word `Cv` appears nowhere, so a query
|
|
217
|
+
* naming a column could only ever match the header line and never a row — and
|
|
218
|
+
* hybrid search would silently become vector-only for every table in the
|
|
219
|
+
* corpus. Pairing each cell with its header is what fixes that, and it embeds
|
|
220
|
+
* better too, because `Cv: 45` is a sentence and `| 45 |` is not.
|
|
221
|
+
*/
|
|
222
|
+
export function rowText(columns, cells) {
|
|
223
|
+
return cells
|
|
224
|
+
.map((cell, at) => cellText(columns[at], cell))
|
|
225
|
+
.filter(Boolean)
|
|
226
|
+
.join(' ');
|
|
227
|
+
}
|
|
228
|
+
export function cellText(column, cell) {
|
|
229
|
+
const value = cell.trim();
|
|
230
|
+
if (!value) {
|
|
231
|
+
return '';
|
|
232
|
+
}
|
|
233
|
+
return column ? `${column}: ${value}.` : `${value}.`;
|
|
234
|
+
}
|
|
235
|
+
/** Column 1, unless it is numeric — a slice with no name on it is unattributable. */
|
|
236
|
+
function keyColumnOf(columns, rows) {
|
|
237
|
+
const numeric = (at) => rows.length > 0 &&
|
|
238
|
+
rows.every((row) => {
|
|
239
|
+
const cell = (row.cells[at] ?? '').trim();
|
|
240
|
+
return cell === '' || /^[-+]?[\d.,%\s]+$/.test(cell);
|
|
241
|
+
});
|
|
242
|
+
for (let at = 0; at < columns.length; at++) {
|
|
243
|
+
if (!numeric(at)) {
|
|
244
|
+
return at;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
return 0;
|
|
248
|
+
}
|
|
249
|
+
const cellsOf = (row) => row.children.map((cell) => collapse(inline(cell.children)));
|
|
250
|
+
// ---------------------------------------------------------------------------
|
|
251
|
+
// plain text
|
|
252
|
+
// ---------------------------------------------------------------------------
|
|
253
|
+
/**
|
|
254
|
+
* A `.txt` file is read as paragraphs and nothing else. It would be easy to run
|
|
255
|
+
* it through the markdown parser and get headings for free, but they would be
|
|
256
|
+
* invented: a line beginning with `#` in a log or a licence is not a title, and
|
|
257
|
+
* an index that says it is would scope searches to sections nobody wrote.
|
|
258
|
+
*/
|
|
259
|
+
function plain(text, lines, name) {
|
|
260
|
+
const root = {
|
|
261
|
+
id: ROOT_SEGMENT,
|
|
262
|
+
path: ROOT_SEGMENT,
|
|
263
|
+
title: stripExtension(name),
|
|
264
|
+
level: 0,
|
|
265
|
+
line: undefined,
|
|
266
|
+
parent: undefined,
|
|
267
|
+
};
|
|
268
|
+
const blocks = [];
|
|
269
|
+
let at = 0;
|
|
270
|
+
let ordinal = 0;
|
|
271
|
+
while (at < lines.length) {
|
|
272
|
+
if ((lines[at] ?? '').trim() === '') {
|
|
273
|
+
at++;
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
const start = at;
|
|
277
|
+
while (at < lines.length && (lines[at] ?? '').trim() !== '') {
|
|
278
|
+
at++;
|
|
279
|
+
}
|
|
280
|
+
const own = `${SEGMENT.paragraph}:${++ordinal}`;
|
|
281
|
+
blocks.push({
|
|
282
|
+
id: own,
|
|
283
|
+
path: `${root.path}/${own}`,
|
|
284
|
+
kind: 'paragraph',
|
|
285
|
+
start: start + 1,
|
|
286
|
+
end: at,
|
|
287
|
+
section: root,
|
|
288
|
+
text: collapse(lines.slice(start, at).join(' ')),
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
const first = lines.find((line) => line.trim() !== '')?.trim() ?? '';
|
|
292
|
+
return {
|
|
293
|
+
name,
|
|
294
|
+
title: first.length > 0 && first.length <= 80 ? first : stripExtension(basenameOf(name)),
|
|
295
|
+
format: 'text',
|
|
296
|
+
lines,
|
|
297
|
+
sections: [root],
|
|
298
|
+
blocks,
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
// ---------------------------------------------------------------------------
|
|
302
|
+
// serialization — walking the inline tree, never a regex over the markup
|
|
303
|
+
// ---------------------------------------------------------------------------
|
|
304
|
+
function inline(nodes) {
|
|
305
|
+
return nodes.map(one).join('');
|
|
306
|
+
}
|
|
307
|
+
function one(node) {
|
|
308
|
+
switch (node.type) {
|
|
309
|
+
case 'text':
|
|
310
|
+
return node.value;
|
|
311
|
+
// The value, not the markup: this is the node a regex stripper eats.
|
|
312
|
+
case 'inlineCode':
|
|
313
|
+
return node.value;
|
|
314
|
+
case 'image':
|
|
315
|
+
case 'imageReference':
|
|
316
|
+
// Distinguishable from a sentence on purpose.
|
|
317
|
+
return node.alt ? `image: ${node.alt}` : '';
|
|
318
|
+
case 'break':
|
|
319
|
+
return ' ';
|
|
320
|
+
// Inline html carries no words worth indexing, and its angle brackets
|
|
321
|
+
// would only ever match a query by accident.
|
|
322
|
+
case 'html':
|
|
323
|
+
return '';
|
|
324
|
+
case 'footnoteReference':
|
|
325
|
+
return '';
|
|
326
|
+
default:
|
|
327
|
+
return 'children' in node ? inline(node.children) : '';
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
/** Nested block content — a blockquote's paragraphs, a list item's children. */
|
|
331
|
+
function blocks(nodes) {
|
|
332
|
+
return nodes
|
|
333
|
+
.map((node) => {
|
|
334
|
+
switch (node.type) {
|
|
335
|
+
case 'paragraph':
|
|
336
|
+
case 'heading':
|
|
337
|
+
return inline(node.children);
|
|
338
|
+
case 'code':
|
|
339
|
+
return node.value;
|
|
340
|
+
case 'blockquote':
|
|
341
|
+
return blocks(node.children);
|
|
342
|
+
case 'list':
|
|
343
|
+
return node.children
|
|
344
|
+
.map((item) => blocks(item.children))
|
|
345
|
+
.join(' ');
|
|
346
|
+
case 'table':
|
|
347
|
+
return node.children
|
|
348
|
+
.slice(1)
|
|
349
|
+
.map((row) => rowText(cellsOf(node.children[0]), cellsOf(row)))
|
|
350
|
+
.join(' ');
|
|
351
|
+
default:
|
|
352
|
+
return '';
|
|
353
|
+
}
|
|
354
|
+
})
|
|
355
|
+
.filter(Boolean)
|
|
356
|
+
.join(' ');
|
|
357
|
+
}
|
|
358
|
+
/** Soft wraps inside a paragraph are wrapping, not meaning, so they rejoin. */
|
|
359
|
+
export const collapse = (text) => text.replace(/\s+/g, ' ').trim();
|
|
360
|
+
const tags = (html) => html.replace(/<[^>]*>/g, ' ');
|
|
361
|
+
// ---------------------------------------------------------------------------
|
|
362
|
+
function id(walk, kind) {
|
|
363
|
+
const segment = SEGMENT[kind];
|
|
364
|
+
const next = (walk.counters.get(segment) ?? 0) + 1;
|
|
365
|
+
walk.counters.set(segment, next);
|
|
366
|
+
return `${segment}:${next}`;
|
|
367
|
+
}
|
|
368
|
+
const lineOf = (node) => node.position?.start.line ?? 1;
|
|
369
|
+
const endLineOf = (node) => node.position?.end.line ?? 1;
|
|
370
|
+
const basenameOf = (name) => name.split('/').pop() ?? name;
|
|
371
|
+
const stripExtension = (name) => name.replace(/\.[^./]+$/, '');
|
|
372
|
+
//# sourceMappingURL=parse.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,122 @@
|
|
|
1
|
+
import { basename } from 'node:path';
|
|
2
|
+
import { INTERVAL_MS, } from "../common/progress.js";
|
|
3
|
+
import { 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
|
+
export const DOCS_REPORT = { building, complete, failed };
|
|
10
|
+
function building(state) {
|
|
11
|
+
const rows = [
|
|
12
|
+
['documents', summary(state.documents)],
|
|
13
|
+
['embedding', state.embedding],
|
|
14
|
+
[
|
|
15
|
+
'started',
|
|
16
|
+
`${new Date(state.started).toISOString()} (${duration(state.now - state.started)} ago)`,
|
|
17
|
+
],
|
|
18
|
+
['step', state.step],
|
|
19
|
+
];
|
|
20
|
+
if (state.summary) {
|
|
21
|
+
rows.push(['found', counted(state.summary)]);
|
|
22
|
+
}
|
|
23
|
+
if (state.total > 0) {
|
|
24
|
+
const percent = Math.round((state.done / state.total) * 100);
|
|
25
|
+
rows.push(['embedded', `${state.done} of ${state.total} · ${percent}%`]);
|
|
26
|
+
}
|
|
27
|
+
rows.push(['updated', new Date(state.now).toISOString()]);
|
|
28
|
+
return [
|
|
29
|
+
'# Document index — being built',
|
|
30
|
+
'',
|
|
31
|
+
'A searchable index of the documents named below, written by `zen rag docs index`.',
|
|
32
|
+
'**It is incomplete. Nothing should read it yet.**',
|
|
33
|
+
'',
|
|
34
|
+
...fields(rows),
|
|
35
|
+
'',
|
|
36
|
+
`These lines are refreshed at most every ${INTERVAL_MS / 1000} seconds while the build runs, and`,
|
|
37
|
+
'the whole file is replaced by a description of the index when it finishes. If it still says',
|
|
38
|
+
'"being built" and `.lock` names no living process, the build died part way.',
|
|
39
|
+
'',
|
|
40
|
+
].join('\n');
|
|
41
|
+
}
|
|
42
|
+
function complete(state) {
|
|
43
|
+
const { manifest } = state;
|
|
44
|
+
return [
|
|
45
|
+
`# Document index — ${basename(state.dir)}`,
|
|
46
|
+
'',
|
|
47
|
+
`A searchable index of ${plural(manifest.sources.length, 'document')}, built with`,
|
|
48
|
+
`${manifest.embedding.ref} (${manifest.embedding.dimensions}d) in ${duration(state.ms)}.`,
|
|
49
|
+
'Ask it a question and it answers with the passages that matched, quoted verbatim from',
|
|
50
|
+
'the copies kept in `sources/` — line numbers included, and with a marker wherever',
|
|
51
|
+
'something between two of them was left out.',
|
|
52
|
+
'',
|
|
53
|
+
'## What it covers',
|
|
54
|
+
'',
|
|
55
|
+
...documentTable(manifest.sources),
|
|
56
|
+
'',
|
|
57
|
+
`${counted(manifest.counts)},`,
|
|
58
|
+
`${searched(manifest.indexes)}.`,
|
|
59
|
+
'',
|
|
60
|
+
'## Files',
|
|
61
|
+
'',
|
|
62
|
+
...fields([
|
|
63
|
+
['manifest.json', 'what this index is and what built it — read this first'],
|
|
64
|
+
['outline.json', 'every heading and table, with the lines they cover'],
|
|
65
|
+
['sources/', 'the documents themselves, verbatim: where the quotes come from'],
|
|
66
|
+
['lance/', 'the chunks: the search text, the vectors, the filter columns'],
|
|
67
|
+
]),
|
|
68
|
+
'',
|
|
69
|
+
'## Asking it something',
|
|
70
|
+
'',
|
|
71
|
+
'From this directory:',
|
|
72
|
+
'',
|
|
73
|
+
'```',
|
|
74
|
+
'zen rag docs search --dir . "what you are after"',
|
|
75
|
+
'zen rag docs search --dir . --file "guides/**" --kind table "pressure rating"',
|
|
76
|
+
'zen rag docs list files --dir .',
|
|
77
|
+
'```',
|
|
78
|
+
'',
|
|
79
|
+
`Built by ${manifest.indexer} on ${manifest.createdAt}.`,
|
|
80
|
+
'',
|
|
81
|
+
].join('\n');
|
|
82
|
+
}
|
|
83
|
+
function failed(state) {
|
|
84
|
+
return [
|
|
85
|
+
'# Document index — failed',
|
|
86
|
+
'',
|
|
87
|
+
'This index was not finished and what is here is incomplete. Nothing should read it;',
|
|
88
|
+
'build it again with `zen rag docs index`.',
|
|
89
|
+
'',
|
|
90
|
+
...fields([
|
|
91
|
+
['documents', summary(state.documents)],
|
|
92
|
+
['step', state.step],
|
|
93
|
+
['reason', message(state.reason)],
|
|
94
|
+
['started', new Date(state.started).toISOString()],
|
|
95
|
+
[
|
|
96
|
+
'failed',
|
|
97
|
+
`${new Date().toISOString()} (after ${duration(Date.now() - state.started)})`,
|
|
98
|
+
],
|
|
99
|
+
]),
|
|
100
|
+
'',
|
|
101
|
+
].join('\n');
|
|
102
|
+
}
|
|
103
|
+
// ---------------------------------------------------------------------------
|
|
104
|
+
const HEADERS = ['document', 'format', 'lines', 'sections', 'tables', 'chunks'];
|
|
105
|
+
const documentTable = (sources) => grid(HEADERS, sources.map((s) => [
|
|
106
|
+
s.name,
|
|
107
|
+
s.format,
|
|
108
|
+
String(s.lines),
|
|
109
|
+
String(s.sections),
|
|
110
|
+
String(s.tables),
|
|
111
|
+
String(s.chunks),
|
|
112
|
+
]));
|
|
113
|
+
function counted(counts) {
|
|
114
|
+
return (`${plural(counts.chunks, 'chunk')} over ${plural(counts.documents, 'document')}: ` +
|
|
115
|
+
`${counts.lines} lines, ${counts.sections} sections, ${counts.tables} tables`);
|
|
116
|
+
}
|
|
117
|
+
/** A corpus can be thousands of files; a README listing them all is a wall. */
|
|
118
|
+
function summary(documents) {
|
|
119
|
+
const shown = documents.slice(0, 12).join(', ');
|
|
120
|
+
return documents.length > 12 ? `${shown}, and ${documents.length - 12} more` : shown;
|
|
121
|
+
}
|
|
122
|
+
//# 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
|