Changelog¶
All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
Unreleased¶
Added¶
The chat page can keep a trace of every answered question — the opt-in
ask_your_docs.traceknob, off by default. Withtrace.enabled: true, every serve child the page starts (the first and each restart) records its tool calls under a fresh trajectory id, and each answered question becomes its own trajectory directory,<dir>/<id>/questions/<n>/. It holds that question’sserver_events.jsonl,model_turns.jsonandmodel_usage.json— the files an evaluation run writes, which the eval readers open unchanged — plus the result blobs they name and aquestion.jsonwith the question as typed, its standalone rewrite, the answer and afinalizedflag. An emptytrace.dirkeeps the traces beside the index bundles (~/.pydocs-mcp/chat-traces, or whereverPYDOCS_CACHE_DIRpoints); a path there is~-expanded. The folder holds every tool result, so it stays on your machine until you clear it. Off, the page, its serve child and everyask()call are exactly as before, andPYDOCS_TRACE__*variables exported by hand still reach no child: the knob is the only way in.A GitHub Release for every release tag. Since v0.5.0 the tag-push workflows published to PyPI but never created a GitHub Release (the ones up to v0.4.1 were made by hand), so the repository’s “Latest release” stayed at v0.4.1. Both
release.ymlandrelease-eval.ymlnow end with agithub-releasejob that attaches the built wheels and sdist and takes the notes from the matching## [<version>]section of the changelog (scripts/release_notes_from_changelog.py), falling back to GitHub’s generated notes when the section is missing.decision_capture.include_deps: truenow mines decisions from your dependencies. The setting used to be accepted and ignored. It now reads the inline decision markers (# WHY:,# DECISION:, …) in each installed dependency’s own source, and stores each decision under that dependency’s name. The other sources stay project-only. Git never runs for a dependency: from site-packages,git logwalks up into your project’s repository and would copy its commits into every dependency. ADR files, the changelog and prose docs are not read either, because a dependency’s files sit in the site-packages directory it shares with every other package. The LLM structuring pass never runs on a dependency.The decision tools return dependency decisions only when a request asks for them:
search_codebase(kind="decision", scope="deps")orpackage="<dependency>", andget_why(targets=[...])on one of that dependency’s symbols. Their cards name the package they came from, andget_overview(package="<dependency>")counts them.get_why(query), the governance dashboard,get_overview()and a defaultsearch_codebase(kind="decision")stay project-only, with the setting on or off.Ordinary searches keep them out too. Each dependency decision is also one of its dependency’s docs chunks, so a
kind="any"orkind="docs"search — the defaultsearch_codebase(query)included, andscope="deps"— returns it only whenpackage="<dependency>"names that dependency. This holds whichever config serves the index, a read-only--workspace/--dbload included: the server checks what each index holds when it loads it, so a running server whose index gains its first dependency decision (aserve --watchreindex) needs a restart to leave it out. The default search returns the same results, in the same order, as before the dependency decisions were mined (a score can differ in its last float digits), and an index that holds none changes no search at all.Behaviour change, whatever
include_depssays:search_codebase(kind="decision")now honoursscopeandpackage. It used to ignore both, soscope="deps"orpackage="<dependency>"returned your project’s decisions. They now return that slice’s decisions, or “No decisions found.” when it has none. The default call (scope="all") still searches your project’s decisions.Upgrading: the setting is off by default, so nothing re-extracts. A deployment that already sets it re-extracts each dependency once on its first pass after upgrading (none while
decision_capture.enabledis false); its project re-extracts once too, as the #263 entry under Fixed describes. Turning the setting on, or back off, re-extracts your project and each dependency once, and only your project whiledecision_capture.enabledis false:include_depsis part of the project’sdecision_capturedigest. Turning it off also deletes the dependency decisions. (#346)
An ask-your-docs run that uses its whole turn budget now says so. At the cap, LangGraph’s prebuilt agent never raised: it swapped the reply that would have called tools once more for the canned “Sorry, need more steps to process this request.” and returned it as the answer. The eval binding now recognises that reply (
turn_budget.is_budget_exhausted_reply, pinned byte for byte against the real prebuilt agent) and returns aTrajectoryflaggedbudget_exhausted, with an empty answer, the turn count at the budget and its trace sidecars in place.Trajectoryalso gainstimed_out, which only an eval timeout wrapper sets. A hand-built graph that hits LangGraph’s recursion limit still raisesTurnBudgetExceededError, which now carries the run’s trajectory id, trace directory and turn count, so the calls it made stay readable — and it now reaches the caller as that typed error: raised inside the MCP serve session, it used to come out wrapped in anExceptionGroup, which the eval’s timeout wrapper could not catch. Each record in themodel_usage.jsonsidecar now carries the reply’sfinish_reason(schema version 2; a version-1 file still reads), so a reply the endpoint cut at its output cap can be counted. The run contract gainsNOT_CONFIRMED_LABEL. (#371)The chat model’s timeout, retry count and OpenRouter upstream are keys of the
ask_your_docs.llmblock:timeout_seconds,max_retriesandprovider_routing({order, allow_fallbacks}, sent as the request body’sproviderobject;allow_fallbacksdefaults to false). Unset, they send nothing. Every model the agent builds carries them — the main model and a separate vision model — while the Test button and the image probe keep their own short bounds.provider_routingis refused, naming the key and the profile, unless the endpoint resolves to the OpenRouter profile.params.timeoutandparams.max_retriesnow point at the new keys.
A caller that kills an ask run with its own timeout still learns where the run wrote. The ask-your-docs harness binding records each run’s trajectory id and trace directory, and the graph’s messages as each step lands, into a per-run
AskRunTraceHandlethe caller makes active (harness/ask_your_docs/run_trace_handle.py, aContextVar: the run contract’srun(sample, guidance_sections)port is unchanged). When the run is killed, the binding still writes itsmodel_turns.jsonandmodel_usage.jsonfrom the messages it had, so the trace directory reads as one trajectory. A write that fails there (a trace the kill cut, a full disk) is one JSON log line, never an error in place of the cancellation. The binding now streams the graph (stream_mode="values", the same final state as before). A caller that sets no handle, such as the chat page or the CLI, sees no change. The run contract’s “no trace” directory is public asharness.core.run_contract.NO_TRACE_DIR.
Fixed¶
A chat question that runs out of steps still gets an answer. At its step limit (
ask_your_docs.max_agent_turns, still 12) the agent used to show LangGraph’s canned “Sorry, need more steps to process this request.” Now the harness drops that reply and makes one more call with tools unavailable (tool_choice="none", the tools still bound). That call writes the Finalized answer from the results the agent already saw. It cites only the lines it saw and ends with aNot confirmed:line naming what it could not verify.The chat page labels such a turn “Answered at the step limit”, keeps the answer in the history and shows the call in the activity panel. A hand-built graph that raises at its limit is finalized over the state it reached.
The eval binding returns that answer with
budget_exhaustedset andturnsstill equal to the budget.The call runs on the agent’s own model, under the block’s
timeout_seconds/max_retries, and is metered like any reply. An endpoint that rejectstool_choicegets one retry with the history as plain text and withoutparallel_tool_calls. An endpoint that ignores it has its tool calls stripped. An empty reply takes the same text fallback once. Each call logs one JSON line,turn_finalized, naming the request that answered:tools_bound(INFO), ortool_choice_rejected/empty_reply(WARNING, a fallback).The trace sidecars:
model_turns.jsonis now schema version 2 and carries"finalized": trueon such a run, andquestion.json’sfinalizedflag (the opt-in chat trace) is now real. A run answered within its budget makes no extra call and writes nothing new besides the version number.For callers:
ask()now requires afinalizer=keyword, andBuiltAgentgainsfinalizer. The frozen note the call ends on isprompts/freeze/finalize_note_v1.j2.
urllib3(transitive) lock 2.7.0 → 2.8.0. This resolves PYSEC-2026-4175, 4176 and 4177 (CVE-2026-97687, 97688 and 97689) in the locked environment.The CI audit now ignores two advisories that the lock cannot fix yet, each with its exposure written in
.github/workflows/ci.yml:sentence-transformers PYSEC-2026-4164 (CVE-2026-68770): a local model directory can run code despite
trust_remote_code=False;transformers PYSEC-2026-4174 (CVE-2026-80047):
load_custom_generate()writes remote code to the cache before the trust check, a path pydocs-mcp never calls.
pylate 1.6.0 pins sentence-transformers 5.3.0 and caps transformers at 5.3.0. Upgrade both in your own environment if you do not use
[late-interaction].Four tool answers that cost an agent a turn it did not need, and ADR 0023 ratified. Each fix comes from a live chat reproduction; the nine tools’ names, parameters, defaults,
items[]andmetafields are unchanged.grep(path=…)accepts one file as well as a directory. A file path matched nothing before, because the directory rule appended/. It now returns that file’s matches in every output mode, on the live checkout, on a branch’s committed tree and withscope="deps". A directory path gives the same bytes as before, and a path that is only a prefix of a name (src/corebesidesrc/core.py) still finds nothing.A
get_contextbatch no longer fails as a whole because one target is not indexed. Each missing target renders first, as# Context for `<target>` — not indexedfollowed by the message the single-target call raises. The resolved targets’ cards follow, sharing the whole budget among themselves, with oneitems[]row each. The call raises only when every target misses, with the same first error as before.get_contextbody slots skip a node with no indexed source, such as a builtin. Such a node used to cost nothing, always fit, and take a slot, so the symbol you asked about could be left with only its signature. The focus symbol now gets the first slot when its body fits, andmax_bodiescounts only nodes that have a body. A node with no source renders# (source unavailable)and offers no source call, which could only have failed.render: fulloutput is byte-identical.A prose search hit (markdown, notebook, text/config) no longer offers
get_symbol(depth="source"): that call returned the same text the hit had just shown. A prose hit whose text falls short of its span, such as a heading whose code example the chunker stripped, offers oneread_filewindow over the span instead, at mostoutput.pointers.read_windowlines. A hit counts as short once it misses more than 4 lines, the heading line and blank edges a whole section always drops (owner decision on #376). The window is built from the stored span alone, with no tree lookup. It renders in place of thesearch_hit_proserow’ssourcestep, so dropping that step from the row turns it off.
ADR 0023 is Accepted, with amendments (g)–(j) (the grep
pathrule, partialget_context, the prose-hit pointer rule, and an allowance for aMembers (N):line on class and module hits). The ten “pending owner ratification” markers are gone fromdocs/tool-contracts.md. Thegrepdescription gained one sentence aboutpath. That moves the descriptions artifact hash (current_artifact_hash(), as the startup log prints it) fromfdddcbcf44f4toa005008773e1, and regenerates both description goldens. Tool names and inputSchemas are unchanged. (#376)The eval binding’s seeded search now asks the task’s question, not the whole prompt. With
ask_your_docs.seed_search_with_questionon, the harness runs onesearch_codebasebefore the model’s first turn. The eval binding (harness/ask_your_docs/binding.py) sent that search the sample’srendered_prompt— the shared task scaffold wrapped around the question — so it queried the instructions along with the question, and retrieved worse than the bare question the seed exists for (0.90 at k=10 onrepoqa-qa/small_test). It now sends the sample row’s optionalquestionfield, and falls back torendered_promptfor a row without one; the model still reads the rendered prompt, the required sample keys are unchanged, and the chat page, which always seeded the bare question, is untouched. Because a seed-on arm now measures something different,sent_settings_fingerprintfoldsseed_source: questioninto its identity while the seed is on, so it never resumes rows the old seed measured; with the seed off, every fingerprint and digest is byte-identical. The seed stays off by default. (#384)Cancelling a call in the middle of a SQLite query no longer crashes the process. When a task was cancelled — an MCP request withdrawn, a
serve --watchrefresh loop shut down — while one of its queries was running on a worker thread, the thread kept running but the cancellation went straight on to roll back and close that same connection from other threads: a concurrent close and execute, which crashes Python’ssqlite3module with a segmentation fault. Every repository and unit-of-work call now hands its connection tosqlite_to_thread(retrieval/pipeline/connection.py), which lets a cancelled call wait for its worker before the connection is rolled back or closed; the cancellation still propagates, just after the thread is done. A test reads the storage source so a new repository method cannot reintroduce the pattern. (Found as a crash in PR #399’s CI.)Changing a
decision_capturesetting in YAML no longer re-embeds and throws away your project’s decisions on every index pass. Every mined decision is stored as a searchable chunk, but thedecision_capture:block reached no cache key. Because the package content hash is computed after decision mining and embedding have already run, a changed knob made every pass re-embed the decision chunks it changed, then compare a package hash that had not moved and throw the work away as a cache hit — forever, until someone ranpydocs-mcp index . --force. The settings now fold into the project’s package hash, so a change costs exactly one re-extraction and then settles. The fold only applies whendecision_capturediffers from the shipped defaults, and reaches a dependency only while its decisions are mined (decision_capture.include_deps, see Added), so a stock deployment re-extracts nothing on upgrade; if you have already changed adecision_captureknob, the first pass after upgrading re-extracts your project once and the passes after it settle. (#263)A changed member-extraction, reference-capture or structuring-model setting now reaches packages that are already indexed. These settings shape what a package stores but reached no cache key, so an indexed package kept what its first settings produced until one of its files changed or someone ran
pydocs-mcp index . --force— for a dependency, whose files rarely change, effectively forever.--no-inspect,--depthandextraction.members.*: raisingmembers_per_module_capfor a large dependency, or switching to--no-inspect, left every indexed dependency’s symbols as they were. The effective settings now fold into each dependency’s package hash. Your project’s symbols always come from its source’s AST whatever these say, so the project never re-extracts for them.reference_graph.capture: addingmentionstokindscreated no MENTIONS edge, andenabled: falseleft the old edges answeringget_references, while every pass captured the new edge set and threw it away. The setting now folds into every package’s hash; the order ofkinds, and duplicates in it, do not count. Capture reads the samereference_graph.capturethe hash folds, so a pipeline built in-process from anAppConfig(build_ingestion_pipeline) now captures what that config asks for even when nothing calledconfigure_from_app_config.The structuring model: with
decision_capture.llm_structuringon, switching thellm:provider, model, temperature or max tokens left the stored decisions with the old model’s structured fields: every pass structured them with the new model and then threw that answer away. That identity now folds into your project’s hash, so the next pass stores the new model’s answer; structuring itself still runs on every pass, as before.api_keyis left out, so rotating a key re-extracts nothing.Upgrading: each fold applies only when its settings differ from the shipped defaults, so a stock deployment re-extracts nothing. A deployment that tuned one of these settings, or that indexes dependencies with
--no-inspect, re-extracts the affected packages once on its first pass after upgrading, and the passes after it settle. (#347)
0.8.1 — 2026-09-15¶
Added¶
A search hit names where it lives, on every path.
search_codebasenow renders one block per ranked hit whether one project is loaded or several, and each block opens with## {qualified_name} — {path}:{start_line}-{end_line}followed by the hit’s pointer bundle. With one project loaded the text block was previously the retrieval pipeline’s composite — on a preset without atoken_budget_formatterstep, the top-ranked chunk’s body alone, with no path, no name and no follow-up calls. A row that carries no qualified name or no line span still heads with its chunk title. Nothing changed initems[]ormeta; the frozen nine-tool surface is untouched.ask_your_docs.seed_search_with_question(default off) — the ask-your-docs harness runs ONEsearch_codebasewith the user’s question verbatim before the model’s first turn, and shows the model that finished call so it does not repeat the identical query. The call goes through the agent’s own bound tool, so it crosses the same MCP client, the same trace recorder and the same question-scope interceptor a model-issued call would — it carries only the query, and the “Where to search” scope (pinned project, package and code filter, typedin:/on:tokens, and the fan-out and labeled merge of a multi-target pin) is applied to it exactly as to the model’s own first search. On a follow-up it searches the reformulated standalone question, which is what the model would have seen. The model-turn sidecar stamps it turn 0 and the model’s first message keeps turn 1, and the chat panel names it (“Searched your question first”) because the MCP capture stamps every callinitiator: "model"— the server cannot see who composed one. Turns of a question with attached images are never seeded. WHY off: it spends one call per question whether or not the turn needed retrieval. WHY it exists: onrepoqa-qa/small_testthe verbatim question retrieved 0.90 at k=10 against 0.73-0.77 for the queries the model wrote itself.search.output.budget_tokens(default 2000) — the token budget of the search text block, replacing a module constant. Rows the budget cuts are still returned initems[]and the cut is named in the truncation footer. The retrieval pipeline’s owntoken_budget_formatterbudget is a separate, unchanged knob, and its elisions no longer reach the response footer.ask-your-docs “Where to search”: the sidebar scope pickers are replaced by one always-visible strip above the question (“Searching in …”) with a Where to search picker (one row per indexed project, a More block for code / package, an Only these checkbox), sticky for the session and seeded from
ask_your_docs.scopein YAML; two or more targets run as separate searches with labeled results (capped byscope.max_cells);in:<project>/on:<branch>tokens inside a question search there for that question only and refuse the send on an unknown name (scope.tokens_enabled); every answer carries a footer naming the project, branch, commit, whose choice it was and the index state, closing with a hint that teaches the typed form (scope.footer_hint), plus Ask this on … too / Compare with … / Keep searching … / Show what changed buttons. Branch and slice controls stay hidden until the server advertisesbranch/changed/diff.ask-your-docs leaves two sidecars beside every recorded trajectory.
model_turns.jsonmaps each recorded tool call (itsseq) to the model message that issued it, andmodel_usage.jsonkeeps each model message’s token usage — input, output, the reasoning and cached slices, and the endpoint’s reported cost when it quotes one — counted once per message id, so a message an endpoint re-sent on a retry is billed once. The eval suite’s per-turn call-efficiency metrics and its spend rows read them; a trajectory recorded by an older product readsn/athere instead of a fabricated number. (#327, #335)
Changed¶
The nine tool descriptions are rewritten for decisions, and a lint keeps them that way. Every section now carries four labels in a fixed order —
When to use,When NOT to use(naming at least one alternative tool),Arguments(non-obvious semantics only),Examples— and drops the workflow line and the response-contract line all nine repeated. Those live once in the server instructions, which also gained the mutual-context rules: follow the call a response offers instead of searching again, never re-fetch what a result already rendered,Together:calls go out in one turn, one batch call beats a fan-out, exact strings go togrep/read_file, and read the card before asking for the source.Argumentsstates what a first call otherwise gets wrong — theglobtool’s pattern matches the full project-relative path ("*.py"finds nothing undersrc/,"**/*.py"recurses),read_file’s offset and limit are 1-indexed lines, asearch_codebaselimit above the deployment maximum is capped rather than refused and says so,get_contexttakes up to 20 targets in one call — and two stale sections are corrected, includingget_symbol, which still advertiseddepth="tree"as the full nested subtree. The nine sections come out 138 tokens cheaper while saying more per tool. A structural lint enforces the labels, the alternative-tool mention and a runaway ceiling (300 words per tool section, 400 for the server instructions), andpydocs-mcp --helpnow prints the server-instructions block as its epilog, so a terminal reader is oriented the way the server orients an agent. The description artifact hash and the registration snapshot move with the text;docs/description-authoring.mdrecords the new shape. (#302)
Removed¶
output.pointers.bundles_enabled— the staged-rollout gate that let a deployment fall back to the pre-table pointers while the renderers migrated. The pointer table is now the only source of a follow-up call, so the gate is gone: a config that still sets it is rejected at load with a message naming what replaced it (clear the table row you want silent). It never appeared in a released version. (#293, #326)
Fixed¶
A full governing-decisions page no longer leaks a raw pointer token.
governed_byis one of the five directionsget_referencesaccepts, but no pointer verb was registered for it, so the recovery pointer on a capped page resolved to nothing and printed its token verbatim. (#326)
0.8.0 — 2026-09-15¶
Added¶
embedding.query_prefix: a query-only instruction for instruction-tuned embedders. Asymmetric models such as Qwen3-Embedding expect queries in the formInstruct: {task}\nQuery:{query}and documents with no instruction. pydocs-mcp previously sent queries verbatim. Setembedding.query_prefixin YAML (double-quoted, so\nis a real newline) and the embedded query becomesquery_prefix + query.strip().openai(including OpenRouter and other OpenAI-compatible endpoints) andfastembedapply it through a query-side wrapper.sentence_transformersapplies it natively viaencode_query(prompt=…), replacing the model’s own query prompt rather than stacking on it.Documents, SIMILAR edges and late-interaction queries never see it. It is excluded from the pipeline hash, so existing indexes are reused without re-embedding. It is folded into the query-cache identity.
The default (
null) is byte-identical to before.Mutually exclusive with
query_prompt_name. Blank values,{query}templates and an unescaped literal\nare rejected at config load.PYDOCS_EMBEDDING__QUERY_PREFIXworks for directpydocs-mcp serve/CLI runs and inheriting ask-your-docs children. The sealed eval/harness serve child withholdsPYDOCS_*(exceptPYDOCS_CACHE_DIR), so set the key in that child’s--configYAML.Off by default, and unproven. A paired RepoQA sweep of the 80-needle
testsplit, run with a Qwen3-Embedding prefix against the same model without one, measured no improvement. The 50 needles that had never been used for tuning moved by a mean MRR of −0.0015 and showed no recall@5 change at all; the whole gain sat in the 30 needles that had authorised the run. A magnitude-aware sign-flip permutation test over the paired per-needle deltas gives two-sided p = 0.369, so the difference is indistinguishable from noise. The knob ships because instruction-tuned embedders document the format, not because this repository measured it helping. Leave it unset unless your own evaluation shows a gain.
Pointer bundles: every response ends with the calls that come next. One pointer table (
output.pointers.table), keyed by response kind, is now the single source of the follow-up calls a response offers — no renderer invents its own. A bundle prints on two lines:Together:for calls that are independent of each other and can be issued at once,Then:for calls that need an earlier result. A renderer never points at content the same response already rendered, and both surfaces render the same bundle (MCP call form, CLI command form).Shipped rows: a search hit offers the symbol card and, for a code hit, its callers, then the source; the symbol card offers the outline, the callers and a context pack, then the source; an outline offers the root’s source and its callers; a context skeleton block offers the body it elided; a decision offers the symbols it governs; an overview module row offers that module’s outline. The overview card’s entry points, indexed dependencies and governance row, a workspace project bullet, a package-doc recovery and an empty search result read the table too.
Reference and impact listings collapse into ONE multi-target
get_contextbatch call once the rows reachoutput.pointers.batch_threshold(3); below it each row offers its own card. A batch call names at mostoutput.pointers.batch_max(8) targets and says how many rows it did not name. The counterparts it names are checked against the index first, so a module row cannot poison the one call that covers every other row.The rows are YAML. Clearing a row turns that response kind’s follow-ups off; a row key or action word that is not in the closed vocabulary fails at
AppConfig.load()naming the offending row, its group and the expected words, rather than silently dropping a pointer. (#293, #298, #301, #326)
The
readpointer action — elided lines come back as a ready-made call. A grep content hit, aread_fileresponse cut by its own limit and a cappedget_symbol(depth="source")body now end in aread_filecall with a concrete line window instead of prose telling the agent to build one.output.pointers.read_window(default 40 lines) sizes the window, which starts 10 lines above a grep match so the lead-up is included and is clamped to the file so the advertised call is exact. The old... (file continues: …; re-read with offset=N)and[… N more lines — read <path> directly]footers are gone: each is a recovery pointer on the truncation ledger now, so the cut and the call that resumes it render together. A grep block whose own-A/-B/-Ccontext already shows the whole window offers nothing, andglobnever offers a pointer. (#297)Three knobs for parallel tool calls in the ask-your-docs harness.
ask_your_docs.llm.parallel_tool_calls(default unset) reaches the chat model only when set, so an OpenAI-compatible endpoint that rejects the field keeps working, and only the tool-bound main model ever carries it.embedding.query_concurrency(default 2) bounds how many QUERY embeddings run at once in a serve process, so several searches arriving in one message cannot oversubscribe a single embedding runtime; index-time batches are never capped and the value changes no vector, so raising it re-embeds nothing.ask_your_docs.max_agent_turns(default 12) becomes the one turn budget both the chat page and an eval campaign derive their limit from. Budgets stay counted in graph steps, never per call, so a turn that issues four calls together costs exactly what a single-call turn costs. (#292)The repo glossary (
CONTEXT.md) and thedocs/agents/configuration — the fourteen terms this work is written in (pointer, pointer bundle, recovery pointer, batch call, pointer table, symbol card, outline, level cut, self-pointing, needed and needless call, …) plus the issue-tracker, triage-label and domain-doc layout the engineering skills read. (#287)
Changed¶
get_symbol(depth="summary")returns a symbol card, not nested PageIndex JSON. The card is the signature, the first doc line and the names of the immediate children, capped bysymbol_card.child_cap(default 20), ending inand N moreplus a pointer at the outline when the cap bites (which also setsmeta.truncated). A module target’s card carries the module’s first doc line and its top-level members;items[]carries the card’s node set under an unchanged field set. WHY: both cheap depths rendered the same document, so the DEFAULT call on a large module returned the whole tree — measured at up to 5,825 tokens — and shipped it twice per response, as JSON in the text and as rows initems[]. The cheapest-looking call is now cheap by construction. (#294)get_symbol(depth="tree")returns a budgeted outline. One compact line per node — kind, qualified name, line span, indentation showing nesting, no source text — fitted tosymbol_outline.token_budget(default 2048;0turns fitting off) by level cut: the deepest whole level that fits is kept, and when even one level overflows, children are trimmed per parent with an inlineand N more. When the cut bites,items[]is pruned to exactly the node set the text shows,meta.truncatedis true, the footer readslevels L of D shown, N nodes elided, and up tosymbol_outline.recovery_pointer_count(default 3) ready-made outline calls name the largest elided subtrees, ranked by descendant count. The budget is measured on the rendered text with its footer and pointers included, so the lines announcing a cut can never push it back over the bound. Small modules render whole and change only in form. Upgrade note: a client that parsed the PageIndex JSON out of either depth’s text block readsitems[]instead — the same nodes, the same field set. (#296)ask-your-docs runs on system prompt v2. The new active template drops the nine-tool restatement — the model is already served every description as an MCP schema, so each one was read twice per turn — and adds the mutual-context rules as rules 7-12: calls on a
Together:line are independent and go out in ONE turn while aThen:line waits for the earlier result, follow the offered call instead of searching again, never re-fetch content a result rendered, oneget_contextwith many targets beats a fan-out, exact strings and error text go togrep/read_filewhile ranked questions go tosearch_codebase, and read the symbol card before asking fordepth="source". Rules 1-6 are unchanged but for rule 5, which stops sending the model todepth="source"for a signature the card now carries. v1 stays on disk, renderable by name, serving nobody. (#300)docs/tool-contracts.mdrecords the new text (ADR 0023 — Proposed, pending owner ratification). Ten marked amendments across six sites: the symbol card and the outline replace the “byte-identical since 0.5.x” clause for those two depths, the pointer grammar gains thereadaction, the follow-up-line contract gains the together/then groups and batch consolidation, the dotted-target grammar widens, andmeta.truncatedcovers both the outline’s level cut and a listing cut by the client’slimit. No tool name, parameter schema or envelope field changes anywhere in it, so no client needs a version bump; the markers drop when the owner ratifies. (#288)
Fixed¶
search_codebase(limit=N)reaches the retrieval pipeline, and a cut says so. The client’s limit never left the application layer: retrieval capped at the limit step’s own default of 8 and the router could only shrink that, so an agent asking for 30 rows got 8 — and because nothing recorded the drop the response came backtruncated=false, a partial listing that reads as an exhausted corpus. The limit now travels into retrieval as the query’s maximum result count, bounded bysearch.output.max_limit, and every cut — the pipeline’s own drop, the multi-repo merge’s cut, and the clamp itself — is registered on the response’s truncation ledger, someta.truncatedis true and the footer names what to change. A limit above the ceiling is capped rather than rejected, which is the only shape in which a response can report the ceiling (limitbelow 1 is still refused). Behavior note: an omitted limit now returnssearch.output.default_limit(10) rows instead of the pipeline preset’s 8, andkind="api"honours the request over the member-search preset’s cap of 15. (#291)Errors, the session-start pack and the package-doc recovery hand over a real call instead of raw pointer machinery. A raise unwound past the envelope’s pointer resolution, so an error message carried the literal
[[next:…]]token to MCP and to the CLI; an unwinding error’s message now goes through the same per-surface resolve/strip pass the body takes, leaving the exception class, the traceback and__cause__untouched — only the text moves, and a message with no token stays byte-identical. The session-start pack resolves its card to MCP call form on BOTH channels (the ask-your-docs agent prompt and thesession-start-contextCLI verb), because both feed an agent that issues MCP calls, and it honoursoutput.next_pointers.enabledexactly as a response body does. The package-doc truncation recovery renderedget_overview(project=<package>)— the wrong selector, which never resolved — and now names the package selector through a newoverview-packagepointer action. (#290)Every identifier a response advertises round-trips through the symbol tools. The target validator accepts the qualified names the index actually emits: module ids that keep a file suffix (
src.lib.rs,docs.adr.0001-greeting-format.md,my-pkg.mod) and heading anchors written as a fragment (README.md#install-steps,settings.toml#tool-widget), which resolve to the heading node the text-section chunker stored — atsummary,treeandsourcealike.search_codebaseadvertised such names in its structured rows while the validator refused them, so a follow-up failed on the name the server had just given it. Rejections now carry the offending value and the expected shape, from one shared helper across all four symbol-shaped inputs, and a hit’s pointer names the heading it rendered instead of widening to the whole document. Empty segments, leading.or-, spaces, path separators and the characters that would corrupt the pointer grammar stay rejected. (#295)
0.7.0 — 2026-09-12¶
Two themes this release.
Reference graph: it goes multilanguage. Per-language tree-sitter analyzers
capture CALLS / INHERITS / IMPORTS edges (plus import-alias tables) for Rust,
C, JavaScript, TypeScript/TSX, and Java behind the existing get_references
surface, attributed to the same top-level symbols the multilanguage chunker
persists. Capability declarations are honest per bundle: meta.resolution
reports syntactic only for a bundle indexed with the language’s grammar
loaded. No new tools, parameters, or envelope fields.
Chat UI: the harness-ask-your-docs page gains an activity panel that says
what each turn did — its steps, the files it touched and the model’s reasoning
when the endpoint returns it — and now holds one pydocs-mcp serve child per
browser session instead of one per tool call. The Connection dialog gains
model settings (Thinking, Temperature, Max output tokens, and Top p / Seed
under More), showing only the controls the chosen model and endpoint can
honour and pre-filling the maker’s recommended values where a model card
publishes them. Light mode is readable again.
Added¶
Per-language reference analyzers for
.rs,.c/.h,.js,.ts/.tsx, and.java(extraction/strategies/analyzers/), joinable by construction with the persisted document trees.Java end-to-end: extension ceiling, structural chunker spec (classes/interfaces/enums/records), grammar wheel, analyzer.
A loadable-grammar fingerprint salt in the package-level content hash: deployments indexed while grammars were unavailable re-extract automatically once grammars appear (no file touch needed).
A chunk-tree salt in the package-level content hash, so a chunker change reaches an existing index on its own. The hash folded paths and mtimes, the exclusion fingerprint, the grammar fingerprint and the pipeline identity — nothing about what the chunkers emit. So changing a chunker left every cached package untouched, and the only cures were touching the files or
pydocs-mcp index . --force; both fixes below shipped with exactly that instruction. The salt has three parts: a hand-bumpedCHUNK_TREE_RULE_VERSIONfor chunker rules that live in code, a digest of the tree-sitter query table (query text, grammar module, accessor and item-kind map per extension) so a query edit invalidates on its own and cannot be forgotten, and a digest of the deployment’sextraction.chunkingsettings (see the loop fix below). The query digest reads the rendered queries, not the code that renders them, so refactoring the renderer costs nobody a re-extraction — though reformatting a query does, since the digest cannot tell cosmetic edits from real ones. Bumping any part re-extracts every package once and re-embeds only the chunks whose text actually moves.target_resolution.*YAML block (source_root_strip,unique_bare_name,miss_candidates,max_candidates,candidate_similarity_cutoff), all rules on by default.harness-ask-your-docs: an activity panel above every answer. One line says what the turn did (“Done in 6.4 s · 4 steps · 3 files · reasoning shown”, or “Answered without searching”); one click lists the steps in plain words (each tool call led by its own Material icon, with its outcome and up to three file chips, the model’s reasoning when the endpoint returns it, notes such as truncated results or a stale index); the sidebar’s Show technical details toggle adds each call’s arguments (as proposed and as sent after the scope pin),metaand a result preview. Once every tool call has returned, a “Writing the answer…” line sits below the panel until the answer appears (answers are not streamed yet). A separate sidebar caption says whether this model’s reasoning is visible, learned from its answers and updated as each one lands (no extra calls; the model listing’s advertised parameters are not consulted yet). Tuned underask_your_docs.ui.activity/ask_your_docs.ui.reasoning;activity.enabled: falserestores the plain spinner andreasoning.capture: falsebuilds the stock chat model, which reads no reasoning at all. Everything shown is redacted (the bearer and the configured key variable), error text is shown as plain text, and the oneturn_activitylog record per turn holds counts only.ask()gains keyword-onlyon_event/livefor the page’s panel; with noon_event(every eval and CLI caller) the agent runs exactly as before.harness-ask-your-docs: model settings in the Connection dialog. Below the model picker sit a Thinking switch (Auto · Off · Low · Medium · High, orAuto · Off · Onfor a model that only turns thinking on and off), Temperature and Max output tokens, with Top p and Seed under More; a blank field means the model’s own default and is not sent. Only the controls the chosen model and endpoint can honour are shown — read from OpenRouter’s listing, a LiteLLM gateway’s/model_group/infoand a small model-family table — and a saved value for a hidden one is not sent either. What was sent is visible in the Test connection result line (test passed: OK · sent reasoning_effort=low, temperature=0.2, max_completion_tokens=4096) and in onechat_params_effectivelog line that names the settings sent and dropped, never their values. A 400 naming a setting the request carried hides that control for the session, says so in the chat and is never retried on its own; Restore hidden settings brings it back, and a reply that ran out of tokens while thinking says which knob to move. Configured underask_your_docs.llm.provider(autoreads the endpoint URL) andask_your_docs.llm.params(thinking,temperature,max_tokens,top_p,seed; every other key is refused by name), with the dialog’s set replacing the YAML one for the session. Behind a LiteLLM proxy,drop_paramscan still remove any of these before the upstream call, invisibly to any client — verifying the proxy is the operator’s job; the first detection of such an endpoint writes alitellm_detectedlog line saying so.harness-ask-your-docs: a model whose maker publishes recommended sampling values opens the Connection dialog with them already in the fields. A Qwen3.8 model starts on the card’s thinking-mode pair (Temperature 1.0,Top p 0.95) and swaps to the instruct pair (0.7/0.80) when Thinking is turned off. They are pre-filled, never a silent default: the dialog shows them, Test connection sends exactly them, and they take effect on Apply. A value from YAML or typed by hand wins and survives the switch; a number the endpoint already reports as its own default stays a placeholder instead of being sent twice, and Use YAML settings ends the recommendation for the session. The recommendation never reaches an eval arm: an arm that configures nothing still sends nothing, and its fingerprint is unchanged. Qwen3.8 also gains its own family row, so its real effort vocabulary is offered (Lowis reachable; theHighit has no effort for is not) — the one arm this changes is a Qwen3.8 arm pinningparams.thinking: high, which now fails before the run starts instead of asking for an effort the model has no name for.harness-ask-your-docs: the sidebar’s reasoning caption readsReasoning: off (your setting)as soon as the request carries Thinking off, instead of waiting two answers to conclude the model shares nothing.harness-ask-your-docs: an eval arm’s model settings come only from the arm itself — settings reaching the binding from a config file or the environment are refused by key name — and an arm asking for something the model cannot honour fails before the run spends anything. Each rollout records the provider profile, the exact settings sent and the mapping version beside its trajectory.
Changed¶
One-time full re-embed + re-extract on the first index after upgrading. The extension-scope fold re-embeds when the effective extension scope changes (it does under the stock scope configs; an overlay that already pins both scopes’
include_extensionsonly re-extracts), and the grammar salt, the new chunk-tree salt and the new pipeline-identity salt (pipeline:<ingestion_pipeline_hash>|tier:<embed tier>) are folded into every package hash, so the project AND every dependency package re-extract once. Expected duration scales with corpus size like a--forcereindex.The
Embedding model changed; re-embedding N package(s)sweep is gone. It compared the embedder identity stamped on each package againstembedding.model_name, two independently-derived strings that legitimately differ (a side-loaded model directory; the late-interaction preset), and it had never fired anyway because the stamp never landed. A changed embedder, pipeline YAML, extension scope or embed tier now invalidates the package cache through the identity salt instead.Project-scope discovery now indexes code files (
.js .ts .tsx .c .h .rs .java) by default; dependency scope keeps the text/config default. Narrowdiscovery.project.include_extensionsin YAML to opt out (allowlist semantics unchanged).The file watcher (
serve --watch/watch) now follows the project discovery scope by default:serve.watch.extensionsdefaults tonull, meaning every extension inextraction.discovery.project.include_extensions, so edits to indexed config and code files (.toml,.rs, …) reindex too. An explicitserve.watch.extensionslist still overrides it. The watcher also skips the directories in discovery’s fixed exclusion floor (build output such astarget/,dist/andbuild/, tool caches, vendored trees), so a compiler or bundler writing its output no longer triggers a reindex; yourserve.watch.ignore_globsstill apply on top. Upgrade note: an overlay that restates the oldextensions: [".py", ".md", ".ipynb"]list (earlier DOCUMENTATION.md samples did) counts as an explicit override and keeps watching only those three types; removeserve.watch.extensionsfrom it (or set it tonull) to follow the project scope.The
get_referencestool description now states that edges are syntactic — matched by name and import alias, not scope-resolved — and thatmeta.resolutionreports the level per target. Description text only; no parameter or envelope change.tree-sitterand the five official MIT grammar wheels are required runtime dependencies (about 6–10 MB). Wheel-less installs still index code as searchable text and honestly report reference resolution as unavailable.docs/tool-contracts.mdrecords the change (ADR 0022; amendments owner-ratified 2026-09-10): §2.2 says whenmeta.resolutionisunavailable, §4.1 adds.javato the extension ceiling and states the per-scope defaults, §5.1 adds the two-state capability rows for the tree-sitter languages and states each flag’s value set (outline/definitions:available | unavailable;references:semantic | syntactic | unavailable), and §3.5 names the tree-sitter analyzers as aget_referencesbackend.harness-ask-your-docs: the chat model’s bearer is now renewed on 403 and 407 as well as 401 (ask_your_docs.llm.renew_on_statusdefault[401, 403, 407]; the accepted set is unchanged). Internal gateways routinely answer 403 for an expired token, so a token service now works behind one without configuration. If your endpoint means 403 as “this key may not use this model”, setrenew_on_status: [401]— otherwise each such failure costs one wasted renew and retry before it surfaces.harness-ask-your-docs: a question that fails after it was sent now stays in the chat with its steps and the redacted error (“Your question was not answered”), and a turn you stop stays as “Stopped by you”, instead of vanishing on the next rerun. Refusals before any call still say “(not sent)”.harness-ask-your-docs: each browser session now holds ONEpydocs-mcp servechild for all of its questions, instead of one child per tool call (a single question used to start up to 12). The child starts with the first question and is closed when the tab disconnects, the endpoint or model changes, or Streamlit’s caches are cleared.harness-ask-your-docs: the chat agent is no longer shared across browser sessions.The
[harness-ask-your-docs]extra now requiresstreamlit>=1.59(session-scoped resource caches with a release hook); the lockfile already resolved 1.59.1.The
[harness-ask-your-docs]extra now requireslangchain-openai>=0.2.14,<2. The floor is the release that typesreasoning_effort, the field the Thinking switch sends; the cap is there because the chat model keeps the reasoning text an OpenAI-compatible endpoint already returns (OpenRouterreasoning, vLLM / DeepSeekreasoning_content) through two privateChatOpenAIhooks, and a contract test fails if a release renames them. The lockfile already resolved 1.1.9.
Deprecated¶
[multilang]is now an empty no-op alias — remove it from install scripts at leisure.
Fixed¶
Changing a chunker setting in YAML no longer re-chunks and re-embeds your whole project on every single index pass.
extraction.chunking—text_section.window_linesandjson_max_chunks,markdown.min_heading_levelandmax_heading_level,notebook.include_outputs— parameterizes the chunkers, but reached no cache key at all: the chunking stage contributes only its name to the ingestion pipeline hash, and that hash covers the ingestion pipeline YAML, notdefault_config.yamlor your overlay. Because the package content hash is computed after chunking and embedding have already run, a changed knob did not merely serve stale chunks — every pass re-chunked the project, re-embedded the changed chunks, then compared a package hash that had not moved and threw the work away as a cache hit. Forever, until someone ranpydocs-mcp index . --force. The settings now fold into the package hash, so a change costs exactly one re-extraction and then settles. If you have been running with a customizedextraction.chunkingblock, the first pass after upgrading is the last slow one.Exported JavaScript/TypeScript declarations get their own symbols.
export class B {},export function f() {},export const x = …,export interface I {},export type T = …,export enum E {}andexport default class D {}— the dominant shape in ES modules — produced no symbol node, because the chunker’s queries only matched declarations sitting directly under the file root. They now produce the samefunction/classnodes as their unexported twins (the chunk keeps theexportkeyword and any decorator written above it), soget_symbolfinds them and the CALLS / INHERITS edges inside them attach to the symbol instead of the file’s module node. Export lists (export { x }) and anonymousexport defaultexpressions are not declarations and still get no symbol. This changes the chunk trees of every.js/.ts/.tsxfile with exported declarations, so those chunks re-embed — covered by this release’s one-time re-extract on the first index after upgrading, and, for anyone who indexed with an earlier build of this release, by the new chunk-tree salt: the query change moves the salt, so the affected packages re-extract on their own with no file touch and no--force.Code chunks after a form feed or a lone carriage return are sliced on the right lines. The tree-sitter chunker built its line list with
str.splitlines(), which also breaks on\ralone,\x0b,\x0c,\x1c–\x1e,\x85,U+2028andU+2029, while tree-sitter’s rows count\nonly. After any of those characters the list ran one element ahead of the rows: every later symbol’s chunk text started a line early and lost its own last line, and the character itself came back out as a newline. Lines now follow tree-sitter’s rows, and the character stays part of its line. No chunk text changes for a file with only LF or CRLF line endings — the new splitter is proven identical tosplitlines()on every such file in this repository and against node hashes recorded before the change — so no re-embedding is triggered by this fix. A file that does contain such a character keeps its drifted chunks until it is re-extracted, which the new chunk-tree salt now triggers on its own: introducing that fold moves every package hash once, so the affected files are re-extracted with no file touch and no--force. (This fix is a chunker rule that lives in code rather than in the query table, so any FUTURE change of its kind rides on the hand-bumpedCHUNK_TREE_RULE_VERSION, not on the query digest.) The inline decision-marker miner (# DECISION:comments) now counts chunk rows the same way, so a marker after such a character gets the rightfile:linelocator. Reference-graph edges were never affected: attribution uses tree-sitter rows on both sides.get_references:meta.resolutiondescribes the index, not the serving process. A bundle built while a tree-sitter grammar could not load, served later by a process that can, reportedsyntacticfor that language over a graph that was never captured. Every index pass now stamps the grammars the bundle can vouch for (index_metadata.loadable_grammars; schema v17, additive — no re-extraction, no re-embed), andget_referencesreads the stamp of the bundle that answered, as it is on disk at request time — so a re-index by a separateindexorwatchprocess is reflected without a restart, and under multi-repo the value describes the bundle the answer came from. A complete pass stamps every grammar that loaded. A pass that leaves rows it did not re-check — a skipped scope that already holds rows (--skip-deps/--skip-project, whichserve --watchinherits), or a dependency whose re-extraction failed — never widens the stamp, since those rows may predate the grammar; the index log names the grammars withheld and why. A skipped scope that holds no rows leaves nothing unchecked, so aserve --skip-deps --watchdeployment picks a grammar install up on its next pass, andindex --forcealways stamps in full. A bundle indexed with the grammar reportssyntacticfrom any process; one indexed without it — or built before this release and not yet re-indexed — reportsunavailablefor.rs .c .h .js .ts .tsx .javatargets until re-indexed..pyand.mdare unaffected. Schema v16 → v17 is additive and in place; downgrading afterwards is not: 0.6.1 does not recognize v17, so it rebuilds a local cache from scratch on open and refuses a v17 read-only bundle.JavaScript/TypeScript: a re-export no longer claims a local binding.
export { X } from './a'forwardsXwithout introducing it into the exporting module’s scope, andexport * as ns from './a'binds nothing either — but both recorded an import alias. The reference resolver rewrites every later target’s leading segment through that table, so a same-named local was attributed to the re-exported module; and because the table is last-write-wins, a re-export appearing after a realimportof the same name overwrote that import’s binding and turned a correct edge into a wrong one. Re-exports now contribute their IMPORTS row and nothing else. TypeScript recorded these aliases in 0.6.x; re-index to clear them.JavaScript/TypeScript: only a binding clause can bind. Alias parsing read the whole import statement, so an import-attribute clause (
import './m' with { raw }) boundraw, and a specifier containing braces or a* assequence (import './a{Foo}.js') bound what looked like a clause inside the filename. Clauses are now read only from the part of the statement that precedes the module specifier, which is where ECMAScript puts them.JavaScript: a
requirespecifier ending in a quote is read literally. The module string was stripped of every leading and trailing quote rather than one delimiter per side, sorequire("./a'")emitted a row toa— a module the file never names. Read asa'it is not an identifier chain and produces no row. Vanishingly rare, but a wrong edge.TypeScript: a string inside an export clause could fabricate an import.
export { totals as "sum from 'legacy'" } from './stats'emitted an IMPORTS row tolegacy— a module the file never names — and dropped the realstatsrow entirely. ES2022 allows an arbitrary string as an export alias, and the analyzer searched the statement’s TEXT for the leftmostfrom '…', so the clause’s own string won. JavaScript and TypeScript now read the module off the statement’ssource:node, which the grammar has already resolved. Re-indexing an affected project replaces the bad rows.Reference graph: a formatter’s line break no longer changes the graph. rustfmt and prettier wrap long call chains at the dot, and a target carrying internal whitespace was dropped, so
items.iter().map(f).collect()produced a CALLS row and its wrapped twin produced none. Layout next to a./::separator is healed, in Rust, JavaScript, TypeScript/TSX and Java, for CALLS and INHERITS alike. Every edge this adds is identical to the one the same code on one line already emitted.Rust: turbofish calls are captured.
f::<T>()andx.collect::<Vec<_>>()matched no CALLS pattern at all. A turbofish whose type arguments sit inside the path (Vec::<u8>::new()) is still dropped.Rust:
pub(crate)/pub(super)/pub(self)/pub(in …)usedeclarations produce rows. Only a barepubwas stripped, so every parenthesised visibility form yielded neither an alias nor an IMPORTS row.JavaScript: side-effect imports and
export … fromre-exports are captured.import './x'carries nofromkeyword and was invisible to the text search;export … fromwas never queried in.js, though.tsqueried it. Minified forms (export{X}from'./a') work too, since the module is read from the grammar rather than matched with a whitespace-bearing pattern.Scoped npm sources (
@scope/pkg) still emit no IMPORTS row, now by explicit decision: the only mapping that would pass validation,scope.pkg, cannot be told apart from a localscope/pkgmodule or from a bundler root alias (@app/,@src/). See ADR 0022’s v1 capture limits.--watch: apyproject.tomlorrequirements*.txtunder an excluded directory no longer triggers a reindex. Manifests are exempt from the watchedextensionsso that adding a package always reindexes, and that exemption skipped the directory checks as well — leaving onlyignore_globs, whose shipped defaults cover.venv/,node_modules/and.git/but notbuild/,dist/,.tox/,htmlcov/,target/,extern/,third_party/or a virtualenv named anything else. A manifest there kept firing cached reindex cycles that could not change the index, because dependency discovery is handed the same exclusions and never reads it. Manifests now skip the extension allowlist only; the discovery floor and yourexclude_dirsapply to them as they do to source files. A project whose own root lives under such a name still reindexes on its own manifest — every check is root-relative.--watch: anexclude_dirsentry directly under the project root is now honored. Withexclude_dirs = ["gen"], an edit to<root>/gen/x.rsfired a reindex while<root>/src/gen/x.rswas correctly filtered: user exclusions were translated intofnmatchglobs, andfnmatchhas no globstar, so the derived<root>/**/gen/**could not match at the first level below the root. The watcher now applies the user’s entries with the same predicate the discovery walk uses, root-relative — superseding the derived-glob mechanism entirely. Directory names holding a glob metacharacter (gen[1]) are matched literally instead of as a character class, and anchored entries (docs/generated) keep matching that subtree only.serve.watch.ignore_globsis unchanged — those stay operator-authoredfnmatchpatterns over the absolute path.--watchon macOS: a symlinked project root no longer disables the watcher’s directory filtering. macOS resolves the watched path before reporting events, so an unresolved symlink as the root made every root-relative check fall through and let build output and excluded directories fire reindexes. The watcher resolves its root at construction; theserve --watchandwatchcommands already passed a resolved path, so their behavior is unchanged.harness-ask-your-docs: Light mode is readable again. The launcher pinned Streamlit’s own theme to dark and the sidebar’s Light mode toggle only swapped a partial CSS overlay, so chat text (about 1.1:1), inline code, code-block highlighting and sidebar dropdowns and radios kept dark colours. The launcher now registers both palettes as Streamlit themes, and you switch with Streamlit’s menu (⋮ → System / Light / Dark); the in-app toggle is gone. Every text colour in both palettes clears 4.5:1 (the light accent darkens slightly to#096B5A).harness-ask-your-docsturns Streamlit’s file watcher off by default. With the[sentence-transformers]extra installed it printed about 1,400 benign traceback lines per rerun; pass-- --server.fileWatcherType autoto turn it back on.harness-ask-your-docs: every docs-server request (the handshake and each tool call) is now bounded by a 300 s timeout, so a hung child can no longer hang a question.harness-ask-your-docs: a docs server that crashed or exited is restarted once, on the next question, with a visible notice above the answer.pydocs-mcp index --gpu(andserve --gpu) against a config withembedding.backend: openvinois now refused at config load with the same error a YAMLdevice: cudaline raises.--gpuapplied the device through an unvalidated model copy, so an OpenVINO serve config indexed fine under--gpuand re-embedded the whole corpus under the OpenVINO backend identity (backendfolds into the chunk-cache identity).Symbol hits in
src/- andpython/-layout projects reported names such assrc.mypkg.core.Thingthatget_symbolcould not resolve, and had no file or line span. Project member ids now come from the same package-root rule that chunk ids, document-tree ids and reference-graph node ids already use, so the namesearch_codebasepublishes (qualified_name, the[[next:lookup:…]]pointer, the truncation-recovery pointer) is the nameget_symbolresolves, and member hits carry their file path and line span. Dependency member ids are byte-identical to before.Upgrading an existing index: the next
index,serveorwatchpass over the project source (anything but--skip-project) re-reads the project once. On its own, this fix re-embeds nothing, re-indexes no dependency, and calls no LLM unlessdecision_capture.llm_structuringis on (files reached through a symlink may be re-embedded once); the one-time full re-embed + re-extract listed under Changed still applies to that same first pass.index --skip-projectnever runs a project pass, so ids stay stale until a pass without the flag.Bundles served with
serve --workspace/serve --dbnever index, so they keep the old names until their project is re-indexed.Benchmark-cache users can drop cached entries carrying the old names with
pydocs-eval-bench-cache evict.The index format is unchanged (no
SCHEMA_VERSIONbump), so older and newer installs can share an index. Alternating between them re-reads the project on each switch; neither wipes it.Known consequence: package rooting can map two files to one member module id (
examples/a/app/main.pyandexamples/b/app/main.pyboth becomeapp.main). Chunks and document trees already collide the same way; members now match them rather than holding unique ids nothing can resolve, and a colliding member hit’s span comes from whichever file’s tree was stored last.The
[late-interaction]extra loads on macOS 14 again: it now capsnumkong<7.5. numkong >= 7.5 ships macOS-arm64 wheels built against the macOS 26 SDK that import a libSystem symbol (___sme_memset) only macOS 15+ exports, soimport numkongdied at dlopen and usearch — fast-plaid’s index — then failed on_nk_capabilities. The two late-interaction integration tests also skip, with a reason, when the native wheels cannot load instead of erroring at collection.get_referenceson a module target failed instead of answering. Every module-only target was routed to the module outline, which then failedReferencesEnvelopevalidation on MCP (get_references failed: 27 validation errors …) while the CLI printed page-index JSON and exited 0. A module target now answers its import graph:callersreturns the modules importing it or its members,calleesits own imports,impactthe transitive callers of it and its members with its own internals excluded, andgoverned_bythe decisions recorded against it.inheritson a module raises a clearInvalidArgumentErrornaming the target and its kind. Member fan-out is bounded by the newreference_graph.impact.max_module_seedsYAML key (default 32); hitting the cap records a truncation entry rather than silently searching less.get_symbol(target=<module>, depth="tree")is byte-identical.get_symbol(depth="source")returned only a fragment for classes and modules. A class returned its header chunk (class line plus docstring) and a module returned its docstring, both withtruncated=false, even though the reported span covered the whole node. The whole span is now rebuilt from indexed node text: each run of covered lines is its own verbatim fence, and each run the index does not store is an explicit[lines a-b not in the index]marker outside the fences, closed by one note naming the gap-line count and the file to read. Spans are unchanged,truncatedstill means only “cut by a limit”, and nothing is read from disk. Functions, methods, markdown headings, text sections and notebook cells are byte-identical to before.grep(glob="*.py")matched only root-level files. grep’s glob was root-anchored POSIX glob, so the pattern both the tool description and the contract use as their example returned “No matches.” on any project with subdirectories. grep’s glob now followsrg --globanchoring: a pattern without/matches file names at any depth, one with/matches the root-relative path, a leading/(or./) anchors at the root, and a trailing/matches everything under that directory. Theglobtool’s own pattern semantics are unchanged.get_overviewmerged bullets onto one line. Suppressing a follow-up pointer removed the line break it sat in front of, so with unresolvable targets — or withoutput.next_pointers.enabled=false— whole blocks ran together and the blank line before each heading disappeared. Pointer elision is now line-aware: an inline token is removed but its line break is kept, and a token on its own line still takes the whole line. A module entry with no docstring no longer renders a dangling em dash.get_overviewemitted pointers that could not be followed. The module map pointed atget_context, which rejects module targets; dependency pointers were emitted for packages that are not indexed; and script pointers named the script rather than its callable. The module map now points atget_symbol(depth="tree"), a dependency pointer appears only for an indexed package, and a script points at its dotted callable only when that callable is a real node in the index — otherwise no pointer is emitted at all.The
inheritserror text leaked CLI vocabulary into MCP. It quoted the internalshow='inherits' … CLASS nodeswording on both surfaces; it now names the direction, the target and the target’s kind, and lists the directions that do accept it.lookup --helpadvertised__project__.<module>.<symbol>, a form that never resolves. Project code is addressed by its bare dotted name, and the help text now says so.Tool descriptions. The
get_contextexample named a module target, which that tool rejects; it now names a class.grepdocuments itsrg --globanchoring, andget_referencesdocuments what a module target answers. Because these edits change the descriptions artifact, any local seed-anchored or campaign lockfile built from the previous descriptions hash is stale.get_symbol/get_context/get_referencesnow resolve targets prefixed with the source root, and unique bare project targets.src.pkg.mod.Clsresolves when the file lives undersrc/, and a bareClsresolves when exactly one project code symbol has that name. Misses that remain list the closest indexed names in the error text. Targets that already resolved are unchanged, and an exact match in any loaded project still wins.
CI¶
Fresh-install coverage. CI installed only from
uv.lock, so a break in the dependency set a user actually resolves (mcp 2.x crashingpydocs-mcp serve) could reach PyPI unseen. A nightlyfresh-installworkflow now installs from the checkout with no lock or constraints on Python 3.11 and 3.13, runsscripts/fresh_install_smoke.py --with-agent(index, real MCP stdio handshake, one search, ask-your-docs agent import) and pip-audits the resolved environment. The release workflow runs the same smoke test against the built Linux x86_64 wheel, and publishing now waits for it.
0.6.1 — 2026-09-10¶
Eval suite. The eval suite’s pydocs-mcp floor raise to 0.6.0
([retrieval] and [all]) ships with pydocs-mcp-eval 0.2.0 and is recorded
in benchmarks/CHANGELOG.md.
Fixed¶
The chunk-level embed skip never fired, so every index pass re-embedded every eligible chunk of every package — cache hits included. The skip-set loader keyed its query on
state.package, which the shipped ingestion presets only fill inpackage_build, their last stage. It now keys on the package name known from discovery. An unchanged second pass makes zero embedder calls.packages.embedding_modelwas NULL in every database ever produced. The embed stages wrote it tostate.package(stillNoneat that point) andpackage_buildthen built a fresh row without it. The identity now travels the pipeline state and lands on the package. multirepo’s serve-time embedder guard can finally read it back for bundles with noindex_metadatarow.Any pipeline change re-embedded the whole corpus and discarded the result, on every pass, forever. An ingestion-YAML edit, an extension-scope change, an embedder swap, or
--full-dep/dependency_policymoved every chunk hash but not the package hash, so the pass re-embedded everything and then reported a cache hit without persisting. The package hash now folds the same pipeline identity and embed tier the chunk hashes fold, so such a change re-indexes once and settles.--full-dephad been a silent no-op on an incremental index.A duplicated section could be persisted without a vector. The embed skip tested hash membership while the chunk diff is a multiset (#69), so a second copy of an already-indexed chunk skipped the embedder and was then inserted as a new, vectorless row. The skip is now a per-hash budget of persisted copies.
The late-interaction ingestion preset now honours
embedding.dependency_policyand--full-dep.EmbedChunksMultiVectorStagewas cloned before the embed policy existed and never applied it, so underingestion_late_interaction.yamlevery dependency chunk received a ColBERT multi-vector —dependency_policy: noneincluded. It now uses the same per-package tier as the dense stage (doc_pagesby default). Existing late-interaction indexes keep the multi-vectors already written for now-ineligible chunks untilindex --force; the tier was already part of their chunk hashes, so nothing is re-embedded or dropped by the upgrade re-extract.Side-loading a local model directory (
embedding.model_name: ~/models/x) no longer rewrites the embedder’s reportedmodel_nameto the expanded path; the loader gets the expanded path, the identity stays as configured. Applies to thesentence_transformersandpylateproviders.harness-ask-your-docs: every question failed withMcpError: Connection closedwhen the index uses an API-key embedder. The UI started itspydocs-mcp servechild with only the MCP SDK’s default variables (HOME LOGNAME PATH SHELL TERM USERon POSIX), so withembedding.provider: openaithe child could not find its key (OPENAI_API_KEY, or the variable named byembedding.api_key_env) and exited before the handshake. The child now inherits the environment of the shell you launched from, so it behaves likepydocs-mcp serverun in that shell. That includesTMPDIR(the default FastEmbed model cache on macOS),PYDOCS_CONFIG_PATHand otherPYDOCS_*overlays,PYDOCS_CACHE_DIR, and proxy and CA-bundle variables. Three kinds of variable are withheld:PYDOCS_TRACE*in any spelling (trace identity is set per run by the evaluation harness only), exported shell functions, and any value containing${…}(the MCP adapter would rewrite it, or log it in full if unresolved). A names-only JSON warning lists what was withheld.harness-ask-your-docs --base-url/--modelno longer writeOPENAI_BASE_URL/LLM_MODEL. They reach the app under privateHARNESS_ASK_YOUR_DOCS_*names and take the CLI tier of the documented precedence (YAML <OPENAI_BASE_URL/LLM_MODEL<--base-url/--model< Connection dialog), so the chat endpoint and model you get are unchanged. Otherwise the now-inherited environment would have pointed anembedding.provider: openaiembedder withembedding.base_url: nullat the chat endpoint, sending it the embedding key. If you exportOPENAI_BASE_URLfor embeddings, it still works, but prefer settingembedding.base_urlin the YAML. AnOPENAI_BASE_URLexported for the chat model reaches the server too, so give the chat endpoint with--base-urlorask_your_docs.llm.base_urlinstead.Evaluation binding (
harness.ask_your_docs.binding): the serve child now also receives the embedder key,TMPDIR, proxies and CA bundles, but its configuration tier stays sealed as before. InheritedPYDOCS_*variables (exceptPYDOCS_CACHE_DIR) andOPENAI_BASE_URL/LLM_MODELare withheld, so a shell export cannot change what an arm measures; set endpoints such asembedding.base_urlin the arm’s YAML. A names-only warning lists any withheld variable once per process.scripts/validate_traced_run.pyimportstrace_subprocess_envfrompydocs_mcp.observability.trace_envagain (it referenced a removed private name).Package build no longer warns on missing metadata — optional package metadata (such as
Home-page) is read with.get()instead of indexing, so indexing a dependency without that field no longer emits the implicit-NoneDeprecationWarningfromimportlib.metadata.docs/tool-contracts.md§4.1 lists the full excluded-directory floor — 26 names, adding.yarn,bower_components,extern,third_partyandcrosscommitvuln, which 0.6.0’s code already excluded (owner-ratified amendment, 2026-09-10). A conformance test now fails whenever the floor in code and the contract diverge.
0.6.0 — 2026-09-10¶
Headline: the MCP surface grows from six to nine task-shaped tools — three
filesystem tools (grep, glob, read_file) join the six indexed tools — and
the whole surface is frozen by contract: docs/tool-contracts.md is the
normative inventory (rationale in docs/adr/0001–0004). No renames, no
removals — existing six-tool clients that read the text content block keep
working unmodified; clients that parsed structuredContent.result must
switch to structuredContent.text (see Changed).
Upgrade notes (from 0.5.1). The first 0.6.0 open migrates each index in
place, schema v14 → v16 (additive: chunk source spans in v15, the branch
tables in v16); nothing is dropped. Downgrading afterwards is not in place:
0.5.1 does not recognize v16, so it drops the tables and re-indexes from
scratch. The upgrade also re-embeds, because the extension scope is now part
of the chunk-cache identity. The first index pass re-extracts and re-embeds
the whole project: the migration clears the project’s package hash so the
branch tables get filled (git checkout or not), and its discovered file set
grows under the widened default scope anyway. It also re-extracts most
dependencies: any whose installed file list includes a newly default
text/config file, which the common *.dist-info/entry_points.txt,
top_level.txt and LICENSE.txt make most of them. Their doc pages are
re-embedded under the default embedding.dependency_policy: doc_pages.
Dependencies whose discovered files are unchanged keep their cache hit and
vectors until their files next change. pydocs-mcp index . --force rebuilds
everything in one pass; either way, budget embedding time (or API spend with
the openai provider). Also check:
Late-interaction deployments (the shipped
ingestion_late_interaction.yamlpreset) gain decision mining and dependency doc pages only in packages that re-extract; runpydocs-mcp index . --forceonce to populate them everywhere.If your environment already exports
PYDOCS_CACHE_DIR(0.5.1 accepted and ignored it), 0.6.0 reads and writes bundles there instead of~/.pydocs-mcpand indexes from scratch: move the existing files or unset the variable.If your config already sets
serve.watch.enabled: true(orPYDOCS_SERVE__WATCH__ENABLED=true), which 0.5.1 accepted and ignored, plainpydocs-mcp servenow starts the file watcher; set it tofalseto keep the 0.5.1 behavior.A config with any
pipelines:key other thanchunkormember(commonly a deadpipelines.ingestionlist, which 0.5.1 ignored) now fails at load; select an ingestion pipeline withextraction.ingestion.pipeline_pathinstead.Multi-repo workspaces: cross-repo linking is on by default, so the first 0.6.0
serveof two or more bundles runs a full link pass at startup and writespydocs-links.sqlite3into the workspace directory. With--dbbundles or an unwritable workspace it writes<cache root>/links/<digest>.sqlite3instead, and if nothing is writable it keeps the links in memory.get_referencesthen includes sibling-repo rows. Setreference_graph.cross_repo.enabled: falseto opt out; the workspaceget_overviewcard then showscross-repo links: disabled.Install scripts using
[ask-your-docs]or theask-your-docscommand must switch to theharness-names, and clients that parsedstructuredContent.resultmust readstructuredContent.text(see Changed).Ask-your-docs users on the default model:
gpt-4o-mini, still the default without anask_your_docs.llmblock, is now detected as vision-capable. The defaultarchitecture: autotherefore builds theinlineagent, which adds an image-analysis section to the system prompt and an extrareinspect_imagestool even when no image is attached. Pinningarchitecture: text_reactdoes not remove the tool. For a text-only agent, setask_your_docs.multimodal.detection.override: false; image attachments are then refused. Environments that install[harness-ask-your-docs]must allowstreamlit>=1.43.Eval suite users (
pydocs-mcp-eval0.2.0) need pydocs-mcp 0.6.0: its[retrieval],[ask]and[all]extras require it. Upgrade both together, e.g.pip install -U pydocs-mcp "pydocs-mcp-eval[retrieval]". The eval suite’s own upgrade notes are inbenchmarks/CHANGELOG.md.
Eval suite. Changes to the separately published eval suite
(pydocs-mcp-eval 0.2.0, under benchmarks/) — its new datasets, optimizers,
scoring, console commands, module moves and upgrade notes — are recorded in
benchmarks/CHANGELOG.md, which also rebuilds its
0.1.0 and 0.1.1 releases. The entries below mention the eval suite only where
the product itself changed.
Security¶
mcpdependency floor raised>=1.0→>=1.28.1(lock updated 1.27.1 → 1.28.1) — resolves CVE-2026-52869, CVE-2026-52870, and CVE-2026-59950 reported against mcp 1.27.1.cryptography(pulled in throughmcp→pyjwt[crypto]) constrained>=48.0.1→>=50.0.0in[tool.uv] constraint-dependencies(lock 49.0.0 → 50.0.1), which resolves PYSEC-2026-3552 in the locked and audited environment. The constraint is not part of the published wheel metadata, sopip install pydocs-mcpdoes not enforce it: upgradecryptographyto 50.0.0 or later in your own environment.
Added¶
Branch dimension, foundation (schema v16) — every project index now stamps the checked-out branch (
branches), its file manifest with git blob ids (branch_files), chunk membership with per-branch spans (branch_chunks), and a blob-keyed extraction cache (file_extractions); project chunks with no branch references are garbage-collected with their vectors. Every tool response carries an additivemeta.branchfield (nullfor non-git projects and the other cases enumerated indocs/tool-contracts.md§2.4). New verb:pydocs-mcp brancheslists the indexed branches. Git is optional: without agitbinary or repository, behavior is unchanged except for onegit_unavailablelog. The v16 migration is additive and in place, and the first index pass after upgrading re-extracts the project package once to populate the new tables. Branch stamping by itself changes no chunk content hash, but an upgrade from 0.5.1 re-embeds the project anyway, because the extension scope is now part of the chunk-cache identity (see Changed and Upgrade notes). Text output of every tool is byte-identical. Design:docs/superpowers/specs/2026-09-03-multi-branch-indexing-design.md(P0).Multilanguage indexing: wider extension scope (ADR 0021) — the extension ceiling (
ALLOWED_EXTENSIONS) grows from.py .md .ipynbto also admit the text/config set.toml .yaml .yml .cfg .ini .rst .txt .jsonand the code set.js .ts .tsx .c .h .rs. The defaultextraction.discovery.{project,dependency}.include_extensionsnow add the text/config set, so a project’spyproject.toml, YAML configs and.rst/.txtdocs index out of the box, as do dependencies’ installed text/config files (including*.dist-infofiles such asentry_points.txt). Those dependency sections are BM25-only under the defaultembedding.dependency_policy: doc_pages; they get no vectors. Code extensions stay opt-in: name them in YAMLinclude_extensions. Extensions outside the ceiling are still rejected at config load.grepandglobwalk the same widened scope. The widening re-embeds on upgrade (see the extension-scope entry under Changed). Rationale:docs/adr/0021-multilanguage-indexing.md; the discovery-scope section ofdocs/tool-contracts.mdis amended to match.TextSectionChunkerfor text/config files — one language-agnostic chunker for.rst .txt .toml .yaml .yml .cfg .ini .json:.rst/.txtsplit on reStructuredText section titles (fixed-line windows when a file has none),.toml/.cfg/.inion[table]/[[array]]header lines,.yaml/.ymlon column-0 top-level keys, and.jsonon top-level keys. Each section becomes a searchable chunk with 1-indexed line spans and the newtext_sectionnode kind and chunk origin. A.jsonfile with more thanextraction.chunking.text_section.json_max_chunks(default 50) top-level keys, or an unkeyed/minified blob over 2,000 characters, collapses to one module node holding a truncated 2,000-character preview.extraction.chunking.text_section.window_lines(default 80) sizes the fallback windows, the[multilang]chunker’s included. Empty or malformed files degrade to a single module node instead of failing the build.[multilang]extra: structural chunking for JS/TS/C/Rust —pip install 'pydocs-mcp[multilang]'addstree-sitter>=0.25,<0.26(capped because 0.26.0 has a use-after-free inQueryCursor.matches()) plus the individually MIT-licensed grammar wheelstree-sitter-javascript,-typescript,-cand-rust. The newMultilangChunker, registered for.js .ts .tsx .c .h .rs, extracts top-level symbols (functions, classes, structs, enums, traits, impls, interfaces and similar) with 1-indexed spans; it runs only on code extensions you opt into viainclude_extensions. Without the extra those files still index as fixed-line text windows, and the build logs one structuredmultilang_fallbackJSON warning per extension carrying the install hint; a file that fails to parse or has no top-level symbols gets the same windows.tree_sitteris imported lazily, soimport pydocs_mcpnever loads it.Per-project directory exclusions — YAML
extraction.discovery.project.exclude_dirsandextraction.discovery.dependency.exclude_dirs(both default[]), plus anexclude_dirslist in the[tool.pydocs-mcp]table of the indexed project’s ownpyproject.toml(project scope only; a dependency’spyproject.tomlis never read). Bare names match a directory at any depth; entries containing/anchor at the project root, or for dependencies below the first path component of each installed file (sodocs/examplesexcludes<pkg>/docs/examples/in every dependency). No globs (*,?,[are literal); matching is case-sensitive and names directories only. Entries only add to the built-in floor, never remove from it. A non-string, absolute or empty entry, or one with an empty,.or..segment, is an error: in YAML it fails config load; inpyproject.tomlit (like a non-list value) raisesProjectExcludeConfigErrorat index time, and under--watchthat error is logged and the reindex cycle skipped. Apyproject.tomlthat cannot be read or parsed only logs a warning and applies no project entries. In the project walk every indexing reader honors them (chunks, symbols, mined decisions forget_why, dependency manifests), as dogrep/globand the file watcher’s ignore globs; under--watch,pyproject.tomledits apply on the next reindex without a restart (YAML changes need one).dependency.exclude_dirsprunes dependency files (their chunks and trees, and dependency-scopegrep) but not dependency symbols, since dependency member extraction (live import, or an AST pass over every shipped.pyfile with--no-inspect) ignores exclusions. With no user entries every package content hash is unchanged; adding or changing entries re-extracts the affected packages once, which fordependency.exclude_dirsmeans every dependency.Cross-repo reference linking in multi-repo workspaces — when
serve --workspace <dir>or repeated--dbloads two or more bundles, a link pass resolves each bundle’s still-unresolved references against its siblings’ symbols, soget_referencescrosses repository boundaries:callers/inherits/governed_byadd rows from sibling repos (marked(project: <name>)and counted as, N cross-repoin the summary line),calleesreplaces an unresolved callee with its resolved sibling target, andimpactwalks into sibling repos. References the local index already resolved always win; cross rows only add what one bundle could not see. Re-exports resolve through the sibling’s import graph when exactly one candidate matches. Cross rows initems[]carrynullpath/start_line/end_line. Links live in a disposable sidecar:pydocs-links.sqlite3in the workspace directory; with--dbbundles, or when the workspace directory is not writable,<cache root>/links/<digest>.sqlite3; if neither is writable, in memory, recomputed at every serve. Bundles are never modified and their schema is unchanged. On by default and tuned underreference_graph.cross_repo:enabled: true(inert with one bundle);link_on_serve: true(refresh stale links at serve startup;falseserves detection-only, dropping edges that touch a stale or removed bundle and warning to runpydocs-mcp link);match_scope: project_only(all_packagesalso matches dependency symbols);kinds: [calls, imports, inherits, governs](mentions/similaropt in);max_projects_per_walk: 8(sibling repos oneimpactwalk may enter, 1-32);workspace_scores: true(workspace ranking forimpact: in-degree always, PageRank with the[graph]extra);alias_resolution: imports_graph(offdisables re-export resolution);similar.{top_k: 5, min_score: 0.6}; andoverlay_dir(puts the sidecar there instead of any default location). Opt-insimilaredges re-embed the source repo’s chunks with the serving embedder and search the sibling’s.tq; a pair is skipped unless both bundles’ stamped embedder identity matches the serving embedder. New operator verbpydocs-mcp link --workspace DIR(or--db A --db B) runs a full pass and prints per-project counts (exit 2 if no overlay location is writable; exit 0 with fewer than two bundles);--checkwrites nothing and exits 1 when links are stale or missing, or a linked bundle has left the workspace. The no-selector multi-repoget_overviewcard gains across-repo links:line (fresh,stale(<projects>),stale(unlinked)ordisabled). One-shot CLI queries read links persisted by an earlierserveorlinkbut never run a pass. A workspace.dbwhose name starts withpydocs-links.is never loaded as a bundle. No new MCP tool or parameter; see Upgrade notes. Design:docs/superpowers/specs/2026-07-11-multirepo-cross-linking-spec.md.Opt-in server-side tool-call tracing — a new
trace:YAML block (trace.enabled, defaultfalse;trace.dir) makespydocs-mcp serverecord every MCP tool call, successful or raising, to<trace.dir>/<trajectory_id>/server_events.jsonl: a header line (trajectory id, traceschema_version1, the served description artifact’s hash, installedpydocs-mcpandmcpversions, timestamp), then one sorted-key JSON line per call with its raw arguments, latency and a monotonic per-processseq. A successful call adds its per-item identifiers,hit_count,meta.truncated,meta.suggestionand a 2048-byte preview, and stores its full serialized result in a shared content-addressed<trace.dir>/blobs/<sha256>; a raising call records the error’s type and message and its direct cause’s type. Fired routing suggestions get their own lines, keyed to the call’sseq. The per-run id comes fromPYDOCS_TRACE__TRAJECTORY_ID; it is deliberately not documented as a YAML key, since a fixed id would make every run collide.PYDOCS_TRACE__ENABLED/PYDOCS_TRACE__DIRoverride the YAML keys. Tracing enabled without an id or a directory fails startup withTraceStartupError, and an id whose trace file already has content fails withTrajectoryIdReuseError(both exported frompydocs_mcp.observability). Unknowntrace:keys are rejected. No tool schema changes; with tracing off the server is a plainFastMCPas before. Traces hold raw arguments and full results in plain files, so keeptrace.dirprivate. No new dependency. Rationale:docs/adr/0009–0010.The external CLI harness ships in the product wheel — a second in-tree harness, and the first composed one.
pydocs_mcp/harness/external/owns a run’s corpus, trace, guidance policy and trajectory, and delegates only “what is the command line” and “what does the transcript say” to a CLI coding agent ENGINE underpydocs_mcp/harness/cli_agents/(a CLI agent is an engine, not a harness: several engines run under one harness, sharing its guidance sections, while the engine name is recorded separately). It satisfies the same harness run contract as the in-process agent — one sample in, one trajectory out, with both observation points joined — and needs no optional extra: the engine is driven with stdlibsubprocess, so a plainpip install pydocs-mcpcan run it. Adding another CLI agent is one adapter subclass plus one registry line, checked by a shared adapter conformance battery. Its guidance partition/fold lives in the newharness/core/guidance_fold.py, parameterized on the harness name (the in-process ask-your-docs harness keeps its own), and the three trace-correlation environment variables have exactly one spelling (observability/trace_env.py), shared by both harnesses.The harness run contract (
pydocs_mcp.harness.core.run_contract) — the port every agent harness implements:HarnessRunner(one sample + guidance sections in, oneTrajectoryout), with tool calls derived from the server-side trace (observed_by: server|clientprovenance) and typed failure semantics (UndeliverableGuidanceError,TurnBudgetExceededError). Companions: a product-side trace reader (pydocs_mcp.observability.trace_reader), the ask-your-docs harness binding (pydocs_mcp.harness.ask_your_docs.binding— factorymake_harness_runner, declared guidance delivery map, one serve session held open for a whole traced run), the publicparse_skill_artifactentrypoint on the skill-artifact loader, and six harness-privatebuild_agentkeywords (tool_names,skill_override,task_name,scope_pin,subprocess_env,mcp_tools) whose defaults together reproduce the previous build byte-for-byte.tool_namescan only narrow the tools the server advertises: an unknown name or an empty tuple raisesToolBindingErrorinstead of binding a smaller surface.mcp_toolshands over tools already bound to a caller-owned session, so no serve subprocess is spawned. The serve subprocess’s stdio connection (argv plussubprocess_env) is built by one helper,serve_connection(), whichbuild_agentand the binding both use.The packaged search-guidance skill artifact — one delimited document (
pydocs_mcp.harness.core.skills) in three tiers, every section required: the sharedBACKBONEsearch policy, one harness-invariantTASK_HEAD: <task_name>section per task name (every harness running a task reads and updates the same one), and oneHARNESS_TASK_HEAD: <harness>.<task_name>section per harness/task pair for per-harness convention. The v1 task names arerepo_qa(repository-comprehension QA),vuln(security needle-search) andbug_loc(file-level bug localization: name the file(s) a described bug requires changing); the section count is derived from that enumeration times the two harness names — ten today — so widening it is a single, reviewed edit. A task name names a FRAMING, not a corpus — several corpora share one task head, which is the tier’s whole point, and evaluation dataset names and task-id prefixes are a separate vocabulary this one never touches. Loaded and firewalled bypydocs_mcp.harness.core.skill_artifact_loader(strict parse against the enumerated section set, per-section token caps); the shipped seed is hand-written, and an explicitly named override that is missing or invalid is a hard error, never a silent fallback.Three filesystem tools:
grep,glob,read_file— exact-string / regex search (Pythonreflavor;content/files_with_matches/countoutput modes; the flag parameters are the literal names-i,-n,-A,-B,-Con the MCP wire), file-name matching (**recursion, results ordered by modification time, newest first), and line-numbered file reads (cat -nstyle, so line references round-trip withgrepoutput).grepandglobwalk the indexer’s discovery scope — the same excluded-dirs floor, extension allowlist, and size cap the semantic index sees, not.gitignore;read_filereads any file inside the project root or an indexed dependency’s root, so any path another tool returns is readable. Every response is freshness-stamped against the index snapshot. Additive: MCP clients discover the tools at connect time. Each is mirrored by an identically-named CLI subcommand. Output caps are YAML-wired underfiles.*:grep_head_limitandglob_head_limit(default100) andread_limit(default2000lines) apply when the client omitshead_limit/limit, andmax_head_limit(default10000) is the ceiling. A largergrep/globhead_limitfails input validation, while a largerread_filelimitis silently clamped to the ceiling. An unknownfiles.*key, or a default abovemax_head_limit, fails at config load. The ask-your-docs agent’s system prompt now describes the three tools:grepfor literal identifiers, error strings and config keys,search_codebasefor ranked or conceptual questions.Frozen tool contract —
docs/tool-contracts.mdpins the nine tool names, every parameter schema, the response envelope (structureditems[]field sets +metafields), and the frozen vocabularies; changing any of it is a design-doc-level versioning event. Tool descriptions stay deliberately mutable (they are the substrate the description optimizer rewrites).Externalized description source — every LLM-visible description string (nine tool descriptions, server instructions, session-start preamble) now lives in one packaged delimited document,
defaults/descriptions.md, validated at load (closed section set, required markers, token budgets) and swappable per deployment:pydocs-mcp serve . --descriptions PATH>PYDOCS_SERVE__DESCRIPTIONS_PATHenv var > YAMLserve.descriptions_path> packaged default. An explicitly named source that is missing or invalid is a hard startup error — never a silent fallback — and so is a set-but-emptyPYDOCS_SERVE__DESCRIPTIONS_PATH(EmptyDescriptionsEnvError), which would otherwise mask the YAML key. Every run logs the fingerprint of the surface it serves (descriptions artifact <hash12> source=…). CLI--helprenders the bundle named byPYDOCS_SERVE__DESCRIPTIONS_PATH; a YAMLserve.descriptions_pathoverride or theserve-only--descriptionsflag changes what the server serves but not--help, which is built before either is read. Default behavior is byte-identical to the previous hardcoded text; authoring guide indocs/description-authoring.md(rationale:docs/adr/0005–0006).Deterministic routing suggestions — dead-end responses now carry a fixed
[suggestion: …]line with the escape hatch: zero-hitgrepredirects conceptual queries tosearch_codebase, truncatedgrepshows how to narrow (path=/glob=/head_limit=), and the existing zero-hitsearch_codebase/get_whyoverview pointer gains its own switch. One YAML flag per rule (output.suggestions.{grep_zero_hit,grep_truncated, search_zero_hit}, all default on); the fired suggestion also travels as the additive envelope fieldmeta.suggestionon those three tools (docs/tool-contracts.md§2.3; rationale:docs/adr/0007). With a flag off, that rule’s output is byte-identical to before.Session-start context pack — an opt-in, deterministic context block for agent-session start: a fixed harness-injected marker line, the session-start preamble from the description source, the same overview card
get_overviewserves, and an installed-package version inventory. Off by default (serve.session_start_context.enabled); budget-capped in real tokens (serve.session_start_context.budget_tokens, default2000; card trimmed before inventory, truncation always noted). When enabled, the ask-your-docs agent injects it into its prompt; the newpydocs-mcp session-start-contextsubcommand prints the pack for external harnesses regardless of the flag (rationale:docs/adr/0008).Chunk source spans persisted (schema v15) — chunks now carry
source_path/start_line/end_linethrough SQLite, so structured items cite exact file spans. Additive in-place migration; rows indexed before v15 carry empty spans until the next reindex re-extracts their package. Within the 0.6.x line that reindex backfills spans onto unchanged (hash-matched) rows without re-embedding them; an index upgraded from 0.5.1 instead gets fresh rows, with spans, for every re-extracted package, because the chunk-cache identity moved (see Upgrade notes).OpenAI-compatible embedding endpoints from YAML — three new
embedding.*keys, read only by theopenaiprovider.base_urlpoints the client at any OpenAI-shaped/v1/embeddingsservice such as OpenRouter (nullkeeps the client default:OPENAI_BASE_URLif set, else api.openai.com).api_key_envnames the environment variable that holds the key (null=OPENAI_API_KEY), so a third-party key never lives in YAML; the missing-key error names that variable.send_dimensions(defaulttrue) keeps sending OpenAI’s Matryoshkadimensionsparameter;falseomits it, for endpoints that reject it and return the model’s native size. Three remote models join the known-dimension table, so a config naming one must setembedding.dimto its native size or fail at load:mistralai/codestral-embed-2505(1536),qwen/qwen3-embedding-4b(2560),qwen/qwen3-embedding-8b(4096). Onlysend_dimensions: falsechanges the embedder hash;base_url/api_key_envare never hashed, so moving a model id to another endpoint keeps its vectors. Flippingsend_dimensionsdoes not invalidate the package-level cache, so only re-extracted packages re-embed; runpydocs-mcp index . --forceto re-embed everything.Query-embedding cache —
serveand the CLI query commands now wrap query-time embedding in an in-process LRU cache. A repeated query skips the model, and concurrent identical queries (a multi-repo fan-out, or a hybrid pipeline’s fetcher and scorer embedding the same text) share one computation. It is on by default and tuned underembedding.query_cache(enabled: true,max_entries: 512,ttl_seconds: 0, meaning no age-based expiry; envPYDOCS_EMBEDDING__QUERY_CACHE__*). The[late-interaction]query encoder has its ownlate_interaction.query_cache(defaultmax_entries: 128). Cache keys are the embedder identity,embedding.query_prompt_nameincluded, plus the whitespace-stripped query text. A failed embedding is never cached. Cache settings are not part of any pipeline hash, so changing them never forces a reindex, and stored document vectors are untouched. A multi-repo server (--dbbundles or workspace mode) now builds the embedding model, multi-vector encoder and LLM client once for all bundles instead of once per bundle.parent_rollupretrieval step (opt-in) — a rerank-only step for chunk pipeline YAML that collapses co-retrieved siblings into their parent. When at least two results are children of the same symbol or document section (say, three methods of one class) and they cover enough of that parent’s chunk-bearing children, they are replaced by the parent’s own indexed chunk at the group’s best rank, keeping the group’s best relevance. The coverage threshold is per kind:min_coverage_by_kinddefaults toclass: 0.3,module: 0.6,markdown_heading: 0.5. A supplied mapping replaces those defaults wholesale, and its keys must be node-kind values with thresholds in [0.0, 1.0].min_coverage(default0.5, must be in (0.0, 1.0]) covers every other kind. The two-sibling floor is fixed. The result list never grows. A group whose tree or parent chunk is missing, whose gates are unmet, or whose metadata is malformed is passed through unchanged. Place it aftertop_k_filterand beforelimit. No shipped pipeline enables it, so default retrieval is unchanged; seeDOCUMENTATION.mdfor a YAML example.PYDOCS_CACHE_DIRrelocates the bundle root — when set, per-project.db/.tq(and.plaid) bundles live under it instead of~/.pydocs-mcp(a leading~is expanded; an empty value is ignored). Precedence:--cache-dir>PYDOCS_CACHE_DIR>~/.pydocs-mcp. Child processes inherit it, so one export relocates a whole shell session. The multi-repo cross-link overlay’s home-cache file (<root>/links/<digest>.sqlite3, used with explicit--dbbundles and, in workspace mode, when the workspace directory is unwritable) followsPYDOCS_CACHE_DIRbut not--cache-dir;reference_graph.cross_repo.overlay_dirstill overrides both. The YAMLcache_dirkey is still not read. 0.5.1 accepted this variable but ignored it; see Upgrade notes.Multimodal ask-your-docs agent — a new top-level
ask_your_docs:config block (validated at load, unknown keys rejected; env overrides asPYDOCS_ASK_YOUR_DOCS__<KEY>__<SUBKEY>) and image attachments in the chat UI: PNG / JPEG / WebP / GIF, up toimages.max_per_turnper question (default 3; extra files are dropped with a warning), each at mostimages.max_bytes(default 5,000,000; a larger file is dropped with an error).ask_your_docs.architecture(defaultauto) picks the agent from a registry:text_react(the image-free ReAct agent),inline(one prompt answers and sees; image tokens ride on every ReAct step),vision_subagent(a separate describe hop; image tokens are paid once per turn) andauto.autobuildsvision_subagentwhenask_your_docs.llm.visionnames a second model (see the next bullet),text_reactwhen the model is text-only, and otherwisemultimodal.preferred_architecture(defaultinline). Selectinginlineorvision_subagentfor a model that cannot see fails at build, with the fix in the message. Unlessask_your_docs.llm.visionsettles it, vision capability comes from the ladder undermultimodal.detection:override(true/false/null), then a model-name prefix table (static_table, on), then two opt-in probes, both off:endpoint_probe(/modelsmetadata; needs a base URL) andimage_probe(one tiny-image call). A model no rung recognizes counts as text-only. Images sent when no configured model can see them are refused with the fix spelled out, or, undermultimodal.text_only_fallback: describe, answered from text with an explicit cannot-see note. When the model receiving images can see, the agent also gets a localreinspect_imagestool (not an MCP tool) that re-reads images from earlier turns against a new question. The session keeps the lastimages.session_retentionimages (12; 0 disables), and each turn allows at mostimages.max_reinspect_per_turnvision calls (2; repeated calls are free). The[harness-ask-your-docs]extra now needsstreamlit>=1.43(0.5.1’s[ask-your-docs]needed>=1.36). MCP surface unchanged.Ask-your-docs LLM connection. One
ask_your_docs.llmYAML block configures the chat model’s OpenAI-format endpoint, its bearer (an internal token service renewed on401with the request retried once, or a named environment variable), and vision (true/false/ detect / a second model on the same endpoint). The sidebar’s four connection inputs become one status line — host, model, bearer, vision verdict — plus a Connection dialog that lists the endpoint’s models, renews the token and tests the connection, all scoped to the session. Secrets stay out of YAML, argv and the UI: the dialog has no key field, no launch flag carries one, only a token’s last four characters are ever shown, and every failure the page renders — a rejected bearer included — is redacted. A bearer that cannot be fetched, or a model listing that fails, degrades to a caption rather than breaking the page, and a question that cannot be answered is echoed back instead of lost. Both capability probes now use the agent’s credential (without a block, the endpoint probe therefore carriesOPENAI_API_KEYwhen that variable is set). The bearer follows the effective endpoint; an override on another origin, or a plain-http non-loopback endpoint, is flagged on the status line and in one log line. No block ⇒ otherwise unchanged behavior. Design:docs/superpowers/specs/2026-09-05-ask-your-docs-llm-connection-design.md.Prompt overrides for the ask agent —
build_agenttakes an optionalprompts=override (AskPrompts, an alias ofpydocs_mcp.harness.core.prompt_override.PromptOverrides) whosesystem_promptreplaces the shipped system prompt, andreformulatetakes an optionalrewrite_template=(astr.formattemplate with{history}/{question}) for the follow-up rewrite. The app and CLI pass neither, so default prompts are unchanged. The eval suite’s ask-agent optimization drives these seams (seebenchmarks/CHANGELOG.md).
Changed¶
BREAKING: the ask-your-docs harness moved under the
harness/namespace with harness-scoped install names. The console scriptask-your-docsis nowharness-ask-your-docs, the extra[ask-your-docs]is now[harness-ask-your-docs](pip install 'pydocs-mcp[harness-ask-your-docs]'), and the module pathpydocs_mcp.ask_your_docsis nowpydocs_mcp.harness.ask_your_docs. There is no compatibility shim: the old script name vanishes from PATH, and pip treats an unknown extra as a warning, so an old install command silently yields an agent-less install — update install scripts and MCP/CLI wrappers together. The move does not rename the newask_your_docs:YAML block (see Added).Extension scope is now part of the chunk-cache identity; upgrading re-embeds.
ingestion_pipeline_hashnow always folds in the sorted union ofextraction.discovery.project.include_extensionsandextraction.discovery.dependency.include_extensions, so it changes for every deployment on upgrade, the default config included, and again whenever either list is widened or narrowed. The hash feeds every chunk’scontent_hash, so each chunk of a re-extracted package gets a new hash and is re-inserted, and re-embedded wherever the embed policy gives it a vector. Packages whose discovered files are unchanged keep their package-level cache hit and existing vectors. See Upgrade notes for the first 0.6.0 index pass.Five more built-in directory exclusions:
extern,third_party,bower_components,.yarn,crosscommitvulnjoin the non-removable floor (node_moduleswas already there). The first four are vendored second-language trees (ADR 0021);crosscommitvulnis a leak guard that keeps the eval suite’s vendored CrossCommitVuln-Bench gold answers, whichpydocs-mcp-evalships as package data, out of every index. Like every floor entry, each matches a whole directory name at any depth (a file namedcrosscommitvuln_fixtures.jsonlstill indexes) in project and dependency file discovery and in the project member walk, and therefore ingrep/glob. Neither YAML nor[tool.pydocs-mcp] exclude_dirscan re-include them, since user exclusions only add to the floor; rename a real source directory with one of these names (a vendoredextern/package, say) to keep it indexed.read_filestill opens such files, since it checks only the project and dependency roots. A package containing such a directory re-extracts once on the next index pass and drops those files’ chunks and trees (and, in the project, their symbols); dependency symbols are unaffected, since dependency member extraction has never applied the floor. Indexes without such a directory are unaffected.Unknown
pipelines:handler keys fail at config load — onlypipelines.chunkandpipelines.memberare read. Any other key, most commonly apipelines.ingestionroute list, used to load and be silently ignored, which left the default single-vector ingestion in place. It is now a config validation error that names the offending keys; foringestionthe message points atextraction.ingestion.pipeline_path, the key that actually selects the ingestion pipeline. A config that loaded under 0.5.1 with a stray handler key must be fixed before upgrading.structuredContentis now the typed envelope{text, items, meta}, with a matchingoutputSchemaadvertised per tool at registration. Previously the SDK auto-wrapped the markdown string as{"result": "<markdown>"}. The text content block is byte-identical for the six pre-existing tools, so text-reading clients see no difference; clients that parsedstructuredContent.resultmust readstructuredContent.textinstead.metacarriestool,project,indexed_git_head,live_git_head,index_stale,truncatedand (see Added)branchon every tool.inputSchemaadvertises enum values — handler parameters are typed asLiterals, so the advertised JSON schema carries the same enums the CLI always did. Values unchanged; no call-shape change for existing clients.CLI canonical subcommands named exactly like the tools —
pydocs-mcp get_overview,pydocs-mcp search_codebase, … The short verbs (overview,search,symbol,context,refs,why) remain as aliases, andlookupstays a deprecated alias. All nine subcommands source their help text fromTOOL_DOCS(single source with the MCP descriptions), and the CLI-local--limitdefault literal is removed in favor of the YAML-wired default. Existing invocations keep working; scripts may migrate to canonical names at leisure.Tool-description token budget raised for nine tools —
TOTAL_TOKEN_BUDGET(public inpydocs_mcp.application.tool_docssince 0.5.1) rises from2400to3600estimated tokens (characters ÷CHARS_PER_TOKEN), summed over the nine tool descriptions only; the server instructions and the session-start preamble do not count.PER_TOOL_TOKEN_BUDGET(500),CHARS_PER_TOKEN(4) andREQUIRED_MARKERSare unchanged. The constants are now defined inpydocs_mcp.application.description_sourceand remain importable fromtool_docs.get_referencesdeclares its resolution level — the tool description is re-hedged (edges are name/alias-matched with import awareness, not scope-resolved) and responses carry one additive meta field,meta.resolution: "syntactic" | "semantic" | "unavailable", the capability level for the target’s language..pyand.mdtargets report"syntactic"; every other target —.ipynb, the new text/config and code files, a bare dependency-package name, or a target with no resolvable source file — reports"unavailable"rather than overstating the Python-only reference graph. A future semantic backend flips only this value; the tool contract is invariant under the swap (docs/tool-contracts.md§2.2, ADR 0021).watchdogis a required dependency —serve --watchandwatchwork on a plainpip install pydocs-mcp:watchdog>=4.0,<6.0moved from the[watch]extra into the runtime deps (~684 KB installed, no transitive dependencies).[watch]is now an empty alias (see Deprecated).[sentence-transformers]and[openvino]captransformers<6.0(previously>=4.48with no upper bound;[late-interaction]keeps its uncappedtransformers>=4.57.3). When thesentence_transformersprovider fails to build its model, on anyembedding.backend, with an error that mentions torchvision, the error is now re-raised as anImportErrorlisting the remedies. transformers 5.0–5.9 require torchvision for image-processing classes that some model repos reference. The remedies: upgrade totransformers>=5.10,<6, install atorchvisionbuild matching the installed torch, or pointembedding.model_nameat a text-only model. torchvision stays deliberately out of both extras because it exact-pins its torch version.
Deprecated¶
The
[watch]extra — now an empty alias, sincewatchdogis a required dependency (see Changed). Existingpip install 'pydocs-mcp[watch]'commands keep working; the alias goes away in the next major version.
Fixed¶
mcpcapped below 2.0 — the requirement is nowmcp>=1.28.1,<2. mcp 2.x (2.0.0 onward) removedmcp.server.fastmcp, so an uncapped fresh install resolved mcp 2.2.0 andpydocs-mcp servefailed at startup withModuleNotFoundError; the[harness-ask-your-docs]agent also failed to import (langchain-mcp-adapters under mcp 2.x). 0.5.1 (mcp>=1.0) is affected the same way on fresh installs; pinmcp<2when installing it.Project-code addressing — dotted targets now resolve bare project-qualified names for project source (stored under the reserved
__project__package) inget_symbol/get_context/get_references. Previously project-source symbols were unreachable through target strings; now previously-erroring targets resolve and no working call changes behavior. Companion reference-graph fixes: relative imports honorast.ImportFrom.level, and suffix matching is scoped to project qualified names so dependency symbols can’t shadow project code.get_symbol(depth="source")source header — the# Source — <target> · <path>header renders a real file path again: source paths now round-trip through the chunk store (schema v15) instead of being dropped on persist.get_references(direction="inherits")answers both senses — the tool now returns the target’s base classes (from-side edges, kept even when the base name is unresolved) AND its subclasses (edges into the target), each under its own labelled section; previously dotted targets returned “No bases found” and any rows that did match were subclasses mislabeled as bases.Search pointers are always callable — a markdown heading hit (qualified name
pkg.FILE.md#section-slug) used to advertiseget_symbol(target="pkg.FILE.md#section-slug"), which the tool’s own input validator rejects. The#fragmentis now stripped, so the pointer targets the parent document node (pkg.FILE.md), and so does the recovery pointer shown when the token budget elides results. A pointer whose target is still not a dotted identifier, such as a markdown or decision document path likedocs.adr.0001-greeting-format.md, is suppressed at render time rather than promising a call that always fails validation.The
[late-interaction]ingestion preset no longer drops decision mining and dependency doc pages — the shippedingestion_late_interaction.yaml(selected viaextraction.ingestion.pipeline_path) had fallen behindingestion.yamland lacked thecapture_decisionsanddependency_doc_pagesstages. A late-interaction deployment therefore mined no decisions, leavingget_whyandsearch_codebase(kind="decision")nothing to return, and embedded no dependency doc pages. Both stages are back. The preset now differs from the default in exactly two ways:embed_chunks_multi_vectorreplacesembed_chunks, andsynthesize_similar_edgesis left out (it reads only single-vector embeddings). Only packages that re-extract pick up the two stages; a package whose files are unchanged keeps its cache hit. Runpydocs-mcp index . --forceonce after upgrading to cover every package.serve.watch.enabled: truenow turns on the file watcher — 0.5.1 accepted the key (its docs said the--watchflag “overrides” it) but never read it, sopydocs-mcp servewatched only with--watch. Now either switch enables watching: the YAML key (orPYDOCS_SERVE__WATCH__ENABLED=true) or the flag. The flag cannot turn watching off while the key istrue. The default staysfalse, and multi-repo serves (--workspace/--db) still never watch. A config that already sets the key totruestarts reindexing on edits after upgrading.get_whytargets accept file paths — eachtargetsitem (and eachwhy --targetvalue on the CLI) may be a repo-relative path such aspython/pydocs_mcp/db.pyas well as a dotted name; the grammar is^[A-Za-z0-9_.\-/]+$(docs/tool-contracts.md§3.6). 0.5.1 validated targets as dotted identifiers and rejected any/, even though its ownget_whytool description used a path in its example. Characters outside that set are still rejected, including:and], which would corrupt response pointer tokens.get_symbol,get_contextandget_referencesstill take dotted names only.Ask-your-docs: a collapsed sidebar can be reopened — the chat UI’s theme hid Streamlit’s whole toolbar (
stToolbar), and that container also holds the expand-sidebar chevron. Once the sidebar was collapsed (and Streamlit remembers that across reloads), 0.5.1 offered no on-page way to get back the workspace / model settings or the project / package / code pickers. The theme now hides only the toolbar’s actions, deploy button and status widget, so the chevron stays visible. UI only; no config change.
v0.5.2¶
Fixed¶
mcpcapped below 2.0 — the requirement is nowmcp>=1.28.1,<2(floor: see Security). mcp 2.x (2.0.0 onward) removedmcp.server.fastmcp, so a freshpip install pydocs-mcp==0.5.1resolved mcp 2.2.0 andpydocs-mcp servefailed at startup withModuleNotFoundError; the[ask-your-docs]agent also failed to import (langchain-mcp-adapters under mcp 2.x). 0.6.0 carries the same cap.
Security¶
mcpfloor raised>=1.0→>=1.28.1(lock 1.27.1 → 1.30.0) — resolves PYSEC-2026-3481, PYSEC-2026-3482 and PYSEC-2026-3483 reported against mcp 1.27.1, matching 0.6.0.cryptography(transitive, viamcp→pyjwt[crypto]) constrained>=48.0.1→>=50.0.0in[tool.uv] constraint-dependencies(lock 49.0.0 → 50.0.1) — resolves PYSEC-2026-3552 in the locked and audited environment. The constraint is not part of the published wheel metadata; upgradecryptographyin your own environment.pillow(transitive) lock 12.2.0 → 12.3.0 — resolves the PYSEC-2026-2253…2257 and PYSEC-2026-3451…3454 / 3493…3496 advisories in the locked environment.
v0.5.1¶
Changed¶
The tool-docs contract constants —
REQUIRED_MARKERS,CHARS_PER_TOKEN,PER_TOOL_TOKEN_BUDGET, andTOTAL_TOKEN_BUDGET— are now part of the public importable surface ofpydocs_mcp.application.tool_docs. Thepydocs-mcp-evaloptimizer artifacts import them to share the §D13 tool-docs validation logic, so they need a published release that exposes them.
v0.5.0¶
Headline: the MCP surface becomes six task-shaped tools, every response now
travels in a freshness / next-step / truncation envelope, and the index
grows an architectural-decision layer (mine decisions at index time, ask
get_why at query time).
Added¶
Six task-shaped MCP tools + full CLI parity — the surface is now
get_overview,search_codebase,get_symbol,get_context,get_references, andget_why, each mirrored by a CLI subcommand (overview/search/symbol/context/refs/why).get_contextpacks one or more targets under a shared token budget;get_overviewreturns a structural orientation card for a package or the whole workspace. The oldsearch/lookuppair is retired (lookupstays as a deprecated CLI alias). (#141)Response conventions — one envelope around every answer — each response (MCP or CLI) carries a freshness header (
[index: <sha> · <N>d old · <M> packages], plus a stale warning when the working tree has moved past the indexed commit), inline next-step pointers resolved to the calling surface, and a recoverable truncation footer ([truncated: …]) that names every clipped section and the pointer to fetch it in full. On by default; tunable underoutput.envelope. (#139)Architectural-decision layer — decisions are mined from your project at index time (ADR files, inline markers, commit messages, changelog, docs prose, deduplicated; optional LLM structuring). The read side exposes them via the new
get_whytool (free-text or by-target),search_codebase(kind="decision"),get_references(direction="governed_by"), and dedicated overview blocks; each decision is a graph node withGOVERNSedges to the symbols it affects. Configured underdecision_capture:(write) anddecisions.output(read); schema v14. (#145, #146)Multi-repo workspace orientation card — an empty
get_overviewagainst a multi-repo server now returns one line per loaded repo with its package count, so a freshly connected agent can orient before narrowing to aproject. (#153)graph_expandper-edge-kind trust (kind_weights) +MENTIONStraversal — graph expansion can now traverse weaker edge kinds at a discounted weight (the weight compounds along each path), with sweep configs for tuning. (#166)ask-your-docsas a first-class install extra — the LangGraph ReAct agentStreamlit chat UI now ship inside the package (
pydocs_mcp/ask_your_docs/) behindpip install 'pydocs-mcp[ask-your-docs]'and theask-your-docsconsole command, with sidebar project/package/scope pickers enforced on every tool call and a read-only interactive graph-explorer page. (#157)
Benchmark harness expansion — the harness is now a first-class programmatic surface with a paired agent-efficiency track (indexed vs bare, blind judge, spend guardrails), a SWE-QA / SWE-QA-Pro retrieval track, a
small_devsplit, and acomparing-retrieval-methodsguide. Developer tooling underbenchmarks/. (#132, #133, #144, #171)
Changed¶
dense_scoreris now a post-fusion re-ranker — instead of a standalone dense retriever, it re-scores the fused candidate subset against the TurboQuant vectors via an allowlist search (no fresh ANN scan) and sorts the vector-scored hits to the top; candidates without a dense vector keep their fused order and trail behind, so recall is preserved. Mirrors the late-interaction scorer on the single-vector side. (#154)Docs modernized to the six task-shaped surface — README, DOCUMENTATION, SPEC, IDEAS, EXTENSIONS, and the benchmarks README no longer describe the retired two-tool
search/lookupsurface; the root README is now vendor-neutral (no named third-party comparisons). (#141)Storage / retrieval internals refactored for maintainability —
sqlite.pysplit into a per-repository package with shared CRUD helpers, the CLI write-side composition root extracted intostorage/factories, the retrieval config split into a package, and several hexagonal-seam leaks closed (FilterAdapterwiring, FTS builder dedup,db.pylayering). No user-facing behavior change. (#128, #130, #135, #136, #137)Benchmark suite repackaged for PyPI as
pydocs-mcp-eval— the benchmark distribution is renamed frompyctx7-benchmarkstopydocs-mcp-eval, and its import package is hoisted frombenchmarks.eval.*/benchmarks.optimizetopydocs_eval.*(pydocs_eval.datasets,pydocs_eval.systems,pydocs_eval.optimize, …); thebenchmarks/directory name is unchanged. The optional-dependency extras are now split by coupling, not by feature: the base install serves the black-box agent-efficiency track (needs only thepydocs-mcpCLI onPATH), and a new[retrieval]extra declarespydocs-mcp>=0.5for the library-coupled parts (in-process retrieval systems, the optimize overlay server, thetool_docs/usage_skillartifacts). Those boundaries now carry import guards that raise an actionablepip install "pydocs-mcp-eval[retrieval]"hint instead of a bareModuleNotFoundErrorwhen the extra is absent.
Removed¶
SqliteVectorStoredeprecated alias — the class was renamedSqliteLexicalStore(it is the FTS5/BM25 lexical store, not a vector store); the back-compat alias is gone frompydocs_mcp.storageandpydocs_mcp.storage.sqlite. ImportSqliteLexicalStoreinstead.
Fixed¶
Two audit-hardening waves — ~65 bug fixes with regression tests — a high-risk wave (18 fixes + 24 new regression-test files) followed by a medium/low wave across storage, db, server, retrieval, extraction, the envelope, the CLI, the watcher, and the Rust core. Includes three reproduced crash bugs (FTS5 operator queries, composite-UoW enter-leak, migration crash-loop), dead watch mode with the real watchdog,
--forceinherited on every save, andchunks_ftsdesync on package deletes. (#148, #150, #152, #158, #159, #160, #161, #162, #163, #164)get_contextbudget accounting —_split_budgetnow honors one shared budget so the summed output never exceeds the requested total. (#161)examples/ask_your_docs_agentcrashed on startup — the agent fetched the removedlookuptool (StopIterationon connect); it now targets the six task-shaped tools and reads the indexed-projects listing viaget_overview.
CI¶
uv lock --checkdrift gate added; the[graph]extra now pullsscipy(PageRank stopped crashing) and is exercised in CI; the heavyask-your-docsextra is kept off the core test matrix. (#168, #169, #170, #173, #174)
0.4.1 — 2026-07-03¶
Added¶
Air-gapped / offline model loading — point
embedding.model_nameat a local directory of side-loaded weights and nothing is ever downloaded, for every provider. fastembed states the model recipe in YAML (newpoolingknob +normalize/model_file_name) and loads via a pinned local path; sentence-transformers and PyLate take the directory natively (the right choice for last-token models like Qwen3-Embedding) with HF offline mode forced so a missing file fails locally;openairejects a local path with an actionable error. Existing configs keep their exact pipeline hashes — nothing re-embeds. (#121)Ask-your-docs Streamlit webapp —
examples/ask_your_docs_agentnow ships a themed chat UI (streamlit run streamlit_app.py) over the same LangGraph agent: sidebar config, conversation memory with follow-up reformulation, code snippets rendered in fenced blocks. The example is now Streamlit + notebook only (the terminal REPL is gone). (#122)
Fixed¶
Full-suite test failures in the fast-plaid storage tests — the default-install no-torch test evicted torch from
sys.moduleswithout restoring it, so any later torch import in the same run crashed (function '_has_torch_function' already has a docstring). The evicted modules are now restored, and the suite is fully green. (#123)
Changed¶
The ask-your-docs example defines its dependencies in a single
requirements.txt(the short-livedrequirements.pyvariant is gone).
0.4.0 — 2026-07-03¶
Added¶
Multi-repo search — one MCP server (or CLI query) over several already- indexed repos:
serve --workspace <dir>/--db <file>load pre-built{name}_{hash}.dbbundles read-only; a newprojectfilter onsearch/lookupscopes one repo, omitted it unions across all with dedup (a repo’s own code beats the same symbol seen as a dependency; most-recently-indexed wins among duplicates). A per-database identity stamp (index_metadata) rejects bundles built with a mismatching embedder up front.Reference-graph readers on
lookup—show="impact"(everything that transitively calls a symbol, ranked — “what breaks if I change X?”) andshow="context"(the symbol’s dependency closure packed under a token budget at graded fidelity — “everything to understand X”).Graph-boosted retrieval —
graph_expandstep (dense-seeded 1-hop reference-graph expansion), index-timenode_scores(PageRank / community, optional[graph]extra) with centrality / diversity rerankers, synthetic embedding-kNNsimilaredges, graph pipeline presets, and a structural-recall benchmark split.Selective dependency embedding — everything stays BM25/FTS-indexed, but dense vectors are written per package tier: the project embeds fully; dependencies embed one docstring page per module (module + public signatures + docstrings) plus markdown/READMEs by default. Promote chosen dependencies to full embedding with
--full-dep NAME/embedding.full_index_dependencies(globs supported);embedding.dependency_policy: full | doc_pages | none. Indexing torch-sized dependencies drops from ~an hour to seconds on CPU.ONNX / OpenVINO backends for
sentence_transformers—embedding.backend: torch | onnx | openvino+embedding.model_file_name(e.g. a qint8-quantized export) for ~2–4× faster CPU inference; new[openvino]extra. Index on GPU, serve on CPU with the same model.New embedders —
gte-modernbert-baseand the code-specializedF2LLM-v2family via thesentence_transformersprovider (RepoQA leaderboard + figures in the benchmark docs).Dependency manifests —
[project.optional-dependencies]and PEP 735[dependency-groups](whatuv add --groupwrites) are now parsed; the--watchwatcher re-indexes whenpyproject.toml/requirements*.txtchange, so adding a package updates the index automatically.Example agent —
examples/ask_your_docs_agent/: a minimal LangGraph ReAct chat agent (terminal or notebook) answering questions about your indexed repos through the MCP tools, with conversation memory, follow-up reformulation, and project inference.Documentation site — Sphinx + Furo under
documentation/.
Changed¶
Default chunk search is now dense + graph expansion (
chunk_search_graph.yaml), replacing BM25-only — RepoQA recall@10 0.40 → 0.77 on standard queries and 0.30 → 1.00 on structurally-reachable answers, at no extra indexing cost. BM25 and hybrid remain as presets.Dependencies embed documentation pages only by default (see Added);
scope="deps"searches route to a BM25 ∥ dense fusion preset so dependency code stays reachable by keyword.graph_expanddecay default raised to 0.9.Schema v10 → v12 (
node_scores,index_metadata,chunks.embedded) — additive, migrated automatically on open. Note: the ingestion pipeline identity changed, so the first re-index after upgrading re-extracts and re-embeds packages; serving existing indexes keeps working without it.
Fixed¶
Reference resolver no longer rescans the whole symbol universe per reference (O(N²) → bucketed) — indexing large dependencies such as numpy / torch previously appeared to hang.
The startup SQLite ↔ vector-store integrity check compares intended embeddings instead of raw chunk counts, ending the repeated re-extract-everything loop for deployments that don’t embed every chunk.
Dense search over a partially-embedded corpus no longer raises when the candidate set contains vectorless chunks.
BM25 candidates carry
qualified_name, unblocking LLM tree reranking.GPU benchmark runs no longer silently fall back to CPU (onnxruntime CUDA library path).
0.3.1 — 2026-06-10¶
Added¶
--skip-depsCLI flag onserve/index/watch— index only the project source, skipping dependency resolution + indexing entirely. The CLI counterpart ofProjectIndexer.index_project(include_dependencies=False)and the inverse of--skip-project.
0.3.0 — 2026-06-10¶
Added (LLM tree-reasoning — enrichment, token budget, two-stage rerank)¶
PageIndex node enrichment — each LLM-visible tree node now carries its real signature (params + type hints + return annotation), its decorators, and a docstring excerpt, beyond the generated summary. Tunable via
doc_excerpt(sections|full|off) anddoc_excerpt_max_chars. A non-destructive schema auto-refresh (v9) re-extracts the metadata on next index without re-embedding unchanged chunks.Token-counted tree budget — the serialized tree handed to the LLM is bounded in real
tiktokentokens (previously whitespace words, which under-counted code ~3× and could overflow the model’s context window with a 400context_length_exceeded).max_tree_words→max_tree_tokens(int | None;Noneauto-derives from the configured model’s context window). Over-budget pruning is content-first — drop per-node doc excerpts before whole nodes. Addstiktokenas a runtime dependency.BM25 → tree two-stage rerank — opt-in
rerank_candidatesmode on thellm_tree_reasoningstep scopes the LLM-visible tree to a prior BM25/dense candidate set and writes its ranked picks back as the pipeline’s final ranking (with arepoqa_bm25_tree_rerankbenchmark config).Persist
chunks.qualified_name(schema v7) so tree-reasoning picks resolve to the correct chunks.
Added (on-device dense embeddings)¶
sentence_transformersembedding provider (provider: sentence_transformers) servingQwen/Qwen3-Embedding-0.6Band other SentenceTransformer models via torch — a GPU-reliable on-device dense embedder (torch frees CUDA memory between sequential index-builds). Opt-in via the[sentence-transformers]extra. NewEmbeddingConfigknobsmax_seq_length/normalize/query_prompt_name(the first two fold into the pipeline hash; the query-only prompt does not).
Removed¶
The
onnxembedding provider (OnnxEmbedderand theonnx_file/query_instructionconfig fields). The torch-backedsentence_transformersprovider replaces it for on-device Qwen3-Embedding — onnxruntime leaked the CUDA arena across the benchmark’s sequential index-builds.
Added (GPU inference)¶
--gpuflag onserve,index, andwatch(and the benchmark runner) to run all embedder inference — FastEmbed, thesentence_transformersprovider, and PyLate late-interaction — on CUDA. No YAML change; covers both index-time and query-time embedding. The execution device is excluded from the pipeline / index-cache hash, so toggling--gpushares the same.tq/ fast-plaid index and never forces a re-index (it is a latency knob, not a quality change).EmbeddingConfig.device(cpu/cuda) wiring throughbuild_embedderinto the FastEmbed and sentence_transformers embedders;AppConfig.with_device(gpu=...)stamps the device after config load. GPU runtimes (onnxruntime-gpu,fastembed-gpu, CUDA torch) are documented inINSTALL.md, not auto-installed.
0.2.0 — 2026-05-28¶
Added (late-interaction retrieval — ColBERT / PyLate via fast-plaid)¶
Late-interaction (multi-vector / MaxSim) retrieval backend, opt-in via
pip install 'pydocs-mcp[late-interaction]'+late_interaction.enabled: truein YAML. Shipslightonai/LateOn-Codeas the default model via PyLate (arXiv:2508.03555) and scores MaxSim through fast-plaid (PLAID — arXiv:2205.09707).chunk_multi_vector_idsSQLite mapping table (schema v6) bridgeschunk_id↔ fast-plaid’s auto-assignedplaid_doc_id. The existingFilterAdapterProtocol scopes MaxSim to the SQLite-filtered candidate set via fast-plaid’ssubset=parameter.Three new YAML presets (
ingestion_late_interaction.yaml,chunk_search_late_interaction.yaml,chunk_search_late_interaction_ranked.yaml) plus benchmark sweep configs (repoqa_hybrid_li_rrf.yaml,ds1000_hybrid_li_rrf.yaml).LateInteractionScorerStepretrieval step +EmbedChunksMultiVectorStageingestion stage +FastPlaidUnitOfWorkstorage adapter +NullMultiVectorStorefor the disabled deployment path.
Added (PyPI packaging polish)¶
[project] authors,keywords,classifiers,[project.urls]for PyPI rendering.Cargo.tomlversion synced to0.2.0.
Fixed¶
build_retrieval_contextnow wiresBuildContext.embedder = build_embedder(config.embedding). Any pipeline that referencedDenseFetcherStep/DenseScorerSteppreviously crashed at decode time with the actionableValueError. FastEmbed’s ONNX model stays lazy so the BM25-only deployment still pays nothing at startup.
Added (final P2 follow-ups — closes #14 audit findings)¶
SECURITY.mdat repo root — GitHub-rendered private vulnerability reporting flow with 72h ack / 7d confirm / 30d fix SLAs.CONTRIBUTING.mdat repo root — external-contributor entry point referencing themake install/make testworkflow.pip-auditsecurity job in.github/workflows/ci.yml— scans the locked dep tree for CVEs in strict mode.release.ymlsyncs version from the pushed git tag —sed -iupdates bothCargo.tomlandpyproject.tomlin every build job, gated onrefs/tags/v.
Changed¶
python/pydocs_mcp/__init__.pyattaches alogging.NullHandlerat the package logger (PEP 282 library convention). Users who configure logging vialogging.basicConfig()see no behaviour change.
Added¶
MIT
LICENSEfile at the repository root.PEP 561
py.typedmarker so downstream type-checkers honor the package’s type hints.PydocsMCPErrorroot exception so embedders can catch any pydocs-mcp failure with oneexcept.__all__declaration inpydocs_mcp/__init__.pywith the public exception hierarchy re-exported.[dependency-groups](PEP 735) for dev / test / lint deps.mypy configuration + CI typecheck step.
Multi-OS CI matrix (macOS + Windows in addition to Linux).
.pre-commit-config.yaml,Makefile,.editorconfigfor contributor ergonomics.This
CHANGELOG.md.
Changed¶
pyproject.tomllicense declaration migrated to PEP 639 SPDX form (license = "MIT"+license-files).pydocs_mcp.__version__sourced from installed metadata viaimportlib.metadata(was hard-coded; drifted frompyproject.toml).Ruff
target-versionbumped topy311(matchesrequires-python).Ruff
selectexpanded withB,UP,S,SIM,RUF,C901,PT,PTH.
Fixed¶
Cross-platform path-separator / encoding / newline handling in six extraction / IO tests so they execute on Windows as well as POSIX hosts. Assertions now compare via
Path.as_posix()(test fixtures previously embedded forward slashes instr(Path)comparisons), test fixtures pin UTF-8 explicitly when writing test files with non-ASCII content (write_text(..., encoding="utf-8")) and usewrite_bytesto avoid Windows CRLF translation on the file-read round-trip.
0.2.0¶
Added¶
pydocs-mcp serve --watchflag — live re-indexing via the newFileWatchermodule.pydocs-mcp watchstandalone subcommand — watcher only, no MCP server.Rich
description=+epilog=on thesearch/lookupCLI subparsers.Server-level
FastMCP(instructions=...)block with workflow framing for AI clients.
Changed¶
Tool annotations on MCP
search+lookup(readOnlyHint,idempotentHint,openWorldHint).
0.1.0¶
Added¶
Initial public release.
Local MCP server indexing Python project + dependency docs/code into a hybrid (BM25 + dense embeddings) index.
2 MCP tools:
search(BM25 + dense, RRF-fused) andlookup(with reference-graph traversal).Rust acceleration via maturin (PyO3) with a pure-Python fallback.