@zenera/rag 1.1.21 → 1.1.22

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
@@ -113,6 +113,8 @@ standalone OpenAPI document.
113
113
 
114
114
  ```
115
115
  zen rag schema index <spec...> Read the documents and write a searchable index.
116
+ zen rag schema restore Re-embed an index from its own copies of the documents.
117
+ zen rag schema ready Can this index answer? Exit 0 if it can, 3 if it cannot.
116
118
  zen rag schema search Ask it something. --interactive for a prompt.
117
119
  zen rag schema list <what> Every method, type or property. No ranking.
118
120
  zen rag schema grep <pattern> Every literal match across the whole index.
@@ -306,6 +308,8 @@ in line numbers and the file can be edited from the answer.
306
308
 
307
309
  ```
308
310
  zen rag docs index <path...> Read the documents and write a searchable index.
311
+ zen rag docs restore Re-embed an index from its own copies of the documents.
312
+ zen rag docs ready Can this index answer? Exit 0 if it can, 3 if it cannot.
309
313
  zen rag docs search [text] Ask it something. --interactive for a prompt.
310
314
  zen rag docs list <what> Every document, section or table. No ranking.
311
315
  zen rag docs grep <pattern> Every matching line, with the section it sits in.
@@ -471,6 +475,17 @@ references; document indexes preserve the source material used for retrieval.
471
475
  For documents, search results are always grounded in the original text, with
472
476
  source names and line ranges available for inspection and follow-up work.
473
477
 
478
+ Because the documents travel inside the index, it can be rebuilt from itself.
479
+ Vector files are large and binary, so a project typically commits the index and
480
+ git-ignores `lance/`; a fresh checkout then has an index that looks complete and
481
+ cannot answer. `ready` reports that in an exit code, and `restore` re-embeds
482
+ from the copies already present.
483
+
484
+ ```sh
485
+ zen rag docs ready --dir assets/docs-db # exit 0 searchable, 3 not
486
+ zen rag docs restore --dir assets/docs-db # re-embed from sources/
487
+ ```
488
+
474
489
  <details>
475
490
  <summary>Built for project workflows</summary>
476
491
 
@@ -493,6 +508,10 @@ zen rag schema index --embedding openai:text-embedding-3-small ./specs
493
508
  The selected provider stays associated with the index, ensuring search uses a
494
509
  compatible representation of its content.
495
510
 
511
+ To change it later, `restore --embedding <ref>` re-embeds an existing index from
512
+ its own copies and rewrites the manifest, so no source material has to be
513
+ located again.
514
+
496
515
  ### Efficient rebuilds
497
516
 
498
517
  Rebuild an index as content changes without repeating work for unchanged files
@@ -1,4 +1,7 @@
1
1
  export declare const MANIFEST_FILE = "manifest.json";
2
+ /** Where the vectors live, in both kinds of index. */
3
+ export declare const LANCE_DIR = "lance";
4
+ export declare const lancePath: (dir: string) => string;
2
5
  export type IndexKind = 'schema' | 'docs';
3
6
  /** How to name a kind when something has to be said about it. */
