@zenera/rag 1.1.2 → 1.1.4
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 +28 -6
- package/dist/command.js +20 -19
- package/dist/schema/build.d.ts +2 -0
- package/dist/schema/build.js +62 -34
- package/dist/schema/files.d.ts +10 -2
- package/dist/schema/files.js +19 -7
- package/dist/schema/progress.d.ts +26 -0
- package/dist/schema/progress.js +316 -0
- package/dist/schema/spec.d.ts +5 -0
- package/dist/schema/spec.js +34 -10
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -29,7 +29,7 @@ standalone OpenAPI document.
|
|
|
29
29
|
Node.js 24+. Install it alongside the CLI:
|
|
30
30
|
|
|
31
31
|
```sh
|
|
32
|
-
npm i -g @zenera/cli @zenera/rag
|
|
32
|
+
npm i -g @zenera/cli @zenera/rag
|
|
33
33
|
zen key add openai # the keyring `zen` already uses
|
|
34
34
|
```
|
|
35
35
|
|
|
@@ -47,8 +47,8 @@ zen rag schema show Type:Invoice # a named node, with no search in between
|
|
|
47
47
|
```
|
|
48
48
|
|
|
49
49
|
Non-interactive search is a machine interface: every field is a flag, the whole
|
|
50
|
-
query can arrive as one JSON object, `--json`
|
|
51
|
-
required, and an empty result exits 0.
|
|
50
|
+
query can arrive as one JSON object, the `--json` output keeps the same
|
|
51
|
+
structure from run to run, no terminal is required, and an empty result exits 0.
|
|
52
52
|
|
|
53
53
|
```sh
|
|
54
54
|
zen rag schema search --query - --format ts <<'JSON'
|
|
@@ -106,16 +106,38 @@ need the word explained again, it needs the list of types that have one.
|
|
|
106
106
|
|
|
107
107
|
```
|
|
108
108
|
schema-db/
|
|
109
|
+
├── README.md what this index holds — a live progress report while it builds
|
|
109
110
|
├── manifest.json written last — its absence means "not indexed"
|
|
110
111
|
├── graph.json topology and light attributes, read whole
|
|
111
|
-
├── schemas.json the raw schemas, read
|
|
112
|
+
├── schemas.json the raw schemas, read the first time one is needed in full
|
|
112
113
|
├── operations.json likewise, for the OpenAPI subset
|
|
114
|
+
├── sources/ the documents themselves, bundled, exactly as indexed
|
|
113
115
|
└── lance/ one table: a row per node, one text column, one vector
|
|
114
116
|
```
|
|
115
117
|
|
|
116
118
|
The manifest records which embedder made the vectors, and a search with a
|
|
117
119
|
different one is refused rather than answered with noise.
|
|
118
120
|
|
|
121
|
+
Indexing a large document is minutes of silence, so the directory says what is
|
|
122
|
+
happening to it. `README.md` appears first as a progress report — the documents,
|
|
123
|
+
the embedder, the step, how many entities have been embedded of how many, and
|
|
124
|
+
how long it has been going — rewritten at most every five seconds, and replaced
|
|
125
|
+
on completion by a description of what the index turned out to hold. A build
|
|
126
|
+
that dies leaves it saying so. While one runs, `.lock` names the process; a
|
|
127
|
+
second build of the same directory is refused, unless the lock is stale.
|
|
128
|
+
|
|
129
|
+
**An index is one portable thing.** Nothing in it names a path outside itself:
|
|
130
|
+
a document is known by a short name taken from its filename, and the document
|
|
131
|
+
itself is copied into `sources/` as it was indexed — bundled, so every external
|
|
132
|
+
`$ref` is already resolved and the copy stands alone. The directory can be moved,
|
|
133
|
+
committed or shipped whole and still says what it is made of. This matters
|
|
134
|
+
because a project's `assets/` is mounted at `/assets` inside an agent's sandbox,
|
|
135
|
+
so an index built here is read under a name this machine never sees. `--no-sources`
|
|
136
|
+
leaves the copies out, for an index that will never travel.
|
|
137
|
+
|
|
138
|
+
The copies are a record, not an input: rebuilding reads the files you name, not
|
|
139
|
+
the ones in `sources/`.
|
|
140
|
+
|
|
119
141
|
## Notes
|
|
120
142
|
|
|
121
143
|
- Documents are **bundled, not dereferenced**: `#/components/schemas/User` is
|
|
@@ -125,8 +147,8 @@ different one is refused rather than answered with noise.
|
|
|
125
147
|
- A **query parameter is a property**, like any field in a body. Nobody should
|
|
126
148
|
have to know in advance which one `page_size` is.
|
|
127
149
|
- Every type carries a **direction** — `input`, `output` or `both` — worked out
|
|
128
|
-
by propagating from the operations through composition, so a
|
|
129
|
-
honestly both rather than whichever side was read last.
|
|
150
|
+
by propagating from the operations through composition, so a type used on both
|
|
151
|
+
sides is honestly both rather than whichever side was read last.
|
|
130
152
|
- Filters reaching the store are **closed enums only**. Exclusion lists are
|
|
131
153
|
applied in JavaScript afterwards, so nothing a model wrote ever reaches a SQL
|
|
132
154
|
predicate.
|
package/dist/command.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { bold, CliError, cyan, dim, ensureHome, EXIT, isInteractive, json, KeyStore, note, parse, PROVIDERS, SHAPES, table, usageError, write, } from '@zenera/cli/lib';
|
|
1
|
+
import { bold, CliError, CURATED, cyan, dim, ensureHome, envNames, EXIT, form, isInteractive, json, KeyStore, note, parse, PROVIDERS, table, usageError, write, } from '@zenera/cli/lib';
|
|
3
2
|
import { createEmbedder } from '@zenera/neo';
|
|
3
|
+
import { relative, resolve } from 'node:path';
|
|
4
4
|
import { isFormat, present } from "./present.js";
|
|
5
5
|
import { isEmpty, parseQuery, QueryError } from "./query.js";
|
|
6
6
|
import { repl } from "./repl.js";
|
|
@@ -46,6 +46,7 @@ export const command = {
|
|
|
46
46
|
' --batch <n>',
|
|
47
47
|
dim('Texts per embedding request, and how often progress prints. Default 96.'),
|
|
48
48
|
],
|
|
49
|
+
[' --no-sources', dim('Do not keep a copy of each document in the index.')],
|
|
49
50
|
]),
|
|
50
51
|
'',
|
|
51
52
|
'Search terms (repeatable)',
|
|
@@ -107,6 +108,7 @@ async function index(args, ctx) {
|
|
|
107
108
|
out: { type: 'string', short: 'o' },
|
|
108
109
|
embedding: { type: 'string' },
|
|
109
110
|
batch: { type: 'string' },
|
|
111
|
+
'no-sources': { type: 'boolean' },
|
|
110
112
|
quiet: { type: 'boolean' },
|
|
111
113
|
}, INDEX_USAGE);
|
|
112
114
|
if (positionals.length === 0) {
|
|
@@ -123,9 +125,10 @@ async function index(args, ctx) {
|
|
|
123
125
|
embeddingRef: values.embedding,
|
|
124
126
|
indexer: 'zenera-rag',
|
|
125
127
|
batch: values.batch ? count(values.batch, '--batch') : undefined,
|
|
128
|
+
sources: !values['no-sources'],
|
|
126
129
|
onRead: loud
|
|
127
130
|
? (summary) => {
|
|
128
|
-
printSources(summary.sources
|
|
131
|
+
printSources(summary.sources);
|
|
129
132
|
// The first batch can take a while and says nothing while it
|
|
130
133
|
// does; this is the line that makes that a wait, not a hang.
|
|
131
134
|
note(dim(` embedding ${summary.counts.entities} entities with ${chosen.id} …`));
|
|
@@ -153,9 +156,9 @@ function elapsed(since) {
|
|
|
153
156
|
const seconds = Math.round((Date.now() - since) / 1000);
|
|
154
157
|
return seconds < 60 ? `${seconds}s` : `${Math.floor(seconds / 60)}m${seconds % 60}s`;
|
|
155
158
|
}
|
|
156
|
-
function printSources(sources
|
|
159
|
+
function printSources(sources) {
|
|
157
160
|
const rows = sources.map((s) => ({
|
|
158
|
-
name:
|
|
161
|
+
name: s.file,
|
|
159
162
|
dialect: s.dialect,
|
|
160
163
|
cells: [s.paths, s.methods, s.types, s.properties],
|
|
161
164
|
}));
|
|
@@ -359,7 +362,7 @@ async function stats(args, ctx) {
|
|
|
359
362
|
`fts ${yes(manifest.indexes.fts)} · vector ${yes(manifest.indexes.vector)}`,
|
|
360
363
|
],
|
|
361
364
|
]));
|
|
362
|
-
printSources(manifest.sources
|
|
365
|
+
printSources(manifest.sources);
|
|
363
366
|
notes(table([[' entities', String(manifest.counts.entities)]]).map(dim));
|
|
364
367
|
}
|
|
365
368
|
function notes(lines) {
|
|
@@ -375,7 +378,7 @@ async function embedder(ref) {
|
|
|
375
378
|
const keys = await KeyStore.open();
|
|
376
379
|
// Asked before materialising, because materialising is what erases the
|
|
377
380
|
// difference between "the environment had it" and "the keyring supplied it".
|
|
378
|
-
const fromEnv = new Set(PROVIDERS.filter((p) => process.env[
|
|
381
|
+
const fromEnv = new Set(PROVIDERS.filter((p) => envNames(p).some((n) => process.env[n])));
|
|
379
382
|
keys.materialize();
|
|
380
383
|
if (!ref) {
|
|
381
384
|
throw choices(keys, fromEnv);
|
|
@@ -383,28 +386,24 @@ async function embedder(ref) {
|
|
|
383
386
|
return createEmbedder(ref);
|
|
384
387
|
}
|
|
385
388
|
/**
|
|
386
|
-
* Well-known embedding models per provider
|
|
387
|
-
*
|
|
388
|
-
*
|
|
389
|
+
* Well-known embedding models per provider, read off the CLI's catalog table so
|
|
390
|
+
* there is one list rather than two that drift. Any ref the registry can parse
|
|
391
|
+
* works; these are the ones worth typing. Anthropic has none because it
|
|
392
|
+
* publishes no embeddings API at all.
|
|
389
393
|
*/
|
|
390
|
-
const
|
|
391
|
-
openai: ['text-embedding-3-small', 'text-embedding-3-large'],
|
|
392
|
-
google: ['gemini-embedding-001'],
|
|
393
|
-
vertex: ['gemini-embedding-001', 'text-embedding-005'],
|
|
394
|
-
openrouter: ['openai/text-embedding-3-small'],
|
|
395
|
-
};
|
|
394
|
+
const embeddingsOf = (provider) => CURATED[provider].filter((m) => m.roles.includes('embedding')).map((m) => m.id);
|
|
396
395
|
/** What could be passed, with the ones this machine can actually use first. */
|
|
397
396
|
function choices(keys, fromEnv) {
|
|
398
397
|
const rows = [];
|
|
399
398
|
const rest = [];
|
|
400
399
|
for (const provider of PROVIDERS) {
|
|
401
|
-
for (const model of
|
|
400
|
+
for (const model of embeddingsOf(provider)) {
|
|
402
401
|
const source = fromEnv.has(provider)
|
|
403
402
|
? 'environment'
|
|
404
403
|
: keys.active(provider)
|
|
405
404
|
? 'keyring'
|
|
406
405
|
: '';
|
|
407
|
-
const row = [` ${cyan(`${provider}:${model}`)}`, dim(source ||
|
|
406
|
+
const row = [` ${cyan(`${provider}:${model}`)}`, dim(source || form(provider).env)];
|
|
408
407
|
(source ? rows : rest).push(row);
|
|
409
408
|
}
|
|
410
409
|
}
|
|
@@ -415,7 +414,9 @@ function choices(keys, fromEnv) {
|
|
|
415
414
|
note(dim(' no provider on this machine has a credential — try: zen key add openai'));
|
|
416
415
|
note('');
|
|
417
416
|
}
|
|
418
|
-
|
|
417
|
+
// `pick` is the one that ends the question rather than restating it: it
|
|
418
|
+
// tries them and prints the first that answers.
|
|
419
|
+
return usageError('no embedder named', 'pass --embedding <ref>, or run: zen models pick --embedding');
|
|
419
420
|
}
|
|
420
421
|
function formatOf(value) {
|
|
421
422
|
if (value === undefined) {
|
package/dist/schema/build.d.ts
CHANGED
|
@@ -11,6 +11,8 @@ export interface BuildOptions {
|
|
|
11
11
|
indexer: string;
|
|
12
12
|
/** texts sent to the embedder at once */
|
|
13
13
|
batch?: number;
|
|
14
|
+
/** keep a bundled copy of each document in the index. On by default. */
|
|
15
|
+
sources?: boolean;
|
|
14
16
|
signal?: AbortSignal;
|
|
15
17
|
/** what the documents turned out to hold, before a vector has been paid for */
|
|
16
18
|
onRead?: (summary: BuildSummary) => void;
|
package/dist/schema/build.js
CHANGED
|
@@ -1,42 +1,67 @@
|
|
|
1
1
|
import { toEntities } from "./entities.js";
|
|
2
|
-
import { INDEX_VERSION, writeIndex, } from "./files.js";
|
|
2
|
+
import { INDEX_VERSION, SOURCES_DIR, writeIndex, } from "./files.js";
|
|
3
3
|
import { buildGraph } from "./graph.js";
|
|
4
|
+
import { beginBuild } from "./progress.js";
|
|
4
5
|
import { loadSpecs } from "./spec.js";
|
|
5
6
|
import { writeStore } from "./store.js";
|
|
6
7
|
const DEFAULT_BATCH = 96;
|
|
7
8
|
export async function buildIndex(options) {
|
|
8
|
-
const
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
sources: sourcesOf(corpus, entities, options.files),
|
|
13
|
-
counts: {
|
|
14
|
-
methods: entities.filter((e) => e.kind === 'method').length,
|
|
15
|
-
types: entities.filter((e) => e.kind === 'type').length,
|
|
16
|
-
properties: entities.filter((e) => e.kind === 'property').length,
|
|
17
|
-
entities: entities.length,
|
|
18
|
-
},
|
|
19
|
-
};
|
|
20
|
-
options.onRead?.(summary);
|
|
21
|
-
const vectors = await embedAll(entities, options);
|
|
22
|
-
const written = await writeStore(options.out, entities, vectors);
|
|
23
|
-
const manifest = {
|
|
24
|
-
version: INDEX_VERSION,
|
|
25
|
-
createdAt: new Date().toISOString(),
|
|
9
|
+
const journal = beginBuild({
|
|
10
|
+
dir: options.out,
|
|
11
|
+
files: options.files,
|
|
12
|
+
embedding: options.embeddingRef ?? options.embedder.id,
|
|
26
13
|
indexer: options.indexer,
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
14
|
+
});
|
|
15
|
+
try {
|
|
16
|
+
const corpus = await loadSpecs(options.files);
|
|
17
|
+
journal.phase('graph');
|
|
18
|
+
const { graph, types } = buildGraph(corpus);
|
|
19
|
+
const entities = toEntities(graph);
|
|
20
|
+
const keep = options.sources !== false;
|
|
21
|
+
const summary = {
|
|
22
|
+
sources: sourcesOf(corpus, entities, keep),
|
|
23
|
+
counts: {
|
|
24
|
+
methods: entities.filter((e) => e.kind === 'method').length,
|
|
25
|
+
types: entities.filter((e) => e.kind === 'type').length,
|
|
26
|
+
properties: entities.filter((e) => e.kind === 'property').length,
|
|
27
|
+
entities: entities.length,
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
journal.read(summary.counts);
|
|
31
|
+
options.onRead?.(summary);
|
|
32
|
+
journal.phase('embedding');
|
|
33
|
+
const vectors = await embedAll(entities, options, journal);
|
|
34
|
+
journal.phase('writing');
|
|
35
|
+
const written = await writeStore(options.out, entities, vectors);
|
|
36
|
+
const manifest = {
|
|
37
|
+
version: INDEX_VERSION,
|
|
38
|
+
createdAt: new Date().toISOString(),
|
|
39
|
+
indexer: options.indexer,
|
|
40
|
+
embedding: {
|
|
41
|
+
ref: options.embeddingRef ?? options.embedder.id,
|
|
42
|
+
id: options.embedder.id,
|
|
43
|
+
dimensions: vectors[0]?.length ?? 0,
|
|
44
|
+
},
|
|
45
|
+
sources: summary.sources,
|
|
46
|
+
counts: summary.counts,
|
|
47
|
+
indexes: { fts: written.fts, vector: written.vector },
|
|
48
|
+
};
|
|
49
|
+
await writeIndex(options.out, {
|
|
50
|
+
manifest,
|
|
51
|
+
graph,
|
|
52
|
+
types,
|
|
53
|
+
operations: corpus.operations,
|
|
54
|
+
documents: keep ? corpus.documents : {},
|
|
55
|
+
});
|
|
56
|
+
journal.finish(manifest);
|
|
57
|
+
return { manifest, entities };
|
|
58
|
+
}
|
|
59
|
+
catch (err) {
|
|
60
|
+
journal.fail(err);
|
|
61
|
+
throw err;
|
|
62
|
+
}
|
|
38
63
|
}
|
|
39
|
-
async function embedAll(entities, options) {
|
|
64
|
+
async function embedAll(entities, options, journal) {
|
|
40
65
|
const size = options.batch ?? DEFAULT_BATCH;
|
|
41
66
|
const out = [];
|
|
42
67
|
for (let at = 0; at < entities.length; at += size) {
|
|
@@ -50,6 +75,7 @@ async function embedAll(entities, options) {
|
|
|
50
75
|
throw new Error(`${options.embedder.id} answered ${response.vectors.length} vectors for ${slice.length} texts`);
|
|
51
76
|
}
|
|
52
77
|
out.push(...response.vectors.map((v) => Float32Array.from(v)));
|
|
78
|
+
journal.progress(out.length, entities.length);
|
|
53
79
|
options.onProgress?.(out.length, entities.length);
|
|
54
80
|
}
|
|
55
81
|
return out;
|
|
@@ -59,12 +85,14 @@ async function embedAll(entities, options) {
|
|
|
59
85
|
* never named — an inline request body, say — is credited to the file it came
|
|
60
86
|
* from instead of going missing.
|
|
61
87
|
*/
|
|
62
|
-
function sourcesOf(corpus, entities,
|
|
63
|
-
return corpus.docs.map((doc
|
|
88
|
+
function sourcesOf(corpus, entities, keep) {
|
|
89
|
+
return corpus.docs.map((doc) => {
|
|
64
90
|
const mine = entities.filter((e) => e.source === doc.source);
|
|
65
91
|
const operations = corpus.operations.filter((op) => op.source === doc.source);
|
|
66
92
|
return {
|
|
67
|
-
|
|
93
|
+
name: doc.source,
|
|
94
|
+
file: doc.file,
|
|
95
|
+
path: keep ? `${SOURCES_DIR}/${doc.source}.json` : undefined,
|
|
68
96
|
sha256: doc.sha256,
|
|
69
97
|
dialect: doc.dialect,
|
|
70
98
|
title: doc.title,
|
package/dist/schema/files.d.ts
CHANGED
|
@@ -1,14 +1,20 @@
|
|
|
1
1
|
import type { ApiGraph } from './graph.ts';
|
|
2
2
|
import type { Schema } from './schema.ts';
|
|
3
3
|
import type { Operation } from './spec.ts';
|
|
4
|
-
export declare const INDEX_VERSION =
|
|
4
|
+
export declare const INDEX_VERSION = 3;
|
|
5
5
|
export declare const MANIFEST_FILE = "manifest.json";
|
|
6
6
|
export declare const GRAPH_FILE = "graph.json";
|
|
7
7
|
export declare const SCHEMAS_FILE = "schemas.json";
|
|
8
8
|
export declare const OPERATIONS_FILE = "operations.json";
|
|
9
9
|
export declare const LANCE_DIR = "lance";
|
|
10
|
+
export declare const SOURCES_DIR = "sources";
|
|
10
11
|
export interface SourceRecord {
|
|
11
|
-
|
|
12
|
+
/** the document's name within the index: what every entity's `source` says */
|
|
13
|
+
name: string;
|
|
14
|
+
/** what the file was called on the machine that built this */
|
|
15
|
+
file: string;
|
|
16
|
+
/** the bundled copy, relative to the index; absent when none was kept */
|
|
17
|
+
path?: string;
|
|
12
18
|
sha256: string;
|
|
13
19
|
dialect: string;
|
|
14
20
|
title: string;
|
|
@@ -47,6 +53,8 @@ export interface WrittenIndex {
|
|
|
47
53
|
graph: ApiGraph;
|
|
48
54
|
types: Readonly<Record<string, Schema>>;
|
|
49
55
|
operations: readonly Operation[];
|
|
56
|
+
/** the bundled documents to keep beside the index, by name */
|
|
57
|
+
documents: Readonly<Record<string, string>>;
|
|
50
58
|
}
|
|
51
59
|
export interface OpenIndex {
|
|
52
60
|
dir: string;
|
package/dist/schema/files.js
CHANGED
|
@@ -1,32 +1,44 @@
|
|
|
1
|
+
import { CliError, EXIT } from '@zenera/cli/lib';
|
|
1
2
|
import { MultiDirectedGraph } from 'graphology';
|
|
2
3
|
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
3
4
|
import { join } from 'node:path';
|
|
4
|
-
import { CliError, EXIT } from '@zenera/cli/lib';
|
|
5
5
|
// ---------------------------------------------------------------------------
|
|
6
6
|
// What an index is, on disk
|
|
7
7
|
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
// operations.
|
|
8
|
+
// A directory, and the order it is written in is the whole crash story: the
|
|
9
|
+
// manifest goes last, so a half-built index has no manifest and reads as "not
|
|
10
|
+
// indexed" rather than as a store that quietly lost half its operations.
|
|
12
11
|
//
|
|
13
12
|
// `graph.json` is deliberately thin — names, directions, edges — because it is
|
|
14
13
|
// parsed whole on every search. The schemas are the bulk of the bytes and are
|
|
15
14
|
// wanted only when something is being printed, so they live apart and are read
|
|
16
15
|
// on first use.
|
|
16
|
+
//
|
|
17
|
+
// `sources/` holds the documents themselves, bundled, so the index is one
|
|
18
|
+
// portable thing: nothing in it names a path outside itself, it can be moved or
|
|
19
|
+
// shipped whole, and the graph can be rebuilt — or re-embedded with another
|
|
20
|
+
// model — without going looking for the files it was made from.
|
|
17
21
|
// ---------------------------------------------------------------------------
|
|
18
|
-
export const INDEX_VERSION =
|
|
22
|
+
export const INDEX_VERSION = 3;
|
|
19
23
|
export const MANIFEST_FILE = 'manifest.json';
|
|
20
24
|
export const GRAPH_FILE = 'graph.json';
|
|
21
25
|
export const SCHEMAS_FILE = 'schemas.json';
|
|
22
26
|
export const OPERATIONS_FILE = 'operations.json';
|
|
23
27
|
export const LANCE_DIR = 'lance';
|
|
28
|
+
export const SOURCES_DIR = 'sources';
|
|
24
29
|
export const lancePath = (dir) => join(dir, LANCE_DIR);
|
|
25
30
|
export async function writeIndex(dir, index) {
|
|
26
31
|
await mkdir(dir, { recursive: true });
|
|
27
32
|
await writeFile(join(dir, GRAPH_FILE), JSON.stringify(index.graph.export()));
|
|
28
33
|
await writeFile(join(dir, SCHEMAS_FILE), JSON.stringify(index.types));
|
|
29
34
|
await writeFile(join(dir, OPERATIONS_FILE), JSON.stringify(index.operations));
|
|
35
|
+
const documents = Object.entries(index.documents);
|
|
36
|
+
if (documents.length > 0) {
|
|
37
|
+
await mkdir(join(dir, SOURCES_DIR), { recursive: true });
|
|
38
|
+
for (const [name, text] of documents) {
|
|
39
|
+
await writeFile(join(dir, SOURCES_DIR, `${name}.json`), text);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
30
42
|
await writeFile(join(dir, MANIFEST_FILE), `${JSON.stringify(index.manifest, null, 4)}\n`);
|
|
31
43
|
}
|
|
32
44
|
export async function openIndex(dir) {
|
|
@@ -58,7 +70,7 @@ export async function readManifest(dir) {
|
|
|
58
70
|
}
|
|
59
71
|
const manifest = JSON.parse(text);
|
|
60
72
|
if (manifest.version !== INDEX_VERSION) {
|
|
61
|
-
throw new CliError(`${dir}
|
|
73
|
+
throw new CliError(`${dir} is a version ${manifest.version} index, and this indexer reads version ${INDEX_VERSION}`, EXIT.invalid, 'rebuild it with `zen rag schema index`');
|
|
62
74
|
}
|
|
63
75
|
return manifest;
|
|
64
76
|
}
|
|
@@ -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/dist/schema/spec.d.ts
CHANGED
|
@@ -36,7 +36,10 @@ export interface Operation {
|
|
|
36
36
|
responses: ResponseSpec[];
|
|
37
37
|
}
|
|
38
38
|
export interface ApiDoc {
|
|
39
|
+
/** the document's name within this index, unique in the corpus */
|
|
39
40
|
source: string;
|
|
41
|
+
/** what the file was called on the machine that read it */
|
|
42
|
+
file: string;
|
|
40
43
|
sha256: string;
|
|
41
44
|
dialect: Dialect;
|
|
42
45
|
title: string;
|
|
@@ -49,6 +52,8 @@ export interface Corpus {
|
|
|
49
52
|
types: Record<string, Schema>;
|
|
50
53
|
/** which document each type id came from */
|
|
51
54
|
typeSource: Record<string, string>;
|
|
55
|
+
/** the bundled document behind each `source`, as JSON text */
|
|
56
|
+
documents: Record<string, string>;
|
|
52
57
|
}
|
|
53
58
|
/** A `CliError` so an unreadable document exits 3 wherever it is raised. */
|
|
54
59
|
export declare class SpecError extends CliError {
|
package/dist/schema/spec.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import SwaggerParser from '@apidevtools/swagger-parser';
|
|
2
|
+
import { CliError, EXIT } from '@zenera/cli/lib';
|
|
2
3
|
import { createHash } from 'node:crypto';
|
|
3
4
|
import { basename, extname } from 'node:path';
|
|
4
|
-
import { CliError, EXIT } from '@zenera/cli/lib';
|
|
5
5
|
import { docOf, isObject, normalize } from "./schema.js";
|
|
6
6
|
// ---------------------------------------------------------------------------
|
|
7
7
|
// Documents, flattened — but not dereferenced
|
|
@@ -38,12 +38,26 @@ export async function loadSpecs(files) {
|
|
|
38
38
|
throw new SpecError('no document given', 'name at least one openapi/swagger file');
|
|
39
39
|
}
|
|
40
40
|
const loaded = [];
|
|
41
|
+
const taken = new Set();
|
|
41
42
|
for (const file of files) {
|
|
42
|
-
loaded.push(await loadSpec(file));
|
|
43
|
+
loaded.push(await loadSpec(file, distinct(slugOf(file), taken)));
|
|
43
44
|
}
|
|
44
45
|
return settle(loaded);
|
|
45
46
|
}
|
|
46
|
-
|
|
47
|
+
/**
|
|
48
|
+
* The slug is the document's identity everywhere below this file — the node
|
|
49
|
+
* attribute, the store column, the name its copy is written under — so two
|
|
50
|
+
* `api.yaml`s in different directories must not answer to the same word.
|
|
51
|
+
*/
|
|
52
|
+
function distinct(slug, taken) {
|
|
53
|
+
let candidate = slug;
|
|
54
|
+
for (let n = 2; taken.has(candidate); n++) {
|
|
55
|
+
candidate = `${slug}_${n}`;
|
|
56
|
+
}
|
|
57
|
+
taken.add(candidate);
|
|
58
|
+
return candidate;
|
|
59
|
+
}
|
|
60
|
+
async function loadSpec(file, slug) {
|
|
47
61
|
let raw;
|
|
48
62
|
try {
|
|
49
63
|
raw = (await SwaggerParser.bundle(file));
|
|
@@ -51,6 +65,8 @@ async function loadSpec(file) {
|
|
|
51
65
|
catch (err) {
|
|
52
66
|
throw new SpecError(`${file}: ${err instanceof Error ? err.message.split('\n')[0] : String(err)}`, 'the document must be a readable OpenAPI 3.x or Swagger 2.0 file');
|
|
53
67
|
}
|
|
68
|
+
// `bundle` has resolved every external `$ref`, so this text stands alone.
|
|
69
|
+
const document = JSON.stringify(raw, null, 2);
|
|
54
70
|
const dialect = dialectOf(raw, file);
|
|
55
71
|
const source = raw.components?.schemas ?? raw.definitions ?? {};
|
|
56
72
|
const types = new Map();
|
|
@@ -59,15 +75,17 @@ async function loadSpec(file) {
|
|
|
59
75
|
}
|
|
60
76
|
return {
|
|
61
77
|
doc: {
|
|
62
|
-
source:
|
|
63
|
-
|
|
78
|
+
source: slug,
|
|
79
|
+
file: basename(file),
|
|
80
|
+
sha256: createHash('sha256').update(document).digest('hex'),
|
|
64
81
|
dialect,
|
|
65
82
|
title: raw.info?.title?.trim() || basename(file),
|
|
66
83
|
version: raw.info?.version?.trim() || '',
|
|
67
84
|
},
|
|
68
|
-
slug
|
|
69
|
-
operations: operationsOf(raw, dialect,
|
|
85
|
+
slug,
|
|
86
|
+
operations: operationsOf(raw, dialect, slug),
|
|
70
87
|
types,
|
|
88
|
+
document,
|
|
71
89
|
};
|
|
72
90
|
}
|
|
73
91
|
function dialectOf(doc, file) {
|
|
@@ -89,7 +107,7 @@ function slugOf(file) {
|
|
|
89
107
|
// ---------------------------------------------------------------------------
|
|
90
108
|
// Operations
|
|
91
109
|
// ---------------------------------------------------------------------------
|
|
92
|
-
function operationsOf(raw, dialect,
|
|
110
|
+
function operationsOf(raw, dialect, source) {
|
|
93
111
|
const prefix = dialect === 'swagger-2.0' ? (raw.basePath ?? '') : '';
|
|
94
112
|
const out = [];
|
|
95
113
|
for (const [template, item] of Object.entries(raw.paths ?? {})) {
|
|
@@ -107,7 +125,7 @@ function operationsOf(raw, dialect, file) {
|
|
|
107
125
|
const path = join(prefix, template);
|
|
108
126
|
const own = paramsOf(op.parameters, dialect);
|
|
109
127
|
out.push({
|
|
110
|
-
source
|
|
128
|
+
source,
|
|
111
129
|
method,
|
|
112
130
|
path,
|
|
113
131
|
operationId: text(op.operationId) || synthesizeId(method, path),
|
|
@@ -275,7 +293,13 @@ function settle(loaded) {
|
|
|
275
293
|
});
|
|
276
294
|
}
|
|
277
295
|
});
|
|
278
|
-
return {
|
|
296
|
+
return {
|
|
297
|
+
docs: loaded.map((one) => one.doc),
|
|
298
|
+
operations,
|
|
299
|
+
types,
|
|
300
|
+
typeSource,
|
|
301
|
+
documents: Object.fromEntries(loaded.map((one) => [one.slug, one.document])),
|
|
302
|
+
};
|
|
279
303
|
}
|
|
280
304
|
function unique(id, slug, seen) {
|
|
281
305
|
let candidate = seen.has(id) ? `${slug}.${id}` : id;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zenera/rag",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.4",
|
|
4
4
|
"description": "Retrieval over API descriptions: openapi/swagger documents as a searchable graph.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"agents",
|
|
@@ -53,7 +53,7 @@
|
|
|
53
53
|
"@apidevtools/swagger-parser": "^12.0.0",
|
|
54
54
|
"@lancedb/lancedb": "^0.38.0",
|
|
55
55
|
"graphology": "^0.26.0",
|
|
56
|
-
"@zenera/cli": "^1.1.
|
|
57
|
-
"@zenera/neo": "^1.1.
|
|
56
|
+
"@zenera/cli": "^1.1.4",
|
|
57
|
+
"@zenera/neo": "^1.1.4"
|
|
58
58
|
}
|
|
59
59
|
}
|