@zenera/rag 1.1.4 → 1.1.5
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 +41 -4
- package/dist/command.js +273 -16
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/schema/entities.d.ts +2 -0
- package/dist/schema/entities.js +2 -1
- package/dist/schema/files.d.ts +7 -0
- package/dist/schema/files.js +17 -0
- package/dist/schema/lookup.d.ts +44 -0
- package/dist/schema/lookup.js +83 -0
- package/dist/schema/match.d.ts +36 -0
- package/dist/schema/match.js +85 -0
- package/dist/schema/subgraph.d.ts +6 -0
- package/dist/schema/subgraph.js +27 -0
- package/dist/schema/tools.js +140 -28
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -46,6 +46,24 @@ zen rag schema stats # what is in the index, and what built it
|
|
|
46
46
|
zen rag schema show Type:Invoice # a named node, with no search in between
|
|
47
47
|
```
|
|
48
48
|
|
|
49
|
+
Search ranks, which means it returns the top of a list — useful when the
|
|
50
|
+
question is vague, and no use at all when the question is whether something
|
|
51
|
+
exists. For that there is exact matching, which needs no embedder, no
|
|
52
|
+
credential and no network:
|
|
53
|
+
|
|
54
|
+
```sh
|
|
55
|
+
zen rag schema list methods --path "*/users*" # every route under /users
|
|
56
|
+
zen rag schema list types --name "*Password*" # every schema so named
|
|
57
|
+
zen rag schema grep password # every literal occurrence
|
|
58
|
+
zen rag schema grep "pass(word|phrase)" --regex
|
|
59
|
+
zen rag schema show --method GetCurrentUserInfo --format openapi --exact
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
`list` and `grep` report `found` as the true total even when `--limit` cuts the
|
|
63
|
+
printed rows, so a shortened answer still tells you how much there is. Nothing
|
|
64
|
+
matching exits 0 — an empty answer is an answer, and here it is a trustworthy
|
|
65
|
+
one: if `grep` finds nothing, the word is not in the description.
|
|
66
|
+
|
|
49
67
|
Non-interactive search is a machine interface: every field is a flag, the whole
|
|
50
68
|
query can arrive as one JSON object, the `--json` output keeps the same
|
|
51
69
|
structure from run to run, no terminal is required, and an empty result exits 0.
|
|
@@ -66,7 +84,9 @@ JSON
|
|
|
66
84
|
```
|
|
67
85
|
zen rag schema index <spec...> Read the documents and write a searchable index.
|
|
68
86
|
zen rag schema search Ask it something. --interactive for a prompt.
|
|
69
|
-
zen rag schema
|
|
87
|
+
zen rag schema list <what> Every method, type or property. No ranking.
|
|
88
|
+
zen rag schema grep <pattern> Every literal match across the whole index.
|
|
89
|
+
zen rag schema show [id...] Print named nodes, with no search in between.
|
|
70
90
|
zen rag schema stats What is in an index, and what built it.
|
|
71
91
|
```
|
|
72
92
|
|
|
@@ -76,6 +96,18 @@ by `--direction`, `--method-type`, `--limit`, `--max-hops`, `--max-nodes` and
|
|
|
76
96
|
the four `--exclude-*` filters, and rendered by `--format text | mermaid |
|
|
77
97
|
mermaid-flowchart | ts | openapi`. `zen help rag` prints the full table.
|
|
78
98
|
|
|
99
|
+
`list` takes `--name` and `--path`, `grep` takes `--regex`, `--case-sensitive`,
|
|
100
|
+
`--kind` and `--ids-only`. A pattern with `*` or `?` in it is a glob matched
|
|
101
|
+
against the whole name; a plain word is a substring, so `--name password` finds
|
|
102
|
+
`ResetPasswordPayload` rather than nothing. `show` takes ids, or `--method` and
|
|
103
|
+
`--type` by name, or `--source` for a whole document, and `--exact` to print
|
|
104
|
+
only what was named instead of its neighbourhood.
|
|
105
|
+
|
|
106
|
+
```sh
|
|
107
|
+
# Everything that mentions a token, rendered as TypeScript.
|
|
108
|
+
zen rag schema grep token --ids-only | xargs zen rag schema show --format ts
|
|
109
|
+
```
|
|
110
|
+
|
|
79
111
|
## From an agent
|
|
80
112
|
|
|
81
113
|
```ts
|
|
@@ -89,18 +121,23 @@ const index = await SchemaIndex.open(
|
|
|
89
121
|
const project = await loadProject('./my-project', { tools: schemaTools(index) });
|
|
90
122
|
```
|
|
91
123
|
|
|
92
|
-
|
|
124
|
+
Five tools in the group `schema`, selectable as `schema:*`:
|
|
93
125
|
|
|
94
126
|
| Tool | For |
|
|
95
127
|
| -------------------------- | ------------------------------------------------------- |
|
|
96
128
|
| `search_api` | the connected piece of the API that matches an intent |
|
|
97
129
|
| `describe_types` | named schemas as declarations that compile on their own |
|
|
98
130
|
| `find_types_with_property` | which types have a field of this name — no search |
|
|
99
|
-
| `
|
|
131
|
+
| `list_api` | the shape of the API: methods, types or fields |
|
|
132
|
+
| `grep_api` | every literal occurrence of a string — no search |
|
|
100
133
|
|
|
101
|
-
|
|
134
|
+
Only the first of those ranks anything. The rest are exact, because a model
|
|
135
|
+
told "no results" by a vector search has learned nothing: a ranking returns the
|
|
136
|
+
top of a list, so an empty answer and an absent thing look identical.
|
|
137
|
+
`find_types_with_property` is the one for the repair loop — when `tsc` says
|
|
102
138
|
`'password' does not exist in type 'PublicUserProfile'`, the model does not
|
|
103
139
|
need the word explained again, it needs the list of types that have one.
|
|
140
|
+
`grep_api` is the same instinct widened to the whole description.
|
|
104
141
|
|
|
105
142
|
## What an index is
|
|
106
143
|
|
package/dist/command.js
CHANGED
|
@@ -5,9 +5,11 @@ import { isFormat, present } from "./present.js";
|
|
|
5
5
|
import { isEmpty, parseQuery, QueryError } from "./query.js";
|
|
6
6
|
import { repl } from "./repl.js";
|
|
7
7
|
import { buildIndex } from "./schema/build.js";
|
|
8
|
-
import { assertSameEmbedding, openIndex, readManifest } from "./schema/files.js";
|
|
8
|
+
import { assertSameEmbedding, openIndex, readManifest, readSource, } from "./schema/files.js";
|
|
9
|
+
import { fields, grepNodes, listNodes, propertyCount } from "./schema/lookup.js";
|
|
10
|
+
import { isGlob, loose, matcher, PatternError, wildcard } from "./schema/match.js";
|
|
9
11
|
import { SchemaIndex } from "./schema/search.js";
|
|
10
|
-
import { stitch } from "./schema/subgraph.js";
|
|
12
|
+
import { select, stitch } from "./schema/subgraph.js";
|
|
11
13
|
// ---------------------------------------------------------------------------
|
|
12
14
|
// zen rag — an api description, as something to search
|
|
13
15
|
//
|
|
@@ -18,10 +20,19 @@ import { stitch } from "./schema/subgraph.js";
|
|
|
18
20
|
// required, and exit 0 when nothing matched — an empty answer is an answer, and
|
|
19
21
|
// a caller that has to tell "no results" from "the index is missing" by parsing
|
|
20
22
|
// stderr will get it wrong.
|
|
23
|
+
//
|
|
24
|
+
// `list`, `grep` and `show` are the other half, and they are deliberately not
|
|
25
|
+
// searches. A ranking can only ever hand back the top of a list, so it cannot
|
|
26
|
+
// answer "is there a field called `password` anywhere" — the honest answer to
|
|
27
|
+
// that question is every match or none, and these three give it without asking
|
|
28
|
+
// a model or a credential for permission.
|
|
21
29
|
// ---------------------------------------------------------------------------
|
|
22
|
-
const USAGE = 'zen rag schema <index|search|show|stats> [spec...]';
|
|
30
|
+
const USAGE = 'zen rag schema <index|search|list|grep|show|stats> [spec...]';
|
|
23
31
|
const INDEX_USAGE = 'zen rag schema index --embedding <ref> [--out <dir>] <spec...>';
|
|
24
32
|
const SEARCH_USAGE = 'zen rag schema search [--dir <dir>] [query...]';
|
|
33
|
+
const LIST_USAGE = 'zen rag schema list <methods|types|properties> [--dir <dir>]';
|
|
34
|
+
const GREP_USAGE = 'zen rag schema grep <pattern> [--dir <dir>]';
|
|
35
|
+
const SHOW_USAGE = 'zen rag schema show [id...] [--method <name>] [--type <name>]';
|
|
25
36
|
const DEFAULT_DIR = './schema-db';
|
|
26
37
|
export const command = {
|
|
27
38
|
summary: 'Search an openapi/swagger document as a graph.',
|
|
@@ -31,7 +42,9 @@ export const command = {
|
|
|
31
42
|
...table([
|
|
32
43
|
[' index <spec...>', dim('Read the documents and write a searchable index.')],
|
|
33
44
|
[' search', dim('Ask it something. --interactive for a prompt.')],
|
|
34
|
-
['
|
|
45
|
+
[' list <what>', dim('Every method, type or property matching a pattern.')],
|
|
46
|
+
[' grep <pattern>', dim('Every literal match, ranked by nothing.')],
|
|
47
|
+
[' show [id...]', dim('Print named nodes, with no search in between.')],
|
|
35
48
|
[' stats', dim('What is in an index, and what built it.')],
|
|
36
49
|
]),
|
|
37
50
|
'',
|
|
@@ -82,6 +95,31 @@ export const command = {
|
|
|
82
95
|
[' --quiet', dim('No narration.')],
|
|
83
96
|
]),
|
|
84
97
|
'',
|
|
98
|
+
'Exact listing — no embedder, no credential',
|
|
99
|
+
...table([
|
|
100
|
+
[' list methods', dim('Operations. Filter with --path and --name.')],
|
|
101
|
+
[' list types', dim('Schemas. Filter with --name.')],
|
|
102
|
+
[' list properties', dim('Fields and parameters. Filter with --name.')],
|
|
103
|
+
[' grep <pattern>', dim('Substring over every node; --regex for a regex.')],
|
|
104
|
+
[' --case-sensitive', dim('grep: match the capitals too.')],
|
|
105
|
+
[' --kind <k>', dim('grep: method | type | property. Repeatable.')],
|
|
106
|
+
[' --ids-only', dim('grep: bare ids, to pipe into show.')],
|
|
107
|
+
[' --source <name>', dim('Only this document, as `stats` names it.')],
|
|
108
|
+
[' --limit <n>', dim('Keep at most n; the count still reports them all.')],
|
|
109
|
+
]),
|
|
110
|
+
'',
|
|
111
|
+
dim(' A pattern with * or ? is a glob over the whole name; otherwise it is'),
|
|
112
|
+
dim(' a substring, so --name password finds ResetPasswordPayload.'),
|
|
113
|
+
'',
|
|
114
|
+
'Show',
|
|
115
|
+
...table([
|
|
116
|
+
[' <id...>', dim('Node ids, e.g. Type:User or Property:User.email.')],
|
|
117
|
+
[' --method <name>', dim('An operation by name. * to take more. Repeatable.')],
|
|
118
|
+
[' --type <name>', dim('A schema by name. * to take more. Repeatable.')],
|
|
119
|
+
[' --source <name>', dim('A whole document, as it was indexed.')],
|
|
120
|
+
[' --exact', dim('Only what was named, without the neighbours.')],
|
|
121
|
+
]),
|
|
122
|
+
'',
|
|
85
123
|
dim(`Credentials come from the ${cyan('zen')} keyring — try ${cyan('zen key ls')}.`),
|
|
86
124
|
],
|
|
87
125
|
async run(ctx) {
|
|
@@ -94,6 +132,10 @@ export const command = {
|
|
|
94
132
|
return await index(tail, ctx);
|
|
95
133
|
case 'search':
|
|
96
134
|
return await search(tail, ctx);
|
|
135
|
+
case 'list':
|
|
136
|
+
return await list(tail, ctx);
|
|
137
|
+
case 'grep':
|
|
138
|
+
return await grep(tail, ctx);
|
|
97
139
|
case 'show':
|
|
98
140
|
return await show(tail, ctx);
|
|
99
141
|
case 'stats':
|
|
@@ -317,33 +359,248 @@ function check(value) {
|
|
|
317
359
|
}
|
|
318
360
|
}
|
|
319
361
|
// ---------------------------------------------------------------------------
|
|
320
|
-
//
|
|
362
|
+
// list, grep
|
|
363
|
+
//
|
|
364
|
+
// The deterministic half. Neither takes an embedder, because neither ranks
|
|
365
|
+
// anything: `list` filters on the attributes a node already has and `grep`
|
|
366
|
+
// reads the same materialized string the index was built from. What comes back
|
|
367
|
+
// is every match, and where a limit cut the list the count still reports the
|
|
368
|
+
// total — being shown three of three hundred is only useful if you are told
|
|
369
|
+
// which of the two happened.
|
|
370
|
+
// ---------------------------------------------------------------------------
|
|
371
|
+
const SUBJECTS = {
|
|
372
|
+
methods: 'method',
|
|
373
|
+
types: 'type',
|
|
374
|
+
properties: 'property',
|
|
375
|
+
};
|
|
376
|
+
async function list(args, ctx) {
|
|
377
|
+
const { values, positionals } = parse(args, {
|
|
378
|
+
dir: { type: 'string', short: 'd' },
|
|
379
|
+
name: MANY,
|
|
380
|
+
path: MANY,
|
|
381
|
+
source: { type: 'string' },
|
|
382
|
+
'method-type': { type: 'string' },
|
|
383
|
+
direction: { type: 'string' },
|
|
384
|
+
limit: { type: 'string' },
|
|
385
|
+
quiet: { type: 'boolean' },
|
|
386
|
+
}, LIST_USAGE);
|
|
387
|
+
const subject = positionals[0];
|
|
388
|
+
const kind = subject ? SUBJECTS[subject] : undefined;
|
|
389
|
+
if (!kind) {
|
|
390
|
+
throw usageError(subject ? `cannot list "${subject}"` : 'nothing named to list', `expected one of ${Object.keys(SUBJECTS).join(', ')}`);
|
|
391
|
+
}
|
|
392
|
+
if (positionals.length > 1) {
|
|
393
|
+
throw usageError('one subject at a time', LIST_USAGE);
|
|
394
|
+
}
|
|
395
|
+
const index = await openIndex(resolve(ctx.cwd, values.dir ?? DEFAULT_DIR));
|
|
396
|
+
const found = listNodes(index.graph, {
|
|
397
|
+
kind,
|
|
398
|
+
name: globs(values.name, '--name'),
|
|
399
|
+
path: globs(values.path, '--path'),
|
|
400
|
+
source: values.source,
|
|
401
|
+
methodType: oneOf(values['method-type'], ['read_only', 'read_write'], '--method-type'),
|
|
402
|
+
direction: oneOf(values.direction, ['input', 'output'], '--direction'),
|
|
403
|
+
limit: values.limit ? count(values.limit, '--limit') : undefined,
|
|
404
|
+
});
|
|
405
|
+
if (ctx.json) {
|
|
406
|
+
json({ found: found.found, truncated: found.truncated, rows: found.rows });
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
const lines = rowLines(index.graph, kind, found.rows);
|
|
410
|
+
if (lines.length > 0) {
|
|
411
|
+
write(lines.join('\n'));
|
|
412
|
+
}
|
|
413
|
+
if (!values.quiet) {
|
|
414
|
+
note(dim(` ${found.found} ${subject}${shown(found.found, found.rows.length)}`));
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
function rowLines(graph, kind, rows) {
|
|
418
|
+
if (kind === 'method') {
|
|
419
|
+
return table(rows.map((r) => [`${r.httpMethod} ${r.path}`, r.name, doc(r.doc)]));
|
|
420
|
+
}
|
|
421
|
+
if (kind === 'type') {
|
|
422
|
+
return table(rows.map((r) => [
|
|
423
|
+
r.name,
|
|
424
|
+
dim(fields(propertyCount(graph, r.id))),
|
|
425
|
+
dim(r.direction === 'none' ? '' : `(${r.direction})`),
|
|
426
|
+
doc(r.doc),
|
|
427
|
+
]));
|
|
428
|
+
}
|
|
429
|
+
return table(rows.map((r) => [
|
|
430
|
+
`${r.parent ? `${r.parent}.` : ''}${r.name}${r.required ? '' : '?'}`,
|
|
431
|
+
`: ${r.signature || 'unknown'}`,
|
|
432
|
+
doc(r.doc),
|
|
433
|
+
]));
|
|
434
|
+
}
|
|
435
|
+
async function grep(args, ctx) {
|
|
436
|
+
const { values, positionals } = parse(args, {
|
|
437
|
+
dir: { type: 'string', short: 'd' },
|
|
438
|
+
regex: { type: 'boolean' },
|
|
439
|
+
'case-sensitive': { type: 'boolean' },
|
|
440
|
+
kind: MANY,
|
|
441
|
+
source: { type: 'string' },
|
|
442
|
+
limit: { type: 'string' },
|
|
443
|
+
'ids-only': { type: 'boolean' },
|
|
444
|
+
quiet: { type: 'boolean' },
|
|
445
|
+
}, GREP_USAGE);
|
|
446
|
+
if (positionals.length === 0) {
|
|
447
|
+
throw usageError('no pattern given', GREP_USAGE);
|
|
448
|
+
}
|
|
449
|
+
if (positionals.length > 1) {
|
|
450
|
+
throw usageError('one pattern at a time — quote it if it has spaces', GREP_USAGE);
|
|
451
|
+
}
|
|
452
|
+
const kinds = (values.kind ?? []).map((k) => oneOf(k, ['method', 'type', 'property'], '--kind'));
|
|
453
|
+
const index = await openIndex(resolve(ctx.cwd, values.dir ?? DEFAULT_DIR));
|
|
454
|
+
const result = pattern(() => grepNodes(index.graph, matcher(positionals[0], {
|
|
455
|
+
regex: values.regex,
|
|
456
|
+
caseSensitive: values['case-sensitive'],
|
|
457
|
+
}), {
|
|
458
|
+
kinds,
|
|
459
|
+
source: values.source,
|
|
460
|
+
limit: values.limit ? count(values.limit, '--limit') : undefined,
|
|
461
|
+
}));
|
|
462
|
+
if (ctx.json) {
|
|
463
|
+
json({
|
|
464
|
+
found: result.found,
|
|
465
|
+
truncated: result.truncated,
|
|
466
|
+
matches: result.matches.map((m) => ({ id: m.id, ...m.attributes, text: m.text })),
|
|
467
|
+
});
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
if (result.matches.length > 0) {
|
|
471
|
+
const lines = values['ids-only']
|
|
472
|
+
? result.matches.map((m) => m.id)
|
|
473
|
+
: table(result.matches.map((m) => [m.id, dim(clip(m.text, 140))]));
|
|
474
|
+
write(lines.join('\n'));
|
|
475
|
+
}
|
|
476
|
+
if (!values.quiet && !values['ids-only']) {
|
|
477
|
+
note(dim(` ${result.found} match(es)${shown(result.found, result.matches.length)}`));
|
|
478
|
+
}
|
|
479
|
+
}
|
|
321
480
|
// ---------------------------------------------------------------------------
|
|
481
|
+
const shown = (found, kept) => (kept < found ? `, showing ${kept}` : '');
|
|
482
|
+
function globs(patterns, flag) {
|
|
483
|
+
if (!patterns || patterns.length === 0) {
|
|
484
|
+
return undefined;
|
|
485
|
+
}
|
|
486
|
+
return patterns.map((p) => pattern(() => loose(p), flag));
|
|
487
|
+
}
|
|
488
|
+
/** A bad pattern is a bad invocation, not a failure of the index. */
|
|
489
|
+
function pattern(run, flag) {
|
|
490
|
+
try {
|
|
491
|
+
return run();
|
|
492
|
+
}
|
|
493
|
+
catch (err) {
|
|
494
|
+
if (err instanceof PatternError) {
|
|
495
|
+
throw usageError(`${flag ? `${flag}: ` : ''}${err.message}`, USAGE);
|
|
496
|
+
}
|
|
497
|
+
throw err;
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
function oneOf(value, allowed, flag) {
|
|
501
|
+
if (value === undefined || value === 'any') {
|
|
502
|
+
return undefined;
|
|
503
|
+
}
|
|
504
|
+
if (!allowed.includes(value)) {
|
|
505
|
+
throw usageError(`${flag} cannot be "${value}"`, `expected ${allowed.join(' or ')}`);
|
|
506
|
+
}
|
|
507
|
+
return value;
|
|
508
|
+
}
|
|
509
|
+
const doc = (text) => (text ? dim(`— ${clip(text.replace(/\s+/g, ' '), 90)}`) : '');
|
|
510
|
+
const clip = (text, max) => text.length <= max ? text : `${text.slice(0, max - 1)}…`;
|
|
322
511
|
/**
|
|
323
512
|
* No embedder and no store: naming a node is a graph lookup, and asking for a
|
|
324
513
|
* credential to print something already on disk would be theatre.
|
|
514
|
+
*
|
|
515
|
+
* Ids are the precise way in, and `--method`/`--type` are the way in for
|
|
516
|
+
* someone who has a name rather than an id — which, with `--format openapi
|
|
517
|
+
* --exact`, is how a resolved slice of the document is got out.
|
|
325
518
|
*/
|
|
326
519
|
async function show(args, ctx) {
|
|
327
|
-
const
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
520
|
+
const { values, positionals } = parse(args, {
|
|
521
|
+
dir: { type: 'string', short: 'd' },
|
|
522
|
+
method: MANY,
|
|
523
|
+
type: MANY,
|
|
524
|
+
source: { type: 'string' },
|
|
525
|
+
exact: { type: 'boolean' },
|
|
526
|
+
format: { type: 'string' },
|
|
527
|
+
'max-nodes': { type: 'string' },
|
|
528
|
+
'no-docs': { type: 'boolean' },
|
|
529
|
+
quiet: { type: 'boolean' },
|
|
530
|
+
}, SHOW_USAGE);
|
|
332
531
|
const format = formatOf(values.format);
|
|
333
|
-
const
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
532
|
+
const dir = resolve(ctx.cwd, values.dir ?? DEFAULT_DIR);
|
|
533
|
+
// A whole document, verbatim: the copy kept at index time is the resolved
|
|
534
|
+
// original, and anything rebuilt from the graph would be a paraphrase.
|
|
535
|
+
if (values.source && format === 'openapi' && positionals.length === 0 && !named(values)) {
|
|
536
|
+
const document = await readSource(dir, values.source);
|
|
537
|
+
if (document) {
|
|
538
|
+
write(document);
|
|
539
|
+
return;
|
|
540
|
+
}
|
|
541
|
+
if (!values.quiet) {
|
|
542
|
+
note(dim(' this index kept no copy of the documents — rebuilding it from the graph'));
|
|
543
|
+
}
|
|
337
544
|
}
|
|
338
|
-
const
|
|
545
|
+
const index = await openIndex(dir);
|
|
546
|
+
const ids = resolveIds(index.graph, positionals, values);
|
|
547
|
+
const subgraphs = values.exact
|
|
548
|
+
? [select(index.graph, ids)]
|
|
549
|
+
: stitch(index.graph, ids.map((id) => ({ id, term: id, field: 'show', score: 1 })), {
|
|
550
|
+
maxNodes: values['max-nodes']
|
|
551
|
+
? count(values['max-nodes'], '--max-nodes')
|
|
552
|
+
: undefined,
|
|
553
|
+
});
|
|
339
554
|
const text = await present(index, subgraphs, format, { docs: !values['no-docs'] });
|
|
340
555
|
if (ctx.json) {
|
|
341
|
-
json({ subgraphs, rendered: text });
|
|
556
|
+
json({ ids, subgraphs, rendered: text });
|
|
342
557
|
}
|
|
343
558
|
else if (text) {
|
|
344
559
|
write(text);
|
|
345
560
|
}
|
|
346
561
|
}
|
|
562
|
+
const named = (values) => Boolean(values.method?.length || values.type?.length);
|
|
563
|
+
/** Ids as given, plus whatever the name selectors resolve to. */
|
|
564
|
+
function resolveIds(graph, ids, values) {
|
|
565
|
+
if (ids.length === 0 && !named(values) && !values.source) {
|
|
566
|
+
throw usageError('no node named', SHOW_USAGE);
|
|
567
|
+
}
|
|
568
|
+
const missing = ids.filter((id) => !graph.hasNode(id));
|
|
569
|
+
if (missing.length > 0) {
|
|
570
|
+
throw new CliError(`no such node: ${missing.join(', ')}`, EXIT.failed, 'ids look like `Type:User` or `Property:User.email`');
|
|
571
|
+
}
|
|
572
|
+
const out = new Set(ids);
|
|
573
|
+
for (const kind of ['method', 'type']) {
|
|
574
|
+
for (const wanted of values[kind] ?? []) {
|
|
575
|
+
// Selecting, not searching: a bare name means that name. A star is
|
|
576
|
+
// the way to ask for more than one.
|
|
577
|
+
const match = isGlob(wanted)
|
|
578
|
+
? pattern(() => wildcard(wanted), `--${kind}`)
|
|
579
|
+
: (name) => name === wanted;
|
|
580
|
+
const rows = listNodes(graph, { kind, name: [match], source: values.source });
|
|
581
|
+
// A selector that matched nothing is a wrong answer, not an empty
|
|
582
|
+
// one: the caller named something they believe is there.
|
|
583
|
+
if (rows.found === 0) {
|
|
584
|
+
throw new CliError(`no ${kind} called ${wanted}`, EXIT.failed, `try: zen rag schema list ${kind}s --name "${wanted}"`);
|
|
585
|
+
}
|
|
586
|
+
for (const row of rows.rows) {
|
|
587
|
+
out.add(row.id);
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
// `--source` on its own means the whole document.
|
|
592
|
+
if (out.size === 0 && values.source) {
|
|
593
|
+
for (const kind of ['method', 'type']) {
|
|
594
|
+
for (const row of listNodes(graph, { kind, source: values.source }).rows) {
|
|
595
|
+
out.add(row.id);
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
if (out.size === 0) {
|
|
599
|
+
throw new CliError(`nothing in this index came from ${values.source}`, EXIT.failed);
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
return [...out];
|
|
603
|
+
}
|
|
347
604
|
async function stats(args, ctx) {
|
|
348
605
|
const { values } = parse(args, { dir: { type: 'string', short: 'd' } }, 'zen rag schema stats [--dir <dir>]');
|
|
349
606
|
const dir = resolve(ctx.cwd, values.dir ?? DEFAULT_DIR);
|
package/dist/index.d.ts
CHANGED
|
@@ -5,6 +5,8 @@ export * from './schema/entities.ts';
|
|
|
5
5
|
export * from './schema/files.ts';
|
|
6
6
|
export * from './schema/graph.ts';
|
|
7
7
|
export * from './schema/hydrate.ts';
|
|
8
|
+
export * from './schema/lookup.ts';
|
|
9
|
+
export * from './schema/match.ts';
|
|
8
10
|
export * from './schema/render.ts';
|
|
9
11
|
export * from './schema/schema.ts';
|
|
10
12
|
export * from './schema/search.ts';
|
package/dist/index.js
CHANGED
|
@@ -5,6 +5,8 @@ export * from "./schema/entities.js";
|
|
|
5
5
|
export * from "./schema/files.js";
|
|
6
6
|
export * from "./schema/graph.js";
|
|
7
7
|
export * from "./schema/hydrate.js";
|
|
8
|
+
export * from "./schema/lookup.js";
|
|
9
|
+
export * from "./schema/match.js";
|
|
8
10
|
export * from "./schema/render.js";
|
|
9
11
|
export * from "./schema/schema.js";
|
|
10
12
|
export * from "./schema/search.js";
|
|
@@ -15,4 +15,6 @@ export interface EntityRecord {
|
|
|
15
15
|
text: string;
|
|
16
16
|
}
|
|
17
17
|
export declare function toEntities(graph: ApiGraph): EntityRecord[];
|
|
18
|
+
/** Exported so a literal search reads the same string the index was built from. */
|
|
19
|
+
export declare function textOf(graph: ApiGraph, id: string): string;
|
|
18
20
|
//# sourceMappingURL=entities.d.ts.map
|
package/dist/schema/entities.js
CHANGED
|
@@ -22,7 +22,8 @@ export function toEntities(graph) {
|
|
|
22
22
|
}
|
|
23
23
|
return out.sort((a, b) => a.id.localeCompare(b.id));
|
|
24
24
|
}
|
|
25
|
-
|
|
25
|
+
/** Exported so a literal search reads the same string the index was built from. */
|
|
26
|
+
export function textOf(graph, id) {
|
|
26
27
|
const a = graph.getNodeAttributes(id);
|
|
27
28
|
const parts = [];
|
|
28
29
|
if (a.kind === 'method') {
|
package/dist/schema/files.d.ts
CHANGED
|
@@ -68,6 +68,13 @@ export interface OpenIndex {
|
|
|
68
68
|
export declare const lancePath: (dir: string) => string;
|
|
69
69
|
export declare function writeIndex(dir: string, index: WrittenIndex): Promise<void>;
|
|
70
70
|
export declare function openIndex(dir: string): Promise<OpenIndex>;
|
|
71
|
+
/**
|
|
72
|
+
* The bundled document as it was indexed. Kept only when the index was built
|
|
73
|
+
* with sources, which is why the manifest is asked first: the difference
|
|
74
|
+
* between "no such document" and "this index did not keep them" is the whole
|
|
75
|
+
* of what the caller can do next.
|
|
76
|
+
*/
|
|
77
|
+
export declare function readSource(dir: string, name: string): Promise<string | undefined>;
|
|
71
78
|
export declare function readManifest(dir: string): Promise<Manifest>;
|
|
72
79
|
/**
|
|
73
80
|
* A store answers with the neighbours of a vector, and a vector means nothing
|
package/dist/schema/files.js
CHANGED
|
@@ -60,6 +60,23 @@ function once(load) {
|
|
|
60
60
|
let pending;
|
|
61
61
|
return () => (pending ??= load());
|
|
62
62
|
}
|
|
63
|
+
/**
|
|
64
|
+
* The bundled document as it was indexed. Kept only when the index was built
|
|
65
|
+
* with sources, which is why the manifest is asked first: the difference
|
|
66
|
+
* between "no such document" and "this index did not keep them" is the whole
|
|
67
|
+
* of what the caller can do next.
|
|
68
|
+
*/
|
|
69
|
+
export async function readSource(dir, name) {
|
|
70
|
+
const manifest = await readManifest(dir);
|
|
71
|
+
const record = manifest.sources.find((s) => s.name === name);
|
|
72
|
+
if (!record) {
|
|
73
|
+
throw new CliError(`${dir} holds no document called ${name}`, EXIT.failed, `it has: ${manifest.sources.map((s) => s.name).join(', ')}`);
|
|
74
|
+
}
|
|
75
|
+
if (!record.path) {
|
|
76
|
+
return undefined;
|
|
77
|
+
}
|
|
78
|
+
return await readFile(join(dir, record.path), 'utf8');
|
|
79
|
+
}
|
|
63
80
|
export async function readManifest(dir) {
|
|
64
81
|
let text;
|
|
65
82
|
try {
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { ApiGraph, NodeAttrs, NodeKind } from './graph.ts';
|
|
2
|
+
import { type Matcher } from './match.ts';
|
|
3
|
+
export interface Row extends NodeAttrs {
|
|
4
|
+
id: string;
|
|
5
|
+
}
|
|
6
|
+
export interface ListFilter {
|
|
7
|
+
kind: NodeKind;
|
|
8
|
+
/** any one matching is enough; none means every name passes */
|
|
9
|
+
name?: readonly Matcher[];
|
|
10
|
+
path?: readonly Matcher[];
|
|
11
|
+
source?: string;
|
|
12
|
+
methodType?: string;
|
|
13
|
+
direction?: string;
|
|
14
|
+
limit?: number;
|
|
15
|
+
}
|
|
16
|
+
export interface Listing {
|
|
17
|
+
/** how many matched, whatever was kept */
|
|
18
|
+
found: number;
|
|
19
|
+
rows: Row[];
|
|
20
|
+
truncated: boolean;
|
|
21
|
+
}
|
|
22
|
+
export interface Match {
|
|
23
|
+
id: string;
|
|
24
|
+
attributes: NodeAttrs;
|
|
25
|
+
/** the indexed text this matched against */
|
|
26
|
+
text: string;
|
|
27
|
+
}
|
|
28
|
+
export interface GrepFilter {
|
|
29
|
+
kinds?: readonly string[];
|
|
30
|
+
source?: string;
|
|
31
|
+
limit?: number;
|
|
32
|
+
}
|
|
33
|
+
export interface Grep {
|
|
34
|
+
found: number;
|
|
35
|
+
matches: Match[];
|
|
36
|
+
truncated: boolean;
|
|
37
|
+
}
|
|
38
|
+
export declare function listNodes(graph: ApiGraph, filter: ListFilter): Listing;
|
|
39
|
+
export declare function grepNodes(graph: ApiGraph, match: Matcher, filter?: GrepFilter): Grep;
|
|
40
|
+
/** How many fields a type carries, which is most of what a listing wants to say. */
|
|
41
|
+
export declare function propertyCount(graph: ApiGraph, id: string): number;
|
|
42
|
+
/** That count, said properly, in the one phrasing the CLI and the tools share. */
|
|
43
|
+
export declare const fields: (n: number) => string;
|
|
44
|
+
//# sourceMappingURL=lookup.d.ts.map
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { textOf } from "./entities.js";
|
|
2
|
+
import { PatternError } from "./match.js";
|
|
3
|
+
/** A scan is bounded, because a pattern may have come from a model. */
|
|
4
|
+
const DEADLINE_MS = 2000;
|
|
5
|
+
const DEADLINE_EVERY = 500;
|
|
6
|
+
export function listNodes(graph, filter) {
|
|
7
|
+
const rows = [];
|
|
8
|
+
graph.forEachNode((id, a) => {
|
|
9
|
+
if (a.kind !== filter.kind || !passes(a, filter)) {
|
|
10
|
+
return;
|
|
11
|
+
}
|
|
12
|
+
if (filter.name && !filter.name.some((match) => match(a.name))) {
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
if (filter.path && !filter.path.some((match) => match(a.path))) {
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
rows.push({ ...a, id });
|
|
19
|
+
});
|
|
20
|
+
rows.sort(order(filter.kind));
|
|
21
|
+
return cut(rows, filter.limit);
|
|
22
|
+
}
|
|
23
|
+
export function grepNodes(graph, match, filter = {}) {
|
|
24
|
+
const kinds = filter.kinds?.length ? new Set(filter.kinds) : undefined;
|
|
25
|
+
const matches = [];
|
|
26
|
+
const until = Date.now() + DEADLINE_MS;
|
|
27
|
+
let seen = 0;
|
|
28
|
+
for (const id of graph.nodes()) {
|
|
29
|
+
if (++seen % DEADLINE_EVERY === 0 && Date.now() > until) {
|
|
30
|
+
throw new PatternError(`the pattern is still running after ${DEADLINE_MS / 1000}s — it is too expensive to be useful`);
|
|
31
|
+
}
|
|
32
|
+
const a = graph.getNodeAttributes(id);
|
|
33
|
+
if (kinds && !kinds.has(a.kind)) {
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
if (filter.source && a.source !== filter.source) {
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
const text = textOf(graph, id);
|
|
40
|
+
if (match(text)) {
|
|
41
|
+
matches.push({ id, attributes: a, text });
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
matches.sort((a, b) => a.id.localeCompare(b.id));
|
|
45
|
+
const kept = cut(matches, filter.limit);
|
|
46
|
+
return { found: kept.found, matches: kept.rows, truncated: kept.truncated };
|
|
47
|
+
}
|
|
48
|
+
/** How many fields a type carries, which is most of what a listing wants to say. */
|
|
49
|
+
export function propertyCount(graph, id) {
|
|
50
|
+
return graph
|
|
51
|
+
.outEdges(id)
|
|
52
|
+
.filter((e) => graph.getEdgeAttribute(e, 'relation') === 'HAS_PROPERTY').length;
|
|
53
|
+
}
|
|
54
|
+
/** That count, said properly, in the one phrasing the CLI and the tools share. */
|
|
55
|
+
export const fields = (n) => `${n} ${n === 1 ? 'field' : 'fields'}`;
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
function passes(a, filter) {
|
|
58
|
+
if (filter.source && a.source !== filter.source) {
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
if (filter.methodType && a.methodType !== filter.methodType) {
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
if (filter.direction && a.direction !== filter.direction && a.direction !== 'both') {
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
69
|
+
/** Operations read as a table of routes; everything else reads as a list of names. */
|
|
70
|
+
function order(kind) {
|
|
71
|
+
if (kind !== 'method') {
|
|
72
|
+
return (a, b) => a.id.localeCompare(b.id);
|
|
73
|
+
}
|
|
74
|
+
return (a, b) => a.path.localeCompare(b.path) || a.httpMethod.localeCompare(b.httpMethod);
|
|
75
|
+
}
|
|
76
|
+
function cut(rows, limit) {
|
|
77
|
+
const found = rows.length;
|
|
78
|
+
if (!limit || limit >= found) {
|
|
79
|
+
return { found, rows, truncated: false };
|
|
80
|
+
}
|
|
81
|
+
return { found, rows: rows.slice(0, limit), truncated: true };
|
|
82
|
+
}
|
|
83
|
+
//# sourceMappingURL=lookup.js.map
|
|
@@ -0,0 +1,36 @@
|
|
|
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
|
+
export declare function loose(pattern: string, options?: MatchOptions): Matcher;
|
|
34
|
+
/** True when any of the patterns matches; no patterns means no opinion. */
|
|
35
|
+
export declare function anyOf(matchers: readonly Matcher[]): Matcher | undefined;
|
|
36
|
+
//# sourceMappingURL=match.d.ts.map
|
|
@@ -0,0 +1,85 @@
|
|
|
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
|
+
export function loose(pattern, options = {}) {
|
|
58
|
+
return isGlob(pattern) ? wildcard(pattern, options) : matcher(pattern, options);
|
|
59
|
+
}
|
|
60
|
+
/** True when any of the patterns matches; no patterns means no opinion. */
|
|
61
|
+
export function anyOf(matchers) {
|
|
62
|
+
if (matchers.length === 0) {
|
|
63
|
+
return undefined;
|
|
64
|
+
}
|
|
65
|
+
return (text) => matchers.some((match) => match(text));
|
|
66
|
+
}
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
function guard(pattern) {
|
|
69
|
+
if (pattern.length === 0) {
|
|
70
|
+
throw new PatternError('the pattern is empty');
|
|
71
|
+
}
|
|
72
|
+
if (pattern.length > MAX_PATTERN) {
|
|
73
|
+
throw new PatternError(`the pattern is longer than ${MAX_PATTERN} characters`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
function compile(source, flags) {
|
|
77
|
+
try {
|
|
78
|
+
return new RegExp(source, flags);
|
|
79
|
+
}
|
|
80
|
+
catch (err) {
|
|
81
|
+
throw new PatternError(`invalid pattern: ${err.message}`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
const escape = (char) => char.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
85
|
+
//# sourceMappingURL=match.js.map
|
|
@@ -37,6 +37,12 @@ export interface StitchOptions {
|
|
|
37
37
|
export declare const DEFAULT_MAX_HOPS = 3;
|
|
38
38
|
export declare const DEFAULT_MAX_NODES = 200;
|
|
39
39
|
export declare function stitch(graph: ApiGraph, seeds: readonly Seed[], options?: StitchOptions): Subgraph[];
|
|
40
|
+
/**
|
|
41
|
+
* Exactly the nodes named, and only the edges between them. The counterpart to
|
|
42
|
+
* `stitch`: no neighbours are gathered, because the caller is not asking what
|
|
43
|
+
* this is connected to — they already know what they want and want it whole.
|
|
44
|
+
*/
|
|
45
|
+
export declare function select(graph: ApiGraph, ids: readonly string[]): Subgraph;
|
|
40
46
|
/** Breadth-first, ignoring edge direction, giving up past `maxHops`. */
|
|
41
47
|
export declare function path(graph: ApiGraph, from: string, to: string, maxHops: number): string[] | undefined;
|
|
42
48
|
//# sourceMappingURL=subgraph.d.ts.map
|
package/dist/schema/subgraph.js
CHANGED
|
@@ -150,6 +150,33 @@ function connect(graph, seeds, maxHops) {
|
|
|
150
150
|
}
|
|
151
151
|
return out;
|
|
152
152
|
}
|
|
153
|
+
/**
|
|
154
|
+
* Exactly the nodes named, and only the edges between them. The counterpart to
|
|
155
|
+
* `stitch`: no neighbours are gathered, because the caller is not asking what
|
|
156
|
+
* this is connected to — they already know what they want and want it whole.
|
|
157
|
+
*/
|
|
158
|
+
export function select(graph, ids) {
|
|
159
|
+
const kept = new Set(ids.filter((id) => graph.hasNode(id)));
|
|
160
|
+
const nodes = [...kept].map((id) => ({
|
|
161
|
+
id,
|
|
162
|
+
kind: graph.getNodeAttribute(id, 'kind'),
|
|
163
|
+
attributes: graph.getNodeAttributes(id),
|
|
164
|
+
hit: true,
|
|
165
|
+
score: 1,
|
|
166
|
+
}));
|
|
167
|
+
const edges = [];
|
|
168
|
+
for (const id of kept) {
|
|
169
|
+
for (const edge of graph.outEdges(id)) {
|
|
170
|
+
const target = graph.target(edge);
|
|
171
|
+
if (!kept.has(target)) {
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
const a = graph.getEdgeAttributes(edge);
|
|
175
|
+
edges.push({ source: id, target, relation: a.relation, status: a.status, in: a.in });
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return { nodes, edges, hits: [...kept], score: kept.size, truncated: false };
|
|
179
|
+
}
|
|
153
180
|
/** Breadth-first, ignoring edge direction, giving up past `maxHops`. */
|
|
154
181
|
export function path(graph, from, to, maxHops) {
|
|
155
182
|
if (from === to) {
|
package/dist/schema/tools.js
CHANGED
|
@@ -2,22 +2,32 @@ import { tool } from '@zenera/neo';
|
|
|
2
2
|
import { FORMATS, isFormat, present } from "../present.js";
|
|
3
3
|
import { isEmpty, parseQuery, QueryError } from "../query.js";
|
|
4
4
|
import { toTypeScript } from "./hydrate.js";
|
|
5
|
+
import { fields, grepNodes, listNodes, propertyCount } from "./lookup.js";
|
|
6
|
+
import { loose, matcher, PatternError } from "./match.js";
|
|
5
7
|
import { stitch } from "./subgraph.js";
|
|
6
8
|
// ---------------------------------------------------------------------------
|
|
7
9
|
// The same index, given to an agent
|
|
8
10
|
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
11
|
+
// Five tools over one engine, and only one of them ranks anything. `search_api`
|
|
12
|
+
// is the way in when the question is vague; the other four are exact, because
|
|
13
|
+
// a model that has been told "no results" by a vector search has learned
|
|
14
|
+
// nothing — a ranking returns the top of a list, so an empty answer and an
|
|
15
|
+
// absent thing look identical.
|
|
16
|
+
//
|
|
17
|
+
// `find_types_with_property` is for the moment after the compiler says
|
|
18
|
+
// `'password' does not exist in type 'PublicUserProfile'`. At that point the
|
|
19
|
+
// model does not need to be reminded what a password is — it needs the list of
|
|
20
|
+
// types that have one. `grep_api` is the same instinct widened: every literal
|
|
21
|
+
// occurrence, counted in full, so "it is not there" can actually be concluded.
|
|
15
22
|
// ---------------------------------------------------------------------------
|
|
16
23
|
const GROUP = 'schema';
|
|
17
24
|
/** Kept small on purpose: a tool result is prompt, and the model asked for one thing. */
|
|
18
25
|
const DEFAULT_LIMIT = 4;
|
|
19
26
|
const DEFAULT_MAX_NODES = 60;
|
|
20
27
|
const MAX_CANDIDATES = 25;
|
|
28
|
+
/** A listing is lines rather than subgraphs, so it can afford more of them. */
|
|
29
|
+
const DEFAULT_ROWS = 50;
|
|
30
|
+
const MAX_ROWS = 200;
|
|
21
31
|
export function schemaTools(index, options = {}) {
|
|
22
32
|
const fallback = options.format ?? 'text';
|
|
23
33
|
const docs = options.docs ?? true;
|
|
@@ -178,43 +188,145 @@ export function schemaTools(index, options = {}) {
|
|
|
178
188
|
: { found: candidates.length, candidates: candidates.slice(0, MAX_CANDIDATES) };
|
|
179
189
|
},
|
|
180
190
|
});
|
|
181
|
-
const
|
|
182
|
-
name: '
|
|
191
|
+
const listApi = tool({
|
|
192
|
+
name: 'list_api',
|
|
183
193
|
group: GROUP,
|
|
184
|
-
description: 'Lists operations by
|
|
185
|
-
'
|
|
194
|
+
description: 'Lists operations, schemas or fields by name, with no searching and no ranking. ' +
|
|
195
|
+
'The answer is complete: every match is counted, so `found` tells you how many ' +
|
|
196
|
+
'exist even when the list was shortened. Use it to see the shape of the API ' +
|
|
197
|
+
'before deciding what to ask for, and to settle whether something exists at all — ' +
|
|
198
|
+
'search can only ever return its best guesses, so it cannot answer that.',
|
|
186
199
|
parameters: {
|
|
187
200
|
type: 'object',
|
|
188
201
|
properties: {
|
|
189
|
-
|
|
202
|
+
kind: {
|
|
203
|
+
type: 'string',
|
|
204
|
+
enum: ['methods', 'types', 'properties'],
|
|
205
|
+
description: 'What to list. Default methods.',
|
|
206
|
+
},
|
|
207
|
+
name: {
|
|
208
|
+
type: 'string',
|
|
209
|
+
description: 'Match the name. A plain word matches anywhere in it; use * and ? ' +
|
|
210
|
+
'to match the whole name, e.g. "*Password*".',
|
|
211
|
+
},
|
|
212
|
+
path: { type: 'string', description: 'Match the route, e.g. "/users*".' },
|
|
190
213
|
method_type: {
|
|
191
214
|
type: 'string',
|
|
192
215
|
enum: ['read_only', 'read_write', 'any'],
|
|
193
216
|
},
|
|
217
|
+
direction: { type: 'string', enum: ['input', 'output', 'any'] },
|
|
218
|
+
limit: {
|
|
219
|
+
type: 'integer',
|
|
220
|
+
description: `Rows to return. Default ${DEFAULT_ROWS}.`,
|
|
221
|
+
},
|
|
194
222
|
},
|
|
195
223
|
additionalProperties: false,
|
|
196
224
|
},
|
|
197
|
-
execute: async ({
|
|
198
|
-
const
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
225
|
+
execute: async ({ kind, name, path, method_type, direction, limit }) => {
|
|
226
|
+
const subject = SUBJECTS[kind ?? 'methods'];
|
|
227
|
+
if (!subject) {
|
|
228
|
+
return { error: `cannot list "${kind}"`, hint: 'kind is methods, types or fields' };
|
|
229
|
+
}
|
|
230
|
+
let result;
|
|
231
|
+
try {
|
|
232
|
+
result = listNodes(index.graph, {
|
|
233
|
+
kind: subject,
|
|
234
|
+
name: name ? [loose(name)] : undefined,
|
|
235
|
+
path: path ? [loose(path)] : undefined,
|
|
236
|
+
methodType: enumerated(method_type),
|
|
237
|
+
direction: enumerated(direction),
|
|
238
|
+
limit: Math.min(limit ?? DEFAULT_ROWS, MAX_ROWS),
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
catch (err) {
|
|
242
|
+
return { error: err instanceof PatternError ? err.message : String(err) };
|
|
243
|
+
}
|
|
244
|
+
return {
|
|
245
|
+
found: result.found,
|
|
246
|
+
truncated: result.truncated,
|
|
247
|
+
[PLURALS[subject]]: result.rows.map((r) => line(index.graph, subject, r)),
|
|
248
|
+
};
|
|
213
249
|
},
|
|
214
250
|
});
|
|
215
|
-
|
|
251
|
+
const grepApi = tool({
|
|
252
|
+
name: 'grep_api',
|
|
253
|
+
group: GROUP,
|
|
254
|
+
description: 'Finds every literal occurrence of a string across the whole API description — ' +
|
|
255
|
+
'operations, schemas and fields alike. No embeddings and no ranking, so ' +
|
|
256
|
+
'nothing is missed for being an unusual word or an odd spelling. This is the ' +
|
|
257
|
+
'tool for "does X exist anywhere", and for checking that a search which ' +
|
|
258
|
+
'returned nothing really means there is nothing.',
|
|
259
|
+
parameters: {
|
|
260
|
+
type: 'object',
|
|
261
|
+
properties: {
|
|
262
|
+
pattern: {
|
|
263
|
+
type: 'string',
|
|
264
|
+
description: 'The text to find. Matched anywhere, ignoring case.',
|
|
265
|
+
},
|
|
266
|
+
regex: {
|
|
267
|
+
type: 'boolean',
|
|
268
|
+
description: 'Read the pattern as a regular expression instead.',
|
|
269
|
+
},
|
|
270
|
+
kind: { type: 'string', enum: ['method', 'type', 'property'] },
|
|
271
|
+
limit: {
|
|
272
|
+
type: 'integer',
|
|
273
|
+
description: `Matches to return. Default ${DEFAULT_ROWS}. \`found\` always counts them all.`,
|
|
274
|
+
},
|
|
275
|
+
},
|
|
276
|
+
required: ['pattern'],
|
|
277
|
+
additionalProperties: false,
|
|
278
|
+
},
|
|
279
|
+
execute: async ({ pattern, regex, kind, limit }) => {
|
|
280
|
+
let result;
|
|
281
|
+
try {
|
|
282
|
+
result = grepNodes(index.graph, matcher(pattern, { regex }), {
|
|
283
|
+
kinds: kind ? [kind] : undefined,
|
|
284
|
+
limit: Math.min(limit ?? DEFAULT_ROWS, MAX_ROWS),
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
catch (err) {
|
|
288
|
+
return { error: err instanceof PatternError ? err.message : String(err) };
|
|
289
|
+
}
|
|
290
|
+
if (result.found === 0) {
|
|
291
|
+
return {
|
|
292
|
+
found: 0,
|
|
293
|
+
hint: 'nothing in the description contains it — it is not there under this name',
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
return {
|
|
297
|
+
found: result.found,
|
|
298
|
+
truncated: result.truncated,
|
|
299
|
+
matches: result.matches.map((m) => ({ id: m.id, text: m.text })),
|
|
300
|
+
};
|
|
301
|
+
},
|
|
302
|
+
});
|
|
303
|
+
return [searchApi, describeTypes, findTypesWithProperty, listApi, grepApi];
|
|
216
304
|
}
|
|
217
305
|
// ---------------------------------------------------------------------------
|
|
306
|
+
const SUBJECTS = {
|
|
307
|
+
methods: 'method',
|
|
308
|
+
types: 'type',
|
|
309
|
+
properties: 'property',
|
|
310
|
+
fields: 'property',
|
|
311
|
+
};
|
|
312
|
+
const PLURALS = {
|
|
313
|
+
method: 'methods',
|
|
314
|
+
type: 'types',
|
|
315
|
+
property: 'properties',
|
|
316
|
+
};
|
|
317
|
+
/** One row, as the line a model reads rather than an object it has to walk. */
|
|
318
|
+
function line(graph, kind, row) {
|
|
319
|
+
if (kind === 'method') {
|
|
320
|
+
return `${row.httpMethod} ${row.path} ${row.name}${row.doc ? ` — ${row.doc}` : ''}`;
|
|
321
|
+
}
|
|
322
|
+
if (kind === 'type') {
|
|
323
|
+
const side = row.direction === 'none' ? '' : ` (${row.direction})`;
|
|
324
|
+
return `${row.name}${side} ${fields(propertyCount(graph, row.id))}${row.doc ? ` — ${row.doc}` : ''}`;
|
|
325
|
+
}
|
|
326
|
+
const owner = row.parent ? `${row.parent}.` : '';
|
|
327
|
+
return `${owner}${row.name}${row.required ? '' : '?'}: ${row.signature || 'unknown'}`;
|
|
328
|
+
}
|
|
329
|
+
const enumerated = (value) => value && value !== 'any' ? value : undefined;
|
|
218
330
|
function list(description) {
|
|
219
331
|
return { type: 'array', items: { type: 'string' }, description };
|
|
220
332
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zenera/rag",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.5",
|
|
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.5",
|
|
57
|
+
"@zenera/neo": "^1.1.5"
|
|
58
58
|
}
|
|
59
59
|
}
|