4
7
  export declare const SUBJECT: Record<IndexKind, {
@@ -53,4 +56,28 @@ export declare function readHead<T extends IndexHead>(dir: string, spec: IndexSp
53
56
  * something anyone should have to remember.
54
57
  */
55
58
  export declare function assertSameEmbedding(head: IndexHead, ref: string): void;
59
+ export type IndexState = 'absent' | 'incomplete' | 'ready';
60
+ interface Closable {
61
+ close(): void;
62
+ }
63
+ export interface IndexHealth<T extends IndexHead = IndexHead> {
64
+ dir: string;
65
+ state: IndexState;
66
+ /** absent when there is no manifest to read */
67
+ head?: T;
68
+ /** what is the matter, or what is there, in one line */
69
+ reason: string;
70
+ /** the command that would change the answer; empty when the answer is yes */
71
+ fix: string;
72
+ }
73
+ /**
74
+ * `open` is the subject's own store opener, so the readiness of an index is
75
+ * decided by the same code a search would use rather than by a second opinion
76
+ * about what a directory ought to contain.
77
+ *
78
+ * A manifest of the wrong kind or the wrong version still throws: those are not
79
+ * states of this index, they are the caller asking about a different one.
80
+ */
81
+ export declare function inspectIndex<T extends IndexHead>(dir: string, spec: IndexSpec, open: (dir: string) => Promise<Closable>): Promise<IndexHealth<T>>;
82
+ export {};
56
83
  //# sourceMappingURL=manifest.d.ts.map
@@ -1,4 +1,5 @@
1
1
  import { CliError, EXIT } from '@zenera/cli/lib';
2
+ import { existsSync } from 'node:fs';
2
3
  import { readFile } from 'node:fs/promises';
3
4
  import { join } from 'node:path';
4
5
  // ---------------------------------------------------------------------------
@@ -15,6 +16,9 @@ import { join } from 'node:path';
15
16
  // worth spending a field on.
16
17
  // ---------------------------------------------------------------------------
17
18
  export const MANIFEST_FILE = 'manifest.json';
19
+ /** Where the vectors live, in both kinds of index. */
20
+ export const LANCE_DIR = 'lance';
21
+ export const lancePath = (dir) => join(dir, LANCE_DIR);
18
22
  /** How to name a kind when something has to be said about it. */
19
23
  export const SUBJECT = {
20
24
  schema: { label: 'a schema index', command: 'zen rag schema' },
@@ -56,7 +60,49 @@ export async function readHead(dir, spec) {
56
60
  */
57
61
  export function assertSameEmbedding(head, ref) {
58
62
  if (ref !== head.embedding.ref && ref !== head.embedding.id) {
59
- throw new CliError(`this index was built with ${head.embedding.ref}, not ${ref}`, EXIT.invalid, `search it with --embedding ${head.embedding.ref}, or rebuild it`);
63
+ throw new CliError(`this index was built with ${head.embedding.ref}, not ${ref}`, EXIT.invalid, `search it with --embedding ${head.embedding.ref}, or move it to ${ref} with ` +
64
+ `\`${SUBJECT[head.kind ?? 'schema'].command} restore --embedding ${ref}\``);
60
65
  }
61
66
  }
67
+ /**
68
+ * `open` is the subject's own store opener, so the readiness of an index is
69
+ * decided by the same code a search would use rather than by a second opinion
70
+ * about what a directory ought to contain.
71
+ *
72
+ * A manifest of the wrong kind or the wrong version still throws: those are not
73
+ * states of this index, they are the caller asking about a different one.
74
+ */
75
+ export async function inspectIndex(dir, spec, open) {
76
+ const { command } = SUBJECT[spec.kind];
77
+ if (!existsSync(join(dir, MANIFEST_FILE))) {
78
+ return {
79
+ dir,
80
+ state: 'absent',
81
+ reason: `${dir} does not hold an index`,
82
+ fix: `build one with \`${command} index\``,
83
+ };
84
+ }
85
+ const head = await readHead(dir, spec);
86
+ let store;
87
+ try {
88
+ store = await open(dir);
89
+ }
90
+ catch {
91
+ return {
92
+ dir,
93
+ state: 'incomplete',
94
+ head,
95
+ reason: `${dir} has no vectors — \`${LANCE_DIR}/\` is missing or unreadable`,
96
+ fix: `re-embed them with \`${command} restore --dir ${dir}\``,
97
+ };
98
+ }
99
+ store.close();
100
+ return {
101
+ dir,
102
+ state: 'ready',
103
+ head,
104
+ reason: `${dir} is searchable — ${head.embedding.ref} (${head.embedding.dimensions}d)`,
105
+ fix: '',
106
+ };
107
+ }
62
108
  //# sourceMappingURL=manifest.js.map
@@ -11,6 +11,13 @@ export declare const breakdown: (timings: readonly {
11
11
  name: string;
12
12
  ms: number;
13
13
  }[]) => string;
14
+ /**
15
+ * The silence after the last embedded row. Building the full-text and vector
16
+ * indexes is one call that reports nothing until it returns, and on a large
17
+ * corpus that is minutes — so it is announced, or it reads as a hang. Small
18
+ * indexes finish before the line is read, and are not promised a wait.
19
+ */
20
+ export declare function indexing(rows: number, unit: string): string[];
14
21
  /** The first line only: a stack trace in a README helps nobody. */
15
22
  export declare function message(reason: unknown): string;
16
23
  export declare function searched(indexes: {
@@ -49,6 +49,22 @@ export function span(ms) {
49
49
  }
50
50
  /** Where the time went, which is the only way to know what is worth making faster. */
51
51
  export const breakdown = (timings) => timings.map((t) => `${t.name} ${span(t.ms)}`).join(' · ');
52
+ /**
53
+ * The silence after the last embedded row. Building the full-text and vector
54
+ * indexes is one call that reports nothing until it returns, and on a large
55
+ * corpus that is minutes — so it is announced, or it reads as a hang. Small
56
+ * indexes finish before the line is read, and are not promised a wait.
57
+ */
58
+ export function indexing(rows, unit) {
59
+ const lines = [`indexing ${rows} ${unit} for search: full text, then vectors …`];
60
+ if (rows >= 50_000) {
61
+ lines.push('this step reports no progress, and at this size it takes several minutes');
62
+ }
63
+ else if (rows >= 5_000) {
64
+ lines.push('this step reports no progress until it is done');
65
+ }
66
+ return lines;
67
+ }
52
68
  /** The first line only: a stack trace in a README helps nobody. */
53
69
  export function message(reason) {
54
70
  const text = reason instanceof Error ? reason.message : String(reason);
@@ -0,0 +1,30 @@
1
+ import { type Context } from '@zenera/cli/lib';
2
+ import type { IndexHead, IndexHealth } from './manifest.ts';
3
+ export interface Staging {
4
+ /** where the rebuild is written */
5
+ dir: string;
6
+ /** the old index, kept until the new one is in place */
7
+ previous: string;
8
+ }
9
+ export declare function stagingFor(dir: string): Staging;
10
+ /**
11
+ * Two renames, because a rename cannot replace a directory that has anything in
12
+ * it. If the second fails the first is undone, so the window in which neither
13
+ * directory is the index is as short as two syscalls and never ends with one
14
+ * missing.
15
+ */
16
+ export declare function swapIn(dir: string, staging: Staging): Promise<void>;
17
+ /**
18
+ * The answer to `ready` is the exit code, so that a setup step can be written
19
+ * as `if zen rag docs ready -d "$OUT" --quiet; then …` and never have to parse
20
+ * anything. With `--json` the same answer is a document, and it is printed for
21
+ * both outcomes: a caller that asked for JSON gets JSON whatever the news is.
22
+ */
23
+ export declare function reportReady(health: IndexHealth, ctx: Context, quiet?: boolean): void;
24
+ /**
25
+ * What a restore is about to do, said before it starts, because the two things
26
+ * worth objecting to — a different model, and chunking that was never recorded
27
+ * — are both cheap to say and expensive to discover afterwards.
28
+ */
29
+ export declare function announce(head: IndexHead, ref: string, entities: string): void;
30
+ //# sourceMappingURL=restore.d.ts.map
@@ -0,0 +1,65 @@
1
+ import { CliError, dim, EXIT, json, note } from '@zenera/cli/lib';
2
+ import { rename, rm } from 'node:fs/promises';
3
+ import { basename, dirname, join } from 'node:path';
4
+ export function stagingFor(dir) {
5
+ const parent = dirname(dir);
6
+ const name = basename(dir);
7
+ return {
8
+ dir: join(parent, `.${name}.restoring`),
9
+ previous: join(parent, `.${name}.previous`),
10
+ };
11
+ }
12
+ /**
13
+ * Two renames, because a rename cannot replace a directory that has anything in
14
+ * it. If the second fails the first is undone, so the window in which neither
15
+ * directory is the index is as short as two syscalls and never ends with one
16
+ * missing.
17
+ */
18
+ export async function swapIn(dir, staging) {
19
+ await rm(staging.previous, { recursive: true, force: true });
20
+ await rename(dir, staging.previous);
21
+ try {
22
+ await rename(staging.dir, dir);
23
+ }
24
+ catch (err) {
25
+ await rename(staging.previous, dir).catch(() => undefined);
26
+ throw err;
27
+ }
28
+ await rm(staging.previous, { recursive: true, force: true });
29
+ }
30
+ /**
31
+ * The answer to `ready` is the exit code, so that a setup step can be written
32
+ * as `if zen rag docs ready -d "$OUT" --quiet; then …` and never have to parse
33
+ * anything. With `--json` the same answer is a document, and it is printed for
34
+ * both outcomes: a caller that asked for JSON gets JSON whatever the news is.
35
+ */
36
+ export function reportReady(health, ctx, quiet = false) {
37
+ if (ctx.json) {
38
+ json({
39
+ ready: health.state === 'ready',
40
+ dir: health.dir,
41
+ state: health.state,
42
+ embedding: health.head?.embedding ?? null,
43
+ reason: health.reason,
44
+ ...(health.fix ? { fix: health.fix } : {}),
45
+ });
46
+ }
47
+ if (health.state !== 'ready') {
48
+ throw new CliError(health.reason, EXIT.invalid, health.fix);
49
+ }
50
+ if (!quiet && !ctx.json) {
51
+ note(dim(` ${health.reason}`));
52
+ }
53
+ }
54
+ /**
55
+ * What a restore is about to do, said before it starts, because the two things
56
+ * worth objecting to — a different model, and chunking that was never recorded
57
+ * — are both cheap to say and expensive to discover afterwards.
58
+ */
59
+ export function announce(head, ref, entities) {
60
+ if (ref !== head.embedding.ref) {
61
+ note(dim(` embedder ${head.embedding.ref} → ${ref}; the manifest will be rewritten`));
62
+ }
63
+ note(dim(` re-embedding ${entities} with ${ref} …`));
64
+ }
65
+ //# sourceMappingURL=restore.js.map
@@ -15,6 +15,8 @@ export interface BuildOptions {
15
15
  /** told the manifest, so a store can say what wrote it */
16
16
  indexer: string;
17
17
  chunk?: ChunkOptions;
18
+ /** name the documents relative to this, rather than to their common root */
19
+ root?: string;
18
20
  /**
19
21
  * The width asked of the embedder, when one was asked for. Part of the cache
20
22
  * key, because a truncated vector is a different vector; left undefined when
@@ -32,6 +34,8 @@ export interface BuildOptions {
32
34
  /** documents parsed so far, and what the pool is still on */
33
35
  onReading?: (done: number, total: number, pending: readonly string[]) => void;
34
36
  onProgress?: (done: number, total: number) => void;
37
+ /** every vector is in and the search indexes are about to be built, silently */
38
+ onWriting?: (rows: number) => void;
35
39
  }
36
40
  export interface BuildSummary {
37
41
  sources: DocRecord[];
@@ -26,6 +26,7 @@ export async function buildIndex(options) {
26
26
  try {
27
27
  const corpus = await loadDocuments(options.files, options.cwd, {
28
28
  chunk: options.chunk,
29
+ root: options.root,
29
30
  cache: options.cache !== false,
30
31
  cacheDir: options.cacheDir,
31
32
  onProgress: (done, total, pending) => {
@@ -60,6 +61,7 @@ export async function buildIndex(options) {
60
61
  writer = await openChunks(options.out);
61
62
  const dimensions = await embedAll(chunks, options, journal, cache, writer);
62
63
  journal.phase('writing');
64
+ options.onWriting?.(chunks.length);
63
65
  const written = await writer.finish();
64
66
  const manifest = {
65
67
  version: INDEX_VERSION,
@@ -74,6 +76,7 @@ export async function buildIndex(options) {
74
76
  },
75
77
  sources,
76
78
  counts,
79
+ chunk: settingsOf(options.chunk),
77
80
  indexes: { fts: written.fts, vector: written.vector },
78
81
  };
79
82
  const outline = { files: corpus.docs.map((doc) => doc.outline) };
@@ -95,6 +98,25 @@ export async function buildIndex(options) {
95
98
  throw err;
96
99
  }
97
100
  }
101
+ /**
102
+ * What the manifest keeps of the chunk settings: the numbers, and only the ones
103
+ * actually given. A `tokenCount` function cannot be written down, and a build
104
+ * handed one is a build a restore cannot reproduce from the manifest alone —
105
+ * which the omission says, rather than a recorded default pretending otherwise.
106
+ */
107
+ function settingsOf(chunk) {
108
+ if (!chunk) {
109
+ return undefined;
110
+ }
111
+ const { chunkTokens, minChunkTokens, maxChunkTokens, tableSliceTokens } = chunk;
112
+ const settings = {
113
+ ...(chunkTokens === undefined ? {} : { chunkTokens }),
114
+ ...(minChunkTokens === undefined ? {} : { minChunkTokens }),
115
+ ...(maxChunkTokens === undefined ? {} : { maxChunkTokens }),
116
+ ...(tableSliceTokens === undefined ? {} : { tableSliceTokens }),
117
+ };
118
+ return Object.keys(settings).length > 0 ? settings : undefined;
119
+ }
98
120
  /** One row per chunk, with the render set encoded and the document name on it. */
99
121
  function recordsOf(corpus) {
100
122
  return corpus.docs.flatMap((doc) => doc.chunks.map((chunk) => ({
@@ -1,20 +1,23 @@
1
1
  import { bold, CliError, cyan, dim, EXIT, isInteractive, json, note, parse, paths, table, usageError, write, } from '@zenera/cli/lib';
2
- import { relative, resolve } from 'node:path';
2
+ import { existsSync } from 'node:fs';
3
+ import { join, relative, resolve } from 'node:path';
3
4
  import { resolveEmbedder } from "../common/embedder.js";
4
5
  import { locateIndex, outputDir } from "../common/locate.js";
5
- import { assertSameEmbedding } from "../common/manifest.js";
6
+ import { assertSameEmbedding, inspectIndex } from "../common/manifest.js";
6
7
  import { PatternError } from "../common/match.js";
7
8
  import { INTERVAL_MS } from "../common/progress.js";
8
- import { breakdown, grid } from "../common/prose.js";
9
+ import { breakdown, grid, indexing } from "../common/prose.js";
10
+ import { announce, reportReady, stagingFor, swapIn } from "../common/restore.js";
9
11
  import { assemble, DEFAULT_MAX_LINES } from "./assemble.js";
10
12
  import { buildIndex } from "./build.js";
11
13
  import { CHUNK_KINDS } from "./chunk.js";
12
- import { DOCS_INDEX, readManifest } from "./files.js";
14
+ import { DOCS_INDEX, readManifest, SOURCES_DIR } from "./files.js";
13
15
  import { DOC_EXTENSIONS } from "./load.js";
14
16
  import { grepLines, listFiles, listSections, listTables, readRange, readSection, } from "./lookup.js";
15
17
  import { MATCH_HEADERS, matchRows, renderAssembly } from "./render.js";
16
18
  import { repl } from "./repl.js";
17
19
  import { DEFAULT_LIMIT, DocsIndex, legsOf, SEARCH_MODES, } from "./search.js";
20
+ import { ChunkStore } from "./store.js";
18
21
  const { defaultDir: DEFAULT_DIR, envName: DIR_ENV } = DOCS_INDEX;
19
22
  // ---------------------------------------------------------------------------
20
23
  // zen rag docs — a pile of documents, as something to search
@@ -39,8 +42,10 @@ const { defaultDir: DEFAULT_DIR, envName: DIR_ENV } = DOCS_INDEX;
39
42
  // answer "does the word `deprecated` appear anywhere" — the honest answer is
40
43
  // every match or none, and these give it with no model and no credential.
41
44
  // ---------------------------------------------------------------------------
42
- const USAGE = 'zen rag docs <index|search|list|grep|show|stats> [args...]';
45
+ const USAGE = 'zen rag docs <index|restore|ready|search|list|grep|show|stats> [args...]';
43
46
  const INDEX_USAGE = 'zen rag docs index --embedding <ref> [--out <dir>] <path...>';
47
+ const RESTORE_USAGE = 'zen rag docs restore [--dir <dir>] [--embedding <ref>]';
48
+ const READY_USAGE = 'zen rag docs ready [--dir <dir>] [--quiet]';
44
49
  const SEARCH_USAGE = 'zen rag docs search [--dir <dir>] [query... | --text-query <text> --vector-query <text>]';
45
50
  const LIST_USAGE = 'zen rag docs list <files|sections|tables> [--dir <dir>]';
46
51
  const GREP_USAGE = 'zen rag docs grep <pattern> [--dir <dir>]';
@@ -55,6 +60,11 @@ export const command = {
55
60
  ' index <path...>',
56
61
  dim('Build or refresh an index before searching changed documents.'),
57
62
  ],
63
+ [
64
+ ' restore',
65
+ dim('Re-embed an index from its own copies, after a clone or a model change.'),
66
+ ],
67
+ [' ready', dim('Ask whether an index can answer. Exit 0 if it can, 3 if it cannot.')],
58
68
  [
59
69
  ' search [text]',
60
70
  dim('Rank relevant passages with hybrid full-text and vector search.'),
@@ -101,6 +111,29 @@ export const command = {
101
111
  dim(' Every document is copied into the index, so it stays portable and'),
102
112
  dim(' every quoted line comes from the document rather than a rebuild of it.'),
103
113
  '',
114
+ 'Restore — the vectors, from the copies the index already holds',
115
+ ...table([
116
+ [' -d, --dir <dir>', dim(`Which index. Found from here if unset; see ${DIR_ENV}.`)],
117
+ [' --embedding <ref>', dim('Move to another embedder. Default: the one recorded.')],
118
+ [' --dimensions <n>', dim('Narrower vectors. Default: the width recorded.')],
119
+ [' --chunk-tokens <n>', dim('Re-chunk at this size. Default: the size recorded.')],
120
+ [' --batch <n>', dim("Texts per embedding request. Default: the model's own cap.")],
121
+ [' --no-cache', dim('Embed everything again, ignoring what is already kept.')],
122
+ [' --cache-dir <dir>', dim('Take them from somewhere other than the shared cache.')],
123
+ [' --quiet', dim('No narration.')],
124
+ ]),
125
+ '',
126
+ dim(' `lance/` is binary and rebuildable, so it is usually git-ignored while the'),
127
+ dim(' rest of the index is committed. A clone then has an index that looks built'),
128
+ dim(` and cannot answer: ${cyan('ready')} says so, and this puts the vectors back`),
129
+ dim(' without going to find the documents again.'),
130
+ '',
131
+ dim(' It is also how an index changes embedder: --embedding re-embeds everything'),
132
+ dim(' and rewrites the manifest, so later searches ask for the new model.'),
133
+ '',
134
+ dim(' The rebuild is written beside the index and renamed into place, so a failed'),
135
+ dim(' restore leaves the old index intact — and needs room for both meanwhile.'),
136
+ '',
104
137
  'Search',
105
138
  ...table([
106
139
  [' <text>', dim('What to look for. One question, not a list of terms.')],
@@ -177,6 +210,10 @@ export const command = {
177
210
  switch (name) {
178
211
  case 'index':
179
212
  return await index(tail, ctx);
213
+ case 'restore':
214
+ return await restore(tail, ctx);
215
+ case 'ready':
216
+ return await ready(tail, ctx);
180
217
  case 'search':
181
218
  return await search(tail, ctx);
182
219
  case 'list':
@@ -250,6 +287,7 @@ async function index(args, ctx) {
250
287
  onProgress: loud
251
288
  ? (done, total) => note(dim(` embedded ${done}/${total} · ${Math.floor((done / total) * 100)}% · ${elapsed(started)}`))
252
289
  : undefined,
290
+ onWriting: loud ? (rows) => notes(indexing(rows, 'chunks').map(aside)) : undefined,
253
291
  });
254
292
  if (ctx.json) {
255
293
  json({ out, manifest });
@@ -269,6 +307,91 @@ async function index(args, ctx) {
269
307
  const where = out === resolve(ctx.cwd, DEFAULT_DIR) ? '' : ` --dir ${relative(ctx.cwd, out) || out}`;
270
308
  note(dim(` search it: ${cyan(`zen rag docs search${where} "what you are after"`)}`));
271
309
  }
310
+ async function restore(args, ctx) {
311
+ const { values } = parse(args, {
312
+ dir: { type: 'string', short: 'd' },
313
+ embedding: { type: 'string' },
314
+ batch: { type: 'string' },
315
+ dimensions: { type: 'string' },
316
+ 'chunk-tokens': { type: 'string' },
317
+ 'no-cache': { type: 'boolean' },
318
+ 'cache-dir': { type: 'string' },
319
+ quiet: { type: 'boolean' },
320
+ }, RESTORE_USAGE);
321
+ const dir = indexDir(ctx, values.dir);
322
+ const manifest = await readManifest(dir);
323
+ const sources = join(dir, SOURCES_DIR);
324
+ if (!existsSync(sources)) {
325
+ throw new CliError(`${dir} kept no copy of its documents`, EXIT.invalid, 'build it again from the documents with `zen rag docs index`');
326
+ }
327
+ const loud = !values.quiet && !ctx.json;
328
+ const cacheDir = values['cache-dir'] ? resolve(ctx.cwd, values['cache-dir']) : paths.cache();
329
+ // Every setting defaults to what the index recorded, so a restore that is
330
+ // asked for nothing produces the index it already had, minus the loss.
331
+ const ref = values.embedding ?? manifest.embedding.ref;
332
+ const dimensions = values.dimensions
333
+ ? count(values.dimensions, '--dimensions')
334
+ : manifest.embedding.requested;
335
+ const chunk = values['chunk-tokens']
336
+ ? { chunkTokens: count(values['chunk-tokens'], '--chunk-tokens') }
337
+ : manifest.chunk;
338
+ if (loud && !chunk) {
339
+ note(dim(' this index records no chunk settings; the defaults are used'));
340
+ }
341
+ const chosen = await resolveEmbedder(ref, {
342
+ maxBatch: values.batch ? count(values.batch, '--batch') : undefined,
343
+ dimensions,
344
+ });
345
+ const started = Date.now();
346
+ if (loud) {
347
+ announce(manifest, ref, `${manifest.counts.chunks} chunks`);
348
+ }
349
+ const staging = stagingFor(dir);
350
+ const built = await buildIndex({
351
+ files: [sources],
352
+ cwd: dir,
353
+ // The names are the identity — chunk ids, --file patterns, what `show`
354
+ // takes — so they are taken from `sources/` rather than from wherever
355
+ // the documents happened to sit on the machine that indexed them.
356
+ root: sources,
357
+ out: staging.dir,
358
+ embedder: chosen,
359
+ embeddingRef: ref,
360
+ // The index is the same index; what rebuilt its vectors is not a new
361
+ // author of it.
362
+ indexer: manifest.indexer,
363
+ chunk,
364
+ dimensions,
365
+ cache: !values['no-cache'],
366
+ cacheDir: values['cache-dir'] ? cacheDir : undefined,
367
+ onProgress: loud
368
+ ? (done, total) => note(dim(` embedded ${done}/${total} · ${Math.floor((done / total) * 100)}% · ${elapsed(started)}`))
369
+ : undefined,
370
+ onWriting: loud ? (rows) => notes(indexing(rows, 'chunks').map(aside)) : undefined,
371
+ });
372
+ await swapIn(dir, staging);
373
+ if (ctx.json) {
374
+ json({ dir, manifest: built.manifest });
375
+ return;
376
+ }
377
+ note();
378
+ write(dir);
379
+ note(` restored ${bold(String(built.manifest.counts.chunks))} chunks from ` +
380
+ `${bold(String(built.manifest.counts.documents))} document(s) in ${bold(dir)}, ` +
381
+ `embedded with ${built.manifest.embedding.ref} ` +
382
+ `(${built.manifest.embedding.dimensions}d)`);
383
+ note(dim(` ${breakdown(built.timings)}`));
384
+ note(dim(` reused ${built.reused.vectors}/${built.manifest.counts.chunks} vectors` +
385
+ `${values['no-cache'] ? ' (--no-cache)' : ` from ${cacheDir}`}`));
386
+ }
387
+ // ---------------------------------------------------------------------------
388
+ // ready
389
+ // ---------------------------------------------------------------------------
390
+ async function ready(args, ctx) {
391
+ const { values } = parse(args, { dir: { type: 'string', short: 'd' }, quiet: { type: 'boolean' } }, READY_USAGE);
392
+ const health = await inspectIndex(indexDir(ctx, values.dir), DOCS_INDEX, ChunkStore.open);
393
+ reportReady(health, ctx, values.quiet === true);
394
+ }
272
395
  const SOURCE_HEADERS = ['LINES', 'SECTIONS', 'TABLES', 'CHUNKS'];
273
396
  function printSources(sources) {
274
397
  // A corpus can be thousands of documents and a wall of them is not a report.
@@ -531,8 +654,9 @@ async function stats(args, ctx) {
531
654
  const { values } = parse(args, { dir: { type: 'string', short: 'd' } }, 'zen rag docs stats [--dir <dir>]');
532
655
  const dir = indexDir(ctx, values.dir);
533
656
  const manifest = await readManifest(dir);
657
+ const health = await inspectIndex(dir, DOCS_INDEX, ChunkStore.open);
534
658
  if (ctx.json) {
535
- json(manifest);
659
+ json({ ...manifest, state: health.state });
536
660
  return;
537
661
  }
538
662
  note(bold(dir));
@@ -544,6 +668,9 @@ async function stats(args, ctx) {
544
668
  ' indexes',
545
669
  `fts ${yes(manifest.indexes.fts)} · vector ${yes(manifest.indexes.vector)}`,
546
670
  ],
671
+ // `stats` reports and never refuses, so a missing store is a row
672
+ // here rather than the error `search` would raise.
673
+ [' vectors', health.state === 'ready' ? 'present' : dim('missing — `restore` them')],
547
674
  ]));
548
675
  printSources(manifest.sources);
549
676
  notes(table([
@@ -619,5 +746,6 @@ function notes(lines) {
619
746
  note(line);
620
747
  }
621
748
  }
749
+ const aside = (line) => dim(` ${line}`);
622
750
  const yes = (value) => (value ? 'yes' : 'no');
623
751
  //# sourceMappingURL=command.js.map
@@ -1,11 +1,12 @@
1
1
  import { type IndexHead, type IndexSpec } from '../common/manifest.ts';
2
+ import type { ChunkOptions } from './chunk.ts';
2
3
  import type { DocFormat } from './parse.ts';
3
4
  export declare const INDEX_VERSION = 1;
4
5
  /** How a document index is found, read, and refused. */
5
6
  export declare const DOCS_INDEX: IndexSpec;
6
7
  export declare const OUTLINE_FILE = "outline.json";
7
- export declare const LANCE_DIR = "lance";
8
8
  export declare const SOURCES_DIR = "sources";
9
+ export { LANCE_DIR, lancePath } from '../common/manifest.ts';
9
10
  export interface HeadingRecord {
10
11
  /** the heading's own line, 1-based */
11
12
  line: number;
@@ -63,9 +64,17 @@ export interface Counts {
63
64
  sections: number;
64
65
  tables: number;
65
66
  }
67
+ /**
68
+ * The chunk settings a build was given, so a restore can cut the documents the
69
+ * same way rather than silently re-chunking them. Numbers only: `tokenCount` is
70
+ * a function and does not survive a manifest, and absent means the defaults.
71
+ */
72
+ export type ChunkSettings = Omit<ChunkOptions, 'tokenCount'>;
66
73
  export interface Manifest extends IndexHead {
67
74
  sources: DocRecord[];
68
75
  counts: Counts;
76
+ /** absent in indexes built before this was recorded, which means the defaults */
77
+ chunk?: ChunkSettings;
69
78
  }
70
79
  export interface WrittenIndex {
71
80
  manifest: Manifest;
@@ -78,7 +87,6 @@ export interface OpenDocs {
78
87
  manifest: Manifest;
79
88
  outline: Outline;
80
89
  }
81
- export declare const lancePath: (dir: string) => string;
82
90
  export declare function writeIndex(dir: string, index: WrittenIndex): Promise<void>;
83
91
  export declare function openIndex(dir: string): Promise<OpenDocs>;
84
92
  export declare const readManifest: (dir: string) => Promise<Manifest>;
@@ -41,9 +41,8 @@ export const DOCS_INDEX = {
41
41
  envName: 'ZEN_DOCS_DB',
42
42
  };
43
43
  export const OUTLINE_FILE = 'outline.json';
44
- export const LANCE_DIR = 'lance';
45
44
  export const SOURCES_DIR = 'sources';
46
- export const lancePath = (dir) => join(dir, LANCE_DIR);
45
+ export { LANCE_DIR, lancePath } from "../common/manifest.js";
47
46
  export async function writeIndex(dir, index) {
48
47
  await mkdir(dir, { recursive: true });
49
48
  await writeFile(join(dir, OUTLINE_FILE), JSON.stringify(index.outline));
@@ -26,6 +26,13 @@ export interface Corpus {
26
26
  }
27
27
  export interface LoadOptions {
28
28
  chunk?: ChunkOptions;
29
+ /**
30
+ * Name documents relative to this, instead of relative to the common root of
31
+ * whatever was found. A restore pins it to the index's own `sources/`, so a
32
+ * corpus that is one nested document keeps the name it was indexed under
33
+ * rather than being renamed to its basename by `commonRoot`.
34
+ */
35
+ root?: string;
29
36
  /** remember what documents parse to; on by default */
30
37
  cache?: boolean;
31
38
  /** keep the parses somewhere other than the shared store */
package/dist/docs/load.js CHANGED
@@ -39,7 +39,7 @@ export async function loadDocuments(inputs, cwd, options = {}) {
39
39
  if (found.length === 0) {
40
40
  throw new CliError('nothing to index', EXIT.invalid, `no ${DOC_EXTENSIONS.join(', ')} files were found under ${inputs.join(', ')}`);
41
41
  }
42
- const root = commonRoot(found);
42
+ const root = options.root ? resolve(cwd, options.root) : commonRoot(found);
43
43
  const taken = new Set();
44
44
  const chunk = options.chunk ?? {};
45
45
  const cache = options.cache === false ? NO_PARSE_CACHE : openParseCache(options.cacheDir);
@@ -107,7 +107,7 @@ export class ChunkStore {
107
107
  }
108
108
  catch {
109
109
  db.close();
110
- throw new CliError(`${dir} holds no searchable table`, EXIT.invalid, 'rebuild it with `zen rag docs index`');
110
+ throw new CliError(`${dir} holds no searchable table`, EXIT.invalid, 'put the vectors back with `zen rag docs restore`, or rebuild it with `zen rag docs index`');
111
111
  }
112
112
  }
113
113
  /** Nearest neighbours. Every row has a vector, so nothing has to be excluded. */
@@ -27,6 +27,8 @@ export interface BuildOptions {
27
27
  /** what the documents turned out to hold, before a vector has been paid for */
28
28
  onRead?: (summary: BuildSummary) => void;
29
29
  onProgress?: (done: number, total: number) => void;
30
+ /** every vector is in and the search indexes are about to be built, silently */
31
+ onWriting?: (rows: number) => void;
30
32
  }
31
33
  export interface BuildSummary {
32
34
  sources: SourceRecord[];
@@ -45,6 +45,7 @@ export async function buildIndex(options) {
45
45
  writer = await openStore(options.out);
46
46
  const dimensions = await embedAll(entities, options, journal, cache, writer);
47
47
  journal.phase('writing');
48
+ options.onWriting?.(entities.length);
48
49
  const written = await writer.finish();
49
50
  const manifest = {
50
51
  version: INDEX_VERSION,
@@ -1,18 +1,21 @@
1
1
  import { bold, CliError, cyan, dim, EXIT, isInteractive, json, note, parse, paths, table, usageError, write, } from '@zenera/cli/lib';
2
- import { relative, resolve } from 'node:path';
2
+ import { existsSync } from 'node:fs';
3
+ import { join, relative, resolve } from 'node:path';
3
4
  import { resolveEmbedder } from "../common/embedder.js";
4
5
  import { locateIndex, outputDir } from "../common/locate.js";
5
- import { assertSameEmbedding } from "../common/manifest.js";
6
+ import { assertSameEmbedding, inspectIndex } from "../common/manifest.js";
6
7
  import { isGlob, loose, matcher, PatternError, wildcard } from "../common/match.js";
7
- import { breakdown } from "../common/prose.js";
8
+ import { breakdown, indexing } from "../common/prose.js";
9
+ import { announce, reportReady, stagingFor, swapIn } from "../common/restore.js";
8
10
  import { buildIndex } from "./build.js";
9
- import { openIndex, readManifest, readSource, SCHEMA_INDEX } from "./files.js";
11
+ import { openIndex, readManifest, readSource, SCHEMA_INDEX, SOURCES_DIR, } from "./files.js";
10
12
  import { fields, grepNodes, listNodes, propertyCount } from "./lookup.js";
11
13
  import { isFormat, present } from "./present.js";
12
14
  import { isEmpty, parseQuery, QueryError } from "./query.js";
13
15
  import { sourceTag } from "./render.js";
14
16
  import { repl } from "./repl.js";
15
17
  import { SchemaIndex } from "./search.js";
18
+ import { EntityStore } from "./store.js";
16
19
  import { select, stitch } from "./subgraph.js";
17
20
  import { chainOf, DEFAULT_TRACE_HOPS, traceNodes } from "./trace.js";
18
21
  const { defaultDir: DEFAULT_DIR, envName: DIR_ENV } = SCHEMA_INDEX;
@@ -37,8 +40,10 @@ const { defaultDir: DEFAULT_DIR, envName: DIR_ENV } = SCHEMA_INDEX;
37
40
  // that question is every match or none, and these three give it without asking
38
41
  // a model or a credential for permission.
39
42
  // ---------------------------------------------------------------------------
40
- const USAGE = 'zen rag schema <index|search|list|grep|trace|show|stats> [spec...]';
43
+ const USAGE = 'zen rag schema <index|restore|ready|search|list|grep|trace|show|stats> [spec...]';
41
44
  const INDEX_USAGE = 'zen rag schema index --embedding <ref> [--out <dir>] <spec...>';
45
+ const RESTORE_USAGE = 'zen rag schema restore [--dir <dir>] [--embedding <ref>]';
46
+ const READY_USAGE = 'zen rag schema ready [--dir <dir>] [--quiet]';
42
47
  const SEARCH_USAGE = 'zen rag schema search [--dir <dir>] [query...]';
43
48
  const LIST_USAGE = 'zen rag schema list <methods|types|properties> [--dir <dir>]';
44
49
  const GREP_USAGE = 'zen rag schema grep <pattern> [--dir <dir>]';
@@ -51,6 +56,8 @@ export const command = {
51
56
  'Commands',
52
57
  ...table([
53
58
  [' index <spec...>', dim('Read the documents and write a searchable index.')],
59
+ [' restore', dim('Re-embed an index from its own copies of the documents.')],
60
+ [' ready', dim('Ask whether an index can answer. Exit 0 if it can, 3 if it cannot.')],
54
61
  [' search', dim('Ask it something. --interactive for a prompt.')],
55
62
  [' list <what>', dim('Every method, type or property matching a pattern.')],
56
63
  [' grep <pattern>', dim('Every literal match, ranked by nothing.')],
@@ -82,6 +89,26 @@ export const command = {
82
89
  [' --cache-dir <dir>', dim('Keep the vectors somewhere other than the shared cache.')],
83
90
  ]),
84
91
  '',
92
+ 'Restore — the vectors, from the copies the index already holds',
93
+ ...table([
94
+ [' -d, --dir <dir>', dim(`Which index. Found from here if unset; see ${DIR_ENV}.`)],
95
+ [' --embedding <ref>', dim('Move to another embedder. Default: the one recorded.')],
96
+ [' --dimensions <n>', dim('Narrower vectors. Default: the width recorded.')],
97
+ [' --batch <n>', dim("Texts per embedding request. Default: the model's own cap.")],
98
+ [' --no-cache', dim('Embed everything again, ignoring what this machine has.')],
99
+ [' --cache-dir <dir>', dim('Take them from somewhere other than the shared cache.')],
100
+ [' --quiet', dim('No narration.')],
101
+ ]),
102
+ '',
103
+ dim(' `lance/` is binary and rebuildable, so it is usually git-ignored while the'),
104
+ dim(' rest of the index is committed. A clone then has an index that looks built'),
105
+ dim(` and cannot answer: ${cyan('ready')} says so, and this puts the vectors back`),
106
+ dim(' without going to find the documents again. It needs an index built with'),
107
+ dim(' sources — one made with --no-sources has nothing to rebuild from.'),
108
+ '',
109
+ dim(' It is also how an index changes embedder: --embedding re-embeds everything'),
110
+ dim(' and rewrites the manifest, so later searches ask for the new model.'),
111
+ '',
85
112
  'Search terms (repeatable)',
86
113
  ...table([
87
114
  [' <text>', dim('A bare phrase, the same as --all.')],
@@ -174,6 +201,10 @@ export const command = {
174
201
  switch (name) {
175
202
  case 'index':
176
203
  return await index(tail, ctx);
204
+ case 'restore':
205
+ return await restore(tail, ctx);
206
+ case 'ready':
207
+ return await ready(tail, ctx);
177
208
  case 'search':
178
209
  return await search(tail, ctx);
179
210
  case 'list':
@@ -239,6 +270,7 @@ async function index(args, ctx) {
239
270
  onProgress: loud
240
271
  ? (done, total) => note(dim(` embedded ${done}/${total} · ${Math.floor((done / total) * 100)}% · ${elapsed(started)}`))
241
272
  : undefined,
273
+ onWriting: loud ? (rows) => notes(indexing(rows, 'entities').map(aside)) : undefined,
242
274
  });
243
275
  if (ctx.json) {
244
276
  json({ out, manifest });
@@ -256,6 +288,90 @@ async function index(args, ctx) {
256
288
  const where = out === resolve(ctx.cwd, DEFAULT_DIR) ? '' : ` --dir ${relative(ctx.cwd, out) || out}`;
257
289
  note(dim(` search it: ${cyan(`zen rag schema search${where} --all "what you are after"`)}`));
258
290
  }
291
+ async function restore(args, ctx) {
292
+ const { values } = parse(args, {
293
+ dir: { type: 'string', short: 'd' },
294
+ embedding: { type: 'string' },
295
+ batch: { type: 'string' },
296
+ dimensions: { type: 'string' },
297
+ 'no-cache': { type: 'boolean' },
298
+ 'cache-dir': { type: 'string' },
299
+ quiet: { type: 'boolean' },
300
+ }, RESTORE_USAGE);
301
+ const dir = indexDir(ctx, values.dir);
302
+ const manifest = await readManifest(dir);
303
+ const files = bundled(dir, manifest);
304
+ const loud = !values.quiet && !ctx.json;
305
+ const cacheDir = values['cache-dir'] ? resolve(ctx.cwd, values['cache-dir']) : paths.cache();
306
+ // Every setting defaults to what the index recorded, so a restore that is
307
+ // asked for nothing produces the index it already had, minus the loss.
308
+ const ref = values.embedding ?? manifest.embedding.ref;
309
+ const dimensions = values.dimensions
310
+ ? count(values.dimensions, '--dimensions')
311
+ : manifest.embedding.requested;
312
+ const chosen = await resolveEmbedder(ref, {
313
+ maxBatch: values.batch ? count(values.batch, '--batch') : undefined,
314
+ dimensions,
315
+ });
316
+ const started = Date.now();
317
+ if (loud) {
318
+ announce(manifest, ref, `${manifest.counts.entities} entities`);
319
+ }
320
+ const staging = stagingFor(dir);
321
+ const built = await buildIndex({
322
+ files,
323
+ out: staging.dir,
324
+ embedder: chosen,
325
+ embeddingRef: ref,
326
+ // The index is the same index; what rebuilt its vectors is not a new
327
+ // author of it.
328
+ indexer: manifest.indexer,
329
+ sources: true,
330
+ dimensions,
331
+ cache: !values['no-cache'],
332
+ cacheDir: values['cache-dir'] ? cacheDir : undefined,
333
+ onProgress: loud
334
+ ? (done, total) => note(dim(` embedded ${done}/${total} · ${Math.floor((done / total) * 100)}% · ${elapsed(started)}`))
335
+ : undefined,
336
+ onWriting: loud ? (rows) => notes(indexing(rows, 'entities').map(aside)) : undefined,
337
+ });
338
+ await swapIn(dir, staging);
339
+ if (ctx.json) {
340
+ json({ dir, manifest: built.manifest });
341
+ return;
342
+ }
343
+ note();
344
+ write(dir);
345
+ note(` restored ${bold(String(built.manifest.counts.entities))} entities in ${bold(dir)}, ` +
346
+ `embedded with ${built.manifest.embedding.ref} ` +
347
+ `(${built.manifest.embedding.dimensions}d)`);
348
+ note(dim(` ${breakdown(built.timings)}`));
349
+ note(dim(` reused ${built.reused}/${built.manifest.counts.entities} vectors` +
350
+ `${values['no-cache'] ? ' (--no-cache)' : ` from ${cacheDir}`}`));
351
+ }
352
+ /**
353
+ * The documents to rebuild from, in the order the index recorded them — which
354
+ * is what keeps the slugs the same, since two documents with the same filename
355
+ * are told apart by which was read first.
356
+ */
357
+ function bundled(dir, manifest) {
358
+ const missing = manifest.sources.filter((s) => !s.path || !existsSync(join(dir, s.path)));
359
+ if (missing.length > 0) {
360
+ throw new CliError(`${dir} has no copy of ${missing.map((s) => s.name).join(', ')}`, EXIT.invalid, manifest.sources.some((s) => s.path)
361
+ ? `the ${SOURCES_DIR}/ directory is incomplete; rebuild with \`zen rag schema index\``
362
+ : 'it was built with --no-sources; rebuild it from the documents with `zen rag schema index`');
363
+ }
364
+ return manifest.sources.map((s) => join(dir, s.path));
365
+ }
366
+ // ---------------------------------------------------------------------------
367
+ // ready
368
+ // ---------------------------------------------------------------------------
369
+ async function ready(args, ctx) {
370
+ const { values } = parse(args, { dir: { type: 'string', short: 'd' }, quiet: { type: 'boolean' } }, READY_USAGE);
371
+ const dir = indexDir(ctx, values.dir);
372
+ const health = await inspectIndex(dir, SCHEMA_INDEX, (at) => EntityStore.open(at));
373
+ reportReady(health, ctx, values.quiet === true);
374
+ }
259
375
  const HEADERS = ['PATHS', 'OPERATIONS', 'SCHEMAS', 'FIELDS'];
260
376
  function elapsed(since) {
261
377
  const seconds = Math.round((Date.now() - since) / 1000);
@@ -805,8 +921,9 @@ async function stats(args, ctx) {
805
921
  const { values } = parse(args, { dir: { type: 'string', short: 'd' } }, 'zen rag schema stats [--dir <dir>]');
806
922
  const dir = indexDir(ctx, values.dir);
807
923
  const manifest = await readManifest(dir);
924
+ const health = await inspectIndex(dir, SCHEMA_INDEX, (at) => EntityStore.open(at));
808
925
  if (ctx.json) {
809
- json(manifest);
926
+ json({ ...manifest, state: health.state });
810
927
  return;
811
928
  }
812
929
  note(bold(dir));
@@ -818,6 +935,9 @@ async function stats(args, ctx) {
818
935
  ' indexes',
819
936
  `fts ${yes(manifest.indexes.fts)} · vector ${yes(manifest.indexes.vector)}`,
820
937
  ],
938
+ // `stats` reports and never refuses, so a missing store is a row
939
+ // here rather than the error `search` would raise.
940
+ [' vectors', health.state === 'ready' ? 'present' : dim('missing — `restore` them')],
821
941
  ]));
822
942
  printSources(manifest.sources);
823
943
  notes(table([[' entities', String(manifest.counts.entities)]]).map(dim));
@@ -827,6 +947,7 @@ function notes(lines) {
827
947
  note(line);
828
948
  }
829
949
  }
950
+ const aside = (line) => dim(` ${line}`);
830
951
  const yes = (value) => (value ? 'yes' : 'no');
831
952
  // ---------------------------------------------------------------------------
832
953
  function formatOf(value) {
@@ -8,8 +8,8 @@ export declare const SCHEMA_INDEX: IndexSpec;
8
8
  export declare const GRAPH_FILE = "graph.json";
9
9
  export declare const SCHEMAS_FILE = "schemas.json";
10
10
  export declare const OPERATIONS_FILE = "operations.json";
11
- export declare const LANCE_DIR = "lance";
12
11
  export declare const SOURCES_DIR = "sources";
12
+ export { LANCE_DIR, lancePath } from '../common/manifest.ts';
13
13
  export interface SourceRecord {
14
14
  /** the document's name within the index: what every entity's `source` says */
15
15
  name: string;
@@ -53,7 +53,6 @@ export interface OpenIndex {
53
53
  /** the operations, likewise */
54
54
  operations(): Promise<Operation[]>;
55
55
  }
56
- export declare const lancePath: (dir: string) => string;
57
56
  export declare function writeIndex(dir: string, index: WrittenIndex): Promise<void>;
58
57
  export declare function openIndex(dir: string): Promise<OpenIndex>;
59
58
  /**
@@ -31,9 +31,8 @@ export const SCHEMA_INDEX = {
31
31
  export const GRAPH_FILE = 'graph.json';
32
32
  export const SCHEMAS_FILE = 'schemas.json';
33
33
  export const OPERATIONS_FILE = 'operations.json';
34
- export const LANCE_DIR = 'lance';
35
34
  export const SOURCES_DIR = 'sources';
36
- export const lancePath = (dir) => join(dir, LANCE_DIR);
35
+ export { LANCE_DIR, lancePath } from "../common/manifest.js";
37
36
  export async function writeIndex(dir, index) {
38
37
  await mkdir(dir, { recursive: true });
39
38
  await writeFile(join(dir, GRAPH_FILE), JSON.stringify(index.graph.export()));
@@ -100,7 +100,7 @@ export class EntityStore {
100
100
  }
101
101
  catch {
102
102
  db.close();
103
- throw new CliError(`${dir} holds no searchable table`, EXIT.invalid, 'rebuild it with `zen rag schema index`');
103
+ throw new CliError(`${dir} holds no searchable table`, EXIT.invalid, 'put the vectors back with `zen rag schema restore`, or rebuild it with `zen rag schema index`');
104
104
  }
105
105
  }
106
106
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zenera/rag",
3
- "version": "1.1.21",
3
+ "version": "1.1.22",
4
4
  "description": "Retrieval over a corpus: openapi/swagger documents as a searchable graph, markdown and text as searchable passages.",
5
5
  "keywords": [
6
6
  "agents",
@@ -61,8 +61,8 @@
61
61
  "dependencies": {
62
62
  "@apidevtools/swagger-parser": "^12.0.0",
63
63
  "@lancedb/lancedb": "^0.38.0",
64
- "@zenera/cli": "^1.1.21",
65
- "@zenera/neo": "^1.1.21",
64
+ "@zenera/cli": "^1.1.22",
65
+ "@zenera/neo": "^1.1.22",
66
66
  "graphology": "^0.26.0",
67
67
  "remark-frontmatter": "^5.0.0",
68
68
  "remark-gfm": "^4.0.1",