@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 +33 -13
- package/openapi.yaml +53 -7
- package/package.json +6 -6
- package/skills/build-digest/SKILL.md +70 -120
- package/src/digest/build.ts +54 -16
- package/src/digest/cli.ts +19 -7
- package/src/digest/frontmatter.ts +7 -0
- package/src/digest/schema.ts +3 -0
- package/src/digest/tree.ts +259 -0
- package/src/digest/verify.ts +2 -11
- package/src/endpoint.ts +121 -5
- package/src/index.ts +1 -1
- package/src/integration.ts +16 -14
- package/src/llm-openai.ts +330 -0
- package/src/observability.ts +3 -1
- package/src/providers.ts +81 -0
- package/src/search/loop.ts +219 -4
- package/src/types.ts +34 -6
package/README.md
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
# @hevmind/ask
|
|
2
2
|
|
|
3
|
-
hev ask is a heading-anchored search overlay for
|
|
4
|
-
|
|
5
|
-
|
|
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
|
-
| `
|
|
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`
|
|
48
|
-
| `
|
|
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
|
|
75
|
-
hash-gated, so unchanged content does not spend another Opus call.
|
|
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
|
-
-
|
|
108
|
-
|
|
109
|
-
- The
|
|
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.
|
|
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`)
|
|
12
|
-
|
|
13
|
-
directly.
|
|
11
|
+
(`/api/ask/glossary`, `/api/ask/sections`, `/api/ask/overview`) plus
|
|
12
|
+
`/api/ask/archive` for bulk tree hydration, so a coding agent — via 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://
|
|
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
|
|
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
|
|
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.
|
|
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-
|
|
32
|
-
"@hevmind/ask-
|
|
33
|
-
"@hevmind/ask-
|
|
34
|
-
"@hevmind/ask-linux-
|
|
35
|
-
"@hevmind/ask-
|
|
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/
|
|
5
|
-
using your
|
|
6
|
-
|
|
7
|
-
graph
|
|
8
|
-
|
|
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`
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
45
|
-
|
|
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
|
-
|
|
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
|
-
|
|
59
|
-
|
|
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
|
-
|
|
63
|
-
content
|
|
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 (
|
|
67
|
-
|
|
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)
|
|
70
|
-
> `shardId`, `shardHash`, and a `sections` array of `{ id, url, title,
|
|
71
|
-
>
|
|
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
|
|
77
|
-
> "notes": "5-10 lines: what this shard covers, its key concepts, and how users
|
|
78
|
-
> "glossary": [
|
|
79
|
-
>
|
|
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
|
-
>
|
|
88
|
-
> -
|
|
89
|
-
>
|
|
90
|
-
>
|
|
91
|
-
>
|
|
92
|
-
>
|
|
93
|
-
>
|
|
94
|
-
> -
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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` (small — never 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
|
-
|
|
128
|
-
|
|
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
|
-
|
|
95
|
+
6. **Verify.** `ask digest verify` — anchor drift is fatal; coverage/fidelity
|
|
96
|
+
warnings are informational.
|
|
143
97
|
|
|
144
|
-
7. **
|
|
145
|
-
|
|
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
|
|
104
|
+
git add .gitignore .hev-ask
|
|
152
105
|
```
|
|
153
106
|
|
|
154
|
-
Only `.hev-ask
|
|
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
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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>`.
|
package/src/digest/build.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
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 =
|
|
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
|
|
144
|
-
|
|
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
|
|
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
|
-
|
|
207
|
-
|
|
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 =
|
|
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
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
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(
|