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/db.ts
CHANGED
|
@@ -10,11 +10,24 @@ const require = createRequire(import.meta.url);
|
|
|
10
10
|
|
|
11
11
|
/**
|
|
12
12
|
* Where a package's text came from. `docs` is written by a human and published as a docs
|
|
13
|
-
* package; `artifact` is derived from an installed library's own type declarations
|
|
13
|
+
* package; `artifact` is derived from an installed library's own type declarations; `local` is a
|
|
14
|
+
* working corpus indexed from this project's own sources and never published. The
|
|
14
15
|
* distinction is not cosmetic — a signature is a fact about the build, prose is a claim by its
|
|
15
|
-
* author
|
|
16
|
+
* author, and a working corpus is a copy that can go stale under the reader — so it is stored
|
|
17
|
+
* rather than inferred from the name.
|
|
16
18
|
*/
|
|
17
|
-
export type PackageKind = "docs" | "artifact";
|
|
19
|
+
export type PackageKind = "docs" | "artifact" | "local";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Reads the stored kind, defaulting an unrecognized one to `docs`.
|
|
23
|
+
*
|
|
24
|
+
* Written once rather than inline at each read: a store may have been written by a newer version
|
|
25
|
+
* that knows a kind this one does not, and silently relabelling it as something it is not is how
|
|
26
|
+
* a local corpus would end up presented as published documentation.
|
|
27
|
+
*/
|
|
28
|
+
export function toPackageKind(value: string): PackageKind {
|
|
29
|
+
return value === "artifact" || value === "local" ? value : "docs";
|
|
30
|
+
}
|
|
18
31
|
|
|
19
32
|
export interface IndexedPackage {
|
|
20
33
|
readonly id: string;
|
|
@@ -31,6 +44,21 @@ export interface SymbolHit {
|
|
|
31
44
|
readonly chunkId: string;
|
|
32
45
|
}
|
|
33
46
|
|
|
47
|
+
/**
|
|
48
|
+
* A file an indexed package was built from, as it was at index time.
|
|
49
|
+
*
|
|
50
|
+
* Only a working corpus records these. Published documentation is immutable for the life of its
|
|
51
|
+
* version, so there is nothing to detect; a corpus of the reader's own files is edited underneath
|
|
52
|
+
* the index, and an answer quoted from a superseded source is worse than no answer.
|
|
53
|
+
*/
|
|
54
|
+
export interface IndexedSource {
|
|
55
|
+
readonly path: string;
|
|
56
|
+
readonly size: number;
|
|
57
|
+
/** ISO timestamp, as text, because that is what survives a round trip through SQLite. */
|
|
58
|
+
readonly mtime: string;
|
|
59
|
+
readonly hash: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
34
62
|
export interface IndexedChunk {
|
|
35
63
|
readonly chunkId: string;
|
|
36
64
|
readonly filePath: string;
|
|
@@ -59,7 +87,10 @@ export interface SearchOptions {
|
|
|
59
87
|
readonly kinds?: readonly PackageKind[];
|
|
60
88
|
}
|
|
61
89
|
|
|
62
|
-
|
|
90
|
+
/** Where per-project state lives, alongside the feedback file already written there. */
|
|
91
|
+
export const LOCAL_STORE_DIR = ".docspack";
|
|
92
|
+
|
|
93
|
+
const SCHEMA_VERSION = 3;
|
|
63
94
|
|
|
64
95
|
const SCHEMA = `
|
|
65
96
|
CREATE TABLE IF NOT EXISTS packages (
|
|
@@ -89,6 +120,15 @@ CREATE TABLE IF NOT EXISTS symbols (
|
|
|
89
120
|
|
|
90
121
|
CREATE INDEX IF NOT EXISTS symbols_by_name ON symbols(name);
|
|
91
122
|
|
|
123
|
+
CREATE TABLE IF NOT EXISTS sources (
|
|
124
|
+
package_id TEXT NOT NULL REFERENCES packages(id),
|
|
125
|
+
path TEXT NOT NULL,
|
|
126
|
+
size INTEGER NOT NULL,
|
|
127
|
+
mtime TEXT NOT NULL,
|
|
128
|
+
hash TEXT NOT NULL,
|
|
129
|
+
PRIMARY KEY (package_id, path)
|
|
130
|
+
);
|
|
131
|
+
|
|
92
132
|
CREATE VIRTUAL TABLE IF NOT EXISTS chunks_fts USING fts5(
|
|
93
133
|
content,
|
|
94
134
|
tags,
|
|
@@ -120,6 +160,17 @@ function loadSqlite(): typeof import("node:sqlite") {
|
|
|
120
160
|
return require("node:sqlite") as typeof import("node:sqlite");
|
|
121
161
|
}
|
|
122
162
|
|
|
163
|
+
/**
|
|
164
|
+
* Where a project's own working corpus is indexed.
|
|
165
|
+
*
|
|
166
|
+
* Deliberately not the global store: these chunks are a plaintext copy of one project's sources,
|
|
167
|
+
* they are worthless to any other project, and a machine-wide store would accumulate them from
|
|
168
|
+
* every checkout with nothing to evict them.
|
|
169
|
+
*/
|
|
170
|
+
export function localStorePath(cwd: string): string {
|
|
171
|
+
return join(cwd, LOCAL_STORE_DIR, "local.db");
|
|
172
|
+
}
|
|
173
|
+
|
|
123
174
|
/** Default location of the shared index, mirroring pnpm's global store. */
|
|
124
175
|
export function defaultStorePath(env: NodeJS.ProcessEnv = process.env): string {
|
|
125
176
|
const override = env.DOCSPACK_STORE;
|
|
@@ -207,7 +258,7 @@ export class Store {
|
|
|
207
258
|
id: row.id,
|
|
208
259
|
name: row.name,
|
|
209
260
|
version: row.version,
|
|
210
|
-
kind: row.kind
|
|
261
|
+
kind: toPackageKind(row.kind),
|
|
211
262
|
indexedAt: row.indexed_at,
|
|
212
263
|
}));
|
|
213
264
|
}
|
|
@@ -351,6 +402,36 @@ export class Store {
|
|
|
351
402
|
}
|
|
352
403
|
}
|
|
353
404
|
|
|
405
|
+
/** Replaces the record of what a package was built from. */
|
|
406
|
+
recordSources(packageId: string, sources: readonly IndexedSource[]): void {
|
|
407
|
+
const insert = this.#db.prepare(
|
|
408
|
+
"INSERT OR REPLACE INTO sources (package_id, path, size, mtime, hash) VALUES (?, ?, ?, ?, ?)",
|
|
409
|
+
);
|
|
410
|
+
this.#db.exec("BEGIN");
|
|
411
|
+
try {
|
|
412
|
+
this.#db.prepare("DELETE FROM sources WHERE package_id = ?").run(packageId);
|
|
413
|
+
for (const source of sources) {
|
|
414
|
+
insert.run(packageId, source.path, source.size, source.mtime, source.hash);
|
|
415
|
+
}
|
|
416
|
+
this.#db.exec("COMMIT");
|
|
417
|
+
} catch (error) {
|
|
418
|
+
this.#db.exec("ROLLBACK");
|
|
419
|
+
throw error;
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
sourcesOf(packageId: string): IndexedSource[] {
|
|
424
|
+
const rows = this.#db
|
|
425
|
+
.prepare("SELECT path, size, mtime, hash FROM sources WHERE package_id = ? ORDER BY path")
|
|
426
|
+
.all(packageId) as { path: string; size: number; mtime: string; hash: string }[];
|
|
427
|
+
return rows.map((row) => ({
|
|
428
|
+
path: row.path,
|
|
429
|
+
size: Number(row.size),
|
|
430
|
+
mtime: row.mtime,
|
|
431
|
+
hash: row.hash,
|
|
432
|
+
}));
|
|
433
|
+
}
|
|
434
|
+
|
|
354
435
|
removePackage(id: string): void {
|
|
355
436
|
this.#db.exec("BEGIN");
|
|
356
437
|
try {
|
|
@@ -430,6 +511,7 @@ export class Store {
|
|
|
430
511
|
}
|
|
431
512
|
|
|
432
513
|
#deleteChunks(packageId: string): void {
|
|
514
|
+
this.#db.prepare("DELETE FROM sources WHERE package_id = ?").run(packageId);
|
|
433
515
|
this.#db.prepare("DELETE FROM chunks_fts WHERE package_id = ?").run(packageId);
|
|
434
516
|
this.#db.prepare("DELETE FROM chunks WHERE package_id = ?").run(packageId);
|
|
435
517
|
this.#db.prepare("DELETE FROM symbols WHERE package_id = ?").run(packageId);
|
package/src/discovery.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { readFile } from "node:fs/promises";
|
|
|
2
2
|
import { dirname, join, resolve } from "node:path";
|
|
3
3
|
import { DocspackError } from "./errors.js";
|
|
4
4
|
import {
|
|
5
|
+
DISCOVERABLE_NAMES,
|
|
5
6
|
isCommunityPackage,
|
|
6
7
|
isDocsPackage,
|
|
7
8
|
LLMS_DIR,
|
|
@@ -38,17 +39,20 @@ export interface Discovery {
|
|
|
38
39
|
}
|
|
39
40
|
|
|
40
41
|
/**
|
|
41
|
-
* Finds the documentation packages a project depends on: every `@vendor/docspack
|
|
42
|
-
* `@docspack-community/*` entry in `dependencies` or
|
|
43
|
-
* ships a `.llms/manifest.json`.
|
|
42
|
+
* Finds the documentation packages a project depends on: every `@vendor/docspack`,
|
|
43
|
+
* `@vendor/<name>-docspack` or `@docspack-community/*` entry in `dependencies` or
|
|
44
|
+
* `devDependencies` that is installed and ships a `.llms/manifest.json`.
|
|
45
|
+
*
|
|
46
|
+
* A dependency that ships a payload under any other name is reported in `problems` rather than
|
|
47
|
+
* indexed, so a pack nobody can read says so instead of going missing from every answer.
|
|
44
48
|
*/
|
|
45
49
|
export async function discoverPackages(cwd: string): Promise<Discovery> {
|
|
46
50
|
const root = resolve(cwd);
|
|
47
|
-
const declared = await
|
|
51
|
+
const declared = await declaredDependencies(root);
|
|
48
52
|
const packages: DiscoveredPackage[] = [];
|
|
49
53
|
const problems: string[] = [];
|
|
50
54
|
|
|
51
|
-
for (const name of declared) {
|
|
55
|
+
for (const name of declared.filter(isDocsPackage)) {
|
|
52
56
|
const dir = await resolvePackageDir(name, root);
|
|
53
57
|
if (dir === undefined) {
|
|
54
58
|
problems.push(`${name} is declared in package.json but is not installed`);
|
|
@@ -94,10 +98,62 @@ export async function discoverPackages(cwd: string): Promise<Discovery> {
|
|
|
94
98
|
});
|
|
95
99
|
}
|
|
96
100
|
|
|
101
|
+
problems.push(...(await skippedPayloads(declared.filter(isNotDocsPackage), root)));
|
|
102
|
+
|
|
97
103
|
packages.sort((a, b) => a.name.localeCompare(b.name));
|
|
98
104
|
return { packages, problems };
|
|
99
105
|
}
|
|
100
106
|
|
|
107
|
+
/**
|
|
108
|
+
* Dependencies that ship a payload under a name discovery does not match.
|
|
109
|
+
*
|
|
110
|
+
* None of them is indexed, and that is the point: the name check is what decides whether a
|
|
111
|
+
* package may put content in front of a model, and widening it to "carries a manifest" would let
|
|
112
|
+
* any transitive dependency opt itself in. But a package built by `docspack build` and then named
|
|
113
|
+
* outside the discoverable shapes is a publishing mistake — a typo, a rename, or a reading of the
|
|
114
|
+
* naming rule — and saying nothing about it is worse than refusing it. The corpus is absent from
|
|
115
|
+
* every answer while the question it should have answered is answered confidently by another pack.
|
|
116
|
+
*/
|
|
117
|
+
async function skippedPayloads(names: readonly string[], root: string): Promise<string[]> {
|
|
118
|
+
// Concurrent, unlike the loop over docs packages above: this one runs over every dependency
|
|
119
|
+
// the project declares rather than the handful that document something, and `docspack ask`
|
|
120
|
+
// pays for it on each question. The reads are independent, and `Promise.all` keeps the order
|
|
121
|
+
// of `names` so the report is the same on every run.
|
|
122
|
+
const found = await Promise.all(names.map((name) => skippedPayload(name, root)));
|
|
123
|
+
return found.filter((problem) => problem !== undefined);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function skippedPayload(name: string, root: string): Promise<string | undefined> {
|
|
127
|
+
const dir = await resolvePackageDir(name, root);
|
|
128
|
+
if (dir === undefined) return undefined;
|
|
129
|
+
|
|
130
|
+
let raw: string;
|
|
131
|
+
try {
|
|
132
|
+
raw = await readFile(join(dir, LLMS_DIR, MANIFEST_FILE), "utf8");
|
|
133
|
+
} catch {
|
|
134
|
+
return undefined;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Counted rather than validated: a manifest too malformed to parse is still a payload its
|
|
138
|
+
// publisher meant to be read, and the name is the problem to report either way.
|
|
139
|
+
let chunks = 0;
|
|
140
|
+
try {
|
|
141
|
+
const parsed = JSON.parse(raw) as { chunks?: unknown };
|
|
142
|
+
if (Array.isArray(parsed.chunks)) chunks = parsed.chunks.length;
|
|
143
|
+
} catch {
|
|
144
|
+
// Not JSON. Report the name anyway; `docspack doctor` is where the payload is checked.
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return (
|
|
148
|
+
`${name} ships ${LLMS_DIR}/${MANIFEST_FILE} with ${chunks} ${chunks === 1 ? "chunk" : "chunks"} but was not indexed: ` +
|
|
149
|
+
`its name is not a discoverable pack shape (${DISCOVERABLE_NAMES})`
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function isNotDocsPackage(name: string): boolean {
|
|
154
|
+
return !isDocsPackage(name);
|
|
155
|
+
}
|
|
156
|
+
|
|
101
157
|
/**
|
|
102
158
|
* Every library this project depends on directly, installed and readable.
|
|
103
159
|
*
|
|
@@ -106,7 +162,7 @@ export async function discoverPackages(cwd: string): Promise<Discovery> {
|
|
|
106
162
|
*/
|
|
107
163
|
export async function discoverLibraries(cwd: string): Promise<readonly DiscoveredLibrary[]> {
|
|
108
164
|
const root = resolve(cwd);
|
|
109
|
-
const names = (await declaredDependencies(root)).filter(
|
|
165
|
+
const names = (await declaredDependencies(root)).filter(isNotDocsPackage);
|
|
110
166
|
const libraries: DiscoveredLibrary[] = [];
|
|
111
167
|
|
|
112
168
|
for (const name of names) {
|
|
@@ -128,11 +184,6 @@ export async function projectPackageIds(cwd: string): Promise<string[]> {
|
|
|
128
184
|
return packages.map((pkg) => pkg.id);
|
|
129
185
|
}
|
|
130
186
|
|
|
131
|
-
/** Docs packages this project depends on, the input to `discoverPackages`. */
|
|
132
|
-
async function declaredDocsPackages(cwd: string): Promise<string[]> {
|
|
133
|
-
return (await declaredDependencies(cwd)).filter(isDocsPackage);
|
|
134
|
-
}
|
|
135
|
-
|
|
136
187
|
/**
|
|
137
188
|
* Every dependency declared by this directory or by an ancestor of it. A workspace declares
|
|
138
189
|
* shared tooling in the repository root and its members inherit the install, so reading only
|
package/src/doctor.ts
CHANGED
|
@@ -5,7 +5,9 @@ import { readBuildConfig } from "./config.js";
|
|
|
5
5
|
import { measureCoverage } from "./coverage.js";
|
|
6
6
|
import { PLACEHOLDER } from "./init/templates.js";
|
|
7
7
|
import {
|
|
8
|
+
DISCOVERABLE_NAMES,
|
|
8
9
|
estimateTokens,
|
|
10
|
+
isDocsPackage,
|
|
9
11
|
LLMS_DIR,
|
|
10
12
|
MANIFEST_FILE,
|
|
11
13
|
type PackageManifest,
|
|
@@ -347,6 +349,21 @@ function checkPackageJson(
|
|
|
347
349
|
});
|
|
348
350
|
}
|
|
349
351
|
|
|
352
|
+
// The one property that decides whether the indexer will open the package at all, and the one
|
|
353
|
+
// this check used to skip: every other check here describes a package that indexes badly, and
|
|
354
|
+
// a name outside the discoverable shapes describes one that is never read. It publishes, it
|
|
355
|
+
// installs, it passes every other check, and the corpus is absent from every answer.
|
|
356
|
+
const name = typeof pkg.name === "string" ? pkg.name : manifest.name;
|
|
357
|
+
if (!isDocsPackage(name)) {
|
|
358
|
+
findings.push({
|
|
359
|
+
check: "name-undiscoverable",
|
|
360
|
+
severity: "error",
|
|
361
|
+
message: `"${name}" is not a name the indexer discovers; the package would install and never be read`,
|
|
362
|
+
where: "package.json",
|
|
363
|
+
fix: `Rename it to one of ${DISCOVERABLE_NAMES}.`,
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
|
|
350
367
|
const files = Array.isArray(pkg.files) ? pkg.files.map(String) : undefined;
|
|
351
368
|
if (
|
|
352
369
|
files !== undefined &&
|
|
@@ -362,21 +379,29 @@ function checkPackageJson(
|
|
|
362
379
|
}
|
|
363
380
|
}
|
|
364
381
|
|
|
365
|
-
/**
|
|
382
|
+
/**
|
|
383
|
+
* Newest mtime across every configured documentation source, used to spot a stale payload.
|
|
384
|
+
*
|
|
385
|
+
* Both sources are checked, not the first one found: a package configured with prose *and* an
|
|
386
|
+
* OpenAPI document would otherwise report a fresh payload after the document changed, which is the
|
|
387
|
+
* one case this check exists to catch.
|
|
388
|
+
*/
|
|
366
389
|
async function newestSourceMtime(dir: string): Promise<number> {
|
|
367
390
|
const config = await readBuildConfig(dir);
|
|
368
|
-
const
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
391
|
+
const newest = await Promise.all([
|
|
392
|
+
config.openapi === undefined ? 0 : mtime(join(dir, config.openapi)),
|
|
393
|
+
config.from === undefined ? 0 : newestInTree(join(dir, config.from)),
|
|
394
|
+
]);
|
|
395
|
+
return Math.max(...newest);
|
|
396
|
+
}
|
|
374
397
|
|
|
398
|
+
/** Newest mtime in a directory tree, or the file's own when the path is a file. */
|
|
399
|
+
async function newestInTree(target: string): Promise<number> {
|
|
375
400
|
let found: Dirent[];
|
|
376
401
|
try {
|
|
377
402
|
found = await readdir(target, { recursive: true, withFileTypes: true });
|
|
378
403
|
} catch {
|
|
379
|
-
return
|
|
404
|
+
return mtime(target);
|
|
380
405
|
}
|
|
381
406
|
|
|
382
407
|
let newest = 0;
|
package/src/endpoints.ts
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { HTTP_METHODS } from "@docspack/openapi";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Answering a question that names an HTTP request, by address rather than by ranking.
|
|
5
|
+
*
|
|
6
|
+
* This is the same argument `search.ts` makes for exported symbols, applied to endpoints. A query
|
|
7
|
+
* like `POST /v1/charges` is not a phrase, it is a key: ranked as prose it matches every chunk that
|
|
8
|
+
* mentions charges, and the overview page — which says "charges" six times — outranks the one chunk
|
|
9
|
+
* that describes the operation. `/v1` and `charges` are also the two worst possible search terms in
|
|
10
|
+
* a document where every path starts with `/v1`.
|
|
11
|
+
*
|
|
12
|
+
* So an endpoint an operation chunk declares is looked up exactly, and the matching chunk is pinned
|
|
13
|
+
* ahead of anything the ranker found. `docspack build --openapi` records both spellings of every
|
|
14
|
+
* operation in its chunk's `entities` — `POST /v1/charges` and `createCharge` — and the second one
|
|
15
|
+
* already worked, because an operation id is identifier-shaped and the symbol path handles it. The
|
|
16
|
+
* first one could not match anything at all.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** An operation a chunk declares. */
|
|
20
|
+
export interface EndpointEntry {
|
|
21
|
+
readonly chunkId: string;
|
|
22
|
+
readonly method: string;
|
|
23
|
+
/** Template path as the document wrote it, braces included. */
|
|
24
|
+
readonly path: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* How many endpoints one answer may pin.
|
|
29
|
+
*
|
|
30
|
+
* Two, matching `MAX_DECLARATIONS`. A question names one request, occasionally two — "how do I
|
|
31
|
+
* create a charge and then refund it". More than that and the addresses are incidental, and pinning
|
|
32
|
+
* them spends the budget on operations nobody asked about.
|
|
33
|
+
*/
|
|
34
|
+
export const MAX_ENDPOINTS = 2;
|
|
35
|
+
|
|
36
|
+
const METHODS = new Set<string>(HTTP_METHODS);
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* An endpoint reference in a query.
|
|
40
|
+
*
|
|
41
|
+
* The path is required to contain a `/`, which is what keeps `GET the newest invoice` from being
|
|
42
|
+
* read as a request for a path called `the`. A full URL is accepted and reduced to its path, because
|
|
43
|
+
* that is what an agent has in front of it when it is reading a log line or a network tab.
|
|
44
|
+
*/
|
|
45
|
+
const REFERENCE = /\b([A-Za-z]+)\s+(\S*\/\S*)/g;
|
|
46
|
+
|
|
47
|
+
/** A bare path, for a question that names an endpoint without naming a method. */
|
|
48
|
+
const BARE_PATH = /(?:^|\s)(\/[A-Za-z0-9{}._~%\-/]*)/g;
|
|
49
|
+
|
|
50
|
+
/** Collects the endpoints a set of chunks declares, from their manifest entities. */
|
|
51
|
+
export function endpointIndex(
|
|
52
|
+
chunks: Iterable<{ readonly chunkId: string; readonly entities: readonly string[] }>,
|
|
53
|
+
): EndpointEntry[] {
|
|
54
|
+
const entries: EndpointEntry[] = [];
|
|
55
|
+
for (const chunk of chunks) {
|
|
56
|
+
for (const entity of chunk.entities) {
|
|
57
|
+
const parsed = parseEndpoint(entity);
|
|
58
|
+
if (parsed !== undefined) entries.push({ chunkId: chunk.chunkId, ...parsed });
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return entries;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Reads `POST /v1/charges` as a method and a path. */
|
|
65
|
+
function parseEndpoint(entity: string): { method: string; path: string } | undefined {
|
|
66
|
+
const match = /^([A-Z]+) (\/\S*)$/.exec(entity);
|
|
67
|
+
const method = match?.[1];
|
|
68
|
+
const path = match?.[2];
|
|
69
|
+
if (method === undefined || path === undefined || !METHODS.has(method)) return undefined;
|
|
70
|
+
return { method, path };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The operations a query addresses, in the order the query named them.
|
|
75
|
+
*
|
|
76
|
+
* An exact match wins. Failing that, the path is matched against the template a document declared,
|
|
77
|
+
* so an agent holding a real URL — `GET /v1/charges/ch_3Ox7`, copied out of a log — still reaches
|
|
78
|
+
* `GET /v1/charges/{charge}`. That is the case this exists for: a concrete request is what somebody
|
|
79
|
+
* actually has, and a template is what the document contains.
|
|
80
|
+
*/
|
|
81
|
+
export function matchEndpoints(
|
|
82
|
+
query: string,
|
|
83
|
+
index: readonly EndpointEntry[],
|
|
84
|
+
limit = MAX_ENDPOINTS,
|
|
85
|
+
): EndpointEntry[] {
|
|
86
|
+
if (index.length === 0) return [];
|
|
87
|
+
|
|
88
|
+
const found: EndpointEntry[] = [];
|
|
89
|
+
const seen = new Set<string>();
|
|
90
|
+
const take = (entry: EndpointEntry): void => {
|
|
91
|
+
if (seen.has(entry.chunkId) || found.length >= limit) return;
|
|
92
|
+
seen.add(entry.chunkId);
|
|
93
|
+
found.push(entry);
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
for (const reference of references(query)) {
|
|
97
|
+
if (found.length >= limit) break;
|
|
98
|
+
const exact = index.filter(
|
|
99
|
+
(entry) => entry.method === reference.method && entry.path === reference.path,
|
|
100
|
+
);
|
|
101
|
+
if (exact.length > 0) {
|
|
102
|
+
for (const entry of exact) take(entry);
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
for (const entry of index) {
|
|
106
|
+
if (entry.method !== reference.method) continue;
|
|
107
|
+
if (templateMatches(entry.path, reference.path)) take(entry);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Only when no method was named anywhere: otherwise a question that spells out `POST /charges`
|
|
112
|
+
// would also pin the `GET` on the same path, which is not what it asked for.
|
|
113
|
+
if (found.length === 0) {
|
|
114
|
+
for (const path of barePaths(query)) {
|
|
115
|
+
for (const entry of index) {
|
|
116
|
+
if (entry.path === path || templateMatches(entry.path, path)) take(entry);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return found;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Method-and-path references in a query, normalized. */
|
|
125
|
+
function references(query: string): { method: string; path: string }[] {
|
|
126
|
+
const found: { method: string; path: string }[] = [];
|
|
127
|
+
for (const match of query.matchAll(REFERENCE)) {
|
|
128
|
+
const method = match[1]?.toUpperCase();
|
|
129
|
+
const raw = match[2];
|
|
130
|
+
if (method === undefined || raw === undefined || !METHODS.has(method)) continue;
|
|
131
|
+
const path = toPath(raw);
|
|
132
|
+
if (path !== undefined) found.push({ method, path });
|
|
133
|
+
}
|
|
134
|
+
return found;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function barePaths(query: string): string[] {
|
|
138
|
+
const found: string[] = [];
|
|
139
|
+
for (const match of query.matchAll(BARE_PATH)) {
|
|
140
|
+
const path = match[1] === undefined ? undefined : toPath(match[1]);
|
|
141
|
+
// A lone `/` addresses nothing, and every sentence with a slash in it would otherwise be read
|
|
142
|
+
// as naming the document's root.
|
|
143
|
+
if (path !== undefined && path.length > 1) found.push(path);
|
|
144
|
+
}
|
|
145
|
+
return found;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Reduces what someone wrote to a path.
|
|
150
|
+
*
|
|
151
|
+
* A full URL keeps only its path. A query string and trailing sentence punctuation are dropped: the
|
|
152
|
+
* query string is values rather than address, and a question mark at the end of `GET /charges?` is
|
|
153
|
+
* almost never an empty query string.
|
|
154
|
+
*/
|
|
155
|
+
function toPath(raw: string): string | undefined {
|
|
156
|
+
let value = raw.replace(/[),.;:'"`]+$/, "");
|
|
157
|
+
const scheme = /^[a-z][a-z0-9+.-]*:\/\//i.exec(value);
|
|
158
|
+
if (scheme !== null) {
|
|
159
|
+
try {
|
|
160
|
+
value = new URL(value).pathname;
|
|
161
|
+
} catch {
|
|
162
|
+
return undefined;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
const queryAt = value.indexOf("?");
|
|
166
|
+
if (queryAt >= 0) value = value.slice(0, queryAt);
|
|
167
|
+
if (!value.startsWith("/")) return undefined;
|
|
168
|
+
// Repeated and trailing slashes are noise a URL bar forgives, and a path whose segment count is
|
|
169
|
+
// off by a stray slash matches no template at all — which reads as "the endpoint is undocumented".
|
|
170
|
+
return value.replace(/\/{2,}/g, "/").replace(/\/+$/, "") || "/";
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Whether a concrete path fills in a template: `/v1/charges/{charge}` against `/v1/charges/ch_3`. */
|
|
174
|
+
function templateMatches(template: string, concrete: string): boolean {
|
|
175
|
+
const left = template.split("/");
|
|
176
|
+
const right = concrete.split("/");
|
|
177
|
+
if (left.length !== right.length) return false;
|
|
178
|
+
return left.every((segment, index) => {
|
|
179
|
+
// A template segment matches any one segment, but not an empty one: `/charges/` is not a
|
|
180
|
+
// request for a charge.
|
|
181
|
+
if (segment.startsWith("{") && segment.endsWith("}")) return (right[index] ?? "").length > 0;
|
|
182
|
+
return segment === right[index];
|
|
183
|
+
});
|
|
184
|
+
}
|
package/src/help.ts
CHANGED
|
@@ -73,6 +73,54 @@ export const COMMANDS: readonly CommandHelp[] = [
|
|
|
73
73
|
'docspack ask "webhook signature" --package stripe --limit 5',
|
|
74
74
|
],
|
|
75
75
|
},
|
|
76
|
+
{
|
|
77
|
+
name: "index",
|
|
78
|
+
summary: "Index this project's own sources, so an agent can ask them instead of reading them",
|
|
79
|
+
group: "core",
|
|
80
|
+
usage: "docspack index [--from <dir>] [--from-json <file|->]",
|
|
81
|
+
options: [
|
|
82
|
+
["--from <dir>", "directory of Markdown to index"],
|
|
83
|
+
["--from-json <f>", "JSON records to index, or `-` for standard input"],
|
|
84
|
+
["--name <s>", "name for the corpus (default: derived from the source)"],
|
|
85
|
+
["--force", "re-index even when no source has changed"],
|
|
86
|
+
],
|
|
87
|
+
detail: [
|
|
88
|
+
"For a corpus this project already has rather than one somebody published: notes, an",
|
|
89
|
+
"export, rows out of a query. The payload is built in a temporary directory and thrown",
|
|
90
|
+
"away; what is kept is the index, in `.docspack/local.db`, which is a plaintext copy of",
|
|
91
|
+
"whatever was indexed and is gitignored on the tool's behalf.",
|
|
92
|
+
"",
|
|
93
|
+
"Records arrive as JSON so no database driver is needed: `sqlite3 -json … | docspack index",
|
|
94
|
+
"--from-json -`. A record with an `id` becomes one chunk under that id, because a row's",
|
|
95
|
+
"identity is its key.",
|
|
96
|
+
"",
|
|
97
|
+
"What was indexed is recorded with each source's size, mtime and hash, so a re-run does",
|
|
98
|
+
"nothing when nothing has changed and `recall` can say when an answer may be superseded.",
|
|
99
|
+
],
|
|
100
|
+
examples: [
|
|
101
|
+
"docspack index --from ./notes",
|
|
102
|
+
"sqlite3 -json shop.db 'select id, title, body as text from posts' | docspack index --from-json -",
|
|
103
|
+
],
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
name: "recall",
|
|
107
|
+
summary: "Answer from this project's indexed corpus, not from its dependencies",
|
|
108
|
+
group: "core",
|
|
109
|
+
usage: 'docspack recall "<question>"',
|
|
110
|
+
options: [
|
|
111
|
+
["--limit <n>", `maximum chunks to return (default ${LIMIT})`],
|
|
112
|
+
["--max-tokens <n>", `token ceiling for the result set (default ${MAX_TOKENS})`],
|
|
113
|
+
],
|
|
114
|
+
detail: [
|
|
115
|
+
"Separate from `ask` on purpose. `ask` answers from the versions this project installed,",
|
|
116
|
+
"and a working corpus is never one of them — so a corpus cannot reach an answer about a",
|
|
117
|
+
"dependency, and a dependency cannot reach an answer about your notes.",
|
|
118
|
+
"",
|
|
119
|
+
"An answer leads with a warning when a source has changed since it was indexed. Exits 1",
|
|
120
|
+
"when nothing matched.",
|
|
121
|
+
],
|
|
122
|
+
examples: ['docspack recall "what did we decide about retries"'],
|
|
123
|
+
},
|
|
76
124
|
{
|
|
77
125
|
name: "search",
|
|
78
126
|
summary: "Same index, formatted for a human reading the terminal",
|
|
@@ -241,7 +289,7 @@ export const COMMANDS: readonly CommandHelp[] = [
|
|
|
241
289
|
usage: "docspack build [source] [options]",
|
|
242
290
|
options: [
|
|
243
291
|
["--from <dir>", "directory of Markdown to package"],
|
|
244
|
-
["--openapi <file>", "OpenAPI JSON
|
|
292
|
+
["--openapi <file>", "OpenAPI 3 document (JSON or YAML), one chunk per operation"],
|
|
245
293
|
["--name <name>", "package name, e.g. @acme/docspack"],
|
|
246
294
|
["--pkg-version <v>", "package version"],
|
|
247
295
|
["--out <dir>", "output directory (default: the current directory)"],
|
|
@@ -256,6 +304,11 @@ export const COMMANDS: readonly CommandHelp[] = [
|
|
|
256
304
|
"the budget: use it for generated reference, where a heading is a field name and one chunk",
|
|
257
305
|
"per heading is hundreds of chunks too small to answer anything.",
|
|
258
306
|
"",
|
|
307
|
+
"--openapi writes one chunk per operation: the base URL, the credential, the inputs with",
|
|
308
|
+
"their types, the response, the failures and a runnable curl call, in LAPIS notation. Each",
|
|
309
|
+
"chunk answers to both `POST /v1/charges` and the operationId, so `docspack ask` can be",
|
|
310
|
+
"given either. Combine it with --from to publish prose and an API in one package.",
|
|
311
|
+
"",
|
|
259
312
|
"With no flags, settings are read from the docspack key of the package.json in --out.",
|
|
260
313
|
],
|
|
261
314
|
examples: [
|
|
@@ -263,6 +316,7 @@ export const COMMANDS: readonly CommandHelp[] = [
|
|
|
263
316
|
"docspack build --from ./docs --name @acme/docspack --pkg-version 1.4.0",
|
|
264
317
|
"docspack build --from ./reference --min-chunk-tokens 400 --max-chunk-tokens 900",
|
|
265
318
|
"docspack build --openapi ./openapi.json",
|
|
319
|
+
"docspack build --from ./docs --openapi ./openapi.json",
|
|
266
320
|
],
|
|
267
321
|
},
|
|
268
322
|
{
|
|
@@ -397,10 +451,11 @@ Authoring
|
|
|
397
451
|
${group("authoring")}
|
|
398
452
|
|
|
399
453
|
How it works
|
|
400
|
-
Documentation ships as npm packages named @vendor/docspack
|
|
401
|
-
@docspack-community/<name>. Add them to
|
|
402
|
-
every chunk is indexed into a shared
|
|
403
|
-
that index locally: no network, no
|
|
454
|
+
Documentation ships as npm packages named @vendor/docspack,
|
|
455
|
+
@vendor/<name>-docspack or @docspack-community/<name>. Add them to
|
|
456
|
+
package.json, run \`docspack sync\`, and every chunk is indexed into a shared
|
|
457
|
+
SQLite database with FTS5. Agents query that index locally: no network, no
|
|
458
|
+
scraping, always the installed version.
|
|
404
459
|
|
|
405
460
|
Giving an agent access
|
|
406
461
|
Any agent with a shell can run \`docspack ask\`, so one line in AGENTS.md or
|
package/src/index.ts
CHANGED
|
@@ -8,7 +8,13 @@ export {
|
|
|
8
8
|
withBlock,
|
|
9
9
|
} from "./agent.js";
|
|
10
10
|
export { type ArtifactPackage, readArtifact } from "./artifact.js";
|
|
11
|
-
export {
|
|
11
|
+
export {
|
|
12
|
+
type BuildOptions,
|
|
13
|
+
type BuildResult,
|
|
14
|
+
buildPackage,
|
|
15
|
+
LOCAL_VERSION,
|
|
16
|
+
localPackageName,
|
|
17
|
+
} from "./build.js";
|
|
12
18
|
export { type ChangedOptions, changedSurface, type SurfaceChange } from "./changed.js";
|
|
13
19
|
export {
|
|
14
20
|
type BuildConfig,
|
|
@@ -29,6 +35,9 @@ export {
|
|
|
29
35
|
defaultStorePath,
|
|
30
36
|
type IndexedChunk,
|
|
31
37
|
type IndexedPackage,
|
|
38
|
+
type IndexedSource,
|
|
39
|
+
LOCAL_STORE_DIR,
|
|
40
|
+
localStorePath,
|
|
32
41
|
type PackageKind,
|
|
33
42
|
type SearchHit,
|
|
34
43
|
type SearchOptions,
|
|
@@ -36,6 +45,7 @@ export {
|
|
|
36
45
|
type SymbolHit,
|
|
37
46
|
silenceSqliteWarning,
|
|
38
47
|
toFtsQuery,
|
|
48
|
+
toPackageKind,
|
|
39
49
|
} from "./db.js";
|
|
40
50
|
export {
|
|
41
51
|
type DiscoveredLibrary,
|
|
@@ -112,6 +122,17 @@ export {
|
|
|
112
122
|
type LlmsTxtSection,
|
|
113
123
|
parseLlmsTxt,
|
|
114
124
|
} from "./llms-txt.js";
|
|
125
|
+
export {
|
|
126
|
+
type IndexLocalOptions,
|
|
127
|
+
type IndexLocalResult,
|
|
128
|
+
indexLocal,
|
|
129
|
+
type RecallHit,
|
|
130
|
+
type RecallOptions,
|
|
131
|
+
type RecallResult,
|
|
132
|
+
recallLocal,
|
|
133
|
+
renderRecall,
|
|
134
|
+
type StaleSource,
|
|
135
|
+
} from "./local.js";
|
|
115
136
|
export { createMcpServer, type McpOptions, startMcpServer } from "./mcp.js";
|
|
116
137
|
export { type PreviewOptions, type PreviewResult, previewPackage } from "./preview.js";
|
|
117
138
|
export { type Choice, defaultStreams, Prompter, type PromptStreams } from "./prompt.js";
|
|
@@ -130,6 +151,7 @@ export {
|
|
|
130
151
|
CHUNKS_DIR,
|
|
131
152
|
type ChunkSpec,
|
|
132
153
|
chunkId,
|
|
154
|
+
DISCOVERABLE_NAMES,
|
|
133
155
|
type DocumentedLibrary,
|
|
134
156
|
estimateTokens,
|
|
135
157
|
formatDocumentedLibrary,
|