@zenera/rag 1.1.5 → 1.1.6

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 CHANGED
@@ -59,6 +59,22 @@ zen rag schema grep "pass(word|phrase)" --regex
59
59
  zen rag schema show --method GetCurrentUserInfo --format openapi --exact
60
60
  ```
61
61
 
62
+ Patterns are globs by default and regular expressions under `--regex`, on
63
+ `list` as well as `grep`, which is the only way to say "one of these prefixes":
64
+
65
+ ```sh
66
+ zen rag schema list methods --regex --path "^/(users|teams)/"
67
+ zen rag schema grep status --path "/invoices/*" --kind property
68
+ ```
69
+
70
+ `grep` takes the same `--name` and `--path` constraints `list` takes, so a
71
+ common word can be narrowed to one corner of the API instead of being read out
72
+ of every document at once.
73
+
74
+ When an index holds more than one document, `--show-source` names the one each
75
+ row came from — `[source: billing_api_v2]` — on `list`, `grep`, `search` and
76
+ `show` alike, so the document does not have to be recovered from `--json`.
77
+
62
78
  `list` and `grep` report `found` as the true total even when `--limit` cuts the
63
79
  printed rows, so a shortened answer still tells you how much there is. Nothing
64
80
  matching exits 0 — an empty answer is an answer, and here it is a trustworthy
@@ -79,6 +95,22 @@ zen rag schema search --query - --format ts <<'JSON'
79
95
  JSON
80
96
  ```
81
97
 
98
+ ## Which index
99
+
100
+ Every reading command takes `-d, --dir`. Without one, `$ZEN_SCHEMA_DB` is used
101
+ if it is set; without that, the nearest index to the working directory is found
102
+ and named on stderr as it is used.
103
+
104
+ Nearest means what it says: this directory, then a short way down into it, then
105
+ up a level and again, stopping at your home directory. What is looked for is a
106
+ `manifest.json` — an index is self-describing, so nothing here searches for a
107
+ directory called `schema-db`, and an index called anything else is found just
108
+ the same. `schema-db` is only the name a new one is given.
109
+
110
+ Two indexes the same distance away is a question, not a tie to break, and it is
111
+ refused: the wrong index does not fail, it answers confidently about a
112
+ different API. Name one with `--dir`, or set `ZEN_SCHEMA_DB`.
113
+
82
114
  ## Commands
83
115
 
84
116
  ```
@@ -96,10 +128,14 @@ by `--direction`, `--method-type`, `--limit`, `--max-hops`, `--max-nodes` and
96
128
  the four `--exclude-*` filters, and rendered by `--format text | mermaid |
