docspack 1.0.0 → 1.1.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/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/doctor.ts CHANGED
@@ -362,21 +362,29 @@ function checkPackageJson(
362
362
  }
363
363
  }
364
364
 
365
- /** Newest mtime across the configured documentation source, used to spot a stale payload. */
365
+ /**
366
+ * Newest mtime across every configured documentation source, used to spot a stale payload.
367
+ *
368
+ * Both sources are checked, not the first one found: a package configured with prose *and* an
369
+ * OpenAPI document would otherwise report a fresh payload after the document changed, which is the
370
+ * one case this check exists to catch.
371
+ */
366
372
  async function newestSourceMtime(dir: string): Promise<number> {
367
373
  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;
374
+ const newest = await Promise.all([
375
+ config.openapi === undefined ? 0 : mtime(join(dir, config.openapi)),
376
+ config.from === undefined ? 0 : newestInTree(join(dir, config.from)),
377
+ ]);
378
+ return Math.max(...newest);
379
+ }
374
380
 
381
+ /** Newest mtime in a directory tree, or the file's own when the path is a file. */
382
+ async function newestInTree(target: string): Promise<number> {
375
383
  let found: Dirent[];
376
384
  try {
377
385
  found = await readdir(target, { recursive: true, withFileTypes: true });
378
386
  } catch {
379
- return single;
387
+ return mtime(target);
380
388
  }
381
389
 
382
390
  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
  {
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";