docspack 1.0.0 → 1.2.0
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 +31 -0
- package/dist/build.d.ts +23 -0
- package/dist/build.d.ts.map +1 -1
- package/dist/build.js +184 -96
- package/dist/build.js.map +1 -1
- package/dist/cli.js +91 -2
- package/dist/cli.js.map +1 -1
- package/dist/db.d.ts +40 -3
- package/dist/db.d.ts.map +1 -1
- package/dist/db.js +61 -2
- package/dist/db.js.map +1 -1
- package/dist/discovery.d.ts +6 -3
- package/dist/discovery.d.ts.map +1 -1
- package/dist/discovery.js +57 -11
- package/dist/discovery.js.map +1 -1
- package/dist/doctor.d.ts.map +1 -1
- package/dist/doctor.js +31 -10
- package/dist/doctor.js.map +1 -1
- package/dist/endpoints.d.ts +45 -0
- package/dist/endpoints.d.ts.map +1 -0
- package/dist/endpoints.js +155 -0
- package/dist/endpoints.js.map +1 -0
- package/dist/help.d.ts.map +1 -1
- package/dist/help.js +60 -5
- package/dist/help.js.map +1 -1
- package/dist/index.d.ts +4 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -3
- package/dist/index.js.map +1 -1
- package/dist/init/plan.js +4 -3
- package/dist/init/plan.js.map +1 -1
- package/dist/local.d.ts +67 -0
- package/dist/local.d.ts.map +1 -0
- package/dist/local.js +242 -0
- package/dist/local.js.map +1 -0
- package/dist/preview.d.ts.map +1 -1
- package/dist/preview.js +25 -7
- package/dist/preview.js.map +1 -1
- package/dist/search.d.ts +8 -0
- package/dist/search.d.ts.map +1 -1
- package/dist/search.js +44 -6
- package/dist/search.js.map +1 -1
- package/dist/spec.d.ts +15 -1
- package/dist/spec.d.ts.map +1 -1
- package/dist/spec.js +16 -2
- package/dist/spec.js.map +1 -1
- package/package.json +7 -5
- package/src/build.ts +221 -111
- package/src/cli.ts +96 -2
- package/src/db.ts +87 -5
- package/src/discovery.ts +62 -11
- package/src/doctor.ts +33 -8
- package/src/endpoints.ts +184 -0
- package/src/help.ts +60 -5
- package/src/index.ts +23 -1
- package/src/init/plan.ts +4 -3
- package/src/local.ts +335 -0
- package/src/preview.ts +38 -14
- package/src/search.ts +60 -6
- package/src/spec.ts +18 -2
package/src/build.ts
CHANGED
|
@@ -1,7 +1,16 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import type { Dirent } from "node:fs";
|
|
3
3
|
import { mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
4
|
-
import { join, relative, sep } from "node:path";
|
|
4
|
+
import { basename, join, relative, sep } from "node:path";
|
|
5
|
+
import { readApiFile } from "@docspack/lapis/read";
|
|
6
|
+
import {
|
|
7
|
+
type ApiDocument,
|
|
8
|
+
OpenApiError,
|
|
9
|
+
operationDigest,
|
|
10
|
+
operationEntities,
|
|
11
|
+
operationTags,
|
|
12
|
+
operationTitle,
|
|
13
|
+
} from "@docspack/openapi";
|
|
5
14
|
import { findEntry } from "@docspack/registry";
|
|
6
15
|
import { readBuildConfig } from "./config.js";
|
|
7
16
|
import { cleanDocument } from "./document.js";
|
|
@@ -34,6 +43,17 @@ export interface BuildOptions {
|
|
|
34
43
|
readonly openapi?: string;
|
|
35
44
|
/** Registry id or llms.txt URL to fetch and package. */
|
|
36
45
|
readonly source?: string;
|
|
46
|
+
/**
|
|
47
|
+
* JSON records to package, or `-` for standard input. One chunk per record, so anything a query
|
|
48
|
+
* can produce rows for — a table, an export, an API response — reaches the index without this
|
|
49
|
+
* tool needing a driver for it.
|
|
50
|
+
*/
|
|
51
|
+
readonly json?: string;
|
|
52
|
+
/**
|
|
53
|
+
* Build a working corpus rather than a publishable package: identity is derived instead of
|
|
54
|
+
* demanded, and no `package.json` is written.
|
|
55
|
+
*/
|
|
56
|
+
readonly local?: boolean;
|
|
37
57
|
readonly pages?: number;
|
|
38
58
|
readonly maxChunkTokens?: number;
|
|
39
59
|
/**
|
|
@@ -59,6 +79,16 @@ export interface BuildResult {
|
|
|
59
79
|
|
|
60
80
|
/** A document before it is split into chunks. */
|
|
61
81
|
interface SourceDocument {
|
|
82
|
+
/**
|
|
83
|
+
* Chunk id to use instead of one derived from the title. Atomic documents only.
|
|
84
|
+
*
|
|
85
|
+
* A derived id is a slug of the prose, which is right for a Markdown page — the heading is the
|
|
86
|
+
* stable thing about it. It is wrong for a generated reference, where the title carries a summary
|
|
87
|
+
* somebody will reword: `POST /v1/charges — Create a charge` becoming `…— Creates a charge`
|
|
88
|
+
* renames the chunk, and a chunk id is what `feedback add --chunk` pins and what a published link
|
|
89
|
+
* resolves to.
|
|
90
|
+
*/
|
|
91
|
+
readonly id?: string;
|
|
62
92
|
readonly title: string;
|
|
63
93
|
/** File path or URL the text came from, recorded in the chunk for provenance. */
|
|
64
94
|
readonly origin: string;
|
|
@@ -72,8 +102,8 @@ interface SourceDocument {
|
|
|
72
102
|
}
|
|
73
103
|
|
|
74
104
|
const DEFAULT_MAX_CHUNK_TOKENS = 800;
|
|
75
|
-
|
|
76
|
-
const
|
|
105
|
+
/** The source extensions a corpus is collected from. */
|
|
106
|
+
export const MARKDOWN = /\.(md|mdx|markdown)$/i;
|
|
77
107
|
|
|
78
108
|
/**
|
|
79
109
|
* Generates a docs package: `.llms/manifest.json`, `.llms/chunks/*.md` and an `llms.txt` table of
|
|
@@ -203,6 +233,16 @@ async function resolveIdentity(
|
|
|
203
233
|
const version =
|
|
204
234
|
options.version ?? (typeof existing.version === "string" ? existing.version : undefined);
|
|
205
235
|
|
|
236
|
+
// A working corpus has no publisher to name it and no release to version, so demanding both is
|
|
237
|
+
// friction with nothing on the other side of it. A publishable package still fails below.
|
|
238
|
+
if (options.local === true) {
|
|
239
|
+
return {
|
|
240
|
+
name: name ?? localPackageName(options),
|
|
241
|
+
version: version ?? LOCAL_VERSION,
|
|
242
|
+
createPackageJson: false,
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
206
246
|
if (name === undefined) {
|
|
207
247
|
throw new DocspackError("The package needs a name", {
|
|
208
248
|
hint: "Pass --name @vendor/docspack, or run inside a directory that has a package.json.",
|
|
@@ -215,17 +255,134 @@ async function resolveIdentity(
|
|
|
215
255
|
return { name, version, createPackageJson: !found };
|
|
216
256
|
}
|
|
217
257
|
|
|
258
|
+
/**
|
|
259
|
+
* A stable version for a working corpus, rather than a hash of its contents.
|
|
260
|
+
*
|
|
261
|
+
* A content hash would look like the careful choice and is the wrong one: it changes the store key
|
|
262
|
+
* on every edit, so each save leaves the previous index behind as garbage that nothing evicts.
|
|
263
|
+
* Freshness is a property of each source, tracked per source, not of the corpus's name.
|
|
264
|
+
*/
|
|
265
|
+
export const LOCAL_VERSION = "0.0.0";
|
|
266
|
+
|
|
267
|
+
/** Named after where the text came from, which is the only thing that distinguishes one corpus. */
|
|
268
|
+
export function localPackageName(options: Pick<BuildOptions, "from" | "json">): string {
|
|
269
|
+
const source =
|
|
270
|
+
options.from ?? (options.json !== undefined && options.json !== "-" ? options.json : undefined);
|
|
271
|
+
const base = source === undefined ? "corpus" : basename(source).replace(/\.[^.]+$/, "");
|
|
272
|
+
return `@local/${slugify(base, "corpus")}`;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Records as JSON, from a file or standard input: `[{ "id", "title", "text", "tags", "entities" }]`.
|
|
277
|
+
*
|
|
278
|
+
* This is the whole of the non-file input, and it is deliberately not a database adapter. A driver
|
|
279
|
+
* would put a native dependency into a CLI whose startup cost is the reason it is usable from an
|
|
280
|
+
* agent at all, and every store worth indexing can already emit JSON — `psql --json`,
|
|
281
|
+
* `sqlite3 -json`, an API response saved to a file. The conversion belongs to whoever owns the
|
|
282
|
+
* query.
|
|
283
|
+
*
|
|
284
|
+
* A record carrying an `id` is atomic: its id is its key, and splitting it would either duplicate
|
|
285
|
+
* that key across sections or discard it. A record without one is split like any other document.
|
|
286
|
+
*/
|
|
287
|
+
async function collectFromJson(source: string): Promise<SourceDocument[]> {
|
|
288
|
+
const origin = source === "-" ? "stdin" : source;
|
|
289
|
+
const raw = source === "-" ? await readStdin() : await readFile(source, "utf8");
|
|
290
|
+
|
|
291
|
+
let parsed: unknown;
|
|
292
|
+
try {
|
|
293
|
+
parsed = JSON.parse(raw);
|
|
294
|
+
} catch (error) {
|
|
295
|
+
throw new DocspackError(`${origin} is not valid JSON`, {
|
|
296
|
+
hint: 'Expected an array of records: [{ "title": "…", "text": "…" }].',
|
|
297
|
+
cause: error,
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
if (!Array.isArray(parsed)) {
|
|
301
|
+
throw new DocspackError(`${origin} is not a JSON array`, {
|
|
302
|
+
hint: 'Expected [{ "title": "…", "text": "…" }], one entry per record.',
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
if (parsed.length === 0) {
|
|
306
|
+
throw new DocspackError(`${origin} contains no records`, {
|
|
307
|
+
hint: "A query that returned nothing indexes nothing; check the query first.",
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
return parsed.map((entry, index): SourceDocument => {
|
|
312
|
+
const where = `${origin} record ${index}`;
|
|
313
|
+
if (typeof entry !== "object" || entry === null || Array.isArray(entry)) {
|
|
314
|
+
throw new DocspackError(`${where} is not an object`);
|
|
315
|
+
}
|
|
316
|
+
const record = entry as Record<string, unknown>;
|
|
317
|
+
const title = field(record.title, `${where}.title`);
|
|
318
|
+
const text = field(record.text, `${where}.text`);
|
|
319
|
+
const id = record.id === undefined ? undefined : field(record.id, `${where}.id`);
|
|
320
|
+
|
|
321
|
+
return {
|
|
322
|
+
title,
|
|
323
|
+
text,
|
|
324
|
+
origin,
|
|
325
|
+
...(id === undefined ? {} : { id: slugify(id, `record-${index}`), atomic: true }),
|
|
326
|
+
...(record.tags === undefined ? {} : { tags: stringList(record.tags, `${where}.tags`) }),
|
|
327
|
+
...(record.entities === undefined
|
|
328
|
+
? {}
|
|
329
|
+
: { entities: stringList(record.entities, `${where}.entities`) }),
|
|
330
|
+
};
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function field(value: unknown, where: string): string {
|
|
335
|
+
if (typeof value !== "string" || value.trim().length === 0) {
|
|
336
|
+
throw new DocspackError(`${where} must be a non-empty string`);
|
|
337
|
+
}
|
|
338
|
+
return value;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function stringList(value: unknown, where: string): string[] {
|
|
342
|
+
if (!Array.isArray(value) || value.some((entry) => typeof entry !== "string")) {
|
|
343
|
+
throw new DocspackError(`${where} must be an array of strings`);
|
|
344
|
+
}
|
|
345
|
+
return value as string[];
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
async function readStdin(): Promise<string> {
|
|
349
|
+
const chunks: Buffer[] = [];
|
|
350
|
+
for await (const chunk of process.stdin) chunks.push(Buffer.from(chunk));
|
|
351
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Prose and an API document, in that order, or a mirror on its own.
|
|
356
|
+
*
|
|
357
|
+
* `--from` and `--openapi` combine, because a library with an HTTP API usually has both and
|
|
358
|
+
* documents them in the same package — refusing the combination meant a vendor had to pick which
|
|
359
|
+
* half of their documentation to publish. A mirror does not combine with either: it is somebody
|
|
360
|
+
* else's whole documentation set, fetched, and merging our own prose into it would produce a package
|
|
361
|
+
* claiming to be a mirror of something it is not.
|
|
362
|
+
*
|
|
363
|
+
* Operations come first so that their ids win a collision. A prose chunk's id is derived and may
|
|
364
|
+
* take a numeric suffix without anything breaking; an operation's is pinned to its endpoint, and a
|
|
365
|
+
* suffix there is the stable address moving.
|
|
366
|
+
*/
|
|
218
367
|
async function collect(options: BuildOptions, warnings: string[]): Promise<SourceDocument[]> {
|
|
219
|
-
|
|
220
|
-
(
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
368
|
+
if (options.source !== undefined) {
|
|
369
|
+
if (options.from !== undefined || options.openapi !== undefined || options.json !== undefined) {
|
|
370
|
+
throw new DocspackError("A source cannot be combined with --from, --openapi or --from-json", {
|
|
371
|
+
hint: "A mirror is a whole documentation set on its own.",
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
return collectFromSource(options, warnings);
|
|
224
375
|
}
|
|
225
376
|
|
|
226
|
-
|
|
227
|
-
if (options.
|
|
228
|
-
|
|
377
|
+
const documents: SourceDocument[] = [];
|
|
378
|
+
if (options.openapi !== undefined) documents.push(...(await collectFromOpenApi(options.openapi)));
|
|
379
|
+
if (options.json !== undefined) documents.push(...(await collectFromJson(options.json)));
|
|
380
|
+
// The default `docs/` directory is only read when nothing else was named: a build given only an
|
|
381
|
+
// OpenAPI document or a set of records should not fail on a `docs/` directory that does not exist.
|
|
382
|
+
if (options.from !== undefined || (options.openapi === undefined && options.json === undefined)) {
|
|
383
|
+
documents.push(...(await collectFromDirectory(options.from ?? join(options.out, "docs"))));
|
|
384
|
+
}
|
|
385
|
+
return documents;
|
|
229
386
|
}
|
|
230
387
|
|
|
231
388
|
async function collectFromDirectory(dir: string): Promise<SourceDocument[]> {
|
|
@@ -333,121 +490,69 @@ async function collectFromSource(
|
|
|
333
490
|
return documents;
|
|
334
491
|
}
|
|
335
492
|
|
|
493
|
+
/**
|
|
494
|
+
* One chunk per operation, plus the document's own overview.
|
|
495
|
+
*
|
|
496
|
+
* What each chunk carries is `@docspack/openapi`'s digest: the base URL, the credential, the inputs
|
|
497
|
+
* with their types, the body shape, the response, the failures, the types it reaches, and a runnable
|
|
498
|
+
* cURL call — in LAPIS notation, which is an open format for describing an API to a model. That
|
|
499
|
+
* package's README has the measurement; the short version is that a digest is about 72% smaller than
|
|
500
|
+
* the smallest correct slice of OpenAPI JSON for the same operation, and a rounding error against
|
|
501
|
+
* the whole document, which is what an agent without retrieval is handed instead.
|
|
502
|
+
*
|
|
503
|
+
* This is deliberately not a summary. An agent that retrieved the chunk for `POST /v1/charges` can
|
|
504
|
+
* make the call from it without reading anything else, which is the only test a reference chunk has
|
|
505
|
+
* to pass.
|
|
506
|
+
*/
|
|
336
507
|
async function collectFromOpenApi(file: string): Promise<SourceDocument[]> {
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
throw new DocspackError(`Could not read the OpenAPI document ${file}`, {
|
|
342
|
-
hint: "Only JSON is supported. Convert YAML with a tool such as `yq -o json`.",
|
|
343
|
-
cause: error,
|
|
344
|
-
});
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
const spec = parsed as {
|
|
348
|
-
info?: { title?: unknown; description?: unknown; version?: unknown };
|
|
349
|
-
paths?: Record<string, unknown>;
|
|
350
|
-
};
|
|
508
|
+
// JSON or YAML, decided by what the file contains rather than by its extension. The reader is a
|
|
509
|
+
// separate entry point of `@docspack/lapis` so that only the callers who need a YAML parser load
|
|
510
|
+
// one; this is a CLI, so it is one of them.
|
|
511
|
+
const document = await readOpenApi(file);
|
|
351
512
|
const documents: SourceDocument[] = [];
|
|
352
|
-
const apiTitle = typeof spec.info?.title === "string" ? spec.info.title : "API";
|
|
353
513
|
|
|
354
|
-
if (
|
|
514
|
+
if (document.description.length > 0) {
|
|
355
515
|
documents.push({
|
|
356
|
-
|
|
516
|
+
id: "api-overview",
|
|
517
|
+
title: `${document.title} overview`,
|
|
357
518
|
origin: file,
|
|
358
|
-
text:
|
|
519
|
+
text: document.description,
|
|
359
520
|
atomic: true,
|
|
360
|
-
tags: ["overview", "introduction"],
|
|
521
|
+
tags: ["overview", "introduction", "api"],
|
|
361
522
|
});
|
|
362
523
|
}
|
|
363
524
|
|
|
364
|
-
for (const
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
throw new DocspackError(`No operations found in ${file}`, {
|
|
377
|
-
hint: "The document needs a `paths` object with at least one operation.",
|
|
525
|
+
for (const operation of document.operations) {
|
|
526
|
+
documents.push({
|
|
527
|
+
// The endpoint, not the operation id: a vendor renames `createCharge` to `postCharges` and
|
|
528
|
+
// the endpoint is still `POST /v1/charges`. Both are recorded as entities, so either spelling
|
|
529
|
+
// still finds the chunk.
|
|
530
|
+
id: slugify(`${operation.method} ${operation.path}`, "operation"),
|
|
531
|
+
title: operationTitle(operation),
|
|
532
|
+
origin: `${file}#${operation.method} ${operation.path}`,
|
|
533
|
+
text: operationDigest(document, operation),
|
|
534
|
+
atomic: true,
|
|
535
|
+
tags: operationTags(operation),
|
|
536
|
+
entities: operationEntities(operation),
|
|
378
537
|
});
|
|
379
538
|
}
|
|
539
|
+
|
|
380
540
|
return documents;
|
|
381
541
|
}
|
|
382
542
|
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
)
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
? operation.tags.filter((tag): tag is string => typeof tag === "string")
|
|
394
|
-
: [];
|
|
395
|
-
|
|
396
|
-
const lines = [`${method.toUpperCase()} ${path}`, ""];
|
|
397
|
-
if (summary.length > 0) lines.push(summary, "");
|
|
398
|
-
if (description.length > 0) lines.push(description, "");
|
|
399
|
-
if (operationId !== undefined) lines.push(`Operation: \`${operationId}\``, "");
|
|
400
|
-
|
|
401
|
-
const parameters = Array.isArray(operation.parameters) ? operation.parameters : [];
|
|
402
|
-
if (parameters.length > 0) {
|
|
403
|
-
lines.push("## Parameters", "");
|
|
404
|
-
for (const raw of parameters) {
|
|
405
|
-
if (typeof raw !== "object" || raw === null) continue;
|
|
406
|
-
const parameter = raw as {
|
|
407
|
-
name?: unknown;
|
|
408
|
-
in?: unknown;
|
|
409
|
-
required?: unknown;
|
|
410
|
-
description?: unknown;
|
|
411
|
-
};
|
|
412
|
-
if (typeof parameter.name !== "string") continue;
|
|
413
|
-
const location = typeof parameter.in === "string" ? parameter.in : "query";
|
|
414
|
-
const required = parameter.required === true ? ", required" : "";
|
|
415
|
-
const note =
|
|
416
|
-
typeof parameter.description === "string" && parameter.description.length > 0
|
|
417
|
-
? ` — ${parameter.description}`
|
|
418
|
-
: "";
|
|
419
|
-
lines.push(`- \`${parameter.name}\` (${location}${required})${note}`);
|
|
420
|
-
}
|
|
421
|
-
lines.push("");
|
|
422
|
-
}
|
|
423
|
-
|
|
424
|
-
const responses = operation.responses;
|
|
425
|
-
if (typeof responses === "object" && responses !== null) {
|
|
426
|
-
lines.push("## Responses", "");
|
|
427
|
-
for (const [status, raw] of Object.entries(responses as Record<string, unknown>)) {
|
|
428
|
-
const detail =
|
|
429
|
-
typeof raw === "object" &&
|
|
430
|
-
raw !== null &&
|
|
431
|
-
typeof (raw as { description?: unknown }).description === "string"
|
|
432
|
-
? ` — ${(raw as { description: string }).description}`
|
|
433
|
-
: "";
|
|
434
|
-
lines.push(`- \`${status}\`${detail}`);
|
|
543
|
+
/** The reader's failures, re-reported as the CLI's own error so the hint survives. */
|
|
544
|
+
async function readOpenApi(file: string): Promise<ApiDocument> {
|
|
545
|
+
try {
|
|
546
|
+
return (await readApiFile(file)).document;
|
|
547
|
+
} catch (error) {
|
|
548
|
+
if (error instanceof OpenApiError) {
|
|
549
|
+
throw new DocspackError(error.message, {
|
|
550
|
+
...(error.hint === undefined ? {} : { hint: error.hint }),
|
|
551
|
+
cause: error,
|
|
552
|
+
});
|
|
435
553
|
}
|
|
436
|
-
|
|
554
|
+
throw error;
|
|
437
555
|
}
|
|
438
|
-
|
|
439
|
-
return {
|
|
440
|
-
title: summary.length > 0 ? summary : `${method.toUpperCase()} ${path}`,
|
|
441
|
-
origin: `${file}#${method} ${path}`,
|
|
442
|
-
text: lines.join("\n").trim(),
|
|
443
|
-
atomic: true,
|
|
444
|
-
tags: [
|
|
445
|
-
method,
|
|
446
|
-
...tags,
|
|
447
|
-
...path.split("/").filter((part) => part.length > 0 && !part.startsWith("{")),
|
|
448
|
-
],
|
|
449
|
-
...(operationId === undefined ? {} : { entities: [operationId] }),
|
|
450
|
-
};
|
|
451
556
|
}
|
|
452
557
|
|
|
453
558
|
function chunkDocuments(
|
|
@@ -474,7 +579,12 @@ function chunkDocuments(
|
|
|
474
579
|
for (const section of sections) {
|
|
475
580
|
const directives = readDirectives(section.body);
|
|
476
581
|
const body = withoutDuplicateHeading(directives.body, section.heading);
|
|
477
|
-
|
|
582
|
+
// Only for an atomic document: a pinned id on a document that splits would give every
|
|
583
|
+
// section the same id.
|
|
584
|
+
const derived =
|
|
585
|
+
document.atomic === true && document.id !== undefined
|
|
586
|
+
? document.id
|
|
587
|
+
: chunkSlug(document.title, section.heading);
|
|
478
588
|
const id = uniqueId(derived, taken);
|
|
479
589
|
if (id !== derived) collisions.push(derived);
|
|
480
590
|
const libraries = directives.documents.length > 0 ? directives.documents : document.documents;
|
package/src/cli.ts
CHANGED
|
@@ -6,7 +6,7 @@ import { parseArgs } from "node:util";
|
|
|
6
6
|
import { applyAgentSetup, planAgentSetup } from "./agent.js";
|
|
7
7
|
import { changedSurface } from "./changed.js";
|
|
8
8
|
import { measureCoverage } from "./coverage.js";
|
|
9
|
-
import { defaultStorePath, Store, silenceSqliteWarning } from "./db.js";
|
|
9
|
+
import { defaultStorePath, localStorePath, Store, silenceSqliteWarning } from "./db.js";
|
|
10
10
|
import { discoverPackages } from "./discovery.js";
|
|
11
11
|
import { type DoctorReport, runDoctor } from "./doctor.js";
|
|
12
12
|
import { DocspackError } from "./errors.js";
|
|
@@ -40,6 +40,7 @@ const OPTIONS = {
|
|
|
40
40
|
"max-tokens": { type: "string" },
|
|
41
41
|
all: { type: "boolean" },
|
|
42
42
|
from: { type: "string" },
|
|
43
|
+
"from-json": { type: "string" },
|
|
43
44
|
openapi: { type: "string" },
|
|
44
45
|
out: { type: "string" },
|
|
45
46
|
name: { type: "string" },
|
|
@@ -47,6 +48,7 @@ const OPTIONS = {
|
|
|
47
48
|
pages: { type: "string" },
|
|
48
49
|
"max-chunk-tokens": { type: "string" },
|
|
49
50
|
"min-chunk-tokens": { type: "string" },
|
|
51
|
+
local: { type: "boolean" },
|
|
50
52
|
documents: { type: "string", multiple: true },
|
|
51
53
|
"min-hit-rate": { type: "string" },
|
|
52
54
|
mirror: { type: "string" },
|
|
@@ -164,6 +166,11 @@ function openStore(values: Values): Store {
|
|
|
164
166
|
return Store.open(values.store ?? defaultStorePath());
|
|
165
167
|
}
|
|
166
168
|
|
|
169
|
+
/** The project's own corpus, which is never the machine-wide store unless asked for by path. */
|
|
170
|
+
function openLocalStore(values: Values, cwd: string): Store {
|
|
171
|
+
return Store.open(values.store ?? localStorePath(cwd));
|
|
172
|
+
}
|
|
173
|
+
|
|
167
174
|
async function main(argv: readonly string[]): Promise<number> {
|
|
168
175
|
let values: Values;
|
|
169
176
|
let positionals: string[];
|
|
@@ -302,6 +309,83 @@ async function main(argv: readonly string[]): Promise<number> {
|
|
|
302
309
|
}
|
|
303
310
|
}
|
|
304
311
|
|
|
312
|
+
case "index": {
|
|
313
|
+
const { indexLocal } = await import("./local.js");
|
|
314
|
+
const store = openLocalStore(values, cwd);
|
|
315
|
+
try {
|
|
316
|
+
const result = await indexLocal({
|
|
317
|
+
cwd,
|
|
318
|
+
store,
|
|
319
|
+
...(values.from === undefined ? {} : { from: values.from }),
|
|
320
|
+
...(values["from-json"] === undefined ? {} : { json: values["from-json"] }),
|
|
321
|
+
...(values.name === undefined ? {} : { name: values.name }),
|
|
322
|
+
...(values.force === true ? { force: true } : {}),
|
|
323
|
+
...(quiet || json
|
|
324
|
+
? {}
|
|
325
|
+
: {
|
|
326
|
+
onProgress: (message: string): void => {
|
|
327
|
+
process.stderr.write(`${dim(message)}\n`);
|
|
328
|
+
},
|
|
329
|
+
}),
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
if (json) {
|
|
333
|
+
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
334
|
+
return 0;
|
|
335
|
+
}
|
|
336
|
+
const state =
|
|
337
|
+
result.status === "unchanged"
|
|
338
|
+
? dim("unchanged")
|
|
339
|
+
: `${String(result.chunks)} chunks ${dim(`~${String(result.tokens)} tokens`)}`;
|
|
340
|
+
process.stdout.write(`${green("+")} ${bold(result.name)} ${state}\n`);
|
|
341
|
+
for (const warning of result.warnings) process.stderr.write(`${yellow("!")} ${warning}\n`);
|
|
342
|
+
if (!quiet) {
|
|
343
|
+
process.stdout.write(
|
|
344
|
+
result.streamed
|
|
345
|
+
? `\nIndexed from a stream, so nothing can tell later whether it is still current.\nAsk it with \`docspack recall "<question>"\`.\n`
|
|
346
|
+
: `\nAsk it with \`docspack recall "<question>"\`. Re-run this after editing the sources.\n`,
|
|
347
|
+
);
|
|
348
|
+
}
|
|
349
|
+
return 0;
|
|
350
|
+
} finally {
|
|
351
|
+
store.close();
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
case "recall": {
|
|
356
|
+
const question = rest.join(" ");
|
|
357
|
+
if (question.length === 0) {
|
|
358
|
+
throw new DocspackError('Usage: docspack recall "<question>"');
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
const { recallLocal, renderRecall } = await import("./local.js");
|
|
362
|
+
const store = openLocalStore(values, cwd);
|
|
363
|
+
try {
|
|
364
|
+
const result = await recallLocal({
|
|
365
|
+
cwd,
|
|
366
|
+
store,
|
|
367
|
+
query: question,
|
|
368
|
+
...(() => {
|
|
369
|
+
const limit = integer(values.limit, "--limit");
|
|
370
|
+
return limit === undefined ? {} : { limit };
|
|
371
|
+
})(),
|
|
372
|
+
...(() => {
|
|
373
|
+
const maxTokens = integer(values["max-tokens"], "--max-tokens");
|
|
374
|
+
return maxTokens === undefined ? {} : { maxTokens };
|
|
375
|
+
})(),
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
if (json) {
|
|
379
|
+
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
380
|
+
} else {
|
|
381
|
+
process.stdout.write(`${renderRecall(result, question)}\n`);
|
|
382
|
+
}
|
|
383
|
+
return result.hits.length === 0 ? 1 : 0;
|
|
384
|
+
} finally {
|
|
385
|
+
store.close();
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
305
389
|
case "search": {
|
|
306
390
|
const query = rest.join(" ");
|
|
307
391
|
if (query.length === 0) throw new DocspackError("Usage: docspack search <query>");
|
|
@@ -348,6 +432,11 @@ async function main(argv: readonly string[]): Promise<number> {
|
|
|
348
432
|
}
|
|
349
433
|
process.stdout.write("\n");
|
|
350
434
|
}
|
|
435
|
+
if (result.endpoints.length > 0) {
|
|
436
|
+
// Which hits were addressed rather than ranked. A reader who asked about a concrete URL
|
|
437
|
+
// and got a chunk about a template path needs to see that the match was the template.
|
|
438
|
+
process.stdout.write(dim(`matched by endpoint: ${result.endpoints.join(", ")}\n`));
|
|
439
|
+
}
|
|
351
440
|
process.stdout.write(
|
|
352
441
|
dim(`${result.tokens} tokens across ${plural(result.hits.length, "chunk")}\n`),
|
|
353
442
|
);
|
|
@@ -762,7 +851,9 @@ async function main(argv: readonly string[]): Promise<number> {
|
|
|
762
851
|
out: values.out ?? cwd,
|
|
763
852
|
...(rest[0] === undefined ? {} : { source: rest[0] }),
|
|
764
853
|
...(values.from === undefined ? {} : { from: values.from }),
|
|
854
|
+
...(values["from-json"] === undefined ? {} : { json: values["from-json"] }),
|
|
765
855
|
...(values.openapi === undefined ? {} : { openapi: values.openapi }),
|
|
856
|
+
...(values.local === true ? { local: true } : {}),
|
|
766
857
|
...(values.name === undefined ? {} : { name: values.name }),
|
|
767
858
|
...(values["pkg-version"] === undefined ? {} : { version: values["pkg-version"] }),
|
|
768
859
|
...(() => {
|
|
@@ -796,8 +887,11 @@ async function main(argv: readonly string[]): Promise<number> {
|
|
|
796
887
|
);
|
|
797
888
|
for (const warning of result.warnings) process.stderr.write(`${yellow("!")} ${warning}\n`);
|
|
798
889
|
if (!quiet) {
|
|
890
|
+
// A local build is not going to npm, so the publishing instruction would be wrong advice.
|
|
799
891
|
process.stdout.write(
|
|
800
|
-
|
|
892
|
+
values.local === true
|
|
893
|
+
? `\nWrote ${result.dir}/.llms and ${result.dir}/llms.txt.\nTo index a corpus into this project instead, use \`docspack index\`.\n`
|
|
894
|
+
: `\nWrote ${result.dir}/.llms and ${result.dir}/llms.txt.\nPublish with \`npm publish\`, then add it to a project and run \`docspack sync\`.\n`,
|
|
801
895
|
);
|
|
802
896
|
}
|
|
803
897
|
return 0;
|