@zenera/rag 1.1.25 → 1.1.26
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/dist/common/match.d.ts +40 -0
- package/dist/common/match.js +92 -0
- package/dist/present.d.ts +17 -0
- package/dist/present.js +40 -0
- package/dist/query.d.ts +7 -0
- package/dist/query.js +88 -0
- package/dist/repl.d.ts +8 -0
- package/dist/repl.js +119 -0
- package/dist/schema/locate.d.ts +20 -0
- package/dist/schema/locate.js +141 -0
- package/dist/schema/match.d.ts +40 -0
- package/dist/schema/match.js +92 -0
- package/dist/schema/progress.d.ts +26 -0
- package/dist/schema/progress.js +316 -0
- package/package.json +3 -3
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/** Long enough for any honest pattern, short enough to bound a bad one. */
|
|
2
|
+
export declare const MAX_PATTERN = 200;
|
|
3
|
+
export declare class PatternError extends Error {
|
|
4
|
+
}
|
|
5
|
+
export interface MatchOptions {
|
|
6
|
+
/** read the pattern as a regular expression rather than as a literal */
|
|
7
|
+
regex?: boolean;
|
|
8
|
+
caseSensitive?: boolean;
|
|
9
|
+
}
|
|
10
|
+
export type Matcher = (text: string) => boolean;
|
|
11
|
+
/**
|
|
12
|
+
* A predicate over a string. Literal by default: someone typing `user.id` means
|
|
13
|
+
* those seven characters, and a dot that quietly matched anything would be a
|
|
14
|
+
* worse answer than no answer.
|
|
15
|
+
*/
|
|
16
|
+
export declare function matcher(pattern: string, options?: MatchOptions): Matcher;
|
|
17
|
+
/**
|
|
18
|
+
* A glob, matched against the whole string. Globs rather than regexes because
|
|
19
|
+
* these are for naming things — a path, a schema — and a star is what everyone
|
|
20
|
+
* reaches for first.
|
|
21
|
+
*/
|
|
22
|
+
export declare function wildcard(pattern: string, options?: {
|
|
23
|
+
caseSensitive?: boolean;
|
|
24
|
+
}): Matcher;
|
|
25
|
+
/** Whether a pattern is asking to be read as a glob at all. */
|
|
26
|
+
export declare const isGlob: (pattern: string) => boolean;
|
|
27
|
+
/**
|
|
28
|
+
* What someone means when they type a name into a filter. With a star in it,
|
|
29
|
+
* a glob; without one, a substring — because `password` typed into `--name` is
|
|
30
|
+
* a search for the word, and a whole-string match would answer nothing and
|
|
31
|
+
* look like the field does not exist.
|
|
32
|
+
*
|
|
33
|
+
* `regex` settles it outright, and has to: a star is punctuation in both
|
|
34
|
+
* languages, so `^/(users|teams)/.*` read as a glob would match nothing and
|
|
35
|
+
* never say why.
|
|
36
|
+
*/
|
|
37
|
+
export declare function loose(pattern: string, options?: MatchOptions): Matcher;
|
|
38
|
+
/** True when any of the patterns matches; no patterns means no opinion. */
|
|
39
|
+
export declare function anyOf(matchers: readonly Matcher[]): Matcher | undefined;
|
|
40
|
+
//# sourceMappingURL=match.d.ts.map
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// Matching, with nothing learned in between
|
|
3
|
+
//
|
|
4
|
+
// Everything here is exact. A glob matches the characters it names and a
|
|
5
|
+
// substring is a substring, which is the whole point of the surfaces built on
|
|
6
|
+
// it: a vector index answers "what is near this", and near is a ranking, so it
|
|
7
|
+
// can only ever return the top of a list. When the question is "does the word
|
|
8
|
+
// `password` appear anywhere at all", a ranking is the wrong instrument and no
|
|
9
|
+
// amount of tuning makes it the right one.
|
|
10
|
+
//
|
|
11
|
+
// A pattern may arrive from a model, so a regex is a bounded promise: the
|
|
12
|
+
// length is capped here and the scan that uses it keeps a deadline.
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
/** Long enough for any honest pattern, short enough to bound a bad one. */
|
|
15
|
+
export const MAX_PATTERN = 200;
|
|
16
|
+
export class PatternError extends Error {
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* A predicate over a string. Literal by default: someone typing `user.id` means
|
|
20
|
+
* those seven characters, and a dot that quietly matched anything would be a
|
|
21
|
+
* worse answer than no answer.
|
|
22
|
+
*/
|
|
23
|
+
export function matcher(pattern, options = {}) {
|
|
24
|
+
guard(pattern);
|
|
25
|
+
if (!options.regex) {
|
|
26
|
+
if (options.caseSensitive) {
|
|
27
|
+
return (text) => text.includes(pattern);
|
|
28
|
+
}
|
|
29
|
+
const needle = pattern.toLowerCase();
|
|
30
|
+
return (text) => text.toLowerCase().includes(needle);
|
|
31
|
+
}
|
|
32
|
+
const expression = compile(pattern, options.caseSensitive ? '' : 'i');
|
|
33
|
+
// `lastIndex` is not carried between calls: the flags never include `g`.
|
|
34
|
+
return (text) => expression.test(text);
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* A glob, matched against the whole string. Globs rather than regexes because
|
|
38
|
+
* these are for naming things — a path, a schema — and a star is what everyone
|
|
39
|
+
* reaches for first.
|
|
40
|
+
*/
|
|
41
|
+
export function wildcard(pattern, options = {}) {
|
|
42
|
+
guard(pattern);
|
|
43
|
+
const source = [...pattern]
|
|
44
|
+
.map((char) => (char === '*' ? '.*' : char === '?' ? '.' : escape(char)))
|
|
45
|
+
.join('');
|
|
46
|
+
const expression = compile(`^${source}$`, options.caseSensitive ? '' : 'i');
|
|
47
|
+
return (text) => expression.test(text);
|
|
48
|
+
}
|
|
49
|
+
/** Whether a pattern is asking to be read as a glob at all. */
|
|
50
|
+
export const isGlob = (pattern) => /[*?]/.test(pattern);
|
|
51
|
+
/**
|
|
52
|
+
* What someone means when they type a name into a filter. With a star in it,
|
|
53
|
+
* a glob; without one, a substring — because `password` typed into `--name` is
|
|
54
|
+
* a search for the word, and a whole-string match would answer nothing and
|
|
55
|
+
* look like the field does not exist.
|
|
56
|
+
*
|
|
57
|
+
* `regex` settles it outright, and has to: a star is punctuation in both
|
|
58
|
+
* languages, so `^/(users|teams)/.*` read as a glob would match nothing and
|
|
59
|
+
* never say why.
|
|
60
|
+
*/
|
|
61
|
+
export function loose(pattern, options = {}) {
|
|
62
|
+
if (options.regex) {
|
|
63
|
+
return matcher(pattern, options);
|
|
64
|
+
}
|
|
65
|
+
return isGlob(pattern) ? wildcard(pattern, options) : matcher(pattern, options);
|
|
66
|
+
}
|
|
67
|
+
/** True when any of the patterns matches; no patterns means no opinion. */
|
|
68
|
+
export function anyOf(matchers) {
|
|
69
|
+
if (matchers.length === 0) {
|
|
70
|
+
return undefined;
|
|
71
|
+
}
|
|
72
|
+
return (text) => matchers.some((match) => match(text));
|
|
73
|
+
}
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
function guard(pattern) {
|
|
76
|
+
if (pattern.length === 0) {
|
|
77
|
+
throw new PatternError('the pattern is empty');
|
|
78
|
+
}
|
|
79
|
+
if (pattern.length > MAX_PATTERN) {
|
|
80
|
+
throw new PatternError(`the pattern is longer than ${MAX_PATTERN} characters`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
function compile(source, flags) {
|
|
84
|
+
try {
|
|
85
|
+
return new RegExp(source, flags);
|
|
86
|
+
}
|
|
87
|
+
catch (err) {
|
|
88
|
+
throw new PatternError(`invalid pattern: ${err.message}`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
const escape = (char) => char.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
92
|
+
//# sourceMappingURL=match.js.map
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { type HydrateOptions } from './schema/hydrate.ts';
|
|
2
|
+
import { type RenderOptions } from './schema/render.ts';
|
|
3
|
+
import type { Schema } from './schema/schema.ts';
|
|
4
|
+
import type { Operation } from './schema/spec.ts';
|
|
5
|
+
import type { Subgraph } from './schema/subgraph.ts';
|
|
6
|
+
export declare const FORMATS: readonly ["text", "mermaid", "mermaid-flowchart", "ts", "openapi"];
|
|
7
|
+
export type Format = (typeof FORMATS)[number];
|
|
8
|
+
export declare function isFormat(value: string): value is Format;
|
|
9
|
+
export interface OutputOptions extends RenderOptions, HydrateOptions {
|
|
10
|
+
}
|
|
11
|
+
/** Two of the four formats need what is on disk; an open index is enough. */
|
|
12
|
+
export interface SchemaSource {
|
|
13
|
+
schemas(): Promise<Record<string, Schema>>;
|
|
14
|
+
operations(): Promise<Operation[]>;
|
|
15
|
+
}
|
|
16
|
+
export declare function present(index: SchemaSource, subgraphs: readonly Subgraph[], format: Format, options?: OutputOptions): Promise<string>;
|
|
17
|
+
//# sourceMappingURL=present.d.ts.map
|
package/dist/present.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { toOpenApi, toTypeScript } from "./schema/hydrate.js";
|
|
2
|
+
import { render } from "./schema/render.js";
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// One subgraph, in whichever of the four shapes was asked for
|
|
5
|
+
//
|
|
6
|
+
// The command, the prompt loop and the agent tool all print through here, so
|
|
7
|
+
// `--format ts` from a shell and `format: "ts"` from a model are the same
|
|
8
|
+
// bytes. Two of the four need the schemas off disk and two do not, which is
|
|
9
|
+
// the only reason this is async.
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
export const FORMATS = ['text', 'mermaid', 'mermaid-flowchart', 'ts', 'openapi'];
|
|
12
|
+
export function isFormat(value) {
|
|
13
|
+
return FORMATS.includes(value);
|
|
14
|
+
}
|
|
15
|
+
export async function present(index, subgraphs, format, options = {}) {
|
|
16
|
+
if (subgraphs.length === 0) {
|
|
17
|
+
return format === 'openapi' ? '{}' : '';
|
|
18
|
+
}
|
|
19
|
+
if (format === 'ts') {
|
|
20
|
+
const schemas = await index.schemas();
|
|
21
|
+
return subgraphs.map((s) => toTypeScript(s, schemas, options)).join('\n');
|
|
22
|
+
}
|
|
23
|
+
if (format === 'openapi') {
|
|
24
|
+
const [schemas, operations] = await Promise.all([index.schemas(), index.operations()]);
|
|
25
|
+
// One document for the whole answer: several would each have to repeat
|
|
26
|
+
// the components the others also touched.
|
|
27
|
+
const merged = subgraphs.reduce((all, one) => ({
|
|
28
|
+
nodes: [...all.nodes, ...one.nodes],
|
|
29
|
+
edges: [...all.edges, ...one.edges],
|
|
30
|
+
hits: [...all.hits, ...one.hits],
|
|
31
|
+
score: all.score + one.score,
|
|
32
|
+
truncated: all.truncated || one.truncated,
|
|
33
|
+
}), { nodes: [], edges: [], hits: [], score: 0, truncated: false });
|
|
34
|
+
return JSON.stringify(toOpenApi(merged, schemas, operations), null, 2);
|
|
35
|
+
}
|
|
36
|
+
return subgraphs
|
|
37
|
+
.map((s, at) => `${subgraphs.length > 1 ? `# result ${at + 1}\n` : ''}${render(s, format, options)}`)
|
|
38
|
+
.join('\n\n');
|
|
39
|
+
}
|
|
40
|
+
//# sourceMappingURL=present.js.map
|
package/dist/query.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { SchemaQuery } from './schema/search.ts';
|
|
2
|
+
export declare class QueryError extends Error {
|
|
3
|
+
}
|
|
4
|
+
export declare function parseQuery(value: unknown): SchemaQuery;
|
|
5
|
+
/** Whether anything was actually asked, as opposed to only filtered. */
|
|
6
|
+
export declare function isEmpty(query: SchemaQuery): boolean;
|
|
7
|
+
//# sourceMappingURL=query.d.ts.map
|
package/dist/query.js
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// A query, from somewhere untrusted
|
|
3
|
+
//
|
|
4
|
+
// `--query` and the tool both hand over a whole object, which means the shape
|
|
5
|
+
// has to be checked rather than assumed. The rule is the same either way: a
|
|
6
|
+
// key that is not known is an error, because a silently ignored
|
|
7
|
+
// `output_propertys` looks exactly like a search that found nothing.
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
const LISTS = [
|
|
10
|
+
'all',
|
|
11
|
+
'methods',
|
|
12
|
+
'types',
|
|
13
|
+
'input_types',
|
|
14
|
+
'output_types',
|
|
15
|
+
'properties',
|
|
16
|
+
'input_properties',
|
|
17
|
+
'output_properties',
|
|
18
|
+
'exclude_ids',
|
|
19
|
+
'exclude_methods',
|
|
20
|
+
'exclude_types',
|
|
21
|
+
'exclude_properties',
|
|
22
|
+
];
|
|
23
|
+
const NUMBERS = ['limit', 'max_hops', 'max_nodes'];
|
|
24
|
+
const DIRECTIONS = ['input', 'output', 'any'];
|
|
25
|
+
const METHOD_TYPES = ['read_only', 'read_write', 'any'];
|
|
26
|
+
const KNOWN = new Set([...LISTS, ...NUMBERS, 'direction', 'method_type']);
|
|
27
|
+
export class QueryError extends Error {
|
|
28
|
+
}
|
|
29
|
+
export function parseQuery(value) {
|
|
30
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
31
|
+
throw new QueryError('a query must be an object');
|
|
32
|
+
}
|
|
33
|
+
const input = value;
|
|
34
|
+
for (const key of Object.keys(input)) {
|
|
35
|
+
if (!KNOWN.has(key)) {
|
|
36
|
+
throw new QueryError(`unknown query field "${key}" — expected ${[...KNOWN].join(', ')}`);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
const out = {};
|
|
40
|
+
for (const key of LISTS) {
|
|
41
|
+
const list = input[key];
|
|
42
|
+
if (list === undefined) {
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
if (!Array.isArray(list) || list.some((v) => typeof v !== 'string')) {
|
|
46
|
+
throw new QueryError(`${key} must be an array of strings`);
|
|
47
|
+
}
|
|
48
|
+
out[key] = list;
|
|
49
|
+
}
|
|
50
|
+
for (const key of NUMBERS) {
|
|
51
|
+
const number = input[key];
|
|
52
|
+
if (number === undefined) {
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
if (typeof number !== 'number' || !Number.isInteger(number) || number < 1) {
|
|
56
|
+
throw new QueryError(`${key} must be a whole number of at least 1`);
|
|
57
|
+
}
|
|
58
|
+
out[key] = number;
|
|
59
|
+
}
|
|
60
|
+
if (input.direction !== undefined) {
|
|
61
|
+
out.direction = oneOf('direction', input.direction, DIRECTIONS);
|
|
62
|
+
}
|
|
63
|
+
if (input.method_type !== undefined) {
|
|
64
|
+
out.method_type = oneOf('method_type', input.method_type, METHOD_TYPES);
|
|
65
|
+
}
|
|
66
|
+
return out;
|
|
67
|
+
}
|
|
68
|
+
/** Whether anything was actually asked, as opposed to only filtered. */
|
|
69
|
+
export function isEmpty(query) {
|
|
70
|
+
const terms = [
|
|
71
|
+
query.all,
|
|
72
|
+
query.methods,
|
|
73
|
+
query.types,
|
|
74
|
+
query.input_types,
|
|
75
|
+
query.output_types,
|
|
76
|
+
query.properties,
|
|
77
|
+
query.input_properties,
|
|
78
|
+
query.output_properties,
|
|
79
|
+
];
|
|
80
|
+
return terms.every((list) => !list || list.length === 0);
|
|
81
|
+
}
|
|
82
|
+
function oneOf(key, value, allowed) {
|
|
83
|
+
if (typeof value !== 'string' || !allowed.includes(value)) {
|
|
84
|
+
throw new QueryError(`${key} must be one of ${allowed.join(', ')}`);
|
|
85
|
+
}
|
|
86
|
+
return value;
|
|
87
|
+
}
|
|
88
|
+
//# sourceMappingURL=query.js.map
|
package/dist/repl.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { type Format, type OutputOptions } from './present.ts';
|
|
2
|
+
import type { SchemaIndex, SchemaQuery } from './schema/search.ts';
|
|
3
|
+
interface Settings extends OutputOptions {
|
|
4
|
+
format: Format;
|
|
5
|
+
}
|
|
6
|
+
export declare function repl(index: SchemaIndex, initial: SchemaQuery, settings: Settings): Promise<void>;
|
|
7
|
+
export {};
|
|
8
|
+
//# sourceMappingURL=repl.d.ts.map
|
package/dist/repl.js
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { createInterface } from 'node:readline/promises';
|
|
2
|
+
import { bold, cyan, dim, note, write } from '@zenera/cli/lib';
|
|
3
|
+
import { isFormat, present } from "./present.js";
|
|
4
|
+
import { isEmpty, parseQuery, QueryError } from "./query.js";
|
|
5
|
+
const FIELDS = {
|
|
6
|
+
all: 'all',
|
|
7
|
+
method: 'methods',
|
|
8
|
+
type: 'types',
|
|
9
|
+
'input-type': 'input_types',
|
|
10
|
+
'output-type': 'output_types',
|
|
11
|
+
property: 'properties',
|
|
12
|
+
'input-property': 'input_properties',
|
|
13
|
+
'output-property': 'output_properties',
|
|
14
|
+
};
|
|
15
|
+
export async function repl(index, initial, settings) {
|
|
16
|
+
const rl = createInterface({ input: process.stdin, output: process.stderr });
|
|
17
|
+
let query = { ...initial };
|
|
18
|
+
let format = settings.format;
|
|
19
|
+
const options = { docs: settings.docs, onlyHits: settings.onlyHits };
|
|
20
|
+
note(bold(`${index.manifest.sources.map((s) => s.title).join(', ')}`));
|
|
21
|
+
note(dim(' a bare line searches every kind; `help` lists the rest.'));
|
|
22
|
+
try {
|
|
23
|
+
if (!isEmpty(query)) {
|
|
24
|
+
query = await run(index, query, format, options);
|
|
25
|
+
}
|
|
26
|
+
for (;;) {
|
|
27
|
+
const line = (await rl.question(cyan('rag> '))).trim();
|
|
28
|
+
if (line === '') {
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
if (line === 'quit' || line === 'exit') {
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
if (line === 'help') {
|
|
35
|
+
help();
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
if (line === 'reset') {
|
|
39
|
+
query = {};
|
|
40
|
+
note(dim(' cleared, exclusions and all'));
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
if (line === 'show') {
|
|
44
|
+
note(dim(` ${JSON.stringify(query)}`));
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
const [head, ...rest] = line.split(' ');
|
|
48
|
+
const tail = rest.join(' ').trim();
|
|
49
|
+
if (head === 'format') {
|
|
50
|
+
if (isFormat(tail)) {
|
|
51
|
+
format = tail;
|
|
52
|
+
note(dim(` format is ${tail}`));
|
|
53
|
+
}
|
|
54
|
+
else {
|
|
55
|
+
note(dim(' expected text, mermaid, mermaid-flowchart, ts or openapi'));
|
|
56
|
+
}
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
if (head === 'direction' || head === 'method-type') {
|
|
60
|
+
query = patch(query, head === 'direction' ? 'direction' : 'method_type', tail);
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
if (head && head in FIELDS && tail) {
|
|
64
|
+
query = { ...query, [FIELDS[head]]: [tail] };
|
|
65
|
+
query = await run(index, query, format, options);
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
query = await run(index, { ...query, all: [line] }, format, options);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
finally {
|
|
72
|
+
rl.close();
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Runs the search, prints it, and hands back the query with everything just
|
|
77
|
+
* shown added to the exclusions — so asking the same thing twice moves on
|
|
78
|
+
* instead of repeating itself.
|
|
79
|
+
*/
|
|
80
|
+
async function run(index, query, format, options) {
|
|
81
|
+
const result = await index.search(query);
|
|
82
|
+
const text = await present(index, result.subgraphs, format, options);
|
|
83
|
+
if (text) {
|
|
84
|
+
write(text);
|
|
85
|
+
}
|
|
86
|
+
note(dim(` ${result.seeds.length} seed(s) · ${result.subgraphs.length} result(s)` +
|
|
87
|
+
(result.empty.length > 0 ? ` · nothing for: ${result.empty.join(', ')}` : '')));
|
|
88
|
+
if (result.seeds.length === 0) {
|
|
89
|
+
return query;
|
|
90
|
+
}
|
|
91
|
+
const seen = new Set([...(query.exclude_ids ?? []), ...result.seeds.map((s) => s.id)]);
|
|
92
|
+
note(dim(` ${seen.size} node(s) now excluded — \`reset\` to see them again`));
|
|
93
|
+
return { ...query, exclude_ids: [...seen] };
|
|
94
|
+
}
|
|
95
|
+
function patch(query, key, value) {
|
|
96
|
+
try {
|
|
97
|
+
return { ...query, ...parseQuery({ [key]: value }) };
|
|
98
|
+
}
|
|
99
|
+
catch (err) {
|
|
100
|
+
note(dim(` ${err instanceof QueryError ? err.message : String(err)}`));
|
|
101
|
+
return query;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
function help() {
|
|
105
|
+
for (const line of [
|
|
106
|
+
' <text> search everything',
|
|
107
|
+
' all|method|type <text> search one field',
|
|
108
|
+
' input-property <text> also: output-property, property, input-type, output-type',
|
|
109
|
+
' direction <d> input | output | any',
|
|
110
|
+
' method-type <t> read_only | read_write | any',
|
|
111
|
+
' format <f> text | mermaid | mermaid-flowchart | ts | openapi',
|
|
112
|
+
' show the query as it stands',
|
|
113
|
+
' reset forget it, exclusions included',
|
|
114
|
+
' quit',
|
|
115
|
+
]) {
|
|
116
|
+
note(dim(line));
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
//# sourceMappingURL=repl.js.map
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/** The name a new index is given. Nothing searches for it; only `index --out` writes it. */
|
|
2
|
+
export declare const DEFAULT_DIR = "./schema-db";
|
|
3
|
+
export declare const DIR_ENV = "ZEN_SCHEMA_DB";
|
|
4
|
+
/** How the directory was arrived at, which is what decides whether to say so. */
|
|
5
|
+
export type DirSource = 'flag' | 'env' | 'found' | 'default';
|
|
6
|
+
export interface Located {
|
|
7
|
+
dir: string;
|
|
8
|
+
from: DirSource;
|
|
9
|
+
}
|
|
10
|
+
export interface LocateOptions {
|
|
11
|
+
env?: NodeJS.ProcessEnv;
|
|
12
|
+
/** do not climb above this; the home directory, or the root, by default */
|
|
13
|
+
ceiling?: string;
|
|
14
|
+
}
|
|
15
|
+
export declare function locateIndex(cwd: string, flag?: string, options?: LocateOptions): Located;
|
|
16
|
+
/** Where a new index goes: the same environment variable, minus the search. */
|
|
17
|
+
export declare function outputDir(cwd: string, flag?: string, env?: NodeJS.ProcessEnv): string;
|
|
18
|
+
/** An index is a directory with a manifest in it; nothing else is asserted here. */
|
|
19
|
+
export declare function isIndex(dir: string): boolean;
|
|
20
|
+
//# sourceMappingURL=locate.d.ts.map
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { CliError, EXIT } from '@zenera/cli/lib';
|
|
2
|
+
import { readdirSync, statSync } from 'node:fs';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { dirname, isAbsolute, join, parse, relative, resolve } from 'node:path';
|
|
5
|
+
import { MANIFEST_FILE } from "./files.js";
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// Which index, when nobody said
|
|
8
|
+
//
|
|
9
|
+
// `--dir` and `$ZEN_SCHEMA_DB` are taken as written, missing or not: naming a
|
|
10
|
+
// directory that turns out not to hold an index has to fail saying so, because
|
|
11
|
+
// quietly using a different one would be a worse answer than an error.
|
|
12
|
+
//
|
|
13
|
+
// With neither, the directory is looked for. There is no list of blessed names
|
|
14
|
+
// here and there should not be — an index is self-describing, so what is being
|
|
15
|
+
// looked for is a `manifest.json`, not a directory called `schema-db`.
|
|
16
|
+
// `schema-db` is only the name a *new* index is given, and nothing reads it
|
|
17
|
+
// back. The search is nearest-first: this directory, then what is under it,
|
|
18
|
+
// then up a level and again, so `/assets/…/whatever` is reachable from
|
|
19
|
+
// `/workspace` because the two meet at a shared root on the way up.
|
|
20
|
+
//
|
|
21
|
+
// Three things bound it, and each is bounding a different kind of mistake.
|
|
22
|
+
// Depth and a visit budget bound the cost. The ceiling — the home directory,
|
|
23
|
+
// or the filesystem root when the search began outside it — bounds the
|
|
24
|
+
// blast radius, because an index in someone else's tree is not yours. And two
|
|
25
|
+
// indexes at the same distance is an ambiguity rather than a tie to break:
|
|
26
|
+
// choosing one silently is the one failure worth ruling out entirely, since
|
|
27
|
+
// the wrong index does not error, it answers confidently about another API.
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
/** The name a new index is given. Nothing searches for it; only `index --out` writes it. */
|
|
30
|
+
export const DEFAULT_DIR = './schema-db';
|
|
31
|
+
export const DIR_ENV = 'ZEN_SCHEMA_DB';
|
|
32
|
+
/** Far enough to climb out of a package into its workspace, not far enough to roam. */
|
|
33
|
+
const MAX_LEVELS = 6;
|
|
34
|
+
/** How far below a directory an index may sit and still count as being in it. */
|
|
35
|
+
const MAX_DEPTH = 3;
|
|
36
|
+
/** A directory with more entries than this is a data store, not a place to keep an index. */
|
|
37
|
+
const MAX_ENTRIES = 128;
|
|
38
|
+
/** Directories the whole search may read, however it is shaped. */
|
|
39
|
+
const MAX_VISITS = 400;
|
|
40
|
+
export function locateIndex(cwd, flag, options = {}) {
|
|
41
|
+
const env = options.env ?? process.env;
|
|
42
|
+
if (flag) {
|
|
43
|
+
return { dir: resolve(cwd, flag), from: 'flag' };
|
|
44
|
+
}
|
|
45
|
+
const named = env[DIR_ENV]?.trim();
|
|
46
|
+
if (named) {
|
|
47
|
+
return { dir: resolve(cwd, named), from: 'env' };
|
|
48
|
+
}
|
|
49
|
+
const found = search(resolve(cwd), options.ceiling ?? ceilingFor(cwd));
|
|
50
|
+
// Nothing found still answers with the default, so the error names the
|
|
51
|
+
// directory everyone expects rather than the last place that was searched.
|
|
52
|
+
return found
|
|
53
|
+
? { dir: found, from: 'found' }
|
|
54
|
+
: { dir: resolve(cwd, DEFAULT_DIR), from: 'default' };
|
|
55
|
+
}
|
|
56
|
+
/** Where a new index goes: the same environment variable, minus the search. */
|
|
57
|
+
export function outputDir(cwd, flag, env = process.env) {
|
|
58
|
+
return resolve(cwd, flag ?? env[DIR_ENV]?.trim() ?? DEFAULT_DIR);
|
|
59
|
+
}
|
|
60
|
+
/** An index is a directory with a manifest in it; nothing else is asserted here. */
|
|
61
|
+
export function isIndex(dir) {
|
|
62
|
+
try {
|
|
63
|
+
return statSync(join(dir, MANIFEST_FILE)).isFile();
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
function search(cwd, ceiling) {
|
|
70
|
+
const budget = { left: MAX_VISITS, seen: new Set() };
|
|
71
|
+
let dir = cwd;
|
|
72
|
+
for (let level = 0; level < MAX_LEVELS; level++) {
|
|
73
|
+
const found = nearest(dir, budget);
|
|
74
|
+
if (found.length === 1) {
|
|
75
|
+
return found[0];
|
|
76
|
+
}
|
|
77
|
+
if (found.length > 1) {
|
|
78
|
+
throw new CliError(`more than one index is equally close to here: ${found.join(', ')}`, EXIT.usage, `say which with --dir, or set ${DIR_ENV}`);
|
|
79
|
+
}
|
|
80
|
+
budget.seen.add(dir);
|
|
81
|
+
const up = dirname(dir);
|
|
82
|
+
if (up === dir || dir === ceiling) {
|
|
83
|
+
break;
|
|
84
|
+
}
|
|
85
|
+
dir = up;
|
|
86
|
+
}
|
|
87
|
+
return undefined;
|
|
88
|
+
}
|
|
89
|
+
/** Every index at the shallowest depth that has any, so a tie can be reported as one. */
|
|
90
|
+
function nearest(root, budget) {
|
|
91
|
+
let frontier = [root];
|
|
92
|
+
for (let depth = 0; depth <= MAX_DEPTH && frontier.length > 0; depth++) {
|
|
93
|
+
const found = frontier.filter(isIndex);
|
|
94
|
+
if (found.length > 0) {
|
|
95
|
+
return found;
|
|
96
|
+
}
|
|
97
|
+
const next = [];
|
|
98
|
+
for (const dir of frontier) {
|
|
99
|
+
if (budget.left <= 0) {
|
|
100
|
+
return [];
|
|
101
|
+
}
|
|
102
|
+
budget.left--;
|
|
103
|
+
next.push(...children(dir).filter((child) => !budget.seen.has(child)));
|
|
104
|
+
}
|
|
105
|
+
frontier = next;
|
|
106
|
+
}
|
|
107
|
+
return [];
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* The subdirectories of one directory. Hidden directories and `node_modules`
|
|
111
|
+
* are skipped: an index kept out of sight is not one anybody meant to be found
|
|
112
|
+
* by looking.
|
|
113
|
+
*/
|
|
114
|
+
function children(dir) {
|
|
115
|
+
let entries;
|
|
116
|
+
try {
|
|
117
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
return [];
|
|
121
|
+
}
|
|
122
|
+
if (entries.length > MAX_ENTRIES) {
|
|
123
|
+
return [];
|
|
124
|
+
}
|
|
125
|
+
return entries
|
|
126
|
+
.filter((e) => e.isDirectory() && !e.name.startsWith('.') && e.name !== 'node_modules')
|
|
127
|
+
.map((e) => join(dir, e.name));
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Home is the ceiling for anyone working inside it. Starting outside it —
|
|
131
|
+
* a container whose workspace is `/workspace`, a CI checkout — there is no
|
|
132
|
+
* home to stay within, so the root is the only stop.
|
|
133
|
+
*/
|
|
134
|
+
function ceilingFor(cwd) {
|
|
135
|
+
const home = homedir();
|
|
136
|
+
const below = relative(home, resolve(cwd));
|
|
137
|
+
return below === '' || (!below.startsWith('..') && !isAbsolute(below))
|
|
138
|
+
? home
|
|
139
|
+
: parse(resolve(cwd)).root;
|
|
140
|
+
}
|
|
141
|
+
//# sourceMappingURL=locate.js.map
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/** Long enough for any honest pattern, short enough to bound a bad one. */
|
|
2
|
+
export declare const MAX_PATTERN = 200;
|
|
3
|
+
export declare class PatternError extends Error {
|
|
4
|
+
}
|
|
5
|
+
export interface MatchOptions {
|
|
6
|
+
/** read the pattern as a regular expression rather than as a literal */
|
|
7
|
+
regex?: boolean;
|
|
8
|
+
caseSensitive?: boolean;
|
|
9
|
+
}
|
|
10
|
+
export type Matcher = (text: string) => boolean;
|
|
11
|
+
/**
|
|
12
|
+
* A predicate over a string. Literal by default: someone typing `user.id` means
|
|
13
|
+
* those seven characters, and a dot that quietly matched anything would be a
|
|
14
|
+
* worse answer than no answer.
|
|
15
|
+
*/
|
|
16
|
+
export declare function matcher(pattern: string, options?: MatchOptions): Matcher;
|
|
17
|
+
/**
|
|
18
|
+
* A glob, matched against the whole string. Globs rather than regexes because
|
|
19
|
+
* these are for naming things — a path, a schema — and a star is what everyone
|
|
20
|
+
* reaches for first.
|
|
21
|
+
*/
|
|
22
|
+
export declare function wildcard(pattern: string, options?: {
|
|
23
|
+
caseSensitive?: boolean;
|
|
24
|
+
}): Matcher;
|
|
25
|
+
/** Whether a pattern is asking to be read as a glob at all. */
|
|
26
|
+
export declare const isGlob: (pattern: string) => boolean;
|
|
27
|
+
/**
|
|
28
|
+
* What someone means when they type a name into a filter. With a star in it,
|
|
29
|
+
* a glob; without one, a substring — because `password` typed into `--name` is
|
|
30
|
+
* a search for the word, and a whole-string match would answer nothing and
|
|
31
|
+
* look like the field does not exist.
|
|
32
|
+
*
|
|
33
|
+
* `regex` settles it outright, and has to: a star is punctuation in both
|
|
34
|
+
* languages, so `^/(users|teams)/.*` read as a glob would match nothing and
|
|
35
|
+
* never say why.
|
|
36
|
+
*/
|
|
37
|
+
export declare function loose(pattern: string, options?: MatchOptions): Matcher;
|
|
38
|
+
/** True when any of the patterns matches; no patterns means no opinion. */
|
|
39
|
+
export declare function anyOf(matchers: readonly Matcher[]): Matcher | undefined;
|
|
40
|
+
//# sourceMappingURL=match.d.ts.map
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// Matching, with nothing learned in between
|
|
3
|
+
//
|
|
4
|
+
// Everything here is exact. A glob matches the characters it names and a
|
|
5
|
+
// substring is a substring, which is the whole point of the surfaces built on
|
|
6
|
+
// it: a vector index answers "what is near this", and near is a ranking, so it
|
|
7
|
+
// can only ever return the top of a list. When the question is "does the word
|
|
8
|
+
// `password` appear anywhere at all", a ranking is the wrong instrument and no
|
|
9
|
+
// amount of tuning makes it the right one.
|
|
10
|
+
//
|
|
11
|
+
// A pattern may arrive from a model, so a regex is a bounded promise: the
|
|
12
|
+
// length is capped here and the scan that uses it keeps a deadline.
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
/** Long enough for any honest pattern, short enough to bound a bad one. */
|
|
15
|
+
export const MAX_PATTERN = 200;
|
|
16
|
+
export class PatternError extends Error {
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* A predicate over a string. Literal by default: someone typing `user.id` means
|
|
20
|
+
* those seven characters, and a dot that quietly matched anything would be a
|
|
21
|
+
* worse answer than no answer.
|
|
22
|
+
*/
|
|
23
|
+
export function matcher(pattern, options = {}) {
|
|
24
|
+
guard(pattern);
|
|
25
|
+
if (!options.regex) {
|
|
26
|
+
if (options.caseSensitive) {
|
|
27
|
+
return (text) => text.includes(pattern);
|
|
28
|
+
}
|
|
29
|
+
const needle = pattern.toLowerCase();
|
|
30
|
+
return (text) => text.toLowerCase().includes(needle);
|
|
31
|
+
}
|
|
32
|
+
const expression = compile(pattern, options.caseSensitive ? '' : 'i');
|
|
33
|
+
// `lastIndex` is not carried between calls: the flags never include `g`.
|
|
34
|
+
return (text) => expression.test(text);
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* A glob, matched against the whole string. Globs rather than regexes because
|
|
38
|
+
* these are for naming things — a path, a schema — and a star is what everyone
|
|
39
|
+
* reaches for first.
|
|
40
|
+
*/
|
|
41
|
+
export function wildcard(pattern, options = {}) {
|
|
42
|
+
guard(pattern);
|
|
43
|
+
const source = [...pattern]
|
|
44
|
+
.map((char) => (char === '*' ? '.*' : char === '?' ? '.' : escape(char)))
|
|
45
|
+
.join('');
|
|
46
|
+
const expression = compile(`^${source}$`, options.caseSensitive ? '' : 'i');
|
|
47
|
+
return (text) => expression.test(text);
|
|
48
|
+
}
|
|
49
|
+
/** Whether a pattern is asking to be read as a glob at all. */
|
|
50
|
+
export const isGlob = (pattern) => /[*?]/.test(pattern);
|
|
51
|
+
/**
|
|
52
|
+
* What someone means when they type a name into a filter. With a star in it,
|
|
53
|
+
* a glob; without one, a substring — because `password` typed into `--name` is
|
|
54
|
+
* a search for the word, and a whole-string match would answer nothing and
|
|
55
|
+
* look like the field does not exist.
|
|
56
|
+
*
|
|
57
|
+
* `regex` settles it outright, and has to: a star is punctuation in both
|
|
58
|
+
* languages, so `^/(users|teams)/.*` read as a glob would match nothing and
|
|
59
|
+
* never say why.
|
|
60
|
+
*/
|
|
61
|
+
export function loose(pattern, options = {}) {
|
|
62
|
+
if (options.regex) {
|
|
63
|
+
return matcher(pattern, options);
|
|
64
|
+
}
|
|
65
|
+
return isGlob(pattern) ? wildcard(pattern, options) : matcher(pattern, options);
|
|
66
|
+
}
|
|
67
|
+
/** True when any of the patterns matches; no patterns means no opinion. */
|
|
68
|
+
export function anyOf(matchers) {
|
|
69
|
+
if (matchers.length === 0) {
|
|
70
|
+
return undefined;
|
|
71
|
+
}
|
|
72
|
+
return (text) => matchers.some((match) => match(text));
|
|
73
|
+
}
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
function guard(pattern) {
|
|
76
|
+
if (pattern.length === 0) {
|
|
77
|
+
throw new PatternError('the pattern is empty');
|
|
78
|
+
}
|
|
79
|
+
if (pattern.length > MAX_PATTERN) {
|
|
80
|
+
throw new PatternError(`the pattern is longer than ${MAX_PATTERN} characters`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
function compile(source, flags) {
|
|
84
|
+
try {
|
|
85
|
+
return new RegExp(source, flags);
|
|
86
|
+
}
|
|
87
|
+
catch (err) {
|
|
88
|
+
throw new PatternError(`invalid pattern: ${err.message}`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
const escape = (char) => char.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
92
|
+
//# sourceMappingURL=match.js.map
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { Counts, Manifest } from './files.ts';
|
|
2
|
+
export declare const LOCK_FILE = ".lock";
|
|
3
|
+
export declare const README_FILE = "README.md";
|
|
4
|
+
export type Phase = 'reading' | 'graph' | 'embedding' | 'writing';
|
|
5
|
+
export interface BuildPlan {
|
|
6
|
+
/** where the index goes */
|
|
7
|
+
dir: string;
|
|
8
|
+
files: readonly string[];
|
|
9
|
+
/** the embedding reference as it was typed */
|
|
10
|
+
embedding: string;
|
|
11
|
+
indexer: string;
|
|
12
|
+
}
|
|
13
|
+
export interface Journal {
|
|
14
|
+
phase(name: Phase): void;
|
|
15
|
+
/** what the documents turned out to hold, once they have been read */
|
|
16
|
+
read(counts: Counts): void;
|
|
17
|
+
progress(done: number, total: number): void;
|
|
18
|
+
finish(manifest: Manifest): void;
|
|
19
|
+
fail(reason: unknown): void;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Takes the directory, or refuses it. Two builds writing one index would
|
|
23
|
+
* interleave their LanceDB writes and leave a store neither of them describes.
|
|
24
|
+
*/
|
|
25
|
+
export declare function beginBuild(plan: BuildPlan): Journal;
|
|
26
|
+
//# sourceMappingURL=progress.d.ts.map
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
import { CliError, EXIT } from '@zenera/cli/lib';
|
|
2
|
+
import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { hostname } from 'node:os';
|
|
4
|
+
import { basename, join } from 'node:path';
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
// A build that says what it is doing
|
|
7
|
+
//
|
|
8
|
+
// Indexing a large document is minutes of silence with a directory slowly
|
|
9
|
+
// filling up, and the directory is the only thing a second person — or a second
|
|
10
|
+
// process, or an agent reading the tree — ever sees. So the build writes two
|
|
11
|
+
// files into it and keeps them true:
|
|
12
|
+
//
|
|
13
|
+
// .lock who is building this, right now. Gone when nothing is.
|
|
14
|
+
// README.md a progress report while it runs, and a description of what the
|
|
15
|
+
// index holds once it does not.
|
|
16
|
+
//
|
|
17
|
+
// Nothing in either file is an absolute path. A project's `assets/` directory
|
|
18
|
+
// is mounted at `/assets` inside an agent's sandbox, so an index built there is
|
|
19
|
+
// read under a name this process never sees; a host path would be a lie there.
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
export const LOCK_FILE = '.lock';
|
|
22
|
+
export const README_FILE = 'README.md';
|
|
23
|
+
/** The floor on how often README.md is rewritten. */
|
|
24
|
+
const INTERVAL_MS = 5000;
|
|
25
|
+
const PHASES = {
|
|
26
|
+
reading: 'reading the documents',
|
|
27
|
+
graph: 'building the graph',
|
|
28
|
+
embedding: 'embedding',
|
|
29
|
+
writing: 'writing the store',
|
|
30
|
+
};
|
|
31
|
+
/**
|
|
32
|
+
* Takes the directory, or refuses it. Two builds writing one index would
|
|
33
|
+
* interleave their LanceDB writes and leave a store neither of them describes.
|
|
34
|
+
*/
|
|
35
|
+
export function beginBuild(plan) {
|
|
36
|
+
const documents = plan.files.map((file) => basename(file));
|
|
37
|
+
const started = Date.now();
|
|
38
|
+
const lock = {
|
|
39
|
+
pid: process.pid,
|
|
40
|
+
host: hostname(),
|
|
41
|
+
startedAt: new Date(started).toISOString(),
|
|
42
|
+
indexer: plan.indexer,
|
|
43
|
+
embedding: plan.embedding,
|
|
44
|
+
documents,
|
|
45
|
+
};
|
|
46
|
+
mkdirSync(plan.dir, { recursive: true });
|
|
47
|
+
claim(join(plan.dir, LOCK_FILE), lock);
|
|
48
|
+
let phase = 'reading';
|
|
49
|
+
let counts;
|
|
50
|
+
let done = 0;
|
|
51
|
+
let total = 0;
|
|
52
|
+
let wroteAt = 0;
|
|
53
|
+
let closed = false;
|
|
54
|
+
const write = (body) => {
|
|
55
|
+
// Through a temp name, so a reader never catches half a file.
|
|
56
|
+
const target = join(plan.dir, README_FILE);
|
|
57
|
+
const temp = `${target}.tmp`;
|
|
58
|
+
writeFileSync(temp, body);
|
|
59
|
+
renameSync(temp, target);
|
|
60
|
+
wroteAt = Date.now();
|
|
61
|
+
};
|
|
62
|
+
const report = () => write(building({
|
|
63
|
+
documents,
|
|
64
|
+
embedding: plan.embedding,
|
|
65
|
+
started,
|
|
66
|
+
phase,
|
|
67
|
+
done,
|
|
68
|
+
total,
|
|
69
|
+
counts,
|
|
70
|
+
}));
|
|
71
|
+
const maybe = () => {
|
|
72
|
+
if (!closed && Date.now() - wroteAt >= INTERVAL_MS) {
|
|
73
|
+
report();
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
// The first embedding request can block for ten seconds or more, so a purely
|
|
77
|
+
// event-driven throttle would leave the elapsed line frozen through it.
|
|
78
|
+
const ticker = setInterval(maybe, INTERVAL_MS);
|
|
79
|
+
ticker.unref();
|
|
80
|
+
const close = (body) => {
|
|
81
|
+
if (closed) {
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
closed = true;
|
|
85
|
+
clearInterval(ticker);
|
|
86
|
+
write(body);
|
|
87
|
+
rmSync(join(plan.dir, LOCK_FILE), { force: true });
|
|
88
|
+
};
|
|
89
|
+
report();
|
|
90
|
+
return {
|
|
91
|
+
phase(name) {
|
|
92
|
+
phase = name;
|
|
93
|
+
maybe();
|
|
94
|
+
},
|
|
95
|
+
read(seen) {
|
|
96
|
+
counts = seen;
|
|
97
|
+
total = seen.entities;
|
|
98
|
+
maybe();
|
|
99
|
+
},
|
|
100
|
+
progress(at, of) {
|
|
101
|
+
done = at;
|
|
102
|
+
total = of;
|
|
103
|
+
maybe();
|
|
104
|
+
},
|
|
105
|
+
finish(manifest) {
|
|
106
|
+
close(complete(plan.dir, manifest, Date.now() - started));
|
|
107
|
+
},
|
|
108
|
+
fail(reason) {
|
|
109
|
+
close(failed({ documents, phase, reason, started }));
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* `wx` makes the create and the check one operation, so two builds racing for
|
|
115
|
+
* the same directory cannot both win. A lock whose process is gone is stale by
|
|
116
|
+
* definition and is taken rather than respected — a crashed build must not make
|
|
117
|
+
* a directory permanently unbuildable.
|
|
118
|
+
*/
|
|
119
|
+
function claim(path, lock) {
|
|
120
|
+
const body = `${JSON.stringify(lock, null, 4)}\n`;
|
|
121
|
+
try {
|
|
122
|
+
writeFileSync(path, body, { flag: 'wx' });
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
catch (err) {
|
|
126
|
+
if (err.code !== 'EEXIST') {
|
|
127
|
+
throw err;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
const held = readLock(path);
|
|
131
|
+
if (held && held.host === hostname() && alive(held.pid)) {
|
|
132
|
+
throw new CliError(`this index is already being built (pid ${held.pid}, since ${held.startedAt})`, EXIT.failed, `wait for it, or build elsewhere with --out`);
|
|
133
|
+
}
|
|
134
|
+
writeFileSync(path, body);
|
|
135
|
+
}
|
|
136
|
+
function readLock(path) {
|
|
137
|
+
try {
|
|
138
|
+
return JSON.parse(readFileSync(path, 'utf8'));
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
return undefined;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* `kill(pid, 0)` sends no signal and only asks whether the process exists.
|
|
146
|
+
* EPERM means it exists and belongs to someone else, which still counts.
|
|
147
|
+
*/
|
|
148
|
+
function alive(pid) {
|
|
149
|
+
try {
|
|
150
|
+
process.kill(pid, 0);
|
|
151
|
+
return true;
|
|
152
|
+
}
|
|
153
|
+
catch (err) {
|
|
154
|
+
return err.code === 'EPERM';
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
function building(state) {
|
|
158
|
+
const now = Date.now();
|
|
159
|
+
const rows = [
|
|
160
|
+
['documents', state.documents.join(', ')],
|
|
161
|
+
['embedding', state.embedding],
|
|
162
|
+
[
|
|
163
|
+
'started',
|
|
164
|
+
`${new Date(state.started).toISOString()} (${duration(now - state.started)} ago)`,
|
|
165
|
+
],
|
|
166
|
+
['step', PHASES[state.phase]],
|
|
167
|
+
];
|
|
168
|
+
if (state.counts) {
|
|
169
|
+
rows.push(['found', entities(state.counts)]);
|
|
170
|
+
}
|
|
171
|
+
if (state.total > 0) {
|
|
172
|
+
const percent = Math.round((state.done / state.total) * 100);
|
|
173
|
+
rows.push(['embedded', `${state.done} of ${state.total} · ${percent}%`]);
|
|
174
|
+
}
|
|
175
|
+
rows.push(['updated', new Date(now).toISOString()]);
|
|
176
|
+
return [
|
|
177
|
+
'# Schema index — being built',
|
|
178
|
+
'',
|
|
179
|
+
'A searchable index of the API documents named below, written by `zen rag schema index`.',
|
|
180
|
+
'**It is incomplete. Nothing should read it yet.**',
|
|
181
|
+
'',
|
|
182
|
+
...fields(rows),
|
|
183
|
+
'',
|
|
184
|
+
`These lines are refreshed at most every ${INTERVAL_MS / 1000} seconds while the build runs, and`,
|
|
185
|
+
'the whole file is replaced by a description of the index when it finishes. If it still says',
|
|
186
|
+
'"being built" and `.lock` names no living process, the build died part way.',
|
|
187
|
+
'',
|
|
188
|
+
].join('\n');
|
|
189
|
+
}
|
|
190
|
+
function complete(dir, manifest, ms) {
|
|
191
|
+
const titles = manifest.sources.map((s) => s.title).filter(Boolean);
|
|
192
|
+
const what = titles.length > 0 ? titles.join(', ') : basename(dir);
|
|
193
|
+
return [
|
|
194
|
+
`# Schema index — ${what}`,
|
|
195
|
+
'',
|
|
196
|
+
`A searchable index of ${plural(manifest.sources.length, 'API document')}, built with`,
|
|
197
|
+
`${manifest.embedding.ref} (${manifest.embedding.dimensions}d) in ${duration(ms)}.`,
|
|
198
|
+
'Ask it for the operations and types behind a question and it answers with a subgraph:',
|
|
199
|
+
'the endpoints that match, the schemas they carry, and the fields inside those — printed',
|
|
200
|
+
'as text, Mermaid, TypeScript or OpenAPI.',
|
|
201
|
+
'',
|
|
202
|
+
'## What it covers',
|
|
203
|
+
'',
|
|
204
|
+
...sourceTable(manifest.sources),
|
|
205
|
+
'',
|
|
206
|
+
`${entities(manifest.counts)},`,
|
|
207
|
+
`${searched(manifest.indexes)}.`,
|
|
208
|
+
'',
|
|
209
|
+
'## Files',
|
|
210
|
+
'',
|
|
211
|
+
...fields([
|
|
212
|
+
['manifest.json', 'what this index is and what built it — read this first'],
|
|
213
|
+
['graph.json', 'the nodes and edges: operations, types, fields'],
|
|
214
|
+
['schemas.json', 'the JSON Schema of every type'],
|
|
215
|
+
['operations.json', 'every operation, with its parameters and responses'],
|
|
216
|
+
...(manifest.sources.some((s) => s.path)
|
|
217
|
+
? [['sources/', 'the documents themselves, bundled, exactly as indexed']]
|
|
218
|
+
: []),
|
|
219
|
+
['lance/', 'the LanceDB table: the search text, the vectors, the filter columns'],
|
|
220
|
+
]),
|
|
221
|
+
'',
|
|
222
|
+
'## Asking it something',
|
|
223
|
+
'',
|
|
224
|
+
'From this directory:',
|
|
225
|
+
'',
|
|
226
|
+
'```',
|
|
227
|
+
'zen rag schema search --dir . --all "how do I cancel a subscription"',
|
|
228
|
+
'```',
|
|
229
|
+
'',
|
|
230
|
+
`Built by ${manifest.indexer} on ${manifest.createdAt}.`,
|
|
231
|
+
'',
|
|
232
|
+
].join('\n');
|
|
233
|
+
}
|
|
234
|
+
function failed(state) {
|
|
235
|
+
return [
|
|
236
|
+
'# Schema index — failed',
|
|
237
|
+
'',
|
|
238
|
+
'This index was not finished and what is here is incomplete. Nothing should read it;',
|
|
239
|
+
'build it again with `zen rag schema index`.',
|
|
240
|
+
'',
|
|
241
|
+
...fields([
|
|
242
|
+
['documents', state.documents.join(', ')],
|
|
243
|
+
['step', PHASES[state.phase]],
|
|
244
|
+
['reason', message(state.reason)],
|
|
245
|
+
['started', new Date(state.started).toISOString()],
|
|
246
|
+
[
|
|
247
|
+
'failed',
|
|
248
|
+
`${new Date().toISOString()} (after ${duration(Date.now() - state.started)})`,
|
|
249
|
+
],
|
|
250
|
+
]),
|
|
251
|
+
'',
|
|
252
|
+
].join('\n');
|
|
253
|
+
}
|
|
254
|
+
// ---------------------------------------------------------------------------
|
|
255
|
+
// Small renderings
|
|
256
|
+
// ---------------------------------------------------------------------------
|
|
257
|
+
/** An indented block, which markdown renders verbatim and an agent reads as a table. */
|
|
258
|
+
function fields(rows) {
|
|
259
|
+
const width = Math.max(...rows.map((r) => (r[0] ?? '').length));
|
|
260
|
+
return rows.map(([name, value]) => ` ${(name ?? '').padEnd(width)} ${value ?? ''}`);
|
|
261
|
+
}
|
|
262
|
+
const HEADERS = ['document', 'dialect', 'paths', 'operations', 'schemas', 'fields'];
|
|
263
|
+
function sourceTable(sources) {
|
|
264
|
+
const rows = sources.map((s) => [
|
|
265
|
+
s.file,
|
|
266
|
+
s.dialect,
|
|
267
|
+
String(s.paths),
|
|
268
|
+
String(s.methods),
|
|
269
|
+
String(s.types),
|
|
270
|
+
String(s.properties),
|
|
271
|
+
]);
|
|
272
|
+
const widths = HEADERS.map((h, i) => Math.max(h.length, ...rows.map((r) => (r[i] ?? '').length)));
|
|
273
|
+
const line = (cells) => `| ${cells.map((c, i) => c.padEnd(widths[i] ?? 0)).join(' | ')} |`;
|
|
274
|
+
return [
|
|
275
|
+
line(HEADERS),
|
|
276
|
+
`| ${widths.map((w) => '-'.repeat(w)).join(' | ')} |`,
|
|
277
|
+
...rows.map(line),
|
|
278
|
+
];
|
|
279
|
+
}
|
|
280
|
+
function entities(counts) {
|
|
281
|
+
return (`${plural(counts.entities, 'entity', 'entities')}: ` +
|
|
282
|
+
`${counts.methods} operations, ${counts.types} schemas, ${counts.properties} fields`);
|
|
283
|
+
}
|
|
284
|
+
function searched(indexes) {
|
|
285
|
+
if (!indexes.fts && !indexes.vector) {
|
|
286
|
+
return 'scanned flat: neither index was built';
|
|
287
|
+
}
|
|
288
|
+
if (!indexes.vector) {
|
|
289
|
+
// Below a couple of thousand rows an IVF index has nothing to train on.
|
|
290
|
+
return 'searched by full text and by vector, the latter as a flat scan';
|
|
291
|
+
}
|
|
292
|
+
return indexes.fts
|
|
293
|
+
? 'searched by full text and by vector'
|
|
294
|
+
: 'searched by vector, with no full-text index';
|
|
295
|
+
}
|
|
296
|
+
function plural(n, one, many = `${one}s`) {
|
|
297
|
+
return `${n} ${n === 1 ? one : many}`;
|
|
298
|
+
}
|
|
299
|
+
function duration(ms) {
|
|
300
|
+
const seconds = Math.round(ms / 1000);
|
|
301
|
+
if (seconds < 1) {
|
|
302
|
+
return 'under a second';
|
|
303
|
+
}
|
|
304
|
+
if (seconds < 60) {
|
|
305
|
+
return `${seconds}s`;
|
|
306
|
+
}
|
|
307
|
+
const minutes = Math.floor(seconds / 60);
|
|
308
|
+
return minutes < 60
|
|
309
|
+
? `${minutes}m${seconds % 60}s`
|
|
310
|
+
: `${Math.floor(minutes / 60)}h${minutes % 60}m`;
|
|
311
|
+
}
|
|
312
|
+
function message(reason) {
|
|
313
|
+
const text = reason instanceof Error ? reason.message : String(reason);
|
|
314
|
+
return text.split('\n')[0]?.trim() || 'no reason given';
|
|
315
|
+
}
|
|
316
|
+
//# sourceMappingURL=progress.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zenera/rag",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.26",
|
|
4
4
|
"description": "Retrieval over a corpus: openapi/swagger documents as a searchable graph, markdown and text as searchable passages.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"agents",
|
|
@@ -61,8 +61,8 @@
|
|
|
61
61
|
"dependencies": {
|
|
62
62
|
"@apidevtools/swagger-parser": "^12.0.0",
|
|
63
63
|
"@lancedb/lancedb": "^0.38.0",
|
|
64
|
-
"@zenera/cli": "^1.1.
|
|
65
|
-
"@zenera/neo": "^1.1.
|
|
64
|
+
"@zenera/cli": "^1.1.26",
|
|
65
|
+
"@zenera/neo": "^1.1.26",
|
|
66
66
|
"graphology": "^0.26.0",
|
|
67
67
|
"remark-frontmatter": "^5.0.0",
|
|
68
68
|
"remark-gfm": "^4.0.1",
|