Skip to content

Releases: Cloflin/docpilot

DocPilot 3.0 roadmap

DocPilot 3.0 roadmap Pre-release
Pre-release

Choose a tag to compare

@Cloflin Cloflin released this 30 Oct 11:20

DocPilot 3.0 — roadmap and progress. This is not a release: no 3.0 package is published yet, and the 2.x line stays the current version on npm. This note is updated at the end of every phase.

After 3.0: DocPilot moves to a new website and gets a new product name that does not collide with other products, so people who look for it can find it more easily. The npm package becomes @cloflin/ai (Phase 10); the new name and the new site are announced with the 3.0 release.

Progress: 4 of 12 phases complete · updated 2026-11-03

Plan updates since Phase 4 (2026-11-03)

  • Every update now goes through the Markdown snapshot. A webhook, a schedule or docpilot sync rewrites the one page that changed, and the server's index is built from those pages, so the server index can always be rebuilt from Markdown without asking the service again (Phase 5).
  • A site's own Markdown docs sync on the server too: the server re-reads them on a schedule and indexes only the pages that changed (Phase 5).
  • Intercom's article webhooks (article.created, article.updated, article.published, article.unpublished, article.deleted) are confirmed in Intercom's own reference. A webhook re-reads only the one article it names; the schedule still catches anything a webhook misses (Phase 5).
  • An MCP server can be a knowledge-base source: a type: 'mcp' connector reads its resources, or one named read-only tool, into the same Markdown snapshot (Phase 9).
  • Sources can be chosen from both sides. A site or product picks the sources of its own panel with a filters setting (Phase 6), and the server owner can give each consumer a token whose source scope it cannot leave (Phase 9).
  • Phase 6 also carries prompt caching (byte-stable tool schemas and system message, OpenAI explicit cache breakpoints), an offline A/B of the next prompt layout, and the measurement that decides whether the server's vectors default to float32. Each change that alters answers ships only if the evaluation shows it is no worse.
  • Phase 7, the framework-free chat core, is planned in 15 steps; it runs after Phase 6.
  • The 3.0 requirement count is now 140.

Latest update — Phase 4: Server storage and hot swap (2026-11-03)

  • The server's index now lives behind one storage interface, exported as @cloflin/docpilot/store. Five stores ship: the existing index directory, an index served over HTTP (read-only), memory, SQLite through Node's built-in node:sqlite (nothing to install), and Postgres with pgvector through the site owner's own pg client. The package imports no database driver.
  • An existing rag/ directory loads unchanged through the files store and answers as 2.1.1 did: the frozen 2.1.1 rankings replay with no difference.
  • One exported conformance suite checks every store the same way, and a site owner can run it against a store of their own. It passes on the files, memory, SQLite and Postgres stores; the Postgres run uses PGlite, a Postgres build that runs in the test process.
  • A commit is atomic: a reader never sees half of an updated article, and the sync position is saved in the same commit. Each store records the embedding model, the dimensions, the vector precision and the vocabulary once, and a server refuses to start on a store it cannot serve.
  • A running server picks up a changed store without a restart. It checks the store every 5 seconds (or when refresh() is called), builds the next index beside the live one and then swaps to it in one step. A question that is already being answered finishes on the index it started with, and a failed or refused refresh keeps the current index.
  • Leases keep two server instances from writing the same source at the same time.
  • docpilot store migrate <from> <to> moves an index between stores without embedding anything again, and checks the result.
  • A server store can keep its vectors unquantised (float32 or float16) beside the 8-bit default. The default stays 8-bit until Phase 6 measures the difference.
  • 3.0 requires Node 22.13 or newer, and CI runs Node 22.13.0 and 24.
  • The 2.2.0 release notes are now in the 3.0 branch. The design record is engine-spec 045. The docs, both committed indexes, both calibrations and the four frozen baselines were updated once, at the end of the phase.
  • Still open before the release: a run of the Postgres suite against a real Postgres server, and the first CI run on Node 22.13.0.

Roadmap: DocPilot

Overview

DocPilot 3.0 widens where grounded answers come from and how fresh they stay, and shrinks what a site ships. The roadmap starts by freezing the 2.1.1 baselines and moving the existing sources onto one shared document pipeline with unchanged output bytes. Then it improves HTML extraction, builds the connectors (Intercom, Zendesk, Freshdesk, Generic REST, own-origin pages), puts the server index behind a storage adapter with an atomic hot swap, and adds the sync engine that makes an edit at the source reach answers without a restart (Core Value). Confluence and Notion follow once sync and reconcile exist. Next, one turn pipeline serves browser, server and eval, and server retrieval gains filters, a candidate pool, a reranker and contextual chunks, each backed by eval evidence. The client track then moves chat state onto a Vue-free core and splits the bundle into registered features with a custom-bundle CLI; the existing Vue UI ships in 3.0. The release phase ships the migration path and proves the motivating case on stock DocPilot: a production site that today needs a patched DocPilot and hand-written import scripts. After the release, a last phase decides through a go/no-go whether the whole UI moves to Web Components in a 3.x minor release, as a non-breaking swap.

Two phases were added after the roadmap was created. Phase 9 puts MCP on both ends of the server runtime — an endpoint through which coding agents search the docs, and a client through which the assistant calls other services' tools — under one permission policy that only the server owner writes. Phase 10 renames the package to @cloflin/ai and splits it into one subpath per layer, before the release phase (Phase 11).