97
129
  mermaid-flowchart | ts | openapi`. `zen help rag` prints the full table.
98
130
 
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
131
+ `list` and `grep` share `--name`, `--path`, `--regex`, `--case-sensitive`,
132
+ `--source`, `--show-source` and `--limit`; `grep` adds `--kind` and
133
+ `--ids-only`. A pattern with `*` or `?` in it is a glob matched against the
134
+ whole name; a plain word is a substring, so `--name password` finds
135
+ `ResetPasswordPayload` rather than nothing; under `--regex` it is a regular
136
+ expression either way. `--path` selects on the route an operation sits on, and
137
+ on the route a parameter's operation sits on — a schema belongs to no one
138
+ route, so `--path` never selects one. `show` takes ids, or `--method` and
103
139
  `--type` by name, or `--source` for a whole document, and `--exact` to print
104
140
  only what was named instead of its neighbourhood.
105
141
 
package/dist/command.js CHANGED
@@ -6,8 +6,10 @@ import { isEmpty, parseQuery, QueryError } from "./query.js";
6
6
  import { repl } from "./repl.js";
7
7
  import { buildIndex } from "./schema/build.js";
8
8
  import { assertSameEmbedding, openIndex, readManifest, readSource, } from "./schema/files.js";
9
+ import { DEFAULT_DIR, DIR_ENV, locateIndex, outputDir } from "./schema/locate.js";
9
10
  import { fields, grepNodes, listNodes, propertyCount } from "./schema/lookup.js";
10
11
  import { isGlob, loose, matcher, PatternError, wildcard } from "./schema/match.js";
12
+ import { sourceTag } from "./schema/render.js";
11
13
  import { SchemaIndex } from "./schema/search.js";
12
14
  import { select, stitch } from "./schema/subgraph.js";
13
15
  // ---------------------------------------------------------------------------
@@ -33,7 +35,6 @@ const SEARCH_USAGE = 'zen rag schema search [--dir <dir>] [query...]';
33
35
  const LIST_USAGE = 'zen rag schema list <methods|types|properties> [--dir <dir>]';
34
36
  const GREP_USAGE = 'zen rag schema grep <pattern> [--dir <dir>]';
35
37
  const SHOW_USAGE = 'zen rag schema show [id...] [--method <name>] [--type <name>]';
36
- const DEFAULT_DIR = './schema-db';
37
38
  export const command = {
38
39
  summary: 'Search an openapi/swagger document as a graph.',
39
40
  usage: USAGE,
@@ -54,7 +55,10 @@ export const command = {
54
55
  ' --embedding <ref>',
55
56
  dim('Which embedder makes the vectors. Omit it to be shown the choices.'),
56
57
  ],
57
- [' -o, --out <dir>', dim(`Where the index goes. Default ${DEFAULT_DIR}.`)],
58
+ [
59
+ ' -o, --out <dir>',
60
+ dim(`Where the index goes. Default ${DEFAULT_DIR}, or ${DIR_ENV}.`),
61
+ ],
58
62
  [
59
63
  ' --batch <n>',
60
64
  dim('Texts per embedding request, and how often progress prints. Default 96.'),
@@ -78,7 +82,7 @@ export const command = {
78
82
  '',
79
83
  'Search filters and shape',
80
84
  ...table([
81
- [' -d, --dir <dir>', dim(`Which index. Default ${DEFAULT_DIR}.`)],
85
+ [' -d, --dir <dir>', dim(`Which index. Found from here if unset; see ${DIR_ENV}.`)],
82
86
  [' --embedding <ref>', dim('Must be the one the index was built with.')],
83
87
  [' --direction <d>', dim('input | output | any. Default any.')],
84
88
  [' --method-type <t>', dim('read_only | read_write | any. Default any.')],
@@ -90,6 +94,7 @@ export const command = {
90
94
  [' --max-hops <n>', dim('How far apart two hits may be. Default 3.')],
91
95
  [' --max-nodes <n>', dim('Nodes per result. Default 200.')],
92
96
  [' --format <f>', dim('text | mermaid | mermaid-flowchart | ts | openapi.')],
97
+ [' --show-source', dim('Name the document each operation and schema came from.')],
93
98
  [' --no-docs', dim('Leave the descriptions out.')],
94
99
  [' --interactive', dim('Prompt, search, refine. Needs a terminal.')],
95
100
  [' --quiet', dim('No narration.')],
@@ -99,17 +104,22 @@ export const command = {
99
104
  ...table([
100
105
  [' list methods', dim('Operations. Filter with --path and --name.')],
101
106
  [' list types', dim('Schemas. Filter with --name.')],
102
- [' list properties', dim('Fields and parameters. Filter with --name.')],
107
+ [' list properties', dim('Fields and parameters. Filter with --name and --path.')],
103
108
  [' grep <pattern>', dim('Substring over every node; --regex for a regex.')],
104
- [' --case-sensitive', dim('grep: match the capitals too.')],
109
+ [' --regex', dim('Read every pattern as a regex, list and grep alike.')],
110
+ [' --case-sensitive', dim('Match the capitals too.')],
105
111
  [' --kind <k>', dim('grep: method | type | property. Repeatable.')],
112
+ [' --name <p>', dim('grep too: only nodes whose name matches. Repeatable.')],
113
+ [' --path <p>', dim('grep too: only what sits on a matching route.')],
106
114
  [' --ids-only', dim('grep: bare ids, to pipe into show.')],
107
115
  [' --source <name>', dim('Only this document, as `stats` names it.')],
116
+ [' --show-source', dim('Print which document each row came from.')],
108
117
  [' --limit <n>', dim('Keep at most n; the count still reports them all.')],
109
118
  ]),
110
119
  '',
111
120
  dim(' A pattern with * or ? is a glob over the whole name; otherwise it is'),
112
- dim(' a substring, so --name password finds ResetPasswordPayload.'),
121
+ dim(' a substring, so --name password finds ResetPasswordPayload. With'),
122
+ dim(' --regex it is a regex either way, so --path "^/(users|teams)/" works.'),
113
123
  '',
114
124
  'Show',
115
125
  ...table([
@@ -117,9 +127,13 @@ export const command = {
117
127
  [' --method <name>', dim('An operation by name. * to take more. Repeatable.')],
118
128
  [' --type <name>', dim('A schema by name. * to take more. Repeatable.')],
119
129
  [' --source <name>', dim('A whole document, as it was indexed.')],
130
+ [' --show-source', dim('Name the document each node came from.')],
120
131
  [' --exact', dim('Only what was named, without the neighbours.')],
121
132
  ]),
122
133
  '',
134
+ dim(`Without --dir, the index is the nearest one at or above the working`),
135
+ dim(`directory; ${cyan(DIR_ENV)} names it outright.`),
136
+ '',
123
137
  dim(`Credentials come from the ${cyan('zen')} keyring — try ${cyan('zen key ls')}.`),
124
138
  ],
125
139
  async run(ctx) {
@@ -156,7 +170,7 @@ async function index(args, ctx) {
156
170
  if (positionals.length === 0) {
157
171
  throw usageError('no document given', INDEX_USAGE);
158
172
  }
159
- const out = resolve(ctx.cwd, values.out ?? DEFAULT_DIR);
173
+ const out = outputDir(ctx.cwd, values.out);
160
174
  const loud = !values.quiet && !ctx.json;
161
175
  const chosen = await embedder(values.embedding);
162
176
  const started = Date.now();
@@ -250,14 +264,19 @@ const SEARCH_OPTIONS = {
250
264
  format: { type: 'string' },
251
265
  'no-docs': { type: 'boolean' },
252
266
  'only-hits': { type: 'boolean' },
267
+ 'show-source': { type: 'boolean' },
253
268
  interactive: { type: 'boolean' },
254
269
  quiet: { type: 'boolean' },
255
270
  };
256
271
  async function search(args, ctx) {
257
272
  const { values, positionals } = parse(args, SEARCH_OPTIONS, SEARCH_USAGE);
258
- const dir = resolve(ctx.cwd, values.dir ?? DEFAULT_DIR);
273
+ const dir = indexDir(ctx, values.dir);
259
274
  const format = formatOf(values.format);
260
- const options = { docs: !values['no-docs'], onlyHits: values['only-hits'] };
275
+ const options = {
276
+ docs: !values['no-docs'],
277
+ onlyHits: values['only-hits'],
278
+ source: values['show-source'],
279
+ };
261
280
  const query = { ...(await fromStdin(values.query)), ...fromFlags(values, positionals) };
262
281
  // Everything that can be wrong about the invocation is settled before a
263
282
  // credential is asked for, so a typo is a usage error and not a login.
@@ -381,6 +400,9 @@ async function list(args, ctx) {
381
400
  source: { type: 'string' },
382
401
  'method-type': { type: 'string' },
383
402
  direction: { type: 'string' },
403
+ regex: { type: 'boolean' },
404
+ 'case-sensitive': { type: 'boolean' },
405
+ 'show-source': { type: 'boolean' },
384
406
  limit: { type: 'string' },
385
407
  quiet: { type: 'boolean' },
386
408
  }, LIST_USAGE);
@@ -392,11 +414,12 @@ async function list(args, ctx) {
392
414
  if (positionals.length > 1) {
393
415
  throw usageError('one subject at a time', LIST_USAGE);
394
416
  }
395
- const index = await openIndex(resolve(ctx.cwd, values.dir ?? DEFAULT_DIR));
417
+ const how = { regex: values.regex, caseSensitive: values['case-sensitive'] };
418
+ const index = await openIndex(indexDir(ctx, values.dir));
396
419
  const found = listNodes(index.graph, {
397
420
  kind,
398
- name: globs(values.name, '--name'),
399
- path: globs(values.path, '--path'),
421
+ name: patterns(values.name, '--name', how),
422
+ path: patterns(values.path, '--path', how),
400
423
  source: values.source,
401
424
  methodType: oneOf(values['method-type'], ['read_only', 'read_write'], '--method-type'),
402
425
  direction: oneOf(values.direction, ['input', 'output'], '--direction'),
@@ -406,7 +429,7 @@ async function list(args, ctx) {
406
429
  json({ found: found.found, truncated: found.truncated, rows: found.rows });
407
430
  return;
408
431
  }
409
- const lines = rowLines(index.graph, kind, found.rows);
432
+ const lines = rowLines(index.graph, kind, found.rows, values['show-source']);
410
433
  if (lines.length > 0) {
411
434
  write(lines.join('\n'));
412
435
  }
@@ -414,21 +437,24 @@ async function list(args, ctx) {
414
437
  note(dim(` ${found.found} ${subject}${shown(found.found, found.rows.length)}`));
415
438
  }
416
439
  }
417
- function rowLines(graph, kind, rows) {
440
+ function rowLines(graph, kind, rows, showSource = false) {
441
+ const from = (r) => (showSource ? [dim(sourceTag(r.source))] : []);
418
442
  if (kind === 'method') {
419
- return table(rows.map((r) => [`${r.httpMethod} ${r.path}`, r.name, doc(r.doc)]));
443
+ return table(rows.map((r) => [`${r.httpMethod} ${r.path}`, r.name, ...from(r), doc(r.doc)]));
420
444
  }
421
445
  if (kind === 'type') {
422
446
  return table(rows.map((r) => [
423
447
  r.name,
424
448
  dim(fields(propertyCount(graph, r.id))),
425
449
  dim(r.direction === 'none' ? '' : `(${r.direction})`),
450
+ ...from(r),
426
451
  doc(r.doc),
427
452
  ]));
428
453
  }
429
454
  return table(rows.map((r) => [
430
455
  `${r.parent ? `${r.parent}.` : ''}${r.name}${r.required ? '' : '?'}`,
431
456
  `: ${r.signature || 'unknown'}`,
457
+ ...from(r),
432
458
  doc(r.doc),
433
459
  ]));
434
460
  }
@@ -438,7 +464,10 @@ async function grep(args, ctx) {
438
464
  regex: { type: 'boolean' },
439
465
  'case-sensitive': { type: 'boolean' },
440
466
  kind: MANY,
467
+ name: MANY,
468
+ path: MANY,
441
469
  source: { type: 'string' },
470
+ 'show-source': { type: 'boolean' },
442
471
  limit: { type: 'string' },
443
472
  'ids-only': { type: 'boolean' },
444
473
  quiet: { type: 'boolean' },
@@ -450,13 +479,18 @@ async function grep(args, ctx) {
450
479
  throw usageError('one pattern at a time — quote it if it has spaces', GREP_USAGE);
451
480
  }
452
481
  const kinds = (values.kind ?? []).map((k) => oneOf(k, ['method', 'type', 'property'], '--kind'));
453
- const index = await openIndex(resolve(ctx.cwd, values.dir ?? DEFAULT_DIR));
482
+ // The pattern is read as the flags say; the constraints are always names,
483
+ // so they stay globs-or-substrings even under --regex on the pattern.
484
+ const how = { caseSensitive: values['case-sensitive'] };
485
+ const index = await openIndex(indexDir(ctx, values.dir));
454
486
  const result = pattern(() => grepNodes(index.graph, matcher(positionals[0], {
455
487
  regex: values.regex,
456
488
  caseSensitive: values['case-sensitive'],
457
489
  }), {
458
490
  kinds,
459
491
  source: values.source,
492
+ name: patterns(values.name, '--name', how),
493
+ path: patterns(values.path, '--path', how),
460
494
  limit: values.limit ? count(values.limit, '--limit') : undefined,
461
495
  }));
462
496
  if (ctx.json) {
@@ -470,7 +504,11 @@ async function grep(args, ctx) {
470
504
  if (result.matches.length > 0) {
471
505
  const lines = values['ids-only']
472
506
  ? result.matches.map((m) => m.id)
473
- : table(result.matches.map((m) => [m.id, dim(clip(m.text, 140))]));
507
+ : table(result.matches.map((m) => [
508
+ m.id,
509
+ ...(values['show-source'] ? [dim(sourceTag(m.attributes.source))] : []),
510
+ dim(clip(m.text, 140)),
511
+ ]));
474
512
  write(lines.join('\n'));
475
513
  }
476
514
  if (!values.quiet && !values['ids-only']) {
@@ -479,11 +517,22 @@ async function grep(args, ctx) {
479
517
  }
480
518
  // ---------------------------------------------------------------------------
481
519
  const shown = (found, kept) => (kept < found ? `, showing ${kept}` : '');
482
- function globs(patterns, flag) {
483
- if (!patterns || patterns.length === 0) {
520
+ /**
521
+ * Where the index is, said out loud when nobody named it. Finding one and not
522
+ * saying which would make every answer here unattributable.
523
+ */
524
+ function indexDir(ctx, flag) {
525
+ const { dir, from } = locateIndex(ctx.cwd, flag);
526
+ if (from === 'found' && !ctx.json) {
527
+ note(dim(` using ${relative(ctx.cwd, dir) || dir}`));
528
+ }
529
+ return dir;
530
+ }
531
+ function patterns(values, flag, options = {}) {
532
+ if (!values || values.length === 0) {
484
533
  return undefined;
485
534
  }
486
- return patterns.map((p) => pattern(() => loose(p), flag));
535
+ return values.map((p) => pattern(() => loose(p, options), flag));
487
536
  }
488
537
  /** A bad pattern is a bad invocation, not a failure of the index. */
489
538
  function pattern(run, flag) {
@@ -526,10 +575,11 @@ async function show(args, ctx) {
526
575
  format: { type: 'string' },
527
576
  'max-nodes': { type: 'string' },
528
577
  'no-docs': { type: 'boolean' },
578
+ 'show-source': { type: 'boolean' },
529
579
  quiet: { type: 'boolean' },
530
580
  }, SHOW_USAGE);
531
581
  const format = formatOf(values.format);
532
- const dir = resolve(ctx.cwd, values.dir ?? DEFAULT_DIR);
582
+ const dir = indexDir(ctx, values.dir);
533
583
  // A whole document, verbatim: the copy kept at index time is the resolved
534
584
  // original, and anything rebuilt from the graph would be a paraphrase.
535
585
  if (values.source && format === 'openapi' && positionals.length === 0 && !named(values)) {
@@ -551,7 +601,10 @@ async function show(args, ctx) {
551
601
  ? count(values['max-nodes'], '--max-nodes')
552
602
  : undefined,
553
603
  });
554
- const text = await present(index, subgraphs, format, { docs: !values['no-docs'] });
604
+ const text = await present(index, subgraphs, format, {
605
+ docs: !values['no-docs'],
606
+ source: values['show-source'],
607
+ });
555
608
  if (ctx.json) {
556
609
  json({ ids, subgraphs, rendered: text });
557
610
  }
@@ -603,7 +656,7 @@ function resolveIds(graph, ids, values) {
603
656
  }
604
657
  async function stats(args, ctx) {
605
658
  const { values } = parse(args, { dir: { type: 'string', short: 'd' } }, 'zen rag schema stats [--dir <dir>]');
606
- const dir = resolve(ctx.cwd, values.dir ?? DEFAULT_DIR);
659
+ const dir = indexDir(ctx, values.dir);
607
660
  const manifest = await readManifest(dir);
608
661
  if (ctx.json) {
609
662
  json(manifest);
package/dist/index.d.ts CHANGED
@@ -5,6 +5,7 @@ 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/locate.ts';
8
9
  export * from './schema/lookup.ts';
9
10
  export * from './schema/match.ts';
10
11
  export * from './schema/render.ts';
package/dist/index.js CHANGED
@@ -5,6 +5,7 @@ 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/locate.js";
8
9
  export * from "./schema/lookup.js";
9
10
  export * from "./schema/match.js";
10
11
  export * from "./schema/render.js";
@@ -83,7 +83,7 @@ export async function readManifest(dir) {
83
83
  text = await readFile(join(dir, MANIFEST_FILE), 'utf8');
84
84
  }
85
85
  catch {
86
- throw new CliError(`${dir} does not hold an index`, EXIT.invalid, 'build one first with `zen rag schema index`');
86
+ throw new CliError(`${dir} does not hold an index`, EXIT.invalid, 'build one with `zen rag schema index`, or name an existing one with --dir or $ZEN_SCHEMA_DB');
87
87
  }
88
88
  const manifest = JSON.parse(text);
89
89
  if (manifest.version !== INDEX_VERSION) {
@@ -0,0 +1,20 @@
1
+ /** The name a new index is given. Nothing searches for it; only `index --out` writes it. */
2
+ export declare const DEFAULT_DIR = "./schema-db";
3
+ export declare const DIR_ENV = "ZEN_SCHEMA_DB";
4
+ /** How the directory was arrived at, which is what decides whether to say so. */
5
+ export type DirSource = 'flag' | 'env' | 'found' | 'default';
6
+ export interface Located {
7
+ dir: string;
8
+ from: DirSource;
9
+ }
10
+ export interface LocateOptions {
11
+ env?: NodeJS.ProcessEnv;
12
+ /** do not climb above this; the home directory, or the root, by default */
13
+ ceiling?: string;
14
+ }
15
+ export declare function locateIndex(cwd: string, flag?: string, options?: LocateOptions): Located;
16
+ /** Where a new index goes: the same environment variable, minus the search. */
17
+ export declare function outputDir(cwd: string, flag?: string, env?: NodeJS.ProcessEnv): string;
18
+ /** An index is a directory with a manifest in it; nothing else is asserted here. */
19
+ export declare function isIndex(dir: string): boolean;
20
+ //# sourceMappingURL=locate.d.ts.map
@@ -0,0 +1,141 @@
1
+ import { CliError, EXIT } from '@zenera/cli/lib';
2
+ import { readdirSync, statSync } from 'node:fs';
3
+ import { homedir } from 'node:os';
4
+ import { dirname, isAbsolute, join, parse, relative, resolve } from 'node:path';
5
+ import { MANIFEST_FILE } from "./files.js";
6
+ // ---------------------------------------------------------------------------
7
+ // Which index, when nobody said
8
+ //
9
+ // `--dir` and `$ZEN_SCHEMA_DB` are taken as written, missing or not: naming a
10
+ // directory that turns out not to hold an index has to fail saying so, because
11
+ // quietly using a different one would be a worse answer than an error.
12
+ //
13
+ // With neither, the directory is looked for. There is no list of blessed names
14
+ // here and there should not be — an index is self-describing, so what is being
15
+ // looked for is a `manifest.json`, not a directory called `schema-db`.
16
+ // `schema-db` is only the name a *new* index is given, and nothing reads it
17
+ // back. The search is nearest-first: this directory, then what is under it,
18
+ // then up a level and again, so `/assets/…/whatever` is reachable from
19
+ // `/workspace` because the two meet at a shared root on the way up.
20
+ //
21
+ // Three things bound it, and each is bounding a different kind of mistake.
22
+ // Depth and a visit budget bound the cost. The ceiling — the home directory,
23
+ // or the filesystem root when the search began outside it — bounds the
24
+ // blast radius, because an index in someone else's tree is not yours. And two
25
+ // indexes at the same distance is an ambiguity rather than a tie to break:
26
+ // choosing one silently is the one failure worth ruling out entirely, since
27
+ // the wrong index does not error, it answers confidently about another API.
28
+ // ---------------------------------------------------------------------------
29
+ /** The name a new index is given. Nothing searches for it; only `index --out` writes it. */
30
+ export const DEFAULT_DIR = './schema-db';
31
+ export const DIR_ENV = 'ZEN_SCHEMA_DB';
32
+ /** Far enough to climb out of a package into its workspace, not far enough to roam. */
33
+ const MAX_LEVELS = 6;
34
+ /** How far below a directory an index may sit and still count as being in it. */
35
+ const MAX_DEPTH = 3;
36
+ /** A directory with more entries than this is a data store, not a place to keep an index. */
37
+ const MAX_ENTRIES = 128;
38
+ /** Directories the whole search may read, however it is shaped. */
39
+ const MAX_VISITS = 400;
40
+ export function locateIndex(cwd, flag, options = {}) {
41
+ const env = options.env ?? process.env;
42
+ if (flag) {
43
+ return { dir: resolve(cwd, flag), from: 'flag' };
44
+ }
45
+ const named = env[DIR_ENV]?.trim();
46
+ if (named) {
47
+ return { dir: resolve(cwd, named), from: 'env' };
48
+ }
49
+ const found = search(resolve(cwd), options.ceiling ?? ceilingFor(cwd));
50
+ // Nothing found still answers with the default, so the error names the
51
+ // directory everyone expects rather than the last place that was searched.
52
+ return found
53
+ ? { dir: found, from: 'found' }
54
+ : { dir: resolve(cwd, DEFAULT_DIR), from: 'default' };
55
+ }
56
+ /** Where a new index goes: the same environment variable, minus the search. */
57
+ export function outputDir(cwd, flag, env = process.env) {
58
+ return resolve(cwd, flag ?? env[DIR_ENV]?.trim() ?? DEFAULT_DIR);
59
+ }
60
+ /** An index is a directory with a manifest in it; nothing else is asserted here. */
61
+ export function isIndex(dir) {
62
+ try {
63
+ return statSync(join(dir, MANIFEST_FILE)).isFile();
64
+ }
65
+ catch {
66
+ return false;
67
+ }
68
+ }
69
+ function search(cwd, ceiling) {
70
+ const budget = { left: MAX_VISITS, seen: new Set() };
71
+ let dir = cwd;
72
+ for (let level = 0; level < MAX_LEVELS; level++) {
73
+ const found = nearest(dir, budget);
74
+ if (found.length === 1) {
75
+ return found[0];
76
+ }
77
+ if (found.length > 1) {
78
+ throw new CliError(`more than one index is equally close to here: ${found.join(', ')}`, EXIT.usage, `say which with --dir, or set ${DIR_ENV}`);
79
+ }
80
+ budget.seen.add(dir);
81
+ const up = dirname(dir);
82
+ if (up === dir || dir === ceiling) {
83
+ break;
84
+ }
85
+ dir = up;
86
+ }
87
+ return undefined;
88
+ }
89
+ /** Every index at the shallowest depth that has any, so a tie can be reported as one. */
90
+ function nearest(root, budget) {
91
+ let frontier = [root];
92
+ for (let depth = 0; depth <= MAX_DEPTH && frontier.length > 0; depth++) {
93
+ const found = frontier.filter(isIndex);
94
+ if (found.length > 0) {
95
+ return found;
96
+ }
97
+ const next = [];
98
+ for (const dir of frontier) {
99
+ if (budget.left <= 0) {
100
+ return [];
101
+ }
102
+ budget.left--;
103
+ next.push(...children(dir).filter((child) => !budget.seen.has(child)));
104
+ }
105
+ frontier = next;
106
+ }
107
+ return [];
108
+ }
109
+ /**
110
+ * The subdirectories of one directory. Hidden directories and `node_modules`
111
+ * are skipped: an index kept out of sight is not one anybody meant to be found
112
+ * by looking.
113
+ */
114
+ function children(dir) {
115
+ let entries;
116
+ try {
117
+ entries = readdirSync(dir, { withFileTypes: true });
118
+ }
119
+ catch {
120
+ return [];
121
+ }
122
+ if (entries.length > MAX_ENTRIES) {
123
+ return [];
124
+ }
125
+ return entries
126
+ .filter((e) => e.isDirectory() && !e.name.startsWith('.') && e.name !== 'node_modules')
127
+ .map((e) => join(dir, e.name));
128
+ }
129
+ /**
130
+ * Home is the ceiling for anyone working inside it. Starting outside it —
131
+ * a container whose workspace is `/workspace`, a CI checkout — there is no
132
+ * home to stay within, so the root is the only stop.
133
+ */
134
+ function ceilingFor(cwd) {
135
+ const home = homedir();
136
+ const below = relative(home, resolve(cwd));
137
+ return below === '' || (!below.startsWith('..') && !isAbsolute(below))
138
+ ? home
139
+ : parse(resolve(cwd)).root;
140
+ }
141
+ //# sourceMappingURL=locate.js.map
@@ -1,4 +1,4 @@
1
- import type { ApiGraph, NodeAttrs, NodeKind } from './graph.ts';
1
+ import { type ApiGraph, type NodeAttrs, type NodeKind } from './graph.ts';
2
2
  import { type Matcher } from './match.ts';
3
3
  export interface Row extends NodeAttrs {
4
4
  id: string;
@@ -28,6 +28,9 @@ export interface Match {
28
28
  export interface GrepFilter {
29
29
  kinds?: readonly string[];
30
30
  source?: string;
31
+ /** the same two constraints `list` takes, so one question has one spelling */
32
+ name?: readonly Matcher[];
33
+ path?: readonly Matcher[];
31
34
  limit?: number;
32
35
  }
33
36
  export interface Grep {
@@ -41,4 +44,11 @@ export declare function grepNodes(graph: ApiGraph, match: Matcher, filter?: Grep
41
44
  export declare function propertyCount(graph: ApiGraph, id: string): number;
42
45
  /** That count, said properly, in the one phrasing the CLI and the tools share. */
43
46
  export declare const fields: (n: number) => string;
47
+ /**
48
+ * The route a node sits on. An operation carries its own; a parameter carries
49
+ * the operation's name instead, so it is looked up. A schema has no route at
50
+ * all and never will — the same DTO is returned by half the API — which is
51
+ * why a `--path` filter is a filter on the operations and what hangs off them.
52
+ */
53
+ export declare function routeOf(graph: ApiGraph, id: string, a: NodeAttrs): string;
44
54
  //# sourceMappingURL=lookup.d.ts.map
@@ -1,4 +1,5 @@
1
1
  import { textOf } from "./entities.js";
2
+ import { methodId } from "./graph.js";
2
3
  import { PatternError } from "./match.js";
3
4
  /** A scan is bounded, because a pattern may have come from a model. */
4
5
  const DEADLINE_MS = 2000;
@@ -12,7 +13,7 @@ export function listNodes(graph, filter) {
12
13
  if (filter.name && !filter.name.some((match) => match(a.name))) {
13
14
  return;
14
15
  }
15
- if (filter.path && !filter.path.some((match) => match(a.path))) {
16
+ if (filter.path && !matchesRoute(graph, id, a, filter.path)) {
16
17
  return;
17
18
  }
18
19
  rows.push({ ...a, id });
@@ -36,6 +37,12 @@ export function grepNodes(graph, match, filter = {}) {
36
37
  if (filter.source && a.source !== filter.source) {
37
38
  continue;
38
39
  }
40
+ if (filter.name && !filter.name.some((match) => match(a.name))) {
41
+ continue;
42
+ }
43
+ if (filter.path && !matchesRoute(graph, id, a, filter.path)) {
44
+ continue;
45
+ }
39
46
  const text = textOf(graph, id);
40
47
  if (match(text)) {
41
48
  matches.push({ id, attributes: a, text });
@@ -53,7 +60,27 @@ export function propertyCount(graph, id) {
53
60
  }
54
61
  /** That count, said properly, in the one phrasing the CLI and the tools share. */
55
62
  export const fields = (n) => `${n} ${n === 1 ? 'field' : 'fields'}`;
63
+ /**
64
+ * The route a node sits on. An operation carries its own; a parameter carries
65
+ * the operation's name instead, so it is looked up. A schema has no route at
66
+ * all and never will — the same DTO is returned by half the API — which is
67
+ * why a `--path` filter is a filter on the operations and what hangs off them.
68
+ */
69
+ export function routeOf(graph, id, a) {
70
+ if (a.path) {
71
+ return a.path;
72
+ }
73
+ if (a.kind !== 'property' || !a.parent) {
74
+ return '';
75
+ }
76
+ const owner = methodId(a.parent);
77
+ return graph.hasNode(owner) ? graph.getNodeAttribute(owner, 'path') : '';
78
+ }
56
79
  // ---------------------------------------------------------------------------
80
+ function matchesRoute(graph, id, a, patterns) {
81
+ const route = routeOf(graph, id, a);
82
+ return route !== '' && patterns.some((match) => match(route));
83
+ }
57
84
  function passes(a, filter) {
58
85
  if (filter.source && a.source !== filter.source) {
59
86
  return false;
@@ -29,6 +29,10 @@ export declare const isGlob: (pattern: string) => boolean;
29
29
  * a glob; without one, a substring — because `password` typed into `--name` is
30
30
  * a search for the word, and a whole-string match would answer nothing and
31
31
  * look like the field does not exist.
32
+ *
33
+ * `regex` settles it outright, and has to: a star is punctuation in both
34
+ * languages, so `^/(users|teams)/.*` read as a glob would match nothing and
35
+ * never say why.
32
36
  */
33
37
  export declare function loose(pattern: string, options?: MatchOptions): Matcher;
34
38
  /** True when any of the patterns matches; no patterns means no opinion. */
@@ -53,8 +53,15 @@ export const isGlob = (pattern) => /[*?]/.test(pattern);
53
53
  * a glob; without one, a substring — because `password` typed into `--name` is
54
54
  * a search for the word, and a whole-string match would answer nothing and
55
55
  * look like the field does not exist.
56
+ *
57
+ * `regex` settles it outright, and has to: a star is punctuation in both
58
+ * languages, so `^/(users|teams)/.*` read as a glob would match nothing and
59
+ * never say why.
56
60
  */
57
61
  export function loose(pattern, options = {}) {
62
+ if (options.regex) {
63
+ return matcher(pattern, options);
64
+ }
58
65
  return isGlob(pattern) ? wildcard(pattern, options) : matcher(pattern, options);
59
66
  }
60
67
  /** True when any of the patterns matches; no patterns means no opinion. */
@@ -3,7 +3,15 @@ export type RenderFormat = 'text' | 'mermaid' | 'mermaid-flowchart';
3
3
  export interface RenderOptions {
4
4
  docs?: boolean;
5
5
  maxDoc?: number;
6
+ /** name the document each operation and schema came from */
7
+ source?: boolean;
6
8
  }
9
+ /**
10
+ * Which document something came from, spelled one way everywhere. An index
11
+ * over four revisions of one API has four `GET /infra/tier-0s`, and a listing
12
+ * that does not say which is which is a listing you have to go and check.
13
+ */
14
+ export declare const sourceTag: (source: string) => string;
7
15
  export declare function render(sub: Subgraph, format: RenderFormat, options?: RenderOptions): string;
8
16
  export declare function toText(sub: Subgraph, options?: RenderOptions): string;
9
17
  export declare function toMermaid(sub: Subgraph, options?: RenderOptions): string;
@@ -1,4 +1,10 @@
1
1
  const HIT = '»';
2
+ /**
3
+ * Which document something came from, spelled one way everywhere. An index
4
+ * over four revisions of one API has four `GET /infra/tier-0s`, and a listing
5
+ * that does not say which is which is a listing you have to go and check.
6
+ */
7
+ export const sourceTag = (source) => (source ? `[source: ${source}]` : '');
2
8
  export function render(sub, format, options = {}) {
3
9
  switch (format) {
4
10
  case 'mermaid':
@@ -44,7 +50,7 @@ export function toText(sub, options = {}) {
44
50
  function methodLines(view, method, options) {
45
51
  const a = method.attributes;
46
52
  const out = [
47
- ` ${mark(method)}${a.httpMethod} ${a.path} ${a.name}${doc(method, options, ' —')}`,
53
+ ` ${mark(method)}${a.httpMethod} ${a.path} ${a.name}${from(method, options)}${doc(method, options, ' —')}`,
48
54
  ];
49
55
  for (const edge of view.out(method.id, 'HAS_PARAM')) {
50
56
  const node = view.node(edge.target);
@@ -62,7 +68,7 @@ function methodLines(view, method, options) {
62
68
  }
63
69
  function typeLines(view, type, options) {
64
70
  const out = [
65
- ` ${mark(type)}${type.attributes.name}${side(type.attributes)}${doc(type, options, ' —')}`,
71
+ ` ${mark(type)}${type.attributes.name}${side(type.attributes)}${from(type, options)}${doc(type, options, ' —')}`,
66
72
  ];
67
73
  const composes = view.out(type.id, 'COMPOSES').map((e) => view.name(e.target));
68
74
  if (composes.length > 0) {
@@ -96,6 +102,10 @@ function doc(node, options, lead) {
96
102
  }
97
103
  return `${lead} ${clip(node.attributes.doc, options.maxDoc ?? 120)}`;
98
104
  }
105
+ function from(node, options) {
106
+ const tag = options.source ? sourceTag(node.attributes.source) : '';
107
+ return tag ? ` ${tag}` : '';
108
+ }
99
109
  // ---------------------------------------------------------------------------
100
110
  // Mermaid
101
111
  // ---------------------------------------------------------------------------
@@ -5,6 +5,8 @@ export interface SchemaToolOptions {
5
5
  /** what `format` defaults to when the model does not say */
6
6
  format?: Format;
7
7
  docs?: boolean;
8
+ /** name the document each answer came from; on by default past one document */
9
+ source?: boolean;
8
10
  }
9
11
  export declare function schemaTools<TCtx = unknown>(index: SchemaIndex, options?: SchemaToolOptions): AnyTool<TCtx>[];
10
12
  //# sourceMappingURL=tools.d.ts.map
@@ -4,6 +4,7 @@ import { isEmpty, parseQuery, QueryError } from "../query.js";
4
4
  import { toTypeScript } from "./hydrate.js";
5
5
  import { fields, grepNodes, listNodes, propertyCount } from "./lookup.js";
6
6
  import { loose, matcher, PatternError } from "./match.js";
7
+ import { sourceTag } from "./render.js";
7
8
  import { stitch } from "./subgraph.js";
8
9
  // ---------------------------------------------------------------------------
9
10
  // The same index, given to an agent
@@ -31,6 +32,10 @@ const MAX_ROWS = 200;
31
32
  export function schemaTools(index, options = {}) {
32
33
  const fallback = options.format ?? 'text';
33
34
  const docs = options.docs ?? true;
35
+ // With one document there is nothing to disambiguate and naming it on every
36
+ // line is prompt spent saying the same word; with several it is the only
37
+ // way to tell two revisions of one API apart.
38
+ const source = options.source ?? index.manifest.sources.length > 1;
34
39
  const searchApi = tool({
35
40
  name: 'search_api',
36
41
  group: GROUP,
@@ -107,7 +112,10 @@ export function schemaTools(index, options = {}) {
107
112
  found: result.subgraphs.length,
108
113
  ids: result.subgraphs.flatMap((s) => s.hits),
109
114
  truncated: result.subgraphs.some((s) => s.truncated),
110
- api: await present(index, result.subgraphs, chosen(format, fallback), { docs }),
115
+ api: await present(index, result.subgraphs, chosen(format, fallback), {
116
+ docs,
117
+ source,
118
+ }),
111
119
  };
112
120
  },
113
121
  });
@@ -209,12 +217,24 @@ export function schemaTools(index, options = {}) {
209
217
  description: 'Match the name. A plain word matches anywhere in it; use * and ? ' +
210
218
  'to match the whole name, e.g. "*Password*".',
211
219
  },
212
- path: { type: 'string', description: 'Match the route, e.g. "/users*".' },
220
+ path: {
221
+ type: 'string',
222
+ description: 'Match the route, e.g. "/users*". Keeps operations and their ' +
223
+ 'parameters; a schema sits on no one route, so it is left out.',
224
+ },
225
+ regex: {
226
+ type: 'boolean',
227
+ description: 'Read `name` and `path` as regular expressions instead.',
228
+ },
213
229
  method_type: {
214
230
  type: 'string',
215
231
  enum: ['read_only', 'read_write', 'any'],
216
232
  },
217
233
  direction: { type: 'string', enum: ['input', 'output', 'any'] },
234
+ source: {
235
+ type: 'string',
236
+ description: 'Only this document, when the index holds more than one.',
237
+ },
218
238
  limit: {
219
239
  type: 'integer',
220
240
  description: `Rows to return. Default ${DEFAULT_ROWS}.`,
@@ -222,7 +242,7 @@ export function schemaTools(index, options = {}) {
222
242
  },
223
243
  additionalProperties: false,
224
244
  },
225
- execute: async ({ kind, name, path, method_type, direction, limit }) => {
245
+ execute: async ({ kind, name, path, regex, method_type, direction, source: only, limit, }) => {
226
246
  const subject = SUBJECTS[kind ?? 'methods'];
227
247
  if (!subject) {
228
248
  return { error: `cannot list "${kind}"`, hint: 'kind is methods, types or fields' };
@@ -231,8 +251,9 @@ export function schemaTools(index, options = {}) {
231
251
  try {
232
252
  result = listNodes(index.graph, {
233
253
  kind: subject,
234
- name: name ? [loose(name)] : undefined,
235
- path: path ? [loose(path)] : undefined,
254
+ name: name ? [loose(name, { regex })] : undefined,
255
+ path: path ? [loose(path, { regex })] : undefined,
256
+ source: only,
236
257
  methodType: enumerated(method_type),
237
258
  direction: enumerated(direction),
238
259
  limit: Math.min(limit ?? DEFAULT_ROWS, MAX_ROWS),
@@ -244,7 +265,7 @@ export function schemaTools(index, options = {}) {
244
265
  return {
245
266
  found: result.found,
246
267
  truncated: result.truncated,
247
- [PLURALS[subject]]: result.rows.map((r) => line(index.graph, subject, r)),
268
+ [PLURALS[subject]]: result.rows.map((r) => line(index.graph, subject, r, source)),
248
269
  };
249
270
  },
250
271
  });
@@ -255,7 +276,8 @@ export function schemaTools(index, options = {}) {
255
276
  'operations, schemas and fields alike. No embeddings and no ranking, so ' +
256
277
  'nothing is missed for being an unusual word or an odd spelling. This is the ' +
257
278
  'tool for "does X exist anywhere", and for checking that a search which ' +
258
- 'returned nothing really means there is nothing.',
279
+ 'returned nothing really means there is nothing. Narrow it with `path` or ' +
280
+ '`name` when the word is common and only one corner of the API is meant.',
259
281
  parameters: {
260
282
  type: 'object',
261
283
  properties: {
@@ -268,6 +290,18 @@ export function schemaTools(index, options = {}) {
268
290
  description: 'Read the pattern as a regular expression instead.',
269
291
  },
270
292
  kind: { type: 'string', enum: ['method', 'type', 'property'] },
293
+ name: {
294
+ type: 'string',
295
+ description: 'Only nodes whose own name matches this. * and ? allowed.',
296
+ },
297
+ path: {
298
+ type: 'string',
299
+ description: 'Only what sits on a matching route, e.g. "/users*".',
300
+ },
301
+ source: {
302
+ type: 'string',
303
+ description: 'Only this document, when the index holds more than one.',
304
+ },
271
305
  limit: {
272
306
  type: 'integer',
273
307
  description: `Matches to return. Default ${DEFAULT_ROWS}. \`found\` always counts them all.`,
