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.
Files changed (60) hide show
  1. package/README.md +31 -0
  2. package/dist/build.d.ts +23 -0
  3. package/dist/build.d.ts.map +1 -1
  4. package/dist/build.js +184 -96
  5. package/dist/build.js.map +1 -1
  6. package/dist/cli.js +91 -2
  7. package/dist/cli.js.map +1 -1
  8. package/dist/db.d.ts +40 -3
  9. package/dist/db.d.ts.map +1 -1
  10. package/dist/db.js +61 -2
  11. package/dist/db.js.map +1 -1
  12. package/dist/discovery.d.ts +6 -3
  13. package/dist/discovery.d.ts.map +1 -1
  14. package/dist/discovery.js +57 -11
  15. package/dist/discovery.js.map +1 -1
  16. package/dist/doctor.d.ts.map +1 -1
  17. package/dist/doctor.js +31 -10
  18. package/dist/doctor.js.map +1 -1
  19. package/dist/endpoints.d.ts +45 -0
  20. package/dist/endpoints.d.ts.map +1 -0
  21. package/dist/endpoints.js +155 -0
  22. package/dist/endpoints.js.map +1 -0
  23. package/dist/help.d.ts.map +1 -1
  24. package/dist/help.js +60 -5
  25. package/dist/help.js.map +1 -1
  26. package/dist/index.d.ts +4 -3
  27. package/dist/index.d.ts.map +1 -1
  28. package/dist/index.js +4 -3
  29. package/dist/index.js.map +1 -1
  30. package/dist/init/plan.js +4 -3
  31. package/dist/init/plan.js.map +1 -1
  32. package/dist/local.d.ts +67 -0
  33. package/dist/local.d.ts.map +1 -0
  34. package/dist/local.js +242 -0
  35. package/dist/local.js.map +1 -0
  36. package/dist/preview.d.ts.map +1 -1
  37. package/dist/preview.js +25 -7
  38. package/dist/preview.js.map +1 -1
  39. package/dist/search.d.ts +8 -0
  40. package/dist/search.d.ts.map +1 -1
  41. package/dist/search.js +44 -6
  42. package/dist/search.js.map +1 -1
  43. package/dist/spec.d.ts +15 -1
  44. package/dist/spec.d.ts.map +1 -1
  45. package/dist/spec.js +16 -2
  46. package/dist/spec.js.map +1 -1
  47. package/package.json +7 -5
  48. package/src/build.ts +221 -111
  49. package/src/cli.ts +96 -2
  50. package/src/db.ts +87 -5
  51. package/src/discovery.ts +62 -11
  52. package/src/doctor.ts +33 -8
  53. package/src/endpoints.ts +184 -0
  54. package/src/help.ts +60 -5
  55. package/src/index.ts +23 -1
  56. package/src/init/plan.ts +4 -3
  57. package/src/local.ts +335 -0
  58. package/src/preview.ts +38 -14
  59. package/src/search.ts +60 -6
  60. 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. The
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 — so it is stored rather than inferred from the name.
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
- const SCHEMA_VERSION = 2;
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 === "artifact" ? "artifact" : "docs",
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` or
42
- * `@docspack-community/*` entry in `dependencies` or `devDependencies` that is installed and
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 declaredDocsPackages(root);
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((name) => !isDocsPackage(name));
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
- /** Newest mtime across the configured documentation source, used to spot a stale payload. */
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 source = config.from ?? config.openapi;
369
- if (source === undefined) return 0;
370
-
371
- const target = join(dir, source);
372
- const single = await mtime(target);
373
- if (single > 0 && config.openapi !== undefined) return single;
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 single;
404
+ return mtime(target);
380
405
  }
381
406
 
382
407
  let newest = 0;
@@ -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 to package, one chunk per operation"],
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 or
401
- @docspack-community/<name>. Add them to package.json, run \`docspack sync\`, and
402
- every chunk is indexed into a shared SQLite database with FTS5. Agents query
403
- that index locally: no network, no scraping, always the installed version.
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 { type BuildOptions, type BuildResult, buildPackage } from "./build.js";
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,