Structure Notes

  • Ten phases, above the standard 5-8. 106 requirements in 11 categories split into two tracks (server: Phases 2-6, client: Phases 7-8, plus Phase 12 after the release). Research suggested 12 phases. Two merges were made where dependencies stay honest: wiki connectors joined the sync phase (they are the first schedule-first consumers of reconcile), and the turn-driver unification joined server retrieval (the driver is that phase's first deliverable and must be green before any retrieval stage lands). Further merges would cross real boundaries: Phase 1 keeps output byte-identical while Phase 2 deliberately shifts corpus hashes; Phases 4 and 5 would hold 25 requirements; Phases 7 and 8 differ in risk and research needs; Phase 12 is conditional.
  • Requirements placed ahead of their category. REL-01 (Node >= 22.13, CI on 22 and 24) is in Phase 4 because the node:sqlite adapter cannot run in CI before it. SYNC-08 (atomic hot swap) is in Phase 4 because the index holder is built with the store. STORE-08 (ceilings per runtime) is in Phase 1 because the shared pipeline owns the ceilings. SEC-01 to SEC-03 start in Phase 3 because credentials first exist there; later phases keep the secret scan green and extend the visibility fixtures to the wiki connectors.
  • Embedding-moving changes are batched. All extractor changes land as one extractor-version bump in Phase 2. Eval evidence for that bump, the breadcrumb prefix, the contextual prefix and the reranker is collected together in Phase 6 (EVAL-04), once the Intercom golden set exists, and always before 3.0 ships.
  • The 2.x deprecation release (REL-02) is cut before 3.0 final. The list of removed exports and options is final only after Phase 10 (the package split), so Phase 11 owns it. Phase 12 (Web Component UI) is a non-breaking swap in a 3.x minor release and adds nothing to the list.
  • Research flags. Phases 3, 4, 5, 6, 8, 9, 10 and 12 need a research pass before planning.
  • Two phases were added on 2026-10-30, after the roadmap was created, and the last three were renumbered the same day. 27 requirements in 4 new categories (MCPS, MCPC, PERM, PKG) bring the total to 133 in 15; MCPS-07 and MCPS-08 (the local and the Vercel deploy of /mcp) were added the same day. Phase 9 (MCP, engine-spec 042) needs the Phase 6 turn driver, where the reader's approval pause is built once for all three drivers. Phase 10 (the package split) needs per-feature exports (Phase 8) and the mcp layer (Phase 9); its chat-ui subpath ships the current Vue UI on the Phase 7 core. The release is Phase 11. On 2026-10-30 the release moved from Phase 10 to Phase 12, MCP from Phase 11 to 10 and the package split from Phase 12 to 11; the phases run in number order, so the number order must be the dependency order. On 2026-11-02 REL-11 (the new product name and website) was added to Phase 11, which brings the total to 134 in 15.
  • The Web Component phase moved behind the release on 2026-11-02. The owner decided that the existing Vue UI ships in 3.0 and the Web Component follows in a 3.x minor release. It was Phase 9 and is now Phase 12 (CORE-11); MCP, the package split and the release moved down to Phases 9, 10 and 11. The swap stays non-breaking because the public UI surface is the mount function, the thin wrappers (props and events) and the --dp-* tokens.

Phases

Phase Numbering:

  • Integer phases (1, 2, 3): Planned milestone work
  • Decimal phases (2.1, 2.2): Urgent insertions (marked with INSERTED)

Decimal phases appear between their surrounding integers in numeric order.

  • Phase 1: Baselines and shared document pipeline - Freeze 2.1.1 baselines and run existing sources through one SourceDoc pipeline, byte-identical (completed 2026-10-30)
  • **Phase 2: HTML extraction...
Read more

v2.1.1

Choose a tag to compare

@Cloflin Cloflin released this 28 Oct 06:23

A claim links to its source again. With gpt-6-luna at reasoning: 'low',
most answers printed a section id in brackets —
[editor-configuration/image-storage#connecting-your-own-server] — where the
link marker goes. The Sources list under the answer was right, and the claim had
no link. This release fixes the instruction, settles the markers in code for any
model, and makes eval fail on an answer without a link. Engine-spec 039.

It also stops a reader's first question from paying for what the package could
have known: two refused requests on every page load with OpenAI's reasoning
models, and a search step that brought back only what the turn already had.
Measured on ten live turns of one production site: 34 requests and 8 refusals
before, 27 and none after. Engine-spec 040.

Fixed

The citation rule is two sentences. The system prompt said: "Mark every
claim with a citation marker [1], [2] in the order of the citations array. It
holds section ids copied exactly from tool results, never marker numbers."
gpt-6-luna read the second sentence as a rule about the marker. It wrote the
id in brackets in 13 of 16 replays of one production turn, and in 5 of 6 live
turns. The rule now says: in the text, write numbers, and [1] is
citations[0]; section ids go only in citations. On the same replays: 0 of
24. On five questions of that site, 10 of 10 live turns linked every claim. The
prompt hash changes, so eval reports start a new history, and the next
docpilot index bakes the opener answers again.

The markers are settled with the citations. Before anything reads the
answer — the panel, the server's signed history, the turn log, the eval — the
turn checks every bracket in the text against the list it validated:

  • [section-id] becomes its [n]. A known id that only the text cites is
    added to the end of citations; an earlier position never moves.
  • Several ids in one bracket, [a#x, b#y], become [1][2], and so does
    [1, 2], which the panel never linked. A continuation part, #anchor~2,
    maps to its cited #anchor.
  • A number is a position in the list the model sent. When an invented id is
    dropped from that list, the numbers after it are renumbered; before, each of
    them linked the source one row down. A number that pointed at an invented id,
    or past the end of the list, is deleted.
  • A bracket with an id that nobody handed out — a path, a #, no spaces — is
    deleted, as an invented citation's marker always was.
  • Code spans, fenced blocks, link text and image alt text are left alone.

A reply in prose, without the answer tool, that names known ids in brackets
now keeps them as citations and shows as a tentative answer. Before, it was
withdrawn as "I couldn't find this in the docs".

The first question of a page load spends no refusal on OpenAI. The
openai row now states the two rules OpenAI's migration guide gives for every
reasoning model, where 2.0.1 learned them from a 400 on each new page:

  • a search step sends reasoning_effort: 'none', because function tools on
    Chat Completions take nothing else since GPT-5.4, and an omitted effort means
    the model's own default, medium. chat.reasoning: 'auto' promises no
    thinking on a search step, and on this service that has to be said;
  • temperature and top_p are not sent beside any effort but 'none', and
    not beside silence either, which is the model's default effort.

gpt-6-luna pays nothing on its first question now, where it paid two refused
requests on every page load — 0.4 s and a request each. A model the up-front
word does not fit pays one: gpt-4.1 refuses the reasoning field, and the
transport stops sending it; gpt-5 and the o-series refuse the value 'none',
and the transport stops writing the word while the answer keeps your
chat.reasoning. That second refusal used to drop the reasoning field from
every answer of the session.

A search that finds nothing new says so. Every result already shown came
back as a row of its own — id, title, path and "shown in an earlier result" —
and that list was re-sent on every step after it. It is now one line with the
ids. The priming result names the query the gate ran, and the model asking for
that same search, by name or by leaving query out, is answered from the turn
like any repeated call. On the 2.1.0 prompt one step of every live turn ran a
search that returned exactly the primed excerpts; in ten live turns after the
fix none did, and the one question whose search found nothing new got the one
line, in both its turns.

An id is not painted while the answer streams. A section id the model
writes where [n] goes is turned into its marker when the turn settles; every
frame before it showed the id as plain text. It is now hidden until then, and
after a stop. ui-specs/020.

Added

A third hard gate: answers without a link. eval renders every answer to a
question the docs cover with the panel's own renderer. If one is shown with
sources and none of its [n] links, or it prints a section id in brackets, the
run fails with exit code 1, as on a hallucinated citation, and the report names
the records. Answers to negative records and an author's own openers are counted
on a separate line and fail nothing. The scorecard, docpilot status, the
server eval and the answer bench show the same gate.

A small model can fail it on the first run. In earlier reports,
gpt-4o-mini wrote no marker at all in about 30 % of its answers and qwen3:8b
in 35–55 %. Those answers showed the reader Sources and no link; the gate makes
that visible. Re-run with the new prompt before you read it as a regression.

index warns on a written answer with no marker. An opener you answered
in suggestions links its claims the same way: [1] is the first id in its
cite. One that links none still ships, with a warning.

v1.2.0

Choose a tag to compare

@Cloflin Cloflin released this 24 Oct 14:07

Added

A follow-up starts from the evidence the last turn bought — engine-spec 013.
primed was exactly this turn's gateResult.chunks and emittedIds was filled
from it alone, so the chunk the previous answer stood on was neither in front of
the model nor reachable — fetch_section refuses every id outside that set. What
the model had of the turn before was its own text, cut to 300 characters: "and
how do I turn it off?" was being asked to answer from an excerpt of itself. A
follow-up now also inherits up to three chunks that answer cited, plus their
next, resolved through the same retrieval.fetch the tool uses — so the turn's
scope applies and a chunk that left the corpus is dropped rather than trusted.
They enter emittedIds, because visible-but-uncitable is worse than absent. The
gate never sees them: G is unchanged and a refusal is still its decision about
this turn. Turn.citationIds carries the ids and is deliberately not persisted;
DOCPILOT_SEED_FROM_HISTORY sweeps the ceiling.

The instruction names the case the gate cannot reach — engine-spec 014. The
gate scores closeness — G = wDense·D + wLexical·L against tau — and a question
about a feature the corpus does not have is close when a similar one is
documented. Measured on this package's own corpus at tau 0.69: ten of ten
questions about absent features passed, G from 0.735 to 1.000, against
positives living in the same band. No threshold separates them, which is why this
is an instruction and not a calibration. The old text said "if the excerpts do not
contain the answer, call answer with confidence 0" — but in this case the
excerpts are not empty and are not about another product; they are about the
neighbouring feature of the same one. Observed on the development deployment as
an answer about token authentication to a question about SAML SSO, support 1,
zero hallucinated citations, the model faithfully retelling what it was handed.
The second added sentence covers the other undescribed case: a message that is
not a question about the documentation at all. Both land on confidence 0, which
session.js already settles as a refusal — no new surface, no new state, no new
model call. This moves PROMPT_HASH, so reports across it are incomparable.

The feedback receiver ships, and init copies it in. feedbackEndpoint makes
the panel POST one object per vote, fire and forget: its .catch() is silent
unless ?dpdebug=1 is on, so a receiver that 404s, 500s or accepts-and-drops
looks from the reader's side exactly like one that works. The something that
listens has to run in the consumer's deployment, and a file living unpublished in
this repository arrives there never — engine-spec 012, FB-5. So
lib/feedback-receiver.mjs joins package.json#files and npx docpilot init
writes it to docpilot/feedback-receiver.mjs. Copied rather than imported,
because a deployment edits it: the store is a seam, and NDJSON, SQLite or
Postgres is a decision about their infrastructure rather than about this package.
Its 36 reference tests are ported to vitest, including the property that makes
the seam a seam — NDJSON and SQLite produce byte-identical aggregate() output
on a retraction and on a reversed pair.

A re-search can buy its own vector — engine-spec 015, shipped off.
search_docs takes the query the MODEL wrote and hands it to the lexical half;
the dense half went on scoring the vector of the reader's original question,
whatever was asked for. So a rephrase moved one of two equally weighted RRF
inputs, and a rephrase into the corpus language — the case that matters most
where readers do not all write in it — moved nothing the dense channel could see.
runTurn now accepts embedQuery and uses it when the model's query differs
from the question, compared with the same normalise the search cache keys on;
every failure falls back to the turn's vector, and cost.embedRequests makes the
purchase visible. Off by default, on the spec's own argument: the report
counts two re-searches in seventy turns, inside the run-to-run spread of every
metric it could move. The panel passes no embedder, so production is unchanged.
DOCPILOT_EMBED_MODEL_QUERY=1 turns it on for a measurement, which belongs on a
corpus where re-searching actually happens.

usage.cachedTokens, from the only party that knows. Every observation is
re-sent on every step, and whether that costs full price is a fact about the
provider's prefix cache that nothing here could answer.
prompt_tokens_details.cached_tokens on the OpenAI-compatible adapters,
cache_read_input_tokens on Anthropic's, both streaming and not. null is not
zero
: a transport that reports no cache stays null through the sum, because
averaging silence as a miss would invent a hit rate for it.

Changed

A report that says what moved, not only how much. Every number in the table
was a mean over the whole golden set, so four failures with four different fixes
arrived as one figure. A failure taxonomy files each record as
retrieval-miss (the gold is not in the ranked eight — a corpus edit),
gold-below-primed (it is in the eight and not in the window the model was primed
with — a GATE_K and ranking problem), primed-low-f1 (the evidence was in front
of the model and the answer still missed), over-refused, or one of the two
negative outcomes. A by-language table splits the same metrics by the language
each question was asked in, because BM25 shares no term across writing systems and
a mixed mean describes neither population. Pages behind the misses that never
say what they are for
names the markdown files whose frontmatter carries no
description — the one measured dense lever on this pipeline, read as a lead and
not a verdict. A re-search line counts the turns where the model searched in a
language other than the question's. And citationRecall joins
citationPrecision, which divides by how many citations the answerer chose and
therefore moves with terseness alone at one gold entry; it is the covered arm of
retrievalF1Loose over the citations, so a page pin path# is covered by any
anchor of that page and by no sibling route. The report also prints cachedShare.

The report name carries the embedder. The corpus hash cannot tell two vector
spaces apart, and this repository ships that pair at 08e7a87e: measured on one
golden set with one gate, recall@8 0.925 against 0.912, MRR 0.762 against 0.669
and negativesCaught 0.125 against 0.438 were being printed as "changes since the
previous run". The name gains -emb-<hash>, and previousReport refuses the
cross-embedder pair as well, so the overwrite and the comparison are stopped
independently. Absent on either side reads as unknown and still pairs.

The golden set grows 56 → 70. Eight follow-ups where there were two, and eight
adjacent negatives — one record was 2.3 points of the positives. Every new
record's gold was verified by running the retriever, and one was rewritten after
it: "what happens if a key is missing" retrieves API keys, not translation keys.

opener-candidates.js resolves dist/ from its own location. It resolved
from the working directory, which is this repository's layout — and the skill is
copied into consumer projects, where that path is their build or nothing at all.
The cwd form is kept as the fallback. The skill also gains a feedback triage
section, starting with reading the bias note before the first promotion.

Fixed

A follow-up's priming turn runs under its own gate. A follow-up record is two
turns, and the first is a real turn: in production it runs its own retrieval and
is primed with what ITS question found. The runner handed it probe.g — the gate
of the SECOND question — so the priming turn answered the first question from
evidence retrieved for the second. Two consequences: the answer that becomes
history was written from the wrong excerpts, so the three-pair window §4.5
exercises was not the window production builds; and anything the second turn
inherits from the first landed in primed already and deduped away to nothing,
which made spec 013 structurally unmeasurable here. It surfaced as absence rather
than as a number — all eight follow-ups came back with byte-identical answerF1,
citationPrecision and prompt tokens across a change that should have grown the
prompt. The priming turn now evaluates rec.prev_question on its own, and that
question joins the prefetch batch.

npm test pins the webstorage flag the DOM suite needs. test/mount.test.js
exercises the real pagehide → saveIfRunning → slimTurn → localStorage
chain, and on Node 26 localStorage is unavailable unless the runtime is told
where to keep it. Three assertions read an empty archive and failed, while npx vitest run and npm test disagreed about whether the suite passed. The flag was
already documented in this repository's own instructions as being in this script;
it was not.

Documented

engine-specs 013 through 016, each written before its change, per that
directory's rule. 013 carries its baseline — 70 records on qwen3:8b against corpus
08e7a87e — and the row that says what the defect looks like here: three of the
eight follow-ups cite nothing from their gold at all and none of them refuses, so
the missing evidence surfaces as an answer resting on the wrong section rather
than as a refusal, which is worse. 015 carries the measurement that argues against
shipping it on this evidence. 016 is unimplemented: vectors.bin is 991 KB at
2048 dimensions, 59% of the raw bytes every reader downloads before the first
answer, and 496 KB at 1024 — with the two rules that make a truncation safe rather
than cheap.

Two ways to measure the wrong thing, written down. package.json's prepare
is npm run build and npx runs it, so the obvious way to measure one lever
without another — revert it, build, restore the source, ru...

Read more

v1.1.0

Choose a tag to compare

@Cloflin Cloflin released this 02 Sep 07:02

Changed

The window grid is 408, and the eval figures are gated. docs/guide/evaluation.md
said the window search had 272 candidates; spec 006 widened it to 408 two releases
ago, and nothing caught it — the existing figures gate covers only the index
numbers, and its repair script cannot reach a constant inside calibrate.js. Both
mentions are corrected against WINDOWS.length, the sample output block is a real
transcript of this repository's own run, and three new checks derive the printed
window grid, the printed tune grid (11 λ × 9 k = 99) and the probe count from the
code and the artefacts that produce them.

Both shipped indexes carry a measured guard again. They were on corpus
e9985350 against a calibration measured on ab42d56c, so both shipped
guard.source: "provisional" — a tau of 0.3 that nothing measured on this corpus.
The loop was re-run: docs/public/rag-local is calibrated-reduced and
docs/public/rag is transferred-window, both on corpus 08e7a87e, tau 0.69.

One embedding purchase per run. eval, tune and bench emit bought one
vector per request — 58 requests for this repository's golden set, against a
free tier that allows fifty a day. They now use the same batcher calibrate has
used since 1.0.0, which is 2. The batcher moved out of calibrate.ts into
src/eval/prefetch.ts with its behaviour unchanged to the letter — same batch of
32, same retryable statuses, same refusal to guess at a short batch, same
retry-after with a twenty-second ceiling, same silent fallback to the per-text
path — and gained the direct tests none of that had. It is a cache and never a
second code path: a provider that will not batch degrades to the loop that
already worked.

The eval report names its witnesses, and the seed is finally sent. meta
carried eighteen fields and every one described the input — the corpus hash,
the prompt hash, the levers, the flags. A report taken against a metered
provider and one taken against a laptop's Ollama were byte-identical, and a
rerun a week later was indistinguishable from the one it overwrote. It now also
carries ranAt, chatBase, embedBase (origins only — never a path, never a
key), node, package, goldenSha, temperature and seed.
calibration.json gains ranAt, embedBase, probeSha and embedRequests —
the last was already printed during the run and then thrown away, and it is the
one number that says what a rerun costs. tuning.report.md prints sweptAt.

goldenSha is a new incomparability marker. records is a count, so a
question rewritten inside an existing record moved nothing and two different
sets of 56 were identical to every check. Absent in an older report reads as
unknown, not as changed — the rule meta.level already established — so
upgrading does not announce a golden-set change to everybody once.

The seed reaches the call that writes the answer. Every answer metric was
one unseeded sample: the transport has accepted a seed all along, and
config.llm simply carried no tuning, so the guard never fired. It does now,
at 20260829 — the constant calibrate already draws its anchors with. A
related trap is fixed with it: the harness passed the whole tuning record
through its can-this-model-think filter, so on a model without thinking the
seed, topP and verbosity vanished from the final call. "Cannot think" and
"does not take a seed" are separate claims; only the first is that filter's to
make. Sent, not guaranteed — Anthropic's API has no such parameter, and
bench runs remains the arbiter of answer reproducibility.
DOCPILOT_EVAL_SEED="" restores the previous behaviour. The first seeded run is
not comparable with unseeded history on the answer half; meta.seed is where
that line falls.

A rerun no longer erases the run before it. The report name is a pure
function of the inputs, so a rerun with nothing changed wrote over the very file
it was being diffed against. The existing file is now copied into
docpilot/reports/history/ first, under its own ranAt. latest.json,
reportName and the pairing are untouched, and the directory is gitignored:
long-term history is git, this covers one working cycle.

A transferred calibration stops looking measured. --transfer runs no
sweep, and both sweep sections printed a table header with no rows under it —
which reads as a measurement that found nothing. They now say what was inherited
and from where. retrievalMisses printed 0/0 (bound 5%) on a probe set where
not one probe carries gold_page; it now says the bound is not armed, and
writes armed: false.

doctor --json and lint --json, docpilot help <command>, and one law for
.env.local.
doctor is documented as a CI gate and answered only in prose,
so gating on it meant grepping wording that is not a contract; --json puts one
object on stdout — the same facts the rows carry, plus the installed version and
ready as a boolean — while the diagnosis still goes to stderr, so
doctor --json | jq .ready shows the report to the operator and the answer to
the script. The exit code is unchanged in both commands.

.env and .env.local are now read once, by the launcher, and the existing
environment wins
. That law was written down in cli-context.ts and followed by
three of seven readers: bin/docpilot.js and vocabulary inverted it, so a
one-off OPENROUTER_API_KEY=… npx docpilot … was overruled by a checked-in
.env; index put the file in a private object that never reached
process.env; and bench and lint did not read it at all, while this CLI's
own help told you to put your key there. All six copies are gone and every
command sees one environment.

Help gained docpilot help <command> as a synonym for --help, --version in
the global block and in the reference, and the short spelling of a flag that has
one (--yes, -y). feedback's hand-written usage block is deleted — it is
rendered from the flag table like every other command's — and the "Full
reference" footer takes its address from one named constant instead of a string
copied into the renderer.

npx docpilot <command> is correct once the package is installed and wrong as a
one-off, because the unscoped name on npm is not this package. The help and the
reference now say so and show npx @cloflin/docpilot.

Four exit codes, and diagnostics on stderr. There were two, and cancelling
was one of them: Ctrl-C or Ctrl-D at either of the package's prompts returned
0, telling a script that the thing it asked for had been done. It returns
130 now. A command line this package cannot parse returns 2 — an unknown
command, flag, mode or value — which separates a typo from 1, the code a run
that was attempted and failed returns. The scale is documented at
Exit codes; before this it was promised nowhere,
which is why this is a minor and not a major.

CALIBRATION FAILED and doctor's ready NO block moved to stderr, so
docpilot doctor | … no longer swallows the only sentence explaining the
refusal. Progress counters redraw in place only when stderr is a terminal —
piped to a file they print whole lines instead of one line holding every value
the counter ever had. Every error is one line prefixed [docpilot] , the shape
import, feedback and vocabulary already used; the five entry modules' FAIL frame is gone, and so is the stack trace four of them printed by
default. DOCPILOT_DEBUG=1 brings the stack back.

doctor and init are TypeScript, and doctor checks its own flags. Both
were written directly in bin/docpilot.js — 670 lines outside the type checker
and outside the run* contract. doctor --proxyy used to produce no output
about itself and exit on project readiness; it exits 2 and names the flag. A
chat.provider the resolver refuses used to end the command in a stack trace
before it printed the block that would have named the fault; it is now a row in
the report and the diagnosis continues. Its stdout is byte-for-byte what it was.

One flag reader, and one dash is a short option. src/cli-flags.js exported
flagValue and flagGiven and nothing in production called them: seven
hand-written copies read the flags instead, and they had drifted — lint
truncated --file=a=b.jsonl at the first =, index returned '' where it had
been handed a default, vocabulary returned true where --out had been given
no path. All seven are gone. Three command lines that used to be accepted in
silence are now refused, because none of them ever meant what they looked like:
-level=low (one dash on a long name — the table accepted it and every reader
ignored it, so the widest tier ran), --level=low --level=high (every reader
takes the first and drops the rest), and --html-select main (the space form, on
the one command whose other parser has only ever taken =; import and
feedback keep it, because their pages have always shown it). -- now ends the
options instead of being reported as an unknown flag. docpilot bench runs
nothing when it is imported rather than run, and its usage line names the command
rather than answer-bench.js.

The empty state holds five questions, not three. The panel shows what you
configure, up to five; anything past that is still dropped and still named on
stdout. The built-in fallback is still three, so a site that configures none pays
nothing new. Five is not free: five embedding requests plus, with
suggestions.answers on, five model calls — and they are spent again whenever the
corpus hash moves or you edit one opener, because the bundle is fingerprinted
over the whole list rather than per question. On a fifty-a-day free tier that is
ten. The number itself lived in two places, SUGGESTION_LIMIT and a li...

Read more

v1.0.1

Choose a tag to compare

@Cloflin Cloflin released this 24 Oct 14:07

Added

The empty state's three questions are answered at build time. They are the
most-clicked questions a panel has, by construction, and each click cost an
embedding request and a model call for a question the author wrote and has not
changed in weeks. docpilot index now resolves them: it embeds each one, runs
the real retriever and the real gate, and writes the result beside the index.
A reader's first click spends nothing. Two switches, both on by default —
suggestions.precomputed turns the bake and its use off together, because a
bake nobody reads is build-time requests spent on a file that does nothing, and
suggestions.answers drops the expensive half while keeping the evidence.
engine-specs/009, ui-specs/013.

docpilot feedback faq ranks the questions your readers actually asked,
grouped the way the panel groups them.

A content-addressed embedding cache, so a rebuild does not buy the same
vectors twice.
docpilot index re-embedded the whole corpus on every run, and
the loop this project mandates — index → calibrate → lint → eval — therefore
cost 34 requests on this corpus and 57 on a 1216-chunk one, against a free tier
of 50 a day. The process the documentation requires did not fit the tariff the
documentation recommends. The key is the schema, the resolved model, the
provider, the host, the prefix and the chunk's own text; the value is the
post-l2normalise float32, because caching the int8 rows would blind
quantisationError and an f64 round trip breaks the byte-identical-output
contract. Measured: a build that bought its vectors and a build that took all
476 from the cache write the same bytes for vectors.bin, both shards and
df.json. A five-file docs edit then rebuilt on 1 request instead of 15.
The cache lives in ${evalDir}/embed-cache/, docpilot init gitignores it, and
--refresh-embeddings skips reading it — never writing it, so a refresh also
repairs a file that went bad. engine-specs/008.

doctor names a provisional guard. guardFor says so once, in the build
log, at the moment it falls back — and a build log is read while it scrolls.
Afterwards the fact lived in one key of one JSON file and nothing asked about it
again, which is how this package's own deployed index carried unmeasured
thresholds through a release. A note, never a blocker: the build stays soft on a
stale calibration so documentation stays publishable, and a doctor that exited
1 on the same state would be a stricter opinion than the build's.

Fixed

The cosine window grid had a floor of its own, and it could not describe an
embedder whose cosines sit low.
WINDOW_FLOORS started at 0.16. A floor is
the smallest cosine the sweep can call anything other than zero evidence, so the
grid only described embedders whose positives sit above it — which is the exact
class of bug the grid was introduced to fix one level down, where [0.44, 0.64]
was a bge-m3 literal nobody re-measured. On
nvidia/nemotron-3-embed-1b:free — which ranks this corpus as well as bge-m3,
AUC 0.856 against 0.868, but on a lower scale — two positives at cosine 0.146
and 0.142 clamped to D = 0 under all 272 candidates, the largest feasible tau
over the whole grid was 0.12, below wLexical 0.25, and assertWeights
rejected it. The index shipped the provisional guard, which refused 37% of
unscoped positives, 71% of scoped ones and 88% of follow-ups. Floors now start
at 0.00, giving 408 candidates. Measured afterwards on the same probes: window
[0.00, 0.24], tau 0.58, blatantRefusalRate 0.93, and U/S/F all zero.
engine-specs/006.

Compare separation on z_raw, never on D: D is computed after the clamp,
and under the old grid 186 of 271 probes sat on the floor, which destroys the
ordering and invents a gap that is not there.

A guard.source this package did not write is now named. guardFor stamped
it verbatim out of a file a consumer commits and may hand-edit, and from
manifest.guard it reaches every feedback record and every eval report — so a
hand-written "calibrated" re-labelled the whole evidence trail and nothing
downstream could tell. Warn and pass, not reject.

Changed

prev and codeLangs leave the chunk record. Neither was read anywhere at
runtime. prev additionally contradicted engine-spec 004 and the comment in
retriever.js that explains why the field must not exist — the backward pointer
is derived at load in one pass. Together they were 4.1% of the shard bytes every
reader downloads. An index built before this still works: the extra keys are
ignored, which is what the code already did. engine-specs/007.

docpilot index is no longer embed-only. Resolving the openers has the
shipped harness write their answers, so a build now spends chat requests as well
as embedding ones. suggestions.answers: false restores the old cost. The
invariant in the docs-rag skill said otherwise for two releases and has been
corrected.

v1.0.0

Choose a tag to compare

@Cloflin Cloflin released this 24 Oct 14:07

Breaking

The five shipped Vue components are now <script setup lang="ts">, and your
build transpiles them — ours does not.
This is the one most likely to stop an
upgrade, so it goes first. bin/build-js.js copies the components into dist/
verbatim rather than compiling them, because .vue is not TypeScript and tsc
does not see it; the annotations therefore survive into the file your bundler
reads. Every consumer of @cloflin/docpilot/theme, /theme-without-styles,
/mount and /vue pulls them in.

A Vite host is unaffected — @vitejs/plugin-vue hands a lang="ts" block to
esbuild, which covers VitePress and Nuxt, and Vue CLI 5 does the same. A webpack
build whose vue-loader rule handles JavaScript script blocks only now fails to
parse: add ts-loader, or babel-loader with @babel/preset-typescript, to
that rule. DocPilotTrigger.vue uses
withDefaults(defineProps<{ variant?: string }>(), { variant: 'nav' }), a
type-only macro a JavaScript-only compiler cannot handle at all. The script-tag
install is untouched: dist/docpilot.web.js is prebuilt and carries none of
this.

Every code entry point resolves into dist/ now, and src/ holds
TypeScript.

// before                              // after
".":       "./src/index.js",           ".":       "./dist/index.js",
"./theme": "./src/theme/index.js",     "./theme": "./dist/theme/index.js",
"./vue":   "./src/adapters/vue.js",    "./vue":   "./dist/adapters/vue.js",

Sixteen of the twenty-three exports subpaths moved like that. Six already
resolved into dist/ — the five CSS entries and ./web — so twenty-two of the
twenty-three now do, every one except ./package.json. sideEffects moved with
them. No subpath was added, removed or renamed, and the types conditions are
unchanged: they still name the hand-written types/*.d.ts.

Be clear about who this reaches. @cloflin/docpilot/src/… was never a supported
path — 0.5.0's exports map had no ./src/* subpath, no "./*" fallback and no
main field, so Node ESM and every exports-honouring bundler already refused it
with ERR_PACKAGE_PATH_NOT_EXPORTED. What breaks is tooling that goes around
exports by filesystem path: a bundler alias, a Vite resolve.alias, a Jest
moduleNameMapper, a Storybook glob, a patch-package patch. Rewrite those to
name dist/. files is unchanged and still ships src/, but that directory now
holds 88 .ts, 5 .vue and 5 .scss files and no .js at all; globs targeting
src/**/*.vue or src/**/*.scss are fine.

The package no longer works without a built dist/, and the build step fails
open.

"prepare": "npm run build",
"build":   "node bin/build-css.js && node bin/build-js.js && node bin/build-web.js"

prepare was the CSS and web builds; it is the whole build now, and the new
bin/build-js.js is what writes dist/. A registry install is fine — the
tarball carries dist/. A git dependency, a file: dependency, a workspace link
or a vendored checkout runs prepare instead, and bin/build-js.js resolves
tsc through createRequire: when TypeScript is missing it prints a skip notice
and exits 0 unless npm_command is publish or pack. That install produces a
package whose every subpath points into a directory nothing wrote. Make sure
typescript is installed before prepare runs — do not combine those install
shapes with --omit=dev.

In a repo checkout, run npm run build:js before node bin/docpilot.js. Every
dynamic import and all six ENTRY paths in the launcher moved from ../src/ to
../dist/, which is why docs:index gained that prefix. docpilot,
docpilot --help and an unknown command still exit before the first import and
need no build.

The corpus moves, so index → calibrate → eval is owed on upgrade. Four
changes, none of which a committed calibration.json can survive by assumption:

  • .mdx files enter the walk. It matched .md only, which is why the
    shipped Docusaurus adapter mounted a panel over a corpus holding none of that
    site's pages; it is /\.mdx?$/ now, and routeOf and externalIdFor strip
    .mdx so an MDX page routes to /guide/install. Run
    find <docsDir> -name '*.mdx' before upgrading: a guide.md beside a
    guide.mdx produces two pages at /guide and the build dies on
    duplicate chunk id, and any .mdx your generator ignores — a _partials/
    include, a draft — now enters as a page. routeOf is also a public export
    from /mount, /vue, /host and /web, and it strips .html and .htm
    too; exactly one extension goes, so a.htm.md still becomes /a.htm.
  • A new stripMdx pass runs first inside normaliseMarkdown, ahead of
    applyLlmTags, over every page rather than only over .mdx.
    Unindented
    top-level import/export module statements are deleted to bracket balance,
    along with whole-line {/* … */} comments; fenced blocks are untouched, and
    the start regex is anchored so a four-space-indented sample survives too. Two
    guards keep prose that merely begins with the word import or export. On
    this repository the pass changes exactly one file and zero chunks; the exposure
    elsewhere is a .md page carrying a bare module statement outside a fence,
    which in VitePress lives inside a <script setup> block stripVue already
    removed.
  • The lexical scanner changed the order of case-folding and splitting.
    surfaceTokens was norm(s) — NFKC, strip format characters, lowercase —
    followed by the split; it is now a case-preserving rawTokens(s) with
    .toLowerCase() applied per token. For characters whose lowercase form
    introduces a combining mark the tokens differ: İstanbul gave
    ["i", "stanbul"] and now gives one token, i followed by U+0307. Latin, CJK,
    Cyrillic and identifier-shaped input are byte-identical. Nothing hashes the
    scanner, so no guard fires and an unreindexed site keeps loading — it just
    retrieves slightly differently on those terms. Re-index.
  • OpenAPI discovery matches /\.ya?ml$/i where it matched /\.ya?ml$/, and
    two specs sharing a basename stop the build.
    A spec claims
    /reference/<basename>, and while every spec came from one directory listing
    that was nearly safe — but the filesystem only ever enforced unique filenames,
    not unique basenames. api.yaml beside api.yml in the default
    ${docsDir}/public/openapi collided silently in 0.5.0, second wins; it now
    dies naming both files, with openapi left at its default. An uppercase
    API.YAML that was skipped before is now indexed, and can collide the same
    way. Rename one; which should win is not a decision this package can make.

Three settings default to on, and two of them persist where 0.5.x persisted
nothing.

// all three ship as `true`; write any of these to keep exactly what 0.5.x did
composer: { draft: false }              // no sessionStorage draft, as before
history:  { saveOnUnload: false }       // an unfinished turn is dropped, as before
ui:       { waitingEscalation: false }  // one motionless status word, as before

The features are described under Added. What matters here is that an existing
config takes all three without editing. composer.draft adds a sessionStorage
key, docpilot:draft, that nothing wrote before — the text is passed through
redactSecrets() first, and a mount with either switch off actively removes the
key rather than merely declining to write it. history.saveOnUnload writes the
same docpilot:history archive 0.5.0 already wrote after every settled turn, so
it adds a new write moment rather than new storage. Both are additionally gated
on history.enabled, so a site that had already turned history off gets neither.
If your site makes a statement about what the panel keeps, read it again.

ui.waitingEscalation adds two i18n keys, status.stillWorking and
status.takingAWhile — the reader-facing string count went 171 to 173 and the
status group from 7 keys to 9. Translation tables merge over the English
defaults, so nothing breaks, but a localised deployment that believed it had
complete coverage renders two English strings at the 8 s and 25 s marks until it
adds them.

Five declared types were corrected against what the code has been doing, and a
JavaScript consumer sees none of it.
The conformance gate under Added found
seven drifted places; these are the five that reject source a 0.5.x TypeScript
project could have written. Every runtime shape is unchanged.

  • scope.filter is 'auto' | boolean, where it was
    'auto' | 'always' | 'never'. SCOPE_FILTERS has been ['auto', true, false]
    for two releases and pick() logged an error and fell back to 'auto' for
    either string, so a config that wrote filter: 'always' was already being
    ignored at runtime and now also fails tsc. Write true for always and
    false for never. docs/reference/config.md dropped its standing caveat
    about the mismatch in the same commit.
  • DocPilotThemeConfig.vocabulary is gone. It was declared on the client
    half for exactly one release and themeDocPilot never emitted it:
    vocabulary is SERVER_ONLY, folded into the index at build time and read
    back off the manifest. DocPilotThemeConfig has no index signature, so an
    object literal still setting the key fails excess-property checking — which
    lands on code that wrote it more than on code that read it, a reader having
    always got undefined and probably noticed. Delete the line;
    DocPilotSettings.vocabulary and VocabularySettings are untouched, and that
    is where it always belonged.
  • HOST_KEY is InjectionKey<() => HostBinding>, not a bare symbol. The
    contract was always a factory — inject runs inside setup and the value it
    yields is called there, which is what mountDocPilot and the Vue adapter both
    provide — but symbol let a consumer provide a plain binding and fail at
    runtime. Wr...
Read more

v0.3.0

Choose a tag to compare

@Cloflin Cloflin released this 24 Oct 14:07

Breaking

Three defaults moved. None of them changes what an answer contains; all
three change what the panel puts on screen out of the box. A project that had
already written any of these keys is unaffected — an explicit value always wins.

  • citations.passage is now false. The chevron that expanded a source row
    to the retrieved chunk is off by default. The source list itself is untouched:
    every answer still names what it cited and every row is still a link. Write
    citations: { passage: true } to keep the disclosure.
  • budget.showRemaining is now false. The muted line under the composer —
    38 of 50 free answers left today — is off, because on a shared key a
    browser's own count is a lower bound on what other readers have already spent.
    docs/guide/free-tier.md has described the default this way since it was
    written; the code now agrees with it. Write budget: { showRemaining: true }
    on a key only you draw on. Nothing about the rationing changed — one-shot
    mode, the rotation threshold and the 429 message are all where they were.
  • The panel header reads Docpilot. It was DocPilot. One i18n leaf,
    panel.title; override it with
    i18n: { translations: { panel: { title: '…' } } }. The trigger, its tooltip
    and the end-of-article link are unchanged.

Added

The muted line can say a site has no embedder. With
budget.showRemaining: true on a deployment that declared
embed: false,
the same line reads No embedding model — search matches words only. — beside
the request count where there is one. It is deliberately not the degraded-search
warning: that one belongs to an embedder that was configured and could not be
reached, and it appears on the refusal it explains. New i18n leaf
error.noEmbedder.

Every default in one block. docs/reference/config.md opens with an
All defaults section carrying the whole of DEFAULTS, copy-pasteable, plus the
four things the block cannot state on its own — chat.extraBody's
provider-supplied default, the three unions, chat.model not surviving a change
of provider, and the five keys that never reach the browser. A test executes the
block and compares it to DEFAULTS, so a default cannot move without this page
moving with it.

A Where it can go page, second in the guide sidebar. It is the one place
that states the condition plainly, in both directions: what the panel needs from
a page, which is very little — location.pathname, <html lang>, a full page
load and main are all defaults it works out on its own, and only host.search
has none — and what it will not do, which is answer about the page it is sitting
on.

og:description on the docs site. VitePress emits
<meta name="description"> from description but never an Open Graph one —
isDescriptionOverridden only looks at name === "description" — so every
shared link rendered a card with a title and no body.

Changed

The package is no longer described as being for documentation sites. It
mounts on any page that can load a stylesheet and a script — a landing page, a
pricing page, a help centre, an app you already ship — and that is now what
package.json#description, the README, the site's title, its h1 and its og
tags say. Nothing about the panel changed: the copy had been narrower than the
code ever since /mount and /web landed. The limit travels with the claim
everywhere it appears, in the same breath — the panel answers from the corpus
you built, not from the page it sits on.

keywords reordered and retargeted. npm clips the keyword list to a single
line in search results, so the first six now read ask-ai, ai-search,
site-search, ai-chatbot, rag, answer-engine. vitepress is demoted
rather than dropped — it stays in description, which npm weighs higher. Gone:
search, ai and documentation, unrankable head terms, and
documentation-search, a duplicate of docs-search.

The site title is DocPilot, not DocPilot for VitePress. The phrase moved
rather than went. docs/install/{vitepress,docusaurus,vue,react}.md each carry a
titleTemplate, so those four pages render DocPilot for VitePress and its
siblings as their whole <title> — one page whose title is the query, instead of
thirty-three sharing a suffix.

Fixed

docs/guide/free-tier.md printed showRemaining: 'auto', which is not a
value that key accepts — it is a boolean, and 'auto' was reported on stdout and
replaced at build time by anyone who copied the example.

The npm description was being truncated mid-word. The registry caps
description at 255 characters — silently, and documented nowhere — and 0.2.0's
was 286, so the registry has served …every citation is checked against wh since
the day it shipped. A published version cannot be corrected, only superseded. The
new description is 233 characters, and its first sentence closes at 111 so the
whole claim survives Google's ~120-character mobile snippet.

v0.2.0

Choose a tag to compare

@Cloflin Cloflin released this 24 Oct 14:07

The first release published to npm. 0.1.0 existed only in this repository, so
what follows describes the package as it stands rather than a diff anyone could
have installed. The Breaking section still matters: it names changes against
0.1.0 for anyone who was running the package from a clone.

Breaking

Continuation chunk ids moved from #anchor-N to #anchor~N. A section too
long for one chunk is cut into parts, and the second and later parts used to
take a -2 suffix — the same suffix the indexer appends to disambiguate two
headings that slug identically. The two could collide, and the collision is
silent: a gold_chunks pin, a citation, or a deep link resolves to the wrong
half of a page. ~ cannot collide, because the slugger deletes that character
before it ever reaches an anchor, so no heading can produce one.

Upgrading: rebuild with npx docpilot index, then run npx docpilot lint. Any
gold_chunks entry naming a continuation part now errors with matches nothing in index <hash> — repoint it, and that error is the complete list of what has
to change. Records pinned to a first part (page#anchor, no suffix) are
unaffected.

topK woke up. It has been in the config reference since the first release
and was read by nothing: the gate's k was a literal in retriever.js, so the
value an author wrote had no effect. It is now the author's half of the
retrieval-lever channel — clamped to the swept band 1..12, and it overrides the
k that docpilot tune measured, exactly as a hand-set guard.tau overrides a
calibrated threshold. A site that already sets this key starts getting the
effect the reference always promised, which is a behaviour change on upgrade.
Leave it null to keep what the corpus measured.

Added

A block-aware chunker. The cutter now works on blocks — paragraphs, fenced
code, tables, lists — instead of on lines. A table too long for one chunk splits
at a row boundary with its header row re-emitted on each part, so no chunk is
data with unlabelled columns; a fence too long for one chunk is closed and
reopened with the same fence characters, indent and info string, so no chunk
holds code outside a fence and the language survives the cut. Fence detection is
one scanner shared with normalise.js — while the two disagreed, a ~~~ line
inside a ``` sample flipped one of them and whole pages were emptied.

Six golden-set tiers: low, medium, high, xhigh, max, ultra.
eval, bench and tune all take --level=, the tiers are cumulative
(--level=medium runs low + medium), a record with no level reads as high,
and a run with no --level runs everything. A smoke-sized regression is
therefore a regression in the full set too — and two reports are comparable only
within one tier.

docpilot tune — the retrieval levers measured instead of guessed. It
sweeps the MMR relevance/diversity knob and the excerpt count against the golden
set (--lambda=0.5:1.0:0.05, --k=4:12), needs the embedding endpoint only, and
contacts no chat model. Only a full-pool run writes docpilot/tuning.json;
--level and --limit both narrow the pool and both make the run report-only,
because the file is inlined into every reader's bundle.

The tuning-lever channel, end to end. tune writes tuning.json, index
inlines it into the manifest, and the browser resolves levers on the same rule
the refusal thresholds already used: what your corpus measured beats what ours
measured, and what the author wrote beats both. Until index runs again, a swept
lever is a file on disk and nothing more.

Hosts that are not VitePress. Five new entry points, and the dividing line
between them is whether the host's bundler can compile a .vue file:

entry for
@cloflin/docpilot/mount any bundler with the Vue SFC plugin — Vite, Rollup, Nuxt, Astro
@cloflin/docpilot/vue a Vue application
@cloflin/docpilot/react React
@cloflin/docpilot/docusaurus Docusaurus, plus /docusaurus/client
@cloflin/docpilot/web a plain <script> tag, as window.DocPilot

The last three resolve to a pre-built bundle with Vue and Shiki compiled in,
because a Docusaurus site has React and a blog has nothing. /web.css and
/style/docusaurus.css ship the stylesheets those hosts need.

A pluggable highlighter API. @cloflin/docpilot/highlight is the interface;
/shiki, /prism and /hljs are implementations, and each one's library is an
optional peer dependency, so a site that highlights with Prism never installs
Shiki. A host that already has a highlighter can pass its own.

Living on the free tier. OpenRouter's free tier meters requests, not
tokens — 50 a day under 10 lifetime credits — and a turn spends three or four of
them. The panel now keeps a ledger of what is left, reads the provider's
x-ratelimit-* headers, tells a reader how many questions remain instead of
reporting an outage that is not one, and treats the free ids as a pool: a 429 on
one chat model rotates to the next, while a daily exhaustion stops the rotation
because there is nothing left to rotate to. See docs/guide/free-tier.md.

embed: false — a third, declared option beside "use this embedder" and
"guess one". It says the site has no embedder and retrieval is BM25 over the
chunk text. The cost is printed rather than implied: measured once on a
1191-chunk corpus, recall@8 fell from 0.97 to 0.41 and retrieval F1 from 0.35 to
0.18. npx docpilot eval --gate-only --lexical reports the number for your
corpus.

Conversation history. A turn is no longer alone: the panel keeps the
reader's threads on their device — the archive in localStorage, shared across
tabs, and which thread a tab is showing in that tab's sessionStorage, so a new
tab starts a new conversation rather than adopting one mid-way through.

Quoting. Selecting text — in an answer, or in the host's own article — offers
to ask about exactly that passage, which is the gesture a confused reader
actually has and which previously led nowhere.

A switch for every reader-visible action. quote.*, citations.* and
composer.* join the scope.*, history.*, prompt.* and feedback.* blocks:
anything the panel offers a reader can be removed by the project that mounts it.

Hand-written TypeScript declarations for every entry point, under types/.
The types condition comes first in each subpath, because Node and TypeScript
resolve conditions in declaration order and one placed after default never
wins.

Changed

  • vitepress is now an optional peer, and its range widened to
    ^1.6.4 || ^2.0.0-alpha.16. vue is optional too. A React, Docusaurus or
    <script>-tag consumer was being told it needed VitePress for a panel that
    reaches it as dist/docpilot.web.mjs with Vue already bundled in — and the
    narrow range failed the package's own peer contract against the alpha this
    repo develops against, which is where npm install produced ERESOLVE.
  • publishConfig.access is public. A scoped package without it publishes
    restricted, and a free npm account is refused with E402 Payment Required
    after the tarball has already been uploaded.
  • prepublishOnly runs verify, then build, then scripts/check-publish.js.
    The build is repeated there deliberately: npm's order is prepublishOnly →
    prepack → prepare → pack, so a check wired straight into prepublishOnly
    would grade the previous build.

Fixed

  • bin/build-css.js and bin/build-web.js no longer skip quietly during a
    release. Both catch a missing sass/vite so a contributor's install does not
    fail over a stylesheet they can rebuild — but npm publish and npm pack set
    npm_command, and under either of those the missing tool is rethrown. The skip
    had a green exit code, which meant a machine without devDependencies could
    publish a tarball with no dist/ behind an exports map naming six files inside
    it.
  • The comment in bin/build-css.js that said npm runs prepare for a consumer
    installing from the registry. It does not — prepare runs for local-directory
    and git installs and before pack/publish. A registry consumer unpacks
    dist/ from the tarball and never reaches that code path.