@@ -276,11 +310,14 @@ export function schemaTools(index, options = {}) {
276
310
  required: ['pattern'],
277
311
  additionalProperties: false,
278
312
  },
279
- execute: async ({ pattern, regex, kind, limit }) => {
313
+ execute: async ({ pattern, regex, kind, name, path, source: only, limit }) => {
280
314
  let result;
281
315
  try {
282
316
  result = grepNodes(index.graph, matcher(pattern, { regex }), {
283
317
  kinds: kind ? [kind] : undefined,
318
+ name: name ? [loose(name)] : undefined,
319
+ path: path ? [loose(path)] : undefined,
320
+ source: only,
284
321
  limit: Math.min(limit ?? DEFAULT_ROWS, MAX_ROWS),
285
322
  });
286
323
  }
@@ -296,7 +333,11 @@ export function schemaTools(index, options = {}) {
296
333
  return {
297
334
  found: result.found,
298
335
  truncated: result.truncated,
299
- matches: result.matches.map((m) => ({ id: m.id, text: m.text })),
336
+ matches: result.matches.map((m) => ({
337
+ id: m.id,
338
+ ...(source ? { source: m.attributes.source } : {}),
339
+ text: m.text,
340
+ })),
300
341
  };
301
342
  },
302
343
  });
@@ -315,16 +356,17 @@ const PLURALS = {
315
356
  property: 'properties',
316
357
  };
