@hevmind/ask 0.1.1 → 0.3.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 CHANGED
@@ -1,8 +1,11 @@
1
1
  # @hevmind/ask
2
2
 
3
- hev ask is a heading-anchored search overlay for Astro docs sites. Typing runs
4
- instant keyword search; pressing `Enter` runs an optional Claude search loop that
5
- chooses sub-queries and ranks section results.
3
+ hev ask is a heading-anchored search overlay for docs sites. The digest is built
4
+ from your markdown, not your renderer **Astro** gets the turnkey integration
5
+ below, and **Docusaurus, VitePress, MkDocs, or any static site** get the same
6
+ overlay as a one-script drop-in (see [Frameworks](https://hevask.com/docs/frameworks)).
7
+ Typing runs instant keyword search; pressing `Enter` runs an optional Claude
8
+ search loop that chooses sub-queries and ranks section results.
6
9
 
7
10
  ## Install
8
11
 
@@ -36,7 +39,9 @@ export default defineConfig({
36
39
  | Option | Default | Description |
37
40
  | --- | --- | --- |
38
41
  | `collections` | - | Content collections to index. |
39
- | `model` | `claude-haiku-4-5` | Runtime search-loop model. |
42
+ | `provider` | `anthropic` | Inference provider: `anthropic`, `openai`, or `openrouter`. Each reads its own key: `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, or `OPENROUTER_API_KEY`. |
43
+ | `providerBaseUrl` | per provider | Base URL override for the OpenAI-compatible providers (any Chat Completions endpoint). |
44
+ | `model` | per provider | Runtime search-loop model; `claude-haiku-4-5` on the default provider. |
40
45
  | `endpoint` | `/api/ask` | Injected on-demand route. |
41
46
  | `basePath` | `/docs/` | Turns a doc slug into its page URL. |
42
47
  | `maxResults` | `6` | Max results returned. |
@@ -44,8 +49,9 @@ export default defineConfig({
44
49
  | `chunkHeadingDepth` | `3` | Chunk at `##` through this heading depth. |
45
50
  | `candidatePerSearch` | `8` | Chunks returned by each search tool call. |
46
51
  | `perDocCap` | `2` | Max chunks per document in one prefilter call. |
47
- | `digestModel` | `claude-opus-4-8` | Offline digest build model. |
48
- | `digestPath` | `.hev-ask/digest.json` | Committed digest artifact path. |
52
+ | `digestModel` | per provider | Offline digest build model; `claude-opus-4-8` on the default provider. |
53
+ | `digestDir` | `.hev-ask` | Committed digest tree directory. |
54
+ | `digestPath` | `.hev-ask` | Deprecated alias for `digestDir`. |
49
55
  | `digestContentGlobs` | derived from `collections` | Build-time Markdown/MDX corpus globs. |
50
56
 
51
57
  ## Add the overlay
@@ -71,9 +77,9 @@ ask digest build
71
77
  ask digest verify
72
78
  ```
73
79
 
74
- The builder writes `.hev-ask/digest.json`, which should be committed. Builds are
75
- hash-gated, so unchanged content does not spend another Opus call. `verify`
76
- builds the site and checks that every chunk anchor exists in `dist`.
80
+ The builder writes the `.hev-ask/` markdown tree, which should be committed.
81
+ Builds are hash-gated, so unchanged content does not spend another Opus call.
82
+ `verify` builds the site and checks that every chunk anchor exists in `dist`.
77
83
 
78
84
  hev ask uses `github-slugger` to match Astro heading anchors exactly.
79
85
 
@@ -102,12 +108,26 @@ Git `path:/packages/ui` dependency.
102
108
  Git dependencies are acceptable for local integration while the package is not
103
109
  yet published, but they are not the long-term distribution path.
104
110
 
111
+ ## Other frameworks
112
+
113
+ The Astro integration above is the turnkey path. On any other framework you build
114
+ the digest the same way (`ask digest build`), bundle the static assets
115
+ (`ask digest bundle`), and drop in the prebuilt overlay as a `<script>` tag —
116
+ keyword search runs fully static, no server. For agentic answers, deploy the
117
+ standalone endpoint and point the overlay at it. See
118
+ [Frameworks](https://hevask.com/docs/frameworks) for Docusaurus, VitePress,
119
+ MkDocs, and plain-HTML recipes.
120
+
105
121
  ## Server Requirements
106
122
 
107
- - Set `ANTHROPIC_API_KEY` for AI search and fresh digest generation.
108
- - Without a runtime key, `/api/ask` still serves keyword results.
109
- - The search route is rendered on demand, so the site needs a server adapter in
110
- production.
123
+ - Keyword search runs **fully static** the drop-in overlay reads the committed
124
+ digest in the browser, no server required.
125
+ - The **agentic** path needs a runtime: on Astro, `/api/ask` is rendered on
126
+ demand (so the site needs a server adapter in production); on other frameworks,
127
+ it's the standalone hostable endpoint.
128
+ - Set the provider's API key (`ANTHROPIC_API_KEY` by default) in that server
129
+ environment for AI search and fresh digest generation. Without a runtime key,
130
+ the endpoint still serves keyword results.
111
131
 
112
132
  ## Theming
113
133
 
package/openapi.yaml CHANGED
@@ -1,16 +1,16 @@
1
1
  openapi: 3.1.0
2
2
  info:
3
3
  title: hev ask API
4
- version: 3.0.0
4
+ version: 3.1.0
5
5
  summary: Search, answer, and digest read API exposed by the @hevmind/ask Astro integration.
6
6
  description: |
7
7
  `@hevmind/ask` mounts these routes on a consuming Astro site (default base `/api/ask`,
8
8
  configurable via the integration's `endpoint` option). Two paths existed in v2:
9
9
  keyword + agentic **search** (`POST /api/ask`) and **suggestions** (`GET /api/ask`).
10
10
  v3 adds keyless **read** routes over the committed ask digest
11
- (`/api/ask/glossary`, `/api/ask/sections`, `/api/ask/overview`) so a coding agent —
12
- via the `ask` CLI, the MCP server, or a generated clientcan query the docs
13
- directly.
11
+ (`/api/ask/glossary`, `/api/ask/sections`, `/api/ask/overview`) plus
12
+ `/api/ask/archive` for bulk tree hydration, so a coding agentvia the
13
+ `ask` CLI, the MCP server, or a generated client — can query the docs directly.
14
14
 
15
15
  Degradation: with no `ANTHROPIC_API_KEY` configured on the server, `POST /api/ask`
16
16
  falls back to keyword mode (HTTP 200 with a `warning`). The read routes never call a
@@ -18,7 +18,7 @@ info:
18
18
  license:
19
19
  name: MIT
20
20
  servers:
21
- - url: https://askhev.com
21
+ - url: https://hevask.com
22
22
  description: The hev ask docs site (dogfoods @hevmind/ask).
23
23
  - url: "{origin}"
24
24
  description: Any site running the integration.
@@ -30,6 +30,8 @@ tags:
30
30
  description: Keyword and agentic search/answer.
31
31
  - name: digest
32
32
  description: Keyless reads over the committed ask digest.
33
+ - name: archive
34
+ description: Bulk hydrate transport for the committed `.hev-ask/` digest tree.
33
35
 
34
36
  paths:
35
37
  /api/ask:
@@ -38,7 +40,7 @@ paths:
38
40
  operationId: getSuggestions
39
41
  summary: Suggested questions and active model
40
42
  description: |
41
- Returns the model-authored example questions baked into the committed graph,
43
+ Returns the model-authored example questions baked into the committed digest,
42
44
  shown by the overlay on open. Keyless — no model call.
43
45
  responses:
44
46
  "200":
@@ -99,7 +101,7 @@ paths:
99
101
  tags: [digest]
100
102
  operationId: listGlossary
101
103
  summary: List glossary terms
102
- description: All glossary entries from the committed graph. Keyless.
104
+ description: All glossary entries from the committed digest. Keyless.
103
105
  responses:
104
106
  "200":
105
107
  description: The glossary.
@@ -204,6 +206,50 @@ paths:
204
206
  overview: { type: string }
205
207
  context: { type: string }
206
208
 
209
+ /api/ask/archive:
210
+ head:
211
+ tags: [archive]
212
+ operationId: headDigestArchive
213
+ summary: Check digest archive freshness
214
+ description: |
215
+ Returns cache headers for the compressed digest tree without the archive body.
216
+ Clients compare `x-hev-ask-content-hash` against their local cache before
217
+ downloading the full archive.
218
+ responses:
219
+ "200":
220
+ description: Digest archive metadata.
221
+ headers:
222
+ x-hev-ask-content-hash:
223
+ schema: { type: string }
224
+ description: Content hash of the committed digest tree.
225
+ cache-control:
226
+ schema: { type: string }
227
+ content-disposition:
228
+ schema: { type: string }
229
+ get:
230
+ tags: [archive]
231
+ operationId: getDigestArchive
232
+ summary: Download the digest tree archive
233
+ description: |
234
+ Returns a gzip-compressed tar archive of the committed `.hev-ask/`
235
+ markdown tree. This is the bulk hydrate path used by `ask mcp --endpoint`.
236
+ responses:
237
+ "200":
238
+ description: Gzip-compressed tar archive of the digest tree.
239
+ headers:
240
+ x-hev-ask-content-hash:
241
+ schema: { type: string }
242
+ description: Content hash of the committed digest tree.
243
+ cache-control:
244
+ schema: { type: string }
245
+ content-disposition:
246
+ schema: { type: string }
247
+ content:
248
+ application/gzip:
249
+ schema:
250
+ type: string
251
+ format: binary
252
+
207
253
  components:
208
254
  parameters:
209
255
  Term:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hevmind/ask",
3
- "version": "0.1.1",
3
+ "version": "0.3.0",
4
4
  "type": "module",
5
5
  "description": "hev ask: a heading-anchored, agentic search overlay for Astro docs sites.",
6
6
  "keywords": [
@@ -28,11 +28,11 @@
28
28
  "ask": "./bin/ask.mjs"
29
29
  },
30
30
  "optionalDependencies": {
31
- "@hevmind/ask-darwin-arm64": "0.1.1",
32
- "@hevmind/ask-darwin-x64": "0.1.1",
33
- "@hevmind/ask-win32-x64": "0.1.1",
34
- "@hevmind/ask-linux-arm64": "0.1.1",
35
- "@hevmind/ask-linux-x64": "0.1.1"
31
+ "@hevmind/ask-win32-x64": "0.3.0",
32
+ "@hevmind/ask-linux-arm64": "0.3.0",
33
+ "@hevmind/ask-darwin-arm64": "0.3.0",
34
+ "@hevmind/ask-linux-x64": "0.3.0",
35
+ "@hevmind/ask-darwin-x64": "0.3.0"
36
36
  },
37
37
  "exports": {
38
38
  ".": "./src/index.ts",
@@ -1,120 +1,84 @@
1
1
  ---
2
2
  name: build-digest
3
3
  description: >-
4
- Build the @hevmind/ask ask digest (.hev-ask/digest.json) for an Astro docs site
5
- using your Claude Code subscription instead of an ANTHROPIC_API_KEY. Use when
6
- the user asks to build, rebuild, or refresh the hev ask digest, knowledge
7
- graph, KG, or search index, or after docs content changes. Runs `ask digest
8
- corpus --shards-dir`, distils each shard in a fresh context, and runs
9
- `ask digest assemble --input-dir`.
4
+ Build the @hevmind/ask ask digest (the committed .hev-ask/ markdown tree) for a
5
+ docs site using your own agent subscription instead of a provider API key. Use
6
+ when asked to build, rebuild, or refresh the hev ask digest, search index, or
7
+ knowledge graph (KG), or after docs content changes. Shards the corpus, distils
8
+ each shard in a fresh context, then assembles and verifies.
10
9
  ---
11
10
 
12
11
  # Build the hev ask digest
13
12
 
14
- `@hevmind/ask` searches an Astro docs site. Its agentic loop, keyword ranking, and
15
- suggested questions are powered by a committed ask digest at
16
- `.hev-ask/digest.json`. Only the **distillation** needs a model the node
17
- structure, verbatim facts, overview map, and content hash are computed
18
- deterministically by the CLI. This skill performs that distillation here, in
19
- the user's subscription, so it costs **no API tokens on their own key**.
20
-
21
- The corpus is split into **shards** (~200KB of text each, along slug-prefix
22
- boundaries) and each shard is distilled in its own fresh context. Corpus size
23
- is therefore never a context-limit problem — a bigger site just means more
24
- shards. All state lives on disk in `.hev-ask/shards/`, so the build can be
25
- stopped, resumed, and incrementally refreshed: after content edits, only the
26
- shards whose content changed need re-distilling.
27
-
28
- Run every command from the **site root** (the directory whose `astro.config.*`
29
- registers `hevAsk()`). Prefer `pnpm exec ask digest …`; fall back to
30
- `npx -p @hevmind/ask ask digest …` if pnpm isn't used. Pass the same content flags
31
- the site's integration uses if they differ from the defaults (`--collection`,
32
- `--base-path`, `--chunk-heading-depth`, `--content-glob`, `--digest-path`);
33
- they must match across `corpus` and `assemble`.
34
-
35
- **Never read a shard input file into the orchestrating context** (they hold
36
- the full corpus text). The orchestrator works only from command output,
37
- `manifest.json`, and `status`; shard contents are read by the per-shard
38
- distillation agents.
13
+ `@hevmind/ask`'s agentic loop, keyword ranking, and suggested questions run off a
14
+ committed digest tree at `.hev-ask/`. Only the **distillation** needs a model —
15
+ the CLI computes the node structure, verbatim facts, overview map, and content
16
+ hashes deterministically. This skill does that distillation here in your
17
+ subscription, so it costs **no provider API tokens**.
39
18
 
40
- ## Steps
19
+ `ask` is the `@hevmind/ask` binary: install it on PATH, or it resolves via the
20
+ package bin / `HEV_ASK_BINARY` (see `api/cli.mdx`). Run every command from the
21
+ **site root** (the dir whose config registers `hevAsk()`). If the integration
22
+ overrides any content flags (`--collection`, `--base-path`,
23
+ `--chunk-heading-depth`, `--content-glob`, `--digest-dir`), pass the same ones to
24
+ `corpus` and `assemble` — they must match.
41
25
 
42
- 1. **Shard the corpus.**
26
+ The corpus splits into ~200KB **shards** (`--shard-bytes` tunes it), each
27
+ distilled in its own context, so corpus size is never a context limit. State
28
+ lives in `.hev-ask/shards/`, so the build resumes and refreshes incrementally:
29
+ after edits, only changed shards re-distil.
43
30
 
44
- ```sh
45
- pnpm exec ask digest corpus --shards-dir .hev-ask/shards
46
- ```
31
+ **Never read a shard input file yourself** — they hold the full corpus text.
32
+ Work from command output and `status`; the per-shard agents read the shards.
47
33
 
48
- Deterministic and keyless. Writes one `input-<shard-id>.json` per shard
49
- plus `manifest.json`, and reports `(N sections, M shards, P pending,
50
- up-to-date|needs-rebuild)`. Re-running after content edits is safe and is
51
- the refresh mechanism: unchanged shards keep their distillations; changed
52
- ones are marked pending again.
53
-
54
- 2. **Check state.** If the corpus reported `up-to-date` AND `0 pending`, the
55
- committed digest already matches the content — **stop here** and tell the
56
- user nothing needs rebuilding. Otherwise:
34
+ ## Steps
57
35
 
58
- ```sh
59
- pnpm exec ask digest status --shards-dir .hev-ask/shards
60
- ```
36
+ 1. **Shard.** `ask digest corpus --shards-dir .hev-ask/shards`
37
+ Deterministic and keyless. Reports `(N sections, M shards, P pending,
38
+ up-to-date|needs-rebuild)`. Safe to re-run; this is the refresh mechanism.
61
39
 
62
- lists which shards are `pending` or `stale` (distilled against older
63
- content). Both need distilling.
40
+ 2. **Check.** If corpus said `up-to-date` AND `0 pending`, the digest already
41
+ matches the content **stop and tell the user nothing needs rebuilding.**
42
+ Otherwise `ask digest status --shards-dir .hev-ask/shards` lists the
43
+ `pending`/`stale` shards (both need distilling).
64
44
 
65
45
  3. **Distil each pending/stale shard in a fresh context.** Spawn one agent per
66
- shard (run a few in parallel; don't read shard contents yourself). Each
67
- agent gets this prompt, with `<id>` filled in:
46
+ shard (a few in parallel; don't read shards yourself). Give each this prompt
47
+ with `<id>` filled in:
68
48
 
69
- > Read ONLY `.hev-ask/shards/input-<id>.json` (from the site root). It has
70
- > `shardId`, `shardHash`, and a `sections` array of `{ id, url, title,
71
- > text }`. Write `.hev-ask/shards/distill-<id>.json` with exactly this
72
- > shape:
49
+ > Read ONLY `.hev-ask/shards/input-<id>.json` (from the site root): it has
50
+ > `shardId`, `shardHash`, and a `sections` array of `{ id, url, title, text }`.
51
+ > Write `.hev-ask/shards/distill-<id>.json` with exactly this shape:
73
52
  >
74
53
  > ```json
75
54
  > {
76
- > "shardHash": "<copy the shardHash from the input file verbatim>",
77
- > "notes": "5-10 lines: what this shard covers, its key concepts, and how users talk about them.",
78
- > "glossary": [
79
- > { "term": "ask digest", "aliases": ["digest", "kg"], "definition": "One-line definition." }
80
- > ],
81
- > "summaries": [
82
- > { "id": "<exact section id from sections>", "summary": "1-3 sentence distillation." }
83
- > ]
55
+ > "shardHash": "<copy shardHash verbatim>",
56
+ > "notes": "5-10 lines: what this shard covers, its key concepts, and how users phrase them.",
57
+ > "glossary": [{ "term": "...", "aliases": ["..."], "definition": "One line." }],
58
+ > "summaries": [{ "id": "<exact section id>", "summary": "1-3 sentences." }]
84
59
  > }
85
60
  > ```
86
61
  >
87
- > Rules:
88
- > - Emit **one `summaries` entry for every `id`** in `sections` — no more,
89
- > no fewer. Use the exact id strings.
90
- > - Summaries are what the search agent reasons from: faithful,
91
- > self-contained, 1-3 sentences. **Paraphrase prose, but never restate
92
- > code, flags, commands, or exact identifiers** those are extracted
93
- > verbatim by the CLI and would only drift if you retyped them.
94
- > - `glossary`: at most the ~10 terms from this shard a real user would
95
- > type (aliases like `k8s` for `kubernetes`); one-line definitions. The
96
- > CLI dedupes and caps the merged glossary.
97
- > - `notes` is NOT user-facing — it feeds a later site-wide synthesis pass.
98
- > - Your final message: just the shard id and how many summaries you wrote.
99
-
100
- If the run is interrupted, just re-run from step 1 disk state is the
101
- source of truth and `status` shows what's left.
102
-
103
- 4. **Synthesize the global context.** Once every shard is distilled, extract
104
- only the `notes` fields (small) — never the full distill files:
105
-
106
- ```sh
107
- python3 -c "import json,glob; [print('##', f.split('distill-')[1].removesuffix('.json'), '\n' + json.load(open(f)).get('notes','')) for f in sorted(glob.glob('.hev-ask/shards/distill-*.json'))]"
108
- ```
109
-
110
- From those notes, write `.hev-ask/shards/global.json`:
62
+ > - One `summaries` entry for every `id` in `sections` — exact ids, no more, no fewer.
63
+ > - Summaries are what the search agent reasons from: faithful, self-contained.
64
+ > Paraphrase prose; **never restate code, flags, commands, or identifiers** —
65
+ > the CLI extracts those verbatim, and they'd only drift if retyped.
66
+ > - `glossary`: ≤10 terms a real user would actually type (aliases like
67
+ > `k8s`→`kubernetes`); one-line definitions. The CLI dedupes and caps them.
68
+ > - `notes` is not user-facing; it feeds the global synthesis pass.
69
+ > - Reply with just the shard id and how many summaries you wrote.
70
+
71
+ Interrupted? Re-run from step 1 — disk is the source of truth and `status`
72
+ shows what's left.
73
+
74
+ 4. **Synthesize the global context.** Once every shard is distilled, read the
75
+ `notes` field from each `.hev-ask/shards/distill-*.json` (smallnever the
76
+ full files) and write `.hev-ask/shards/global.json`:
111
77
 
112
78
  ```json
113
79
  {
114
80
  "context": "Compact markdown orientation: what the product/site is, its core concepts and feature areas, and how users talk about them.",
115
- "suggestions": [
116
- "A natural question a reader might type that these docs answer."
117
- ],
81
+ "suggestions": ["A natural question a reader might type that these docs answer."],
118
82
  "glossary": []
119
83
  }
120
84
  ```
@@ -122,43 +86,29 @@ distillation agents.
122
86
  `suggestions`: 3-5 questions phrased the way a user would ask them, each
123
87
  genuinely answerable from these docs (they show in the overlay on open).
124
88
 
125
- 5. **Assemble.**
126
-
127
- ```sh
128
- pnpm exec ask digest assemble --input-dir .hev-ask/shards
129
- ```
130
-
131
- Merges every current shard distillation with the global synthesis, derives
132
- the deterministic parts, and writes `.hev-ask/digest.json`. Sections from
133
- undistilled shards fall back to plain excerpts and are reported — the
134
- digest stays usable mid-wave, but aim for 0 pending before committing.
135
-
136
- 6. **Verify.**
137
-
138
- ```sh
139
- pnpm exec ask digest verify
140
- ```
89
+ 5. **Assemble.** `ask digest assemble --input-dir .hev-ask/shards`
90
+ Merges every shard distillation with the global synthesis, derives the
91
+ deterministic parts, and writes the `.hev-ask/` tree. Undistilled shards fall
92
+ back to excerpts and are reported — usable mid-wave, but aim for 0 pending
93
+ before committing.
141
94
 
142
- Anchor drift is fatal; coverage/fidelity warnings are informational.
95
+ 6. **Verify.** `ask digest verify` — anchor drift is fatal; coverage/fidelity
96
+ warnings are informational.
143
97
 
144
- 7. **Clean up and commit.** The shards directory is a local cache — it is the
145
- resume/refresh state, so keep it on disk but out of git, and drop the bulky
146
- input files (regenerated by `corpus` any time):
98
+ 7. **Commit.** The shards dir is the local resume/refresh cache — keep it on disk
99
+ but out of git, and drop the bulky input files (`corpus` regenerates them):
147
100
 
148
101
  ```sh
149
102
  rm -f .hev-ask/shards/input-*.json
150
103
  git check-ignore -q .hev-ask/shards || echo ".hev-ask/shards/" >> .gitignore
151
- git add .gitignore .hev-ask/digest.json
104
+ git add .gitignore .hev-ask
152
105
  ```
153
106
 
154
- Only `.hev-ask/digest.json` is committed.
107
+ Only the `.hev-ask/` tree is committed; `.hev-ask/shards/` stays local.
155
108
 
156
109
  ## Notes
157
110
 
158
- - A small site may produce a single shard; the flow is the same (you can
159
- distil it yourself instead of spawning an agent a single shard's input is
160
- small enough to read directly).
161
- - `--shard-bytes` (default 200000) tunes shard size if a site's sections are
162
- unusually dense.
163
- - If `corpus` fails because no content is found, you're likely not in the
164
- site root, or the collection name isn't `docs` — pass `--collection <name>`.
111
+ - A small site may produce a single shard distil it yourself instead of
112
+ spawning an agent (its input is small enough to read directly).
113
+ - If `corpus` finds no content, you're likely not in the site root, or the
114
+ collection isn't named `docs` pass `--collection <name>`.
@@ -1,11 +1,13 @@
1
1
  import { createHash } from 'node:crypto';
2
- import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises';
2
+ import { mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises';
3
3
  import path from 'node:path';
4
- import { callClaude, type AnthropicTool } from '../llm.ts';
4
+ import { type AnthropicTool } from '../llm.ts';
5
+ import { PROVIDERS, clientFor, resolveProviderName, type ProviderName } from '../providers.ts';
5
6
  import { chunkDocument, hashableChunkText, type Chunk, type SourceDocument } from '../search/chunk.ts';
6
7
  import { classifyMode, distinctiveTokens, extractFacts } from './facts.ts';
7
8
  import { parseFrontmatter } from './frontmatter.ts';
8
9
  import { normalizeDigest, type Digest, type DigestNode } from './schema.ts';
10
+ import { digestTreeFiles, readDigestArtifact } from './tree.ts';
9
11
 
10
12
  export interface DigestBuildOptions {
11
13
  siteRoot: string;
@@ -15,6 +17,8 @@ export interface DigestBuildOptions {
15
17
  digestContentGlobs?: string[];
16
18
  chunkHeadingDepth: number;
17
19
  digestModel: string;
20
+ provider?: ProviderName;
21
+ providerBaseUrl?: string;
18
22
  apiKey?: string;
19
23
  }
20
24
 
@@ -94,7 +98,7 @@ export interface EmittedDistillation {
94
98
  export interface DigestInput {
95
99
  contentHash: string;
96
100
  digestPath: string;
97
- /** True when the committed digest.json already matches this corpus — no rebuild needed. */
101
+ /** True when the committed digest tree already matches this corpus — no rebuild needed. */
98
102
  upToDate: boolean;
99
103
  sections: Array<{ id: string; url: string; title: string; text: string }>;
100
104
  }
@@ -133,21 +137,22 @@ export function assembleDigest(emitted: EmittedDistillation, corpus: CorpusBuild
133
137
  export async function buildDigest(options: DigestBuildOptions): Promise<DigestBuildResult> {
134
138
  const corpus = await buildCorpus(options);
135
139
  const outPath = path.resolve(options.siteRoot, options.digestPath);
136
- const existing = await readExistingDigest(outPath);
140
+ const existing = readExistingDigest(options.siteRoot, options.digestPath);
137
141
  // Skip only when the committed artifact is already a current-version digest with
138
142
  // nodes built from this exact corpus. A v1 (node-less) artifact always rebuilds.
139
143
  if (existing && existing.version === 2 && existing.contentHash === corpus.contentHash && existing.nodes.length > 0) {
140
144
  return { status: 'skipped', path: outPath, contentHash: corpus.contentHash, chunks: corpus.chunks.length };
141
145
  }
142
146
 
143
- const apiKey = options.apiKey ?? process.env.ANTHROPIC_API_KEY;
144
- if (!apiKey) throw new Error('ANTHROPIC_API_KEY is required to build a fresh digest.');
147
+ const provider = resolveProviderName(options.provider);
148
+ const apiKey = options.apiKey ?? process.env[PROVIDERS[provider].envKey];
149
+ if (!apiKey) throw new Error(`${PROVIDERS[provider].envKey} is required to build a fresh digest.`);
145
150
 
146
151
  const corpusText = corpusSections(corpus)
147
152
  .map((section) => `id: ${section.id}\nurl: ${section.url}\ntitle: ${section.title}\n\n${section.text}`)
148
153
  .join('\n\n---\n\n');
149
154
 
150
- const response = await callClaude({
155
+ const response = await clientFor(provider, options.providerBaseUrl).call({
151
156
  apiKey,
152
157
  model: options.digestModel,
153
158
  maxTokens: 8192,
@@ -203,8 +208,21 @@ export function parseEmittedDigest(input: unknown): EmittedDistillation {
203
208
  }
204
209
 
205
210
  async function writeGraph(outPath: string, digest: Digest): Promise<void> {
206
- await mkdir(path.dirname(outPath), { recursive: true });
207
- await writeFile(outPath, JSON.stringify(digest, null, 2) + '\n', 'utf8');
211
+ if (path.extname(outPath).toLowerCase() === '.json') {
212
+ await mkdir(path.dirname(outPath), { recursive: true });
213
+ await writeFile(outPath, JSON.stringify(digest, null, 2) + '\n', 'utf8');
214
+ return;
215
+ }
216
+
217
+ await mkdir(outPath, { recursive: true });
218
+ const desired = new Set<string>();
219
+ for (const file of digestTreeFiles(digest)) {
220
+ const target = path.join(outPath, file.path);
221
+ await mkdir(path.dirname(target), { recursive: true });
222
+ await writeFile(target, file.body, 'utf8');
223
+ desired.add(file.path);
224
+ }
225
+ await removeOrphanDigestMarkdown(outPath, desired);
208
226
  }
209
227
 
210
228
  /**
@@ -221,7 +239,7 @@ export async function writeCorpusInput(options: {
221
239
  chunkHeadingDepth: number;
222
240
  }): Promise<{ path: string; upToDate: boolean; sections: number }> {
223
241
  const corpus = await buildCorpus(options);
224
- const committed = await readExistingDigest(path.resolve(options.siteRoot, options.digestPath));
242
+ const committed = readExistingDigest(options.siteRoot, options.digestPath);
225
243
  const upToDate = Boolean(
226
244
  committed && committed.version === 2 && committed.contentHash === corpus.contentHash && committed.nodes.length > 0,
227
245
  );
@@ -282,6 +300,7 @@ export function buildNodes(chunks: Chunk[], summaryById: Map<string, string>): D
282
300
  group: chunk.group ?? null,
283
301
  url: chunk.url,
284
302
  summary,
303
+ hash: sectionHash(chunk),
285
304
  facts,
286
305
  sources: [{ chunkId: chunk.id, url: chunk.url, anchor: chunk.anchorId ?? null }],
287
306
  mode: classifyMode(chunk.group),
@@ -348,12 +367,31 @@ export function sha256(text: string): string {
348
367
  return createHash('sha256').update(text).digest('hex');
349
368
  }
350
369
 
351
- async function readExistingDigest(file: string): Promise<Digest | null> {
352
- try {
353
- return normalizeDigest(JSON.parse(await readFile(file, 'utf8')));
354
- } catch {
355
- return null;
356
- }
370
+ function sectionHash(chunk: Chunk): string {
371
+ return sha256(`${chunk.id}\n${chunk.text}`);
372
+ }
373
+
374
+ function readExistingDigest(siteRoot: string, digestPath: string): Digest | null {
375
+ const digest = readDigestArtifact(siteRoot, digestPath);
376
+ return digest.version === 2 && digest.nodes.length > 0 ? digest : null;
377
+ }
378
+
379
+ async function removeOrphanDigestMarkdown(root: string, desired: Set<string>, relDir = ''): Promise<void> {
380
+ const dir = path.join(root, relDir);
381
+ const entries = await readdir(dir, { withFileTypes: true }).catch(() => []);
382
+ await Promise.all(
383
+ entries.map(async (entry) => {
384
+ if (entry.isDirectory()) {
385
+ if (entry.name === 'shards') return;
386
+ await removeOrphanDigestMarkdown(root, desired, path.join(relDir, entry.name));
387
+ return;
388
+ }
389
+ const rel = path.join(relDir, entry.name).replace(/\\/g, '/');
390
+ if (path.extname(entry.name).toLowerCase() === '.md' && !desired.has(rel)) {
391
+ await rm(path.join(root, rel), { force: true });
392
+ }
393
+ }),
394
+ );
357
395
  }
358
396
 
359
397
  async function resolveContentFiles(