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/init/plan.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { posix } from "node:path";
|
|
2
2
|
import { DocspackError } from "../errors.js";
|
|
3
|
-
import { isCommunityPackage, isDocsPackage } from "../spec.js";
|
|
3
|
+
import { DISCOVERABLE_NAMES, isCommunityPackage, isDocsPackage } from "../spec.js";
|
|
4
4
|
import type { Detected } from "./detect.js";
|
|
5
5
|
import { docSeeds, gitignore, packageJson, readme, workflow } from "./templates.js";
|
|
6
6
|
|
|
@@ -135,7 +135,7 @@ export function proposePackageName(detected: Detected, community: boolean): stri
|
|
|
135
135
|
function assertPackageName(name: string): void {
|
|
136
136
|
if (!isDocsPackage(name)) {
|
|
137
137
|
throw new DocspackError(`"${name}" is not a name docspack will discover`, {
|
|
138
|
-
hint:
|
|
138
|
+
hint: `Use one of ${DISCOVERABLE_NAMES}.`,
|
|
139
139
|
});
|
|
140
140
|
}
|
|
141
141
|
}
|
|
@@ -156,7 +156,8 @@ function resolveInput(
|
|
|
156
156
|
if (options.mirror !== undefined) {
|
|
157
157
|
if (!isCommunityPackage(options.name ?? "")) {
|
|
158
158
|
notes.push(
|
|
159
|
-
"A mirror republishes someone else's documentation; publish it under @docspack-community
|
|
159
|
+
"A mirror republishes someone else's documentation; publish it under @docspack-community, " +
|
|
160
|
+
"or as @yourscope/<name>-docspack if you own the scope — not as the vendor's own @vendor/docspack.",
|
|
160
161
|
);
|
|
161
162
|
}
|
|
162
163
|
return { kind: "mirror", value: options.mirror };
|
package/src/local.ts
ADDED
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { mkdir, mkdtemp, readdir, readFile, rm, stat, writeFile } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join, resolve } from "node:path";
|
|
5
|
+
import { buildPackage, LOCAL_VERSION, localPackageName, MARKDOWN } from "./build.js";
|
|
6
|
+
import {
|
|
7
|
+
type IndexedChunk,
|
|
8
|
+
type IndexedSource,
|
|
9
|
+
LOCAL_STORE_DIR,
|
|
10
|
+
type SearchHit,
|
|
11
|
+
type Store,
|
|
12
|
+
} from "./db.js";
|
|
13
|
+
import { DocspackError } from "./errors.js";
|
|
14
|
+
import { DEFAULT_LIMIT, DEFAULT_MAX_TOKENS } from "./search.js";
|
|
15
|
+
import {
|
|
16
|
+
chunkId,
|
|
17
|
+
estimateTokens,
|
|
18
|
+
LLMS_DIR,
|
|
19
|
+
MANIFEST_FILE,
|
|
20
|
+
packageId,
|
|
21
|
+
parseManifest,
|
|
22
|
+
resolveChunkFile,
|
|
23
|
+
} from "./spec.js";
|
|
24
|
+
|
|
25
|
+
export interface IndexLocalOptions {
|
|
26
|
+
readonly cwd: string;
|
|
27
|
+
readonly store: Store;
|
|
28
|
+
/** Directory of Markdown to index. */
|
|
29
|
+
readonly from?: string;
|
|
30
|
+
/** JSON records to index, or `-` for standard input. */
|
|
31
|
+
readonly json?: string;
|
|
32
|
+
readonly name?: string;
|
|
33
|
+
/** Re-index even when no source has changed. */
|
|
34
|
+
readonly force?: boolean;
|
|
35
|
+
readonly onProgress?: (message: string) => void;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface IndexLocalResult {
|
|
39
|
+
readonly id: string;
|
|
40
|
+
readonly name: string;
|
|
41
|
+
readonly chunks: number;
|
|
42
|
+
readonly tokens: number;
|
|
43
|
+
readonly status: "indexed" | "unchanged";
|
|
44
|
+
readonly sources: number;
|
|
45
|
+
/** A corpus read from a stream: nothing on disk to compare it against later. */
|
|
46
|
+
readonly streamed: boolean;
|
|
47
|
+
readonly warnings: readonly string[];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Indexes a working corpus into this project's own store.
|
|
52
|
+
*
|
|
53
|
+
* The payload is built in a temporary directory and thrown away. A published package is an
|
|
54
|
+
* artifact somebody installs; this is a cache of the reader's own files, and leaving a copy of it
|
|
55
|
+
* in the project would be one more thing to gitignore, review and let go stale.
|
|
56
|
+
*/
|
|
57
|
+
export async function indexLocal(options: IndexLocalOptions): Promise<IndexLocalResult> {
|
|
58
|
+
if (options.from === undefined && options.json === undefined) {
|
|
59
|
+
throw new DocspackError("Nothing to index", {
|
|
60
|
+
hint: "Pass --from <dir> for Markdown, or --from-json <file|-> for records.",
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const name = options.name ?? localPackageName(options);
|
|
65
|
+
const id = packageId(name, LOCAL_VERSION);
|
|
66
|
+
const streamed = options.json === "-";
|
|
67
|
+
const sources = streamed ? [] : await fingerprint(options);
|
|
68
|
+
|
|
69
|
+
if (
|
|
70
|
+
options.force !== true &&
|
|
71
|
+
!streamed &&
|
|
72
|
+
options.store.hasPackage(id) &&
|
|
73
|
+
unchanged(options.store.sourcesOf(id), sources)
|
|
74
|
+
) {
|
|
75
|
+
return {
|
|
76
|
+
id,
|
|
77
|
+
name,
|
|
78
|
+
chunks: options.store.countChunks(id),
|
|
79
|
+
tokens: 0,
|
|
80
|
+
status: "unchanged",
|
|
81
|
+
sources: sources.length,
|
|
82
|
+
streamed,
|
|
83
|
+
warnings: [],
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const work = await mkdtemp(join(tmpdir(), "docspack-local-"));
|
|
88
|
+
try {
|
|
89
|
+
options.onProgress?.(`indexing ${name}`);
|
|
90
|
+
const built = await buildPackage({
|
|
91
|
+
out: work,
|
|
92
|
+
local: true,
|
|
93
|
+
name,
|
|
94
|
+
version: LOCAL_VERSION,
|
|
95
|
+
...(options.from === undefined ? {} : { from: options.from }),
|
|
96
|
+
...(options.json === undefined ? {} : { json: options.json }),
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
const chunks = await readChunks(work, id);
|
|
100
|
+
options.store.indexPackage({ id, name, version: LOCAL_VERSION, kind: "local" }, chunks);
|
|
101
|
+
options.store.recordSources(id, sources);
|
|
102
|
+
await protectStore(options.cwd);
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
id,
|
|
106
|
+
name,
|
|
107
|
+
chunks: chunks.length,
|
|
108
|
+
tokens: built.tokens,
|
|
109
|
+
status: "indexed",
|
|
110
|
+
sources: sources.length,
|
|
111
|
+
streamed,
|
|
112
|
+
warnings: built.warnings,
|
|
113
|
+
};
|
|
114
|
+
} finally {
|
|
115
|
+
await rm(work, { recursive: true, force: true });
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export interface RecallHit extends SearchHit {
|
|
120
|
+
readonly name: string;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** A source that no longer matches what was indexed from it. */
|
|
124
|
+
export interface StaleSource {
|
|
125
|
+
readonly path: string;
|
|
126
|
+
readonly reason: "changed" | "missing";
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export interface RecallResult {
|
|
130
|
+
readonly hits: readonly RecallHit[];
|
|
131
|
+
readonly tokens: number;
|
|
132
|
+
/** Corpora present in the local store, whether or not they matched. */
|
|
133
|
+
readonly corpora: readonly string[];
|
|
134
|
+
readonly stale: readonly StaleSource[];
|
|
135
|
+
/** Corpora read from a stream, whose freshness cannot be checked. */
|
|
136
|
+
readonly streamed: readonly string[];
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export interface RecallOptions {
|
|
140
|
+
readonly cwd: string;
|
|
141
|
+
readonly store: Store;
|
|
142
|
+
readonly query: string;
|
|
143
|
+
readonly limit?: number;
|
|
144
|
+
readonly maxTokens?: number;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Answers from the working corpora in this project's store, and from nothing else.
|
|
149
|
+
*
|
|
150
|
+
* Kept separate from `queryDocs` rather than added to it as a flag. `ask` promises an answer from
|
|
151
|
+
* the versions this project installed, and the one line that keeps that promise is the `kinds`
|
|
152
|
+
* filter it passes; a flag that flipped it would put a corpus of somebody's notes one typo away
|
|
153
|
+
* from an answer about a dependency.
|
|
154
|
+
*/
|
|
155
|
+
export async function recallLocal(options: RecallOptions): Promise<RecallResult> {
|
|
156
|
+
const corpora = options.store.listPackages().filter((pkg) => pkg.kind === "local");
|
|
157
|
+
const hits = options.store
|
|
158
|
+
.search(options.query, {
|
|
159
|
+
kinds: ["local"],
|
|
160
|
+
limit: options.limit ?? DEFAULT_LIMIT,
|
|
161
|
+
maxTokens: options.maxTokens ?? DEFAULT_MAX_TOKENS,
|
|
162
|
+
})
|
|
163
|
+
.map((hit): RecallHit => ({ ...hit, name: splitLocalName(hit.packageId) }));
|
|
164
|
+
|
|
165
|
+
const stale: StaleSource[] = [];
|
|
166
|
+
const streamed: string[] = [];
|
|
167
|
+
for (const corpus of corpora) {
|
|
168
|
+
const recorded = options.store.sourcesOf(corpus.id);
|
|
169
|
+
if (recorded.length === 0) {
|
|
170
|
+
streamed.push(corpus.name);
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
stale.push(...(await drifted(recorded)));
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return {
|
|
177
|
+
hits,
|
|
178
|
+
tokens: hits.reduce((total, hit) => total + hit.tokens, 0),
|
|
179
|
+
corpora: corpora.map((corpus) => corpus.name),
|
|
180
|
+
stale,
|
|
181
|
+
streamed,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export function renderRecall(result: RecallResult, query: string): string {
|
|
186
|
+
if (result.hits.length === 0) {
|
|
187
|
+
if (result.corpora.length === 0) {
|
|
188
|
+
return `Nothing has been indexed into this project's corpus. Run \`docspack index --from <dir>\` first.`;
|
|
189
|
+
}
|
|
190
|
+
return `Nothing in ${result.corpora.join(", ")} matched "${query}".`;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const sections = result.hits.map((hit) =>
|
|
194
|
+
[`## ${hit.chunkId}`, `Source: ${hit.name} — ${hit.filePath}`, "", hit.content].join("\n"),
|
|
195
|
+
);
|
|
196
|
+
|
|
197
|
+
// Before the passages, not after: a reader who acts on the first thing they read has to be told
|
|
198
|
+
// the text may be superseded before they read it, not once they already have.
|
|
199
|
+
if (result.stale.length > 0) sections.unshift(staleNotice(result.stale));
|
|
200
|
+
if (result.streamed.length > 0) {
|
|
201
|
+
sections.push(
|
|
202
|
+
`NOTE: ${result.streamed.join(", ")} was indexed from a stream, so whether it still matches its source cannot be checked here.`,
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
return sections.join("\n\n---\n\n");
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function staleNotice(stale: readonly StaleSource[]): string {
|
|
209
|
+
const shown = stale.slice(0, 5).map((source) => source.path);
|
|
210
|
+
const rest = stale.length - shown.length;
|
|
211
|
+
const missing = stale.filter((source) => source.reason === "missing").length;
|
|
212
|
+
const tail = rest > 0 ? `, and ${String(rest)} more` : "";
|
|
213
|
+
const gone = missing > 0 ? ` ${String(missing)} of them no longer exist.` : "";
|
|
214
|
+
return `NOTE: the corpus is out of date. ${String(stale.length)} indexed ${stale.length === 1 ? "source has" : "sources have"} changed since it was built: ${shown.join(", ")}${tail}.${gone} The passages below may be superseded — run \`docspack index\` again before relying on them.`;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Which recorded sources no longer match what is on disk. */
|
|
218
|
+
async function drifted(recorded: readonly IndexedSource[]): Promise<StaleSource[]> {
|
|
219
|
+
const stale: StaleSource[] = [];
|
|
220
|
+
for (const source of recorded) {
|
|
221
|
+
let info: Awaited<ReturnType<typeof stat>>;
|
|
222
|
+
try {
|
|
223
|
+
info = await stat(source.path);
|
|
224
|
+
} catch {
|
|
225
|
+
stale.push({ path: source.path, reason: "missing" });
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
// Size and mtime first, and the hash only when one of them moved: a checkout or a formatter
|
|
229
|
+
// touches every file's mtime without changing a byte, and hashing a whole corpus on every
|
|
230
|
+
// query to find that out would make the cheap path the expensive one.
|
|
231
|
+
if (info.size === source.size && info.mtimeMs === Date.parse(source.mtime)) continue;
|
|
232
|
+
if ((await hashOf(source.path)) === source.hash) continue;
|
|
233
|
+
stale.push({ path: source.path, reason: "changed" });
|
|
234
|
+
}
|
|
235
|
+
return stale;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** What a corpus was built from, as it is right now. */
|
|
239
|
+
async function fingerprint(options: IndexLocalOptions): Promise<IndexedSource[]> {
|
|
240
|
+
const paths: string[] = [];
|
|
241
|
+
if (options.from !== undefined) {
|
|
242
|
+
const root = resolve(options.from);
|
|
243
|
+
const found = await readdir(root, { recursive: true, withFileTypes: true }).catch(() => {
|
|
244
|
+
throw new DocspackError(`No such directory: ${options.from ?? ""}`, {
|
|
245
|
+
hint: "Pass --from <dir> to point at the Markdown you want indexed.",
|
|
246
|
+
});
|
|
247
|
+
});
|
|
248
|
+
paths.push(
|
|
249
|
+
...found
|
|
250
|
+
.filter((entry) => entry.isFile() && MARKDOWN.test(entry.name))
|
|
251
|
+
.map((entry) => join(entry.parentPath, entry.name))
|
|
252
|
+
.sort(),
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
if (options.json !== undefined && options.json !== "-") paths.push(resolve(options.json));
|
|
256
|
+
|
|
257
|
+
const sources: IndexedSource[] = [];
|
|
258
|
+
for (const path of paths) {
|
|
259
|
+
const info = await stat(path);
|
|
260
|
+
sources.push({
|
|
261
|
+
path,
|
|
262
|
+
size: info.size,
|
|
263
|
+
mtime: new Date(info.mtimeMs).toISOString(),
|
|
264
|
+
hash: await hashOf(path),
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
return sources;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function unchanged(recorded: readonly IndexedSource[], current: readonly IndexedSource[]): boolean {
|
|
271
|
+
if (recorded.length !== current.length) return false;
|
|
272
|
+
const before = new Map(recorded.map((source) => [source.path, source.hash]));
|
|
273
|
+
return current.every((source) => before.get(source.path) === source.hash);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
async function hashOf(path: string): Promise<string> {
|
|
277
|
+
return createHash("sha256")
|
|
278
|
+
.update(await readFile(path))
|
|
279
|
+
.digest("hex");
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
async function readChunks(dir: string, id: string): Promise<IndexedChunk[]> {
|
|
283
|
+
const llmsDir = join(dir, LLMS_DIR);
|
|
284
|
+
const manifest = parseManifest(
|
|
285
|
+
JSON.parse(await readFile(join(llmsDir, MANIFEST_FILE), "utf8")),
|
|
286
|
+
`${LLMS_DIR}/${MANIFEST_FILE}`,
|
|
287
|
+
);
|
|
288
|
+
|
|
289
|
+
const chunks: IndexedChunk[] = [];
|
|
290
|
+
for (const chunk of manifest.chunks) {
|
|
291
|
+
const content = (await readFile(resolveChunkFile(llmsDir, chunk.file), "utf8")).trim();
|
|
292
|
+
if (content.length === 0) continue;
|
|
293
|
+
chunks.push({
|
|
294
|
+
chunkId: chunkId(id, chunk.id),
|
|
295
|
+
filePath: chunk.file,
|
|
296
|
+
tokens: chunk.tokens ?? estimateTokens(content),
|
|
297
|
+
content,
|
|
298
|
+
tags: [...chunk.tags, ...chunk.entities],
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
if (chunks.length === 0) {
|
|
302
|
+
throw new DocspackError("Nothing readable to index", {
|
|
303
|
+
hint: "The sources produced no chunks. Check they contain text.",
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
return chunks;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Keeps the store out of version control.
|
|
311
|
+
*
|
|
312
|
+
* The index is a plaintext copy of whatever was indexed, so committing it would put the corpus in
|
|
313
|
+
* the repository a second time — and for notes, an export or a query result, somewhere nobody
|
|
314
|
+
* thought to look for it. Written next to the store rather than in the project's own
|
|
315
|
+
* `.gitignore`, which is the project's file and not this tool's to edit.
|
|
316
|
+
*/
|
|
317
|
+
async function protectStore(cwd: string): Promise<void> {
|
|
318
|
+
const dir = join(cwd, LOCAL_STORE_DIR);
|
|
319
|
+
await mkdir(dir, { recursive: true });
|
|
320
|
+
const ignore = join(dir, ".gitignore");
|
|
321
|
+
try {
|
|
322
|
+
await readFile(ignore, "utf8");
|
|
323
|
+
} catch {
|
|
324
|
+
await writeFile(
|
|
325
|
+
ignore,
|
|
326
|
+
"# The local index is a copy of your own sources. Do not commit it.\n*\n",
|
|
327
|
+
);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** `@local/notes@0.0.0/chunk` carries the corpus name; the version is never interesting. */
|
|
332
|
+
function splitLocalName(packageId: string): string {
|
|
333
|
+
const at = packageId.lastIndexOf("@");
|
|
334
|
+
return at <= 0 ? packageId : packageId.slice(0, at);
|
|
335
|
+
}
|
package/src/preview.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { readFile } from "node:fs/promises";
|
|
2
2
|
import { join } from "node:path";
|
|
3
|
-
import { type IndexedChunk, Store } from "./db.js";
|
|
3
|
+
import { type IndexedChunk, type SearchHit, Store } from "./db.js";
|
|
4
|
+
import { endpointIndex, matchEndpoints } from "./endpoints.js";
|
|
4
5
|
import { DocspackError } from "./errors.js";
|
|
5
6
|
import { DEFAULT_LIMIT, DEFAULT_MAX_TOKENS, type QueryHit } from "./search.js";
|
|
6
7
|
import {
|
|
@@ -75,22 +76,45 @@ export async function previewPackage(options: PreviewOptions): Promise<PreviewRe
|
|
|
75
76
|
const store = Store.open(":memory:");
|
|
76
77
|
try {
|
|
77
78
|
store.indexPackage({ id, name: manifest.name, version: manifest.version }, chunks);
|
|
78
|
-
|
|
79
|
+
|
|
80
|
+
const describe = (hit: SearchHit): QueryHit => ({
|
|
81
|
+
...hit,
|
|
82
|
+
name: manifest.name,
|
|
83
|
+
version: manifest.version,
|
|
84
|
+
kind: "docs",
|
|
85
|
+
trusted: !isCommunityPackage(manifest.name),
|
|
86
|
+
...(documents.length === 0 ? {} : { documents }),
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
// The same endpoint pinning `queryDocs` does, because `preview` exists to show an author what an
|
|
90
|
+
// agent would receive. A preview that ranked where the real query path pins would send them
|
|
91
|
+
// chasing a retrieval problem they do not have.
|
|
92
|
+
const limit = options.limit ?? DEFAULT_LIMIT;
|
|
93
|
+
const maxTokens = options.maxTokens ?? DEFAULT_MAX_TOKENS;
|
|
94
|
+
const pinned = matchEndpoints(
|
|
95
|
+
options.query,
|
|
96
|
+
endpointIndex(
|
|
97
|
+
manifest.chunks.map((chunk) => ({
|
|
98
|
+
chunkId: chunkId(id, chunk.id),
|
|
99
|
+
entities: chunk.entities,
|
|
100
|
+
})),
|
|
101
|
+
),
|
|
102
|
+
)
|
|
103
|
+
.map((match) => store.chunk(match.chunkId))
|
|
104
|
+
.filter((chunk): chunk is SearchHit => chunk !== undefined)
|
|
105
|
+
.map(describe);
|
|
106
|
+
const spent = pinned.reduce((total, hit) => total + hit.tokens, 0);
|
|
107
|
+
const pinnedIds = new Set(pinned.map((hit) => hit.chunkId));
|
|
108
|
+
|
|
109
|
+
const ranked = store
|
|
79
110
|
.search(options.query, {
|
|
80
|
-
limit:
|
|
81
|
-
maxTokens:
|
|
111
|
+
limit: Math.max(1, limit - pinned.length),
|
|
112
|
+
maxTokens: Math.max(1, maxTokens - spent),
|
|
82
113
|
})
|
|
83
|
-
.
|
|
84
|
-
|
|
85
|
-
...hit,
|
|
86
|
-
name: manifest.name,
|
|
87
|
-
version: manifest.version,
|
|
88
|
-
kind: "docs",
|
|
89
|
-
trusted: !isCommunityPackage(manifest.name),
|
|
90
|
-
...(documents.length === 0 ? {} : { documents }),
|
|
91
|
-
}),
|
|
92
|
-
);
|
|
114
|
+
.filter((hit) => !pinnedIds.has(hit.chunkId))
|
|
115
|
+
.map(describe);
|
|
93
116
|
|
|
117
|
+
const hits = [...pinned, ...ranked];
|
|
94
118
|
return {
|
|
95
119
|
hits,
|
|
96
120
|
tokens: hits.reduce((total, hit) => total + hit.tokens, 0),
|
package/src/search.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { PackageKind, SearchHit, Store } from "./db.js";
|
|
2
2
|
import { discoverLibraries, discoverPackages } from "./discovery.js";
|
|
3
|
+
import { endpointIndex, matchEndpoints } from "./endpoints.js";
|
|
3
4
|
import { chunkId, formatDocumentedLibrary, isCommunityPackage } from "./spec.js";
|
|
4
5
|
|
|
5
6
|
/** Ceiling on how much context one query may return, per the blueprint's context-exhaustion rule. */
|
|
@@ -67,6 +68,14 @@ export interface QueryResult {
|
|
|
67
68
|
* matched", which a ranker alone cannot tell apart — it always returns its best three.
|
|
68
69
|
*/
|
|
69
70
|
readonly undocumented: readonly string[];
|
|
71
|
+
/**
|
|
72
|
+
* Endpoints the question named that a documented operation answers, pinned rather than ranked.
|
|
73
|
+
*
|
|
74
|
+
* Reported so an answer can say *why* a chunk is first. A reader who asked about
|
|
75
|
+
* `GET /v1/charges/ch_3Ox7` and is shown `GET /v1/charges/{charge}` has been given the right
|
|
76
|
+
* chunk, and has to be told that the match was against a template.
|
|
77
|
+
*/
|
|
78
|
+
readonly endpoints: readonly string[];
|
|
70
79
|
}
|
|
71
80
|
|
|
72
81
|
export async function queryDocs(options: QueryOptions): Promise<QueryResult> {
|
|
@@ -82,41 +91,53 @@ export async function queryDocs(options: QueryOptions): Promise<QueryResult> {
|
|
|
82
91
|
// Keyed by package id and by chunk id in the same map, because a chunk id already carries its
|
|
83
92
|
// package: `@acme/docspack@1.4.0/api-auth` cannot collide with `@acme/docspack@1.4.0`.
|
|
84
93
|
const documented = new Map<string, readonly string[]>();
|
|
94
|
+
const operations: { chunkId: string; entities: readonly string[] }[] = [];
|
|
85
95
|
for (const pkg of installed ?? []) {
|
|
86
96
|
const documents = pkg.manifest.documents;
|
|
87
97
|
if (documents !== undefined && documents.length > 0) {
|
|
88
98
|
documented.set(pkg.id, documents.map(formatDocumentedLibrary));
|
|
89
99
|
}
|
|
90
100
|
for (const chunk of pkg.manifest.chunks) {
|
|
101
|
+
operations.push({ chunkId: chunkId(pkg.id, chunk.id), entities: chunk.entities });
|
|
91
102
|
if (chunk.documents === undefined || chunk.documents.length === 0) continue;
|
|
92
103
|
documented.set(chunkId(pkg.id, chunk.id), chunk.documents.map(formatDocumentedLibrary));
|
|
93
104
|
}
|
|
94
105
|
}
|
|
95
106
|
|
|
96
107
|
const maxTokens = options.maxTokens ?? DEFAULT_MAX_TOKENS;
|
|
108
|
+
const limit = options.limit ?? DEFAULT_LIMIT;
|
|
109
|
+
|
|
110
|
+
// Endpoints first, and the ranker is then asked for less: a pinned operation is the answer, and
|
|
111
|
+
// three ranked chunks beside it would spend the budget restating what it already says.
|
|
112
|
+
const { hits: pinned, endpoints } = findEndpoints(options, operations, documented, maxTokens);
|
|
113
|
+
const spent = pinned.reduce((total, hit) => total + hit.tokens, 0);
|
|
114
|
+
const pinnedIds = new Set(pinned.map((hit) => hit.chunkId));
|
|
115
|
+
|
|
97
116
|
const prose = options.store
|
|
98
117
|
.search(options.query, {
|
|
99
118
|
...(packageIds === undefined ? {} : { packageIds }),
|
|
100
119
|
...(options.packageFilter === undefined
|
|
101
120
|
? {}
|
|
102
121
|
: { packageFilter: `%${options.packageFilter}%` }),
|
|
103
|
-
limit:
|
|
104
|
-
maxTokens,
|
|
122
|
+
limit: Math.max(1, limit - pinned.length),
|
|
123
|
+
maxTokens: Math.max(1, maxTokens - spent),
|
|
105
124
|
// Declarations are addressed by name, never ranked against prose: a library exports far
|
|
106
125
|
// more names than its documentation has pages, and ranking them together would answer
|
|
107
126
|
// every question with type machinery.
|
|
108
127
|
kinds: ["docs"],
|
|
109
128
|
})
|
|
129
|
+
.filter((hit) => !pinnedIds.has(hit.chunkId))
|
|
110
130
|
.map((hit) => toQueryHit(hit, "docs", documented));
|
|
111
131
|
|
|
112
132
|
const { declarations, undocumented } =
|
|
113
133
|
options.artifacts === false
|
|
114
134
|
? { declarations: [] as QueryHit[], undocumented: [] as string[] }
|
|
115
|
-
: await findDeclarations(options, prose, maxTokens);
|
|
135
|
+
: await findDeclarations(options, [...pinned, ...prose], maxTokens - spent);
|
|
116
136
|
|
|
117
|
-
//
|
|
118
|
-
//
|
|
119
|
-
|
|
137
|
+
// Pinned operations, then declarations, then the ranked prose. Both of the first two were
|
|
138
|
+
// addressed by name rather than guessed at, and for an agent about to write a call the exact
|
|
139
|
+
// thing it named is the load-bearing line.
|
|
140
|
+
const hits = [...pinned, ...declarations, ...prose];
|
|
120
141
|
|
|
121
142
|
return {
|
|
122
143
|
hits,
|
|
@@ -124,9 +145,42 @@ export async function queryDocs(options: QueryOptions): Promise<QueryResult> {
|
|
|
124
145
|
untrusted: hits.some((hit) => !hit.trusted),
|
|
125
146
|
unindexed,
|
|
126
147
|
undocumented,
|
|
148
|
+
endpoints,
|
|
127
149
|
};
|
|
128
150
|
}
|
|
129
151
|
|
|
152
|
+
/**
|
|
153
|
+
* Operation chunks the question addressed.
|
|
154
|
+
*
|
|
155
|
+
* Bounded by the same token budget as everything else, and the first match is always kept: an answer
|
|
156
|
+
* whose one pinned operation did not fit would have pinned nothing and said nothing about it.
|
|
157
|
+
*/
|
|
158
|
+
function findEndpoints(
|
|
159
|
+
options: QueryOptions,
|
|
160
|
+
operations: readonly { chunkId: string; entities: readonly string[] }[],
|
|
161
|
+
documented: ReadonlyMap<string, readonly string[]>,
|
|
162
|
+
maxTokens: number,
|
|
163
|
+
): { hits: QueryHit[]; endpoints: string[] } {
|
|
164
|
+
const matches = matchEndpoints(options.query, endpointIndex(operations));
|
|
165
|
+
const hits: QueryHit[] = [];
|
|
166
|
+
const endpoints: string[] = [];
|
|
167
|
+
let spent = 0;
|
|
168
|
+
|
|
169
|
+
for (const match of matches) {
|
|
170
|
+
if (options.packageFilter !== undefined && !match.chunkId.includes(options.packageFilter)) {
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
const chunk = options.store.chunk(match.chunkId);
|
|
174
|
+
if (chunk === undefined) continue;
|
|
175
|
+
if (hits.length > 0 && spent + chunk.tokens > maxTokens) break;
|
|
176
|
+
hits.push(toQueryHit(chunk, "docs", documented));
|
|
177
|
+
endpoints.push(`${match.method} ${match.path}`);
|
|
178
|
+
spent += chunk.tokens;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return { hits, endpoints };
|
|
182
|
+
}
|
|
183
|
+
|
|
130
184
|
/**
|
|
131
185
|
* Looks up the names a question used against the symbols the installed libraries export.
|
|
132
186
|
*
|
package/src/spec.ts
CHANGED
|
@@ -46,9 +46,21 @@ export interface PackageManifest {
|
|
|
46
46
|
|
|
47
47
|
const CHUNK_ID = /^[a-z0-9][a-z0-9._-]*$/i;
|
|
48
48
|
|
|
49
|
-
/**
|
|
49
|
+
/**
|
|
50
|
+
* Official vendor packages: `@stripe/docspack`, and its siblings `@stripe/openapi-docspack`.
|
|
51
|
+
*
|
|
52
|
+
* The suffixed form is for a vendor that also redistributes somebody else's documentation, where
|
|
53
|
+
* licence, version, cadence and attribution belong to a different upstream and cannot be merged
|
|
54
|
+
* into one package. It costs nothing the single name was protecting: discovery stays a pure name
|
|
55
|
+
* check against `package.json`, and npm scope ownership is the boundary trust rests on — whoever
|
|
56
|
+
* can publish `@stripe/docspack` can publish `@stripe/openapi-docspack` and nobody else can.
|
|
57
|
+
*
|
|
58
|
+
* Derivation is a separate question and unchanged: `@stripe/docspack` is the one name inferable
|
|
59
|
+
* from a dependency on `@stripe/sdk`, so that is still the only name `init` suggests. Derive to
|
|
60
|
+
* suggest; match to discover.
|
|
61
|
+
*/
|
|
50
62
|
export function isVendorPackage(name: string): boolean {
|
|
51
|
-
return /^@[^/]+\/docspack$/.test(name);
|
|
63
|
+
return /^@[^/]+\/(?:[^/]+-)?docspack$/.test(name);
|
|
52
64
|
}
|
|
53
65
|
|
|
54
66
|
/** Community packages: `@docspack-community/jira`. */
|
|
@@ -60,6 +72,10 @@ export function isDocsPackage(name: string): boolean {
|
|
|
60
72
|
return isVendorPackage(name) || isCommunityPackage(name);
|
|
61
73
|
}
|
|
62
74
|
|
|
75
|
+
/** The discoverable name shapes, for an error that has to say what it would have accepted. */
|
|
76
|
+
export const DISCOVERABLE_NAMES =
|
|
77
|
+
"@vendor/docspack, @vendor/<name>-docspack or @docspack-community/<name>";
|
|
78
|
+
|
|
63
79
|
/** Stable identifier used as the primary key in the store: `@stripe/docspack@2025.4.1`. */
|
|
64
80
|
export function packageId(name: string, version: string): string {
|
|
65
81
|
return `${name}@${version}`;
|