317
358
  /** One row, as the line a model reads rather than an object it has to walk. */
318
- function line(graph, kind, row) {
359
+ function line(graph, kind, row, source = false) {
360
+ const from = source ? ` ${sourceTag(row.source)}` : '';
319
361
  if (kind === 'method') {
320
- return `${row.httpMethod} ${row.path} ${row.name}${row.doc ? ` — ${row.doc}` : ''}`;
362
+ return `${row.httpMethod} ${row.path} ${row.name}${from}${row.doc ? ` — ${row.doc}` : ''}`;
321
363
  }
322
364
  if (kind === 'type') {
323
365
  const side = row.direction === 'none' ? '' : ` (${row.direction})`;
324
- return `${row.name}${side} ${fields(propertyCount(graph, row.id))}${row.doc ? ` — ${row.doc}` : ''}`;
366
+ return `${row.name}${side} ${fields(propertyCount(graph, row.id))}${from}${row.doc ? ` — ${row.doc}` : ''}`;
325
367
  }
326
368
  const owner = row.parent ? `${row.parent}.` : '';
327
- return `${owner}${row.name}${row.required ? '' : '?'}: ${row.signature || 'unknown'}`;
369
+ return `${owner}${row.name}${row.required ? '' : '?'}: ${row.signature || 'unknown'}${from}`;
328
370
  }
329
371
  const enumerated = (value) => value && value !== 'any' ? value : undefined;
330
372
  function list(description) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zenera/rag",
3
- "version": "1.1.5",
3
+ "version": "1.1.6",
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.5",
57
- "@zenera/neo": "^1.1.5"
56
+ "@zenera/cli": "^1.1.6",
57
+ "@zenera/neo": "^1.1.6"
58
58
  }
59
59
  }