Reference

Changelog

Release history for contextlake.

All notable changes to contextlake will be documented in this file.

The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.

[Unreleased]#

[7.4.0] - 2026-08-13#

Added#

Every node an MCP tool returns carries citation_status, verified, stale, or unverifiable, plus a citation_note when it is not verified. The answer is still returned either way: the guard discloses, it never withholds a result. unverifiable is a real third state and not a polite verified: no local checkout, an unreadable file, or a repo carrying no index timestamp all mean nothing was checked.

Two stages, because either alone is useless. The gate is one stat() per distinct file in a response, against the repo's indexed_at; only files that really were written after indexing escalate to a confirming read, which is eval.verify_citations called rather than reimplemented, so "does this citation still hold" keeps one definition and gains a second caller. A gate alone would fire on everything (a git checkout moves mtimes on identical files); a confirmation alone would read a file per node.

Measured on 43 nodes across 10 real files: +1.7% on a full tool call when nothing changed, +28.6% in the worst case where every file was modified, about 1.5 tokens per node. Past 32 confirming reads in one request the rest are reported stale with modified_after_index rather than quietly passed, a budget nobody is told about reads as a clean bill of health for work that never ran.

blast_radius carries the fields too. It returns hits rather than nodes, so it bypasses the funnel every other verb goes through and would have been the one verb handing back a file and a line with nothing said about either, and an absent disclosure beside twenty present ones reads as "checked, fine". kb steer writes the three-value explanation into the generated agent skills as well, so an agent that reads only its steering files still knows what stale means.

The refusals are the point. A PDF that yields nothing is never ingested as an empty document, an empty node is indistinguishable from a real one in search results and in the wiki. Each of the four ways it can decline says which one by name: the extra is missing (reported once per run, not once per file), the file is over max_bytes, the PDF cannot be parsed, or it has no text layer at all, a scanned page is reported as having none rather than silently ingested blank, because contextlake does not OCR. Page numbers travel with the document as pages / pages_read / page_offsets: a PDF's page number is what a line number is to source, so flattening it away would lose the citation.

kb-pdf is deliberately not folded into kb-full, and pypdf rides in the dev extra as well: the tests skip themselves when it is absent, and a CI job that skips them is green without having exercised the feature at all. The floor is pypdf>=5.0 because the suite was run against 5.0.0, not because that is what happened to be installed while writing it.

It names the pitfalls the code itself warns about rather than generic advice, the headline one being a blanket except that turns a half-registered grammar into a single skip <file>: parse error line while the index still reports success, which would cost a first-time contributor an hour. And it says what not to touch: the golden-query fixture and PARSER_VERSION should not move for a pure addition, with the cost of doing so stated.

Fixed#

Both directions are now pinned by a test beside the kind-registry parity checks. The reverse direction is deliberately loose: c_sharp is kept as an alias of the real id csharp for older stores, so the test asserts the stray set is exactly the aliases chosen rather than banning strays, and fails when a third appears without an explanation.

The cache is deliberately store-wide, not per repo. A per-repo cache invalidates more precisely and would also let a stale count for one repo sit inside a single reported total beside a fresh one for another, a quietly-mixed number, which is the class of bug this codebase keeps having to fix.

It also refuses to cache a run it could not fully observe: if any repo's shard cannot be stat'd, the result is returned and not stored, because encoding "I could not see this" into a fingerprint is how a cache becomes confident about a store it never read.

What was not done is worth recording. Computing this from SQLite instead would be faster still, since the edges are already in a table, but the investigation behind this change observed a real store whose shards had been deleted while the edges table held zero rows, where a SQL anti-join answers "0 dangling edges" about a graph with no edges left. This changes when the walk happens, never what it measures.

There is now a test that resolves every in-repo link and every #anchor on it, including the absolute GitHub blob URLs README and QUICKSTART use so their links work on PyPI. External URLs are deliberately not fetched: a test that reaches the network fails for reasons unrelated to the change in front of you. It found two more dead anchors on its first run, changelog cross-references into the dashboard guide, whose numbered sections had shifted underneath them, and both are repointed.

The two headline numbers were re-measured on this tree rather than copied out of the audit, and the command count is a good example of why: the CLI exposes 21 kb keys but only 19 distinct commands, because impact/blast-radius and owners/who-knows are alias pairs sharing one parser. Counting keys gives 34 commands; counting parsers gives 32.

Where the code looked like the real defect rather than the doc, a stale --help string, a flag that is documented as global and is not, the documented behaviour was made to match what the code does today and the defect was recorded separately, rather than changing behaviour inside a docs pass. Eight such items are now on a list for the owner.

The shard layer gained a way to resolve and stat a shard without reading it, and the cached core now carries the last three things a hit still needed from the file: the head commit, the parser version, and the shard-derived half of the setup signals. A cache hit now touches the file's metadata and nothing else.

Measured on a 306.8 MB shard, counting real parses rather than trusting the clock: 7.00s cold with one parse, 0.03s warm with zero. The cold path is unchanged, because that parse is real work.

Two invariants were kept deliberately, and both had a reason already written into the code. The file is still observed exactly once per call, so a cache entry can never pair one observation's head with another's node_count, a test pins that. And the live-checkout reads still run every call: only the shard-derived half of the setup signals is cached, because freezing the live scan would reintroduce precisely the staleness that scan exists to catch.

[7.3.0] - 2026-08-13#

All 24 accessibility violations, and a favicon you can actually see. An audit of the dashboard and the graph viewer found 24 WCAG 2.2 AA failures; every one is fixed, with each contrast ratio computed from the real token values and then re-read from the rendered page. The graph canvas had an accessible name and no accessible content at all, and now has a parallel text view wired to the same handlers a mouse tap calls.

The favicon changed: below 64px the mark is the context-pebble rather than a picture of the mascot, because a character cannot survive 16 pixels however it is simplified. Pebble is unchanged at 180px and above. Browsers cache favicons hard, so a tab you already had open may keep showing the old one until you force a reload.

Nothing to re-index and nothing to re-embed.

Added#

Every span of repo-derived content now travels inside a delimited block carrying its source path and a content hash, with one rule stated once per prompt: everything inside is data to describe, never instructions to follow. contextlake's own labels and directives stay outside the blocks, and nothing the model is asked to do changed.

The delimiter is unspoofable structurally, not probabilistically. Content that forges a closing marker is escaped in a single pass, with a replacement containing no <, so it cannot reintroduce the marker or supply half of one; the digest is then taken over the emitted bytes. An emitted block provably carries exactly the two markers the wrapper wrote. The load-bearing test builds a README that forges a close marker and then speaks as the operator, and asserts the count is 2, the naive string-interpolation wrapper is constructed alongside it to show it yields 4. A property test extends the invariant to arbitrary strings.

kb steer now also installs a skill telling agents the same boundary, and SECURITY.md documents it. The cost is bounded and flat: one rule plus about 130 characters per block, at most 5 blocks , between +837 and +1,133 characters, which is +3.5% on the largest wiki prompt and +12.7% on the smallest.

Fixed#

--cl-line was carrying both decorative and load-bearing borders at one value, so it is split, with --cl-line-strong measured at 3.25-4.51:1 across every surface it appears on. Dark theme gained its own --cl-lake with a paired --cl-on-lake (5.13:1 fill, 5.79:1 label). And opacity is no longer used to encode state -- a dimmed row said "excluded" only to someone who could compare it with an undimmed one, which meant a low-vision reader could not tell whether the counts they were reading were complete. It is now strike-through (5.65/5.91:1) and a hatched fill (13.85/11.41:1).

Two fixes needed structure rather than attributes: lists that were styled to look like lists are now real <ul>/<li>, and the search results stopped nesting interactive controls inside each other. Keyboard behaviour was confirmed by key sequence: Enter lands focus on the opened panel's Close button, and the p shortcut no longer fires inside a <select> or a contenteditable.

Every ratio is computed from the real token values in both themes and then re-read from the rendered page with getComputedStyle, because a declared value and a composited value are not the same number. That distinction found two things the audit had not: one ring was failing in light theme too once measured against its own tint rather than the card behind it, and the obvious fix for another would have introduced a fresh 1.4.3 failure. 34 tests pin the tokens and roles.

A force-directed diagram cannot be made meaningful by adding attributes to a canvas, so it now has a parallel text view rendering the same visible nodes, kinds and per-node connections as real buttons, wired to the same handlers a mouse tap calls -- namespace drill-in and neighbour expansion work from the keyboard. role="application" is gone, and the canvas pans, zooms and fits with the arrow keys, +/- and 0. Verified from page load in the collapsed-namespace state, the case where a naive text view would list nothing usable.

The contrast work needed structure too: edges were drawn at 0.45 opacity, at which no hue can reach 3:1, so they are opaque now with a per-theme palette, and every relation clears 3.40:1 against the worst gradient stop of its theme. The node stroke became theme-aware, which closed a hole the audit had missed -- the old border was navy on navy at 1.03:1 in dark.

Five adjacent defects were fixed in passing and are named rather than absorbed: the focus ring (2.60 -> 5.02:1), a chip that was white on a pale hue (1.70:1), two link groups that only looked like buttons, the PNG export ground, and a bug the new palette itself introduced where legend swatches kept light hues on a dark first paint.

Both are regenerated from the registry, and both are now gated. The new check recomputes from the real generator into a temp directory and compares bytes, rather than restating a list that would itself drift, the same shape as the existing llms-full.txt sync test. Nothing was added to CI, because CI already runs the whole suite; a separate script is one more thing to forget.

Two details worth keeping. The generator now writes a trailing newline, because the committed files had one and it did not, so a byte gate would have failed on all four for a reason no reader could act on. And its generation calls moved behind a __main__ guard so a test can import it, point the output directory at a temp path and recompute, which also exposed a flaw in the first version of the guard: an import-and-diff check passed even when in-sync files were rewritten, so it now inspects the source with ast instead.

[7.2.1] - 2026-08-12#

A data-loss fix, and three silent-wrongness bugs. The one that matters: kb index could delete repositories it was never asked to look at. If you have ever pointed --workspace somewhere other than where your store was built, upgrade before your next index run.

No re-index and no re-embed are required by this release. If a repo of yours has been re-embedding on every run, that stops now, though a repo carrying one of the empty markers described below will embed once more to catch up, and then settle.

Added#

Fixed#

"" is reachable, and that was proved rather than assumed: kb index --source <shard>.json carries an imported shard's stamps verbatim into the repos row, where every other accessor returns '' while these two returned None. The consequence was silent: such a repo was fully re-embedded on every run for the life of the store, while the command reported success each time.

Fixed on the write side, None now deletes the row, so absence is the absence of a row , rather than by dropping the read-side collapse, because that collapse is load-bearing: an unknown-matches-unknown comparison is what stops a genuinely unstamped repo from looping, which is the same answer kb wiki already reached. get_embedded_parser_version's docstring claimed the opposite and would have led the next reader straight back into the regression; it now sets out all four quadrants of the comparison.

Point a run at a different --workspace than the store was built from and every non-canonically-named repo in it was destroyed, with nothing in that run able to restore it, and no error, because from the migration's point of view the job was done. Observed on a real store: two repos, their shards and their vectors, gone.

The fix is ordering plus scope. Discovery runs first, and the migration is told which checkouts this run will actually index; anything outside that set is left alone. A skipped repo also no longer marks the store "clean" in the process-lifetime cache, because that would make a later run which did include it skip the migration entirely, a fix planting the next silent bug. Five regression tests, three of which fail against the old behaviour.

The walk now reports whether git could be asked at all. An honest empty still falls back, that is what stops a dormant repository reporting no owners, while a timeout does not, and either outcome is cached against the commit. A timeout also now says so, instead of being indistinguishable from a repository whose history attributes nothing to anyone.

[7.2.0] - 2026-08-12#

Three ways to trust what you are told, and one way to check the claim on the tin. A cited file:line can now be verified to actually hold the symbol; a coding session is told up front whether the graph still describes today's code; and --offline refuses every non-loopback connection, so "local-first, no telemetry" is something you can test rather than something you have to believe.

Nothing to re-index and nothing to re-embed. kb steer gains one more generated file (.claude/settings.json), and existing hooks in it are preserved.

Added#

Loopback stays open on purpose: the MCP server, the dashboard, the graph viewer and a local Ollama all live there, and an offline mode that turned those off is one nobody would use. Verified with the network blocked: kb index, kb query, kb embed, semantic search and kb graph all work. Two limits found by testing rather than assumed: the bundled embedding model is fetched from Hugging Face on first use, so a cold cache plus --offline leaves semantic search unavailable (it degrades with a clear message, no crash), and the wiki's LLM tier is only as local as the provider it is pointed at.

The boundary is stated instead of glossed. This is an in-process guard, and git and glab are subprocesses with their own sockets, so mirror fetch|clone|update|branches| sync refuse up front under --offline (exit 2) rather than pretending to be covered. mirror verify and mirror status read the local workspace and stay available. bootstrap composes those same stages, and the first version of this let it walk straight into the forge: the socket guard did stop the enumeration, but only after ~26 seconds of retries, and it then blamed "a VPN/network drop" for a restriction the user had asked for. It now skips the mirror stage and builds the knowledge layer from what is already on disk, which is the same resumable state its network-failure path already produced.

Written adversarially and it paid immediately: the first version of the loopback test was host.startswith("127."), which reads as correct and accepts 127.example.com -- an ordinary remote hostname, straight through the guard. Its own test caught it. The address is now parsed, not prefix-matched. Same unanchored-string bug class as the --repos matching fix in 7.0.0.

The check is cheap by design (one git rev-parse per repo plus two indexed lookups, no parsing) and bounded by --budget seconds, with anything it did not reach reported as unchecked -- a cap nobody is told about reads as a clean bill of health for work that never happened. It reports moved heads, repos built by an older parser, and vectors built from an older text format. A repo whose clone is missing is reported and deliberately not counted as stale: re-indexing cannot fix it, and a session start that proposes work which changes nothing is one people learn to ignore.

--refresh starts kb index then kb steer detached, so a session opens immediately and the graph catches up behind it. Blocking a session start on a fleet re-index would be worse than the problem, and a re-index killed part-way is not a state this project has proved safe. Concurrency needed nothing new: write commands already take the cooperative store lock, so a second session's refresh refuses cleanly.

kb steer now also writes .claude/settings.json, adding a SessionStart hook that runs kb refresh --hook --refresh. Other hooks and settings in that file are preserved, and re-running steer replaces our entry rather than appending another copy -- hooks.SessionStart is a list, not a keyed dict, so appending would quietly run the hook twice, then three times. Claude Code is the only editor whose session-hook schema this was verified against and the only one claimed. CONTEXTLAKE_NO_SESSION_REFRESH=1 switches it off without editing the file.

Failures are named, not counted: file_missing (the graph outlived the file), line_out_of_range (the file shrank under a stale index), name_absent (the line is there, the symbol is not on it), no_citation (a symbol node with no file or line at all). A repository whose recorded clone is not on this machine is unverifiable and stays out of the rate, so a run without the mirror reports "nothing was checked" rather than a pass. Off by default: it does filesystem work per result and needs the checkout.

Measured on a large legacy C/C++ tree, all 48,556 citable nodes: 48,552 verified, 4 broken. Two things worth knowing came out of that run rather than out of the feature.

It found a bug in the checker, in the good sense. SQL table citations verified at 7.7%. Every failure had the name absent case-sensitively and present on exactly the cited line case-insensitively, because kb/sql.py casefolds DDL object names on purpose -- SQL identifiers are case-insensitive and foreign-key attribution matches on the normalised form. The citations were right; the comparison was wrong. It is now case-insensitive for languages whose identifiers are, keyed on the node's language rather than its kind, and emphatically not global: in C++ Draw and draw are different symbols.

And it contradicted its own design note. The two-line window either side of line_start exists because a definition's start line can precede its name (a C++ return type on its own line, a decorator above a def). Measured, widening from 0 to 2 reclaims one node in 3,000, and 5 reclaims nothing further. The comment now carries that number instead of the story. The four remaining failures all sit in one file with the name exactly 3 lines below the cited line, which a window of 3 would paper over -- left at 2 on purpose, because tuning a check until it passes is how a check becomes decoration.

[7.1.0] - 2026-08-12#

Grounding, and the cost of 7.0.0's bigger graphs. Three of these follow directly from 7.0.0 emitting five more kinds of C/C++ symbol: the new kinds are now reachable by semantic search, the wiki's per-kind floors are bounded so they cannot displace the ranking they exist to garnish, and the dashboard's owner panel no longer walks a whole repository's history on every request.

contextlake kb embed must run once after upgrading. Nothing else needs re-indexing: no ids change and PARSER_VERSION stays at 4.

Added#

It is now 30 queries over a synthetic multi-language fixture repo (examples/fixtures/eval-repo , C++ header and source, Python, SQL, XML config) that parses to 42 nodes across 13 kinds, including the five symbol kinds 7.0.0 added. A retrieval change that only helps functions can no longer hide.

Measured: hit-rate 0.80, precision@10 0.7241, recall@10 0.80, MRR 0.80. All six misses are the natural-language phrasings ("reject an implausible sensor value"); every exact-name query hits. FTS5 has no synonym matching, so those six are not a defect, they are the gap semantic search exists to close, which is why they are in the set.

Three properties worth stating, because each is a trap avoided rather than a feature:

Changed#

kb embed must run once after upgrading. EMBED_CONTENT_VERSION moves to 4, which marks stored vectors stale and triggers that automatically. The bump is the load-bearing part: widening the embeddable set does not make existing vectors wrong, it makes the store incomplete, and the incremental skip is keyed on commit and parser version, neither of which moves. Without it you would keep a store with no vectors for the new kinds while doctor reported a healthy row count.

The cost was measured, not assumed. On a large legacy tree, +180% vectors costs 5.25 percentage points of recall@10 for the kinds that were already embedded (273/400 probes to 252/400). Marginally: typedefs, enumerators and file-scope variables together cost 1.00pp and take 12,342 symbols from 0 to 74% findable; macros add 1.50pp for 16,347 symbols at 85%; data members add 2.75pp for 40,948 at 65%. Every step buys more than it costs in the unit that matters, whether the thing you searched for can be returned at all.

field is the heaviest by far (+105.9% vectors alone, and the least distinctive names, 54.8% unique, one name occurring 506 times). It is recorded in the kind registry as the first row to reconsider if that cost ever bites.

Fixed#

Two fixes, both measured. The walk is now bounded to 12 score half-lives (~5.9 years), which is where a commit's weight reaches 0.00024 and can no longer change a ranking, so the truncated history was pure cost. And the result is cached on HEAD, since owners only change when history does.

Repeat requests went from 34-35s to 4.2s. A repository whose newest commit predates the window falls back to the unbounded walk, because answering "no owners" for a dormant-but-real repo would be worse than the slowness being removed.

Two bugs were caught inside this fix and are worth knowing about. --since was first passed a float, and git log --since="2160.0 days ago" exits 0 and returns zero commits, it silently fails to parse, so the bound never matched, the fallback always ran, and the net effect was two full walks instead of one. And the cache key was first stored in a variable the aggregation loop rebinds, so it wrote under a contributor's email and never hit. Both left every existing test passing; there are now tests for the single-walk property and for the cache actually hitting.

Still slow and separately tracked: the first request remains ~30s on a repository this size, because a 253 MB shard costs a measured 2.84 GiB resident and so exceeds the shard cache's whole 2 GiB budget, it is correctly never cached, and 7.0.0's larger graphs are what pushed shards past that line.

Measured after 7.0.0 started emitting five more C/C++ symbol kinds: on a real 12-kind repository with a 15-row cap, the list kept only 47% of the pure degree-ranked top; constructed at 17 kinds, 1 of 15 rows was a genuine high-degree node.

Floors are now bounded to cap // 2 and spent only on kinds the honest ranking left out entirely, a kind already in the top rows needs no reservation. Same bounded-share rule visualize/payload.py has always used. The original purpose survives: a zero-degree kind present in the candidates still gets a slot, which a test pins.

Worth knowing where this bit: the cap is 15 until a repository exceeds ~22,500 nodes, so the harm concentrated in repos with many kinds relative to their size rather than in the largest ones. The floor selection had no test at all before this change.

[7.0.0] - 2026-08-12#

A correct graph, and one re-index to get it. This release fixes what the graph says, so every id changes and PARSER_VERSION moves to 4. Run contextlake kb index once after upgrading; it now notices on its own, and so do the wiki, the vectors and the cluster pages, which previously reported themselves fresh across a parser change.

The headline corrections: node ids no longer contain a file path or a line number, so a header and its .cpp finally describe one symbol; C++ internal linkage is honoured, so two files' static or anonymous-namespace symbols stop merging into one; calls edges are stored per call site rather than per pair; and five kinds of symbol that were never emitted at all -- data members, macros, typedefs, enum constants and file-scope variables -- now exist. On a large legacy C/C++ tree that is 62,066 nodes to 131,603.

Two breaking changes outside the graph: the built-in wiki LLM moved to openvino-genai (no compiler, no wheel index, and CVE-2025-69872 closes by removal), and --repos patterns are anchored, so a bare name no longer selects every repo that merely contains it.

Added#

Each is contained by the scope it really sits in: a data member by its class, an enumerator by its enum, a namespace-scoped variable by its namespace. Macros keep the file in their identity even in C/C++, where other symbols drop it, because the preprocessor runs before C++ scope exists and two headers defining the same macro name really are two macros.

Two traps, both measured rather than guessed, and both handled:

None of the five is embeddable yet. The measurement found the dilution risk is repetition rather than short names: only 46.3% of data-member names are unique in that tree and one occurs 516 times. Turning them on interacts with the per-kind embedding budget floors, so it stays a sequenced decision rather than a side effect.

The cap itself stays, and that is a measured decision rather than an unexamined default. On a large legacy tree 21.6% of resolvable call references sit above it, and the distribution has no knee: admitting them all costs about 3.6x the calls edges for 21.6% more references, because an ambiguous reference emits one edge per candidate. Raising the cap to 8 buys 3.0% more references for 17.4% more edges; raising it to 12 buys 10% for 85%. The line is silent when the count is zero, so it never becomes boilerplate.

All three now carry edge_file and edge_line. For a path, each hop cites the edge that makes it adjacent and the seed node carries nothing, because it was not reached by an edge and inventing provenance for it would be worse than leaving it empty.

These are a separate pair from the call_file/call_line added in 6.7.0, deliberately. A depends_on edge's provenance is a manifest declaration and an inherits edge's is a base-class mention; delivering either under a field named call_line would be a plausible-looking lie, and this project's defect history is made of those. Each verb populates only the pair whose name describes its relation, so no result carries both. call_file/call_line are unchanged, so nothing built against 6.7.0 breaks.

Also recorded in that audit and deliberately NOT changed: blast_radius (a hit several hops out has no single edge to cite, so the current output is coarse rather than wrong) and the repo-level flow verbs (their edges are aggregates rolled up from many, and one line is not a property of an aggregate).

Stated plainly because a partial extraction must not be mistaken for a complete one: the host-side launch kernel<<<grid, block>>>(...) is not C++ syntax and lands in a local ERROR region, so a kernel launch is missed as a call while an ordinary call in the same file resolves normally. tree-sitter degrades locally rather than failing the file, so everything else still extracts.

This one had a measurable cost while it was open: asked "who calls this", a comparator that reads .cu returned genuine callers that contextlake could not see.

Measured on a large legacy C++ tree: 181 .xml files that produced nothing now produce 12,991 settings. The element path is what makes them distinguishable, so the same key name under two sections stays two settings rather than colliding.

Two deliberate choices, both about not lying and not leaking:

Data-shaped XML does not flood the graph: a lookup file of thousands of identical rows collapses to its distinct element paths, so it contributes a schema and where to find it rather than a copy of the data. Per-file output is capped, and files over max_file_bytes were already skipped.

Indexing that tree costs 42.8s against a 39.4s baseline, so 8.6% for the whole config surface. The first working version cost 107% instead: line numbers were counted from the start of the file once per match, which is quadratic on a data-shaped file with thousands of leaf elements. Counting forward from the previous match is linear and produces byte-identical output. This is the third time this exact quadratic-scan shape has been found in this package, after pom.xml parsing and parse_hcl, so it is worth naming as a pattern rather than a one-off: any per-match count or index from position zero over the same buffer is the bug.

Changed#

A filter that silently selects more than you asked for is the expensive direction of this mistake, because you find out after a fleet-wide run you did not want. The old default made that the easy thing to type.

--repos-exact was the opt-in fix for it, and it is removed rather than kept as a no-op: it only ever reached five of the seven places that filter repos, so --repos-exact silently did nothing for kb index --workspace and for the metrics pass, and the same pattern scoped differently depending on which command you ran. There is now one rule and no flag to forget.

Nothing else changes: globs like team/* behave exactly as before, matching is still case-insensitive, and it still matches against both the group-qualified path and the local path.

The extra keeps its name. What changed is what it installs.

The old backend was the one dependency a plain pip install could not finish: upstream publishes no wheels to PyPI at all, so pip fell back to compiling llama.cpp and wanted cmake plus a compiler, and the project had to point pip at a per-accelerator index to avoid it. It also had no wheel for CPython 3.14 on any x86_64 platform, upstream ships exactly two cp314 wheels and both are linux_riscv64, which is what pinned the container base image to an older Python.

openvino-genai ships ordinary manylinux wheels for CPython 3.10 through 3.14. Its closure is openvino-tokenizers and openvino; it pulls neither torch nor transformers, verified by resolving the extra in a clean environment rather than by reading metadata.

The default model is OpenVINO/Qwen2.5-Coder-0.5B-Instruct-int4-ov: Apache-2.0, published pre-converted by the OpenVINO project, and 349 MB against the previous 491 MB. It is the same family and size class as the GGUF it replaces, deliberately, a dependency change is not the place to slip in a bigger model. Pre-converted matters: converting a checkpoint to OpenVINO IR yourself needs optimum-intel, which does pull torch.

The model_file config key is gone. It selected a GGUF quantisation, and an OpenVINO model is a directory rather than a file, so there is nothing for it to pick.

It applies to calls only. The same resolver serves inheritance and the config/SQL streams, and retaining every mention of a base class or every reference to a table is a different question that has not been asked. The choice lives in one shared constant that the parser and every degree consumer read, rather than a relation name copied into three files.

Degree now counts distinct pairs. This is the part worth reading if you maintain a consumer: ranking by raw row count answers "how many call sites" while the number is rendered beside a symbol as "N caller(s)", so a helper called fifty times from one place would present as fifty callers and outrank genuinely popular code in both the wiki's hub list and the node selection for a truncated diagram. Counting distinct pairs is also exactly the historical number, since there was one row per pair before this change -- verified on a large tree by running the new ranking against a pre-change graph and diffing: byte-identical.

Removing the de-duplication exposed a latent bug it had been hiding. References were sorted by line alone, so references sharing a line fell back on tree-sitter's capture order, which is not guaranteed. Only one of them used to survive, so the ambiguity was invisible; now they all do, and the sort is a total order. Shard output stays deterministic.

The reason is not consistency for its own sake. A vector row is keyed by node id, so a stale embedding store yields hits naming nodes the graph no longer holds. These two dropped those hits silently and had nowhere to say so, because a bare list has no field for a note -- the caller got a shorter, entirely plausible answer, and doctor reported a healthy row count throughout. They now disclose it: "N vector hit(s) named nodes that are not in the graph and were dropped ... this result is INCOMPLETE. Re-run kb embed." A healthy store adds no note, which a control test asserts, because a warning that always fires is the defect rather than the fix.

It is still prefixed everywhere the file genuinely forms part of the qualification: every language that puts one module per file (Python stays m.py::Foo.bar), and C/C++ static symbols, whose internal linkage is file-scoped by language rule. That is deliberately the same single rule that decides whether the file enters a node id, computed once and used by both, so the two can never disagree about what a symbol's identity is.

The old id was repo + path + qualified-name + line, which made two things impossible. The path meant a declaration and its out-of-line definition could never be the same symbol. The line meant editing anything above a symbol changed its id, so every edge, vector and wiki reference to it churned for no semantic reason.

Ids are now <readable-slug>_<8 hex digest>. The slug keeps them legible where people actually read them -- answers, dashboards, MCP arguments -- and the digest is what makes them correct, covering repo, language, kind, qualified name, signature, and the file for internal-linkage symbols only.

Three details that are not arbitrary:

A consequence worth knowing: two headers declaring the same S::T now produce one class node. That is accurate rather than lossy -- in well-formed C++ S::T names exactly one class, and two differing definitions of it are an ODR violation.

The lists are deliberately not merged: a colour map and an embeddable set answer different questions, and file legitimately has a colour while never being embeddable. Instead kb/kinds.py holds one row per kind carrying every property a consumer needs, and each list became a one-line comprehension at its original definition site, so no import moved. KindSpec has no field defaults, so a new kind cannot be added without answering every question once, in one diff.

This closes the drift the lists had accumulated: 16 of the 35 produced kinds had no colour, which is not cosmetic -- the graph page builds its kind filter by iterating the colour map rather than the graph, so those kinds (including table, view and resource, routinely hundreds of nodes per repo) had no legend button and could not be isolated or hidden at all. Also fixed: the glyph table had drifted to 15 entries against a 17-symbol sprite; impact's ranking set tested membership against a type kind no producer emits; the published vocabulary diagram documented 16 of 35 kinds while claiming it could never drift; and the MCP link-output comment documented a merge_request kind the git-forge connector has never emitted (it emits mr).

Which kinds are actually embedded is unchanged -- membership feeds the per-kind embedding budget floors, so widening it would evict existing vectors. config_key and test are recorded in the registry as eligible and deliberately deferred, with the reason, and a test now refuses any kind that is excluded without one.

Fixed#

kb index has always been parser-aware. Embeddings, the wiki page and cluster pages were keyed on the repo's commit alone, so a bump refreshed the graph while all three went on reporting themselves fresh. The vectors are the sharpest case, because a vector row is keyed by node id: stale rows name nodes the graph no longer holds, those hits are dropped at query time, and the caller gets a shorter, entirely plausible answer while doctor reports a healthy row count.

Each now records the parser that built what it describes, and asks two questions instead of one. A wiki page carries its stamp in the provenance footer, placed after the backticked commit so the four readers that parse at commit \…`` are undisturbed. An artefact with no stamp regenerates once and then settles -- except where the shard itself has no version, which nothing can conclude from, so that case keeps asking the commit-only question rather than rebuilding forever.

Upgrading from any earlier version requires a re-index (contextlake kb index --force, or simply kb index, which now notices). Without it, ids in your store match nothing this build produces, and no commit-based check would have told you.

Two files each writing namespace { int tally(int); } produced one node. The second file's definition simply vanished, its callers pointed at the first file's function, and a struct declared the same way took its data members down with it -- a member of the losing copy disappeared entirely. static free functions already kept their file, but anonymous-namespace symbols did not, and the file-scope variable path never checked linkage at all.

Resolution now honours it too. An internal-linkage symbol belongs to one translation unit, so a reference from a different file cannot mean it. This is a preference, not a requirement: where the only candidate is defined in a header (measured at roughly one in ten on a large legacy tree, since headers legitimately carry static definitions into their includers), the cross-file candidate is kept rather than dropped. Losing a real caller is the worse error.

Fixing identity alone would have been worse than fixing neither: it splits the symbol and then offers both copies to every caller as ambiguous candidates.

A second-order effect worth knowing, because it moves numbers in the opposite direction to the obvious one: a reference whose candidate set exceeds the ambiguity cap is discarded entirely, so removing unreachable candidates pushes some sets back under the cap. On a large legacy tree 461 references that previously produced no edge at all now resolve, and total calls edges rose even though 33,568 impossible candidates were rejected.

Also corrected: static inside a class declares a member with external linkage, and was being treated as internal. That kept a class's header declaration from matching its out-of-line definition -- the same header/source split fixed elsewhere in this release.

ask was the sharpest case, because its disclosure reports the question's unmatched terms rather than dropped results. So it affirmatively stated that everything you asked about was indexed while quietly discarding most of what the search actually found.

It now counts the unresolvable hits and says so: "2 vector hit(s) named nodes that are not in the graph and were dropped: the embedding store is stale relative to the index, so this answer is INCOMPLETE. Re-run kb embed." A healthy store produces no warning, which is asserted by a control test, because a warning that always fires is the defect rather than the fix.

EMBED_CONTENT_VERSION's contract is also widened. It existed to catch exactly this class of staleness but keyed only on the node-to-text mapping, and the node id is not part of that text -- which is precisely why the failure was invisible. It now documents that any change to how node ids are built must bump it, since it is the only signal that reaches kb embed's incremental path and re-embedding is the only repair.

It now filters. The subtlety is why this was not a one-line wire-up: the default was ["csharp", "typescript", "python"], and passing that through would have silently stopped indexing C, C++, Go, Java, JavaScript, Kotlin, PHP, Ruby, Rust, Scala and TSX for everyone who never set the key -- a far worse bug than the dead setting, and a graph that quietly loses most of a polyglot repo.

So the default is now None, meaning every supported language, which is exactly the behaviour every existing install already has. An explicit list finally restricts. languages = [] also means everything, because "I did not decide" is a much likelier reading than "index no code at all", and the alternative is a silently empty graph. The old three-language constant is gone, with a test guarding against its return.

Both symbols now exist, and a parity test compares the two files by regex (no browser needed) so a kind can never again be registered in one place and missing from the other. The test was verified to fail when a symbol is removed, rather than merely passing today.

Resolution now matches the whole qualifier. An exact hit on the full chain wins; otherwise the chain must be a suffix of the class's own, which accepts a qualifier written relative to an enclosing namespace (void Box::put() inside namespace NS resolves to NS::Box) while still rejecting Other::Box. A qualifier naming no known class, or one that stays genuinely ambiguous, attaches nothing: file-contained is the honest answer.

Measured on a large legacy C++ tree, like for like against the same counting unit: methods per class 5.66 to 5.79, classes carrying zero methods 310 down to 296. Indexing cost is unchanged at 1:08 against a 1:07 control, because both lookups are prebuilt in the single pass that already existed rather than scanned per method.

Nothing in the suite previously forbade a fabricated parent, which is why this survived review: the graph looked richer rather than wrong. There is now an explicit negative test for it.

A template segment now contributes its base name (Box<T> gives Box, which is what the class node is called, since the arguments belong to the specialisation rather than to the class's identity), so NS::Box<T>::put qualifies as NS.Box.put instead of NS.put.

More importantly, every unrecognised scope shape now falls through to its own text instead of disappearing. That matters more than the template case itself: a segment that is merely ugly still resolves or fails visibly, while a segment that is absent quietly changes which class a method belongs to. The next scope type nobody anticipated will not repeat this.

The case name now becomes the node's name, the suite becomes its qualifier, and the kind is test. So TEST(TimerSuite, HandlesMinutes) is a test named HandlesMinutes qualified TimerSuite.HandlesMinutes, and two suites that share a case name stay two distinct nodes.

Absence of a return type is deliberately not the discriminator on its own, because a constructor and a destructor have none either: the macro name is matched against a closed set first. That set was verified macro by macro against the grammar. Catch2's TEST_CASE("a name") is excluded because with a string-literal argument it does not parse as a definition at all, so there is no node to rename and it needs a different mechanism; listing it would have been an unsupported claim.

test is registered in the diagram colour map and the dashboard glyph vocabulary, so it does not render as a file icon or drop out of the kind filter.

A name lookup still found those nodes, which is exactly why this survived: "where is this setting defined" looked answered while nothing could reach the setting by traversal and no diagram of a file could show its contents. Each of these files now gets its file node and a contains edge to everything extracted from it, in one place in the dispatch so it cannot drift per extractor. A file that yielded nothing still gets no node, so the graph gains no empty shells.

Not extended to manifests on purpose: their nodes are cross-repo package nodes that several manifests legitimately share, and the relation that belongs between a manifest and a package is depends_on, which is already emitted. contains would assert the package lives in that file.

Linking a setting to the code that reads it is deliberately not attempted. A config_key named Timeout and a string literal "Timeout" in a source file are a plausible match, not a verified one, and minting that edge is the speculation this graph refuses to do.

[6.7.0] - 2026-08-11#

Added#

Measured on a large legacy C++ tree: 6 of 6 callers of a sampled symbol reported a call line different from their definition line, every one of them readable in the real file at the line given. On that tree the two lines were 3 to 33 lines apart, which is exactly the gap a reader was being asked to close by hand.

Fixed#

Changed#

[6.6.0] - 2026-08-10#

Added#

A ```mermaid fence in any page renders as a diagram, from a vendored copy so the site makes no external request, loaded only on the pages that have one. The same fence renders natively on github.com, so a diagram stays readable in the source tree and in a review.

Roles are carried by shape, not colour: a rectangle runs, a cylinder persists, a rounded box starts or ends, a diamond decides. That is what WCAG 1.4.1 asks for, and it is also the only thing that works, because mermaid renders a classDef as an inline style attribute carrying !important and no stylesheet can override one, so a colour written into a page could never follow the light or dark theme.

Changed#

The SBOM describes the built wheel's dependency closure, generated from a throwaway virtual environment holding that wheel with the kb-full extras and nothing else. That distinction is the whole point: running a generator over the release job's own environment would have produced a document listing ruff, pytest and twine and called it contextlake's SBOM. A canary asserts the result before it is published, so this cannot silently drift back into describing the build machine. The scope is stated rather than implied: kb-full is kb + kb-local + kb-vec, and excludes kb-fastembed and llm-local.

The binaries are attested in the job that uploads them, so the signed digest is the digest of the bytes you download. gh attestation verify <file> --repo sayak-sarkar/contextlake checks one, with no key material. The install docs say plainly what that does not cover: the launcher fetches its Python payload from PyPI on your machine at first run, after any signature, so signing cannot reach that half. Anyone who wants the payload covered should install from PyPI, where the wheel and sdist carry PEP 740 attestations.

Two could not be pinned by simply naming a version, and each says so where it sits. pypa/gh-action-pypi-publish was on the release/v1 branch, which upstream recommends so their fixes arrive unbidden; it is pinned anyway because it is the step holding the publish token, and its head was checked to be exactly v1.14.2 at pin time rather than assumed. dtolnay/rust-toolchain@stable is also a branch, and its comment gives a date instead of a version, because the pin fixes the action that selects a toolchain and not the compiler it goes on to install.

Every finding was read. 4672 were S101 ("assert used") in tests/, which is pytest's whole idiom, and the rest of tests/ is fixtures doing on purpose what the rules warn about, so the ruleset is switched off there: nothing in tests/ ships. Two rules are off in the package as well, S603 and S607, because they fire on every subprocess this tool exists to run and on resolving git and glab from PATH, which is the only portable choice. The remaining 34 sites were each read and now carry their own reason. All 34 turned out to be safe, and two of them are the code that warns you about binding to a wildcard address, which is what the rule flagging them is for. Every one of the 12 "possible SQL injection" findings interpolates a ? placeholder count or a fixed clause fragment, never a value.

S now sits in the ordinary lint select, so it runs in CI on every push and pull request, in the release build gate, and in a contributor's own ruff check. A new finding fails the build. The separate job is gone rather than kept alongside, since it would only re-report what the main gate already refuses.

Fixed#

[6.5.0] - 2026-08-07#

Changed#

This changes what gets indexed. kb index previously used the parser's 5 MiB, so a source file between 5,000,000 and 5,242,880 bytes was parsed and is now skipped, and its symbols leave the graph on the next index. The window is narrow and a source file that size is usually generated, but the change is real and it is not a bug fix.

Fixed#

They are swept by reachability rather than by ownership, which matters: deleting them per repository would take the packages the surviving repositories still import, and that is exactly the bug the stable sentinel was introduced to prevent. A shared node goes only once no edge anywhere still references it.

[6.4.0] - 2026-08-07#

kb index now refuses to bundle a directory of repositories instead of doing it silently. This is a deliberate behaviour change on a path that previously succeeded, and it is the fix for the most damaging quiet failure the tool had: pointing kb index at a folder containing git repositories bundled them all into one pseudo-repository, duplicating every symbol under a second identity. On one real store that reached 63% of all nodes. --bundle opts back in.

Added#

Changed#

It refuses rather than quietly switching to --workspace for you, because switching can lose data. --workspace indexes each nested repository and nothing outside one, so on a tree of your own loose sources that happens to carry a dependency with its own .git it would index the dependency and silently drop your sources -- strictly worse than the bundling it replaced, which at least captured them. So the shape is measured first, from how much indexable content lies outside the nested repositories, and the refusal prints what was found (how many working trees, at what depths, how much content outside them), which shape that indicates, the one command that fits it with the real path in it, and why --bundle exists. It exits non-zero.

Three shapes, three answers. Several repositories with effectively nothing of your own outside them is a workspace mirror, and the command is --workspace <dir>. One repository with nothing at all outside it means the directory is one level too high, and the command names that repository. Real content of yours outside the repositories is a project carrying a dependency, and that is bundled as before, now with a line saying so rather than in silence. A directory that is itself a git repository never reaches the diagnosis at all, however many checkouts it contains, so the ordinary kb index . is untouched.

Fixed#

In a real run it cost coverage a subtler way than that. The operator saw that . was wrong, reasonably inferred the fleet lived one level down, and ran --workspace ./repositories; the repository sitting above that subdirectory was then never indexed under its own identity at all, only inside the bundle. Advice that cannot be followed literally is not a cosmetic defect, because the reader has to guess, and a plausible guess was wrong.

The message now echoes the path as it was typed -- shell-quoted only when the path would not survive a shell -- so it stays . for a bare kb index run, where the short form is both correct and the command the reader will recognise as their own.

[6.3.0] - 2026-08-07#

Accessibility and security hardening. Six WCAG 2.2 AA failures fixed in the dashboard, each verified in a real browser rather than from source; the remaining findings from the security audit that 6.2.0 began; and kb index now sees nested repositories at any depth.

Added#

A second audit job now resolves the full shipped dependency set -- every kb extra plus llm-local and release -- and audits that. It resolves without building anything, so the original flakiness argument does not apply, and it refuses to report a clean result unless the resolved set demonstrably contains the extras it is meant to cover: a resolution that silently returned nothing used to look identical to a clean scan, and now fails loudly instead. The release extra was added to the existing job for the same reason, since it compiles nothing.

Consequence for anyone auditing this project: "is the dependency tree free of known vulnerabilities" is now a question CI can answer for the profiles that ship, rather than only for a subset of them. That check currently surfaces one advisory in a transitive dependency of llm-local with no fixed upstream release; it is listed explicitly in the workflow as known-unresolved with its disposition still open, so the job's pass/fail signal reports newly appearing advisories. - Security response headers on every local HTTP server. kb dashboard --serve, kb graph --serve and the served static site now send a Content-Security-Policy, X-Content-Type-Options: nosniff and Referrer-Policy: no-referrer on every response. The policy is stated once in the shared server base, so all three servers get it and a future server inherits it.

This is defence in depth, not a fix on its own: the policy's job is to contain a page that has already gone wrong. default-src 'none' with connect-src 'self' means a script running on the dashboard's origin cannot send anything to another host -- no fetch, no beacon, no form post -- which is the step that would turn a page-render into data leaving your machine. frame-src/frame-ancestors stay 'self' so the dashboard's architecture panel keeps working, img-src allows the data: URIs the node glyphs use, and the jsDelivr origin is permitted for scripts because kb graph --serve --cdn loads cytoscape from there. Inline scripts and styles are allowed, since the pages inline their own assets by design.

Fixed#

This mattered because a source URL is not necessarily one you chose. contextlake discovers .contextlake.kb.toml by walking up from the current directory, and it clones repositories into your workspace itself, so a checkout could supply the config that supplies the URL -- no action needed beyond working in that directory. The existing workspace-trust gate covers config keys that reach a subprocess and deliberately leaves url alone as "an HTTP endpoint"; that is now true rather than assumed.

Ingest fetchers now open http and https only, and log a warning naming the refused scheme rather than skipping quietly. Configured http(s) sources are unaffected. Requests to private or link-local addresses are still permitted -- SECURITY.md now says so explicitly. If you need the discovered-config tier gone entirely, CONTEXTLAKE_NO_LOCAL_CONFIG=1 still does that.

[6.2.0] - 2026-08-07#

Contains security fixes. Two issues were reachable from ordinary content in a repository you index, so upgrading is recommended for anyone running kb dashboard, kb graph, or the Windows binary. See the first two entries under Fixed, including one manual step: regenerating graph HTML you saved or published.

Added#

Motivating case: running bare kb index in a directory that contains git repos rather than being one bundles everything underneath into a single pseudo-repo named after the directory. contextlake warns first and the warning prints the right command, but on one real store that pseudo-repo ended up holding 63% of all nodes, duplicating every mirrored repo under a second identity, with embed then spending 91% of its vectors on it. - --repos-exact for an exact repo id/path match. --repos has always matched a plain pattern as a substring of a repo's id or local path (documented, but easy to be surprised by): on a real fleet, --repos ledger selected the intended repo plus an unrelated one whose name merely contained "ledger". --repos-exact drops that substring leg while keeping glob patterns (frontend/*) working exactly as before, for anyone who wants --repos to mean "this repo, not also anything that happens to contain its name." The default is unchanged -- --repos alone still matches on substring, so nobody's existing script silently starts matching less.

Fixed#

Every payload entering a script context now goes through one shared escape (kb.security.json_for_script), which the static --site export already applied to its own snapshot and now shares rather than duplicating. Repository text is additionally escaped where it reaches an HTML attribute or element text: the kind and relationship legends, the page title, the wiki staleness badge, and the site index's repo links and headings. Hostile values are rendered verbatim as inert text, so graphs look exactly as before -- output for ordinary content is byte-for-byte unchanged.

Page templates are also filled in a single pass now. Previously each placeholder was substituted in turn over the whole document, so repository text that merely spelled a later placeholder (for example a symbol named __GLYPH__) had template markup inserted into the middle of the data after escaping had run -- corrupting the page in a way no amount of character escaping could prevent.

What to do: upgrade -- no configuration change is needed. Then regenerate any graph HTML you saved, shared or published: files written by an earlier version are static artifacts that still carry the unescaped payload, and upgrading cannot retroactively fix a file already on disk. That means anything produced by kb graph -o …, kb graph --c4 or kb dashboard --site. It matters most for a file you sent to someone else or put on a web server, and least for one you generated from code you wrote yourself. Pages served live by kb dashboard --serve and kb graph --serve are rendered per request, so they are fixed by the upgrade alone. - SECURITY: the dashboard's wiki route could be steered to read Markdown files outside the store on Windows (path traversal). The ?module= value and the repo id in the URL are both turned into a wiki filename, and only / was being replaced -- so a \-separated value walked out of the wiki directory and the file's contents came back rendered. It affected Windows hosts, including the shipped contextlake-windows-x86_64.exe; POSIX happened to be unaffected because \ is an ordinary filename character there. Reading was limited to files whose name ends .md, and required access to the dashboard, which binds to loopback by default.

Wiki filenames are now built with a character allowlist that folds every path separator, and -- independently of that -- the read path verifies the resolved file really sits inside the store's wiki directory before opening it, so the containment holds even if a future change to the naming rules reintroduces a separator. A blocked request reads as "no such page" rather than an error. Legitimate module page names are unchanged, including non-ASCII directory names, so no already-generated page is orphaned. - An MCP tool error is no longer returned as data. _parse_result ignored the isError flag on a tool result, so a failed call came back as its own error text. A caller that iterated the result then found a string, yielded nothing, and reported an empty answer. Live symptom: an Atlassian source reporting 0 site(s) reachable, which reads as a permissions problem on the account. An error result now raises, carrying the server's own text and the tool name. - The Atlassian source asks for the product OAuth scopes it needs. It spawned the mcp-remote bridge with no scope argument. The bridge resolves scope as an explicit value, else the server's advertised scopes_supported, else its own default, and the hosted Atlassian endpoint advertises none, so the request asked for openid email profile: enough to identify the person and nothing about Jira or Confluence. The token then genuinely saw no sites. contextlake now requests read-only product scopes plus offline_access (without which every run re-opens the browser), overridable per source with scopes. - A local model that runs out of time says so, and says which knob to turn. urllib raises a read timeout whose entire message is timed out, and callers print that, so a wiki run against a CPU-only Ollama reported three words per page: not the provider, not the model, not the budget it waited for, and not that the budget is adjustable. It now names all of them, points at timeout under [llm], and says that a local model with no GPU running a council of 3 per page will exceed 300s. timeout is also a declared config field now rather than reaching the client through extra="allow", where it worked but was invisible to the config docs. - kb wiki checks its backends before announcing work. With the llm-local extra absent, --llm builtin printed a reviewer-quality advisory and Generating wiki for 1 repo(s) with builtin (council of 3), and only then failed per repo: it advised on a council that could not convene and claimed work that never started. Both the generation and review clients are now checked first via an optional preflight hook, which for the built-in model is an import check that neither downloads the GGUF nor loads weights. - Atlassian site discovery tells its failure modes apart. A tool error, a renamed tool, a changed response shape and a genuinely empty site list all produced the same empty mapping and the same "no sites accessible to this token" line, which named the one cause that was not true. Discovery now tolerates the wrapped response shapes its sibling parsers already handled, raises on a payload that is not a site list, and reports the three outcomes separately. An empty list still means no sites. - Docs: the working llm-local install command is now unmissable wherever --llm builtin is offered. pip install "contextlake[kb-full,llm-local]" fails on a machine with no C/C++ compiler, building llama-cpp-python from source: upstream ships no PyPI wheels, so a plain install always compiles. The working form already existed at docs/install.md (contextlake doctor --fix llm-local, which attaches the prebuilt CPU wheel index), and the runtime failure in the built-in LLM client already named it too, but neither was reachable from the places a user actually goes to turn on the wiki's local model: docs/keep-fresh.md's bootstrap --llm builtin example, docs/generate-wiki.md's kb wiki --llm builtin example, and docs/dashboard.md's copy-paste Wiki-tab command all showed or named builtin with no pointer to the extra step it needs, and the command-reference tables in README.md and docs/cli-reference.md listed --llm builtin alongside ollama/openai/anthropic/cli with no hint that one of those five needs anything extra at all. Each now names contextlake doctor --fix llm-local (or links to the existing docs/install.md section that does) right next to the --llm builtin example it sits beside, and calls out that --llm ollama needs no compiler at all as the no-install alternative. docs/install.md, docs/model-providers.md, docs/troubleshooting.md and QUICKSTART.md's bootstrap walkthrough already covered this correctly and needed no change beyond one added clause in QUICKSTART.md naming ollama's no-compiler property explicitly. - kb index's "Workspace indexed" summary reports the workspace, not the whole store. It printed store.stats() -- a store-wide count over every repo the store has ever indexed, from any --workspace -- under a line labelled with this run's workspace. On a real fleet the line read "Workspace indexed: 21 repos" two lines after "Found 19 repositories under repositories", and the store itself held 39 distinct repo ids: three disagreeing denominators for what should have been one number. The summary now sums repo_counts() over exactly the repo list this run discovered under the named workspace -- the same list the "Found N repositories" line above it counts -- so the two lines can never disagree, and an unrelated repo indexed by an earlier run under a different workspace can no longer inflate this one's numbers. - HuggingFace Hub download progress bars no longer leak into kb connect/kb embed. hush_hf_hub() was already called before every local-model download, but its env vars and logger-level settings gate HF Hub's own logging and deprecation warnings -- never the separate tqdm progress-bar switch, so three bars still rendered per fetch, two of them showing no file name or percentage (only a byte count stuck at 0.00B). Progress bars are now hushed there too, unless --verbose was passed -- a verbose run still sees them, e.g. to confirm a large model is actually moving.

[6.1.0] - 2026-08-06#

Added#

Changed#

Fixed#

[6.0.0] - 2026-08-05#

This is a major release. Three things need action or awareness before you upgrade, and the first is the one that fails quietly.

Re-index every store. The parser version moved from 2 to 3, so every shard built by an earlier release is stale. A stale store does not error: it keeps answering, from a graph an old parser produced. Run contextlake kb index against each workspace after upgrading. contextlake doctor names the repositories that are out of date, which is the fastest way to see whether you still need to, but read its output rather than its exit code: shard staleness is reported and does not fail the command, the same way kb lint treats it.

shortest_path returns an object, not a list. Any MCP client reading the tool's result as a bare array of nodes must read nodes instead. The shape changed because the old one had nowhere to put a flag: an unknown source, an unknown destination, and two real nodes with no route between them all came back as the same empty list, and only the last is what an empty answer reads as. The new envelope carries nodes, found, hops and a gap naming which miss occurred.

Python 3.10 is the minimum. The mirror core previously claimed 3.9 while the knowledge layer needed 3.10. There is one floor now, and pip declines cleanly on an older interpreter.

Smaller behaviour changes worth knowing: values that were previously accepted and could not work are now refused (out-of-range --port and --tool-concurrency, and dashboard --serve --site), and semantic_search and hybrid_search now return nothing when a question has no anchor in the index rather than always returning k results.

Changed#

Fixed#

Reads now honour the scope of the invocation. A cache built with no filter is a superset, so --repos is applied straight off it with no refetch; a cache built with a different filter can neither confirm nor deny what this run asked for, so it is re-enumerated instead of answered from. status, which never enumerates, names the scope the cache does cover rather than presenting it as the group, and names the scope it is reporting whenever one is in force. status narrows both sides of its comparison, as verify already did: narrowing the project list and not the local tree would report every non-matching clone in a fully-synced workspace as an extra repository. clone's "already cloned locally" count follows the same scope. The "no local repositories matched" warning now fires only when there was something for the filter to match, so clone --repos <name> into an empty workspace, the feature's own happy path, stops telling you to check a pattern that is working. - ask's owners answer claimed a git-history ranking it had never run. who_knows returns an empty owner list early, before a single git command is issued, when the repo has no local clone path on record. The answer was labelled , ranked from git history. regardless, so "nobody owns this" and "no history was ever read" reached the caller as the same sentence with the same provenance claim attached. The line is now derived from whether the ranking actually happened, and an empty result says which of the two produced it. who_knows carries the reason itself, in a new ranking_gap field, so the MCP tool is as honest as the router that wraps it.

[5.1.1] - 2026-08-04#

Two defects found by manual CLI testing, both of which a developer machine hides.

Fixed#

The second half is why it went unnoticed: glab api ran without check=True, so a rejected call returned a non-zero code that nothing raised on. The circuit breaker never counted it, never opened, and the resulting empty list was indistinguishable from "no open merge requests", so a source whose every call was refused still printed ✓ Connect complete: 0 external link(s) stored. A refused call is now a failure the breaker sees and the log names.

The unit tests passed throughout, because their fixtures used a bare api/svc repo id, a form the system stopped emitting. They now derive the fixture from the function that produces it. - mirror update and mirror branches never authenticated. The token env was built inline by the clone path and nothing else could reach it, so every fetch ran unauthenticated. On a workstation an ambient git credential helper supplies the credential and hides this entirely. Where the token is the only credential, a container or a CI job, the first sync clones successfully and every later refresh fails with could not read Username. All three fetch sites funnel through one helper, which now carries the same header the clone path uses. - Removed 106 em-dashes from documentation prose and added a test that keeps them out. The house style has always been to avoid them, but the only thing enforcing it was de_emdash in site/build_docs.py, which rewrites them at render time. That made the built site look correct while the markdown source accumulated them, and they reached readers everywhere the site is not: the repository on GitHub, the project page on PyPI, and llms-full.txt. Fenced code blocks are exempt, since their bytes are meant to match what a terminal actually prints. - doctor printed a green ✓ config loads whether or not a config existed, so a machine with no configuration at all looked identical to one whose config loaded cleanly, and the paths it had searched were never shown. The mirror side already reported both properly; the two halves of the tool now agree. A missing config is a warning rather than a failure, since built-in defaults are legitimate, and it does not change doctor's exit code.

The underlying cause is that "loaded nothing" and "loaded a file that happens to be empty" produce an identical merged result. KbConfig now carries loaded_from and searched, recorded in the one function that knows the precedence chain rather than re-derived by each caller.

[5.1.0] - 2026-08-04#

Added#

The privilege boundary is the point of the design. Python packages install into the current interpreter via sys.executable -m pip. A system package is never installed silently: the exact command is printed and offered with a y/N at a real terminal, and nothing privileged runs without a TTY or under --skip-interactive, so a CI job or a scripted run can never trip a sudo prompt. An externally-managed environment (PEP 668) is reported with the venv/pipx fix rather than pip's raw error. - The local-LLM install now attaches the CPU wheel index automatically, so it no longer needs a C++ toolchain. llama-cpp-python publishes no wheels to PyPI at all: llama.cpp is built per hardware backend, and one namespace cannot hold the CPU, CUDA and Metal builds of a version, so upstream ships an index per accelerator (as PyTorch does). Verified end to end on a Python 3.14 machine with no cmake and no g++. - kb lint, the graph_health MCP tool and the dashboard health payload gain additive parser_stale and parser_stale_repos fields.

Changed#

Fixed#

One new failure mode, deliberately. A bind mount carries the host's ownership, and the container runs as uid 1000, so if your host account is not uid 1000 the write now fails with a permission error where it previously "succeeded" by writing into the container and losing the result. Pass -u "$(id -u):$(id -g)" to run as yourself. - An index left stale by an upgrade is no longer invisible. PARSER_VERSION moved to 2 in 5.0.0, but doctor's staleness check only examined C and C++ repositories, and the re-index decision compared the repository HEAD alone. A Python or TypeScript repository indexed by 4.0.0 therefore stayed stale indefinitely: index reported it unchanged, doctor reported OK, and every answer came from a graph built by the old parser while every surface said healthy. That is the confident-but-wrong failure this tool exists to prevent.

doctor now flags a stale shard in any language, and kb index rebuilds a parser-stale repository instead of skipping it, announcing why. The re-index is scoped to repositories whose parser version differs, so it is not a blanket --force and it settles after one pass. The store schema gains a parser_version column (version 3) via an additive migration that leaves existing rows intact; a repository indexed before the column existed falls back to reading the shard. - kb lint was silent about parser staleness while doctor graded it as a fault, so the two commands disagreed about the same store. lint now reports it as its own category rather than folding it into stale: a parser-stale graph is out of date, not broken, and folding it in would flip lint's exit code from 0 to 1 for every store the moment PARSER_VERSION moves, turning an upgrade into a red CI gate. The exit code, clean semantics and glyph are unchanged. - Opening a store written by a newer contextlake silently re-stamped it to the running schema version, discarding the newer build's claim about its own format. The stamp is now read before it is written, anything newer or unparsable is preserved, and the store is refused with both versions, the path and the remedy named. An older stamp still migrates forward. This protects builds carrying this change only: an older binary will still downgrade a store it does not understand. - Knowledge commands loaded their config twice per invocation, so a single mistyped key produced two identical warnings and read as two separate problems. Resolved once and memoised for the lifetime of one invocation. - The repository-list cache no longer defaults into /tmp. It now lives under ~/.cache/contextlake with 0700 permissions. The old default was world-readable in a predictable location, listed every repository the account can reach along with clone URLs, and was shared by every workspace on the machine, so per-directory configs were not actually isolated. .contextlake.ini.example shipped an active cache_dir = /tmp line, and the "no config found" error points users at that file, so the bad default propagated by being copied. - Mirror commands refuse to run when the configured group is missing or is still the your-gitlab-group placeholder, instead of exiting 0 after printing a plausible sync report against a group that does not exist. init already refused that exact placeholder, so the two halves of the tool now agree. - init --skip-interactive no longer appends a completion block to your shell rc. Editing ~/.zshrc is a side effect well outside what init implies, and a non-interactive run never asked. Use contextlake completion to opt in. - contextlake inti now suggests init rather than kb lint, and an unknown flag on a subcommand prints that subcommand's usage instead of the root parser's. - The generated knowledge config names an explicit local provider rather than auto, so what runs is visible in the file rather than resolved at call time. - The error raised the first time the built-in LLM is used now prints a command that actually works. It previously suggested a plain pip install, which compiles from source and fails on any machine without a toolchain, which is most machines that hit this message. - docker pull ghcr.io/sayak-sarkar/contextlake (no tag) returned the slim image. The slim build's tag metadata did not disable metadata-action's default latest=auto, so it claimed a bare latest alongside its own tags, and because slim is pushed after full it won. latest now belongs to the full image again. If you pulled latest at 5.0.0 and expected the built-in local model, re-pull: the image you have is the slim one. - A failed PyPI upload no longer takes the GitHub Release with it. github-release depended on publish succeeding, so on the 4.0.0 tag a duplicate-file failure skipped it and the wheel and sdist had to be attached by hand. Publishing is now idempotent (skip-existing), and the release job runs whenever the artifacts built, since a GitHub Release has value regardless of whether the index accepted the upload. - Corrected five documentation claims that were wrong at 5.0.0, each verified against the source. The README and QUICKSTART upgrade sections said the graph re-indexes incrementally and nothing needs migrating, when 5.0.0 in fact made every existing shard stale; both now send you to doctor and kb index --force. The .mcp.json and .vscode/mcp.json snippets in serve.md passed serve without the kb namespace, so copy-pasting either produced an unknown-command error. usage.md told you to copy .contextlake.ini, which does not exist (the template is .contextlake.ini.example). cli-reference.md and troubleshooting.md offered a C++ toolchain via doctor --fix that no code path reaches.

[5.0.0] - 2026-08-04#

This release closes a remote-code-execution path and two denial-of-service paths, all three reachable by indexing a repository you cloned. Upgrading is recommended for anyone running 4.0.0.

Migrating from 4.0.0

  1. Run contextlake kb index --force. The parser version moved to 2, so every existing shard is stale, and nothing detects that on its own: needs_reindex compares only the repo HEAD.
  2. Mirror commands now exit 1 when repositories failed. They previously always exited 0. If a script depends on the old behaviour, add --exit-zero-on-partial; if it already checks $?, it starts working as intended and may go red where it was silently failing.
  3. contextlake.py at the repo root is now run-contextlake.py. Only affects running the launcher from a clone; the installed contextlake command and the standalone binaries are unchanged.
  4. kb serve --transport http and sse now require a bearer token, and refuse a non-loopback --host without --allow-remote. stdio is unaffected and needs no token, which is the default and what every documented editor integration uses.

Security#

stdio is completely unaffected and needs no token. That is the default and what every documented editor integration uses, so most setups need no change.

The dashboard's "start MCP server" card spawns that same command with its stderr discarded, which would have thrown the token away and left the card advertising a server nobody could connect to. It now mints the token itself, passes it to the child, stores it in a 0600 pidfile, and shows it on the card. - Security: --llm-chat is now refused with a non-loopback --host, the same guard --allow-mutations already had. The per-launch token that gates the chat route is served inside /dashboard.js, so anyone who could reach the bind could read the token and drive the configured LLM provider at the operator's expense. Host-header pinning does not cover this: pinning is a browser control, and a plain curl -H 'Host: localhost:PORT' http://<lan-ip>:PORT/dashboard.js satisfies it and returns the token. - Security: the dashboard's POST /api/mcp/serve no longer accepts an arbitrary bind address. A caller-supplied host went into the MCP server unvalidated, so a token holder could publish the whole graph on 0.0.0.0 over a transport with no authentication. The host must now be loopback and the port unprivileged; anything else is a 400, including a wrong-typed JSON value, which previously raised and surfaced as a 500. - Security: the dashboard and graph servers now pin the Host header on GET as well as POST. Only POST checked it, so a page whose domain re-resolved to 127.0.0.1 (DNS rebinding) could read the entire code graph cross-origin: /api/overview, /api/repo/<id>, /api/search, /graph/*: file paths, symbol names, owner identities. Static assets are deliberately not exempt, because dashboard.js carries the per-process token and exempting it would hand a rebinding page the key to the mutating routes. One consequence worth knowing: a server bound with --host 0.0.0.0 and browsed via its LAN address now returns 403; use http://localhost:PORT or bind the address you intend to browse. The server prints a hint. - A config file found by directory search can no longer make contextlake execute a program. .contextlake.kb.toml is discovered by walking up from the current directory, so a repository you cloned could ship one setting [llm] provider = "cli" + command/args, handed straight to subprocess.run by the next kb wiki, kb enrich, or dashboard --llm-chat. The same hole existed in [[sources]], whose command/args/mcp_command spawn an MCP server over stdio. Those keys are now honoured only from ~/.contextlake/kb.toml or an explicit --config path; from a discovered file they are dropped with a warning naming the file and the key. Nothing else is distrusted: store_dir, languages, max_file_bytes, [embeddings], [[rules]], and non-cli LLM providers keep working from a project-local file exactly as before, so directory-scoped config is unaffected. Passing --config on that same file still honours it: naming the file is the explicit act the gate asks for.

Added#

Changed#

What counts as a failure is exactly what each stage already logged as an error, so no repo is reclassified. Skipped work (already up to date, protected branch, dry run) is never a failure; neither is a verify that reports repos missing or extra (only a cloned path with no .git, which is corruption). fetch fails on 0 projects only when no --repos/repo_filter is in play, since 0 matches for a narrow pattern is a legitimate answer.

Migrating: if a script or CI job relies on a mirror command always exiting 0, add --exit-zero-on-partial. If it already checks the exit status, it starts working as intended, expect jobs to go red that were silently failing before.

Fixed#

Action required: run contextlake kb index --force (add --workspace <dir> if you keep the store elsewhere). Existing shards are stale, and nothing will tell you so: needs_reindex compares only the repo HEAD and does not consider the parser version, and doctor's stale-parser check is deliberately scoped to C/C++. An unchanged Python or TypeScript repo will therefore be neither flagged nor rebuilt on its own.

Two limits worth knowing: shard bytes are reproducible on one machine, not across machines, since file order still comes from directory traversal, so do not compare shard hashes between CI runners. The regression guard is in-process. - Malformed query parameters return 400 with a JSON body instead of raising inside the handler thread and dumping a traceback with no response. Out-of-range values clamp rather than error, and internal failures return a generic 500 with the traceback going to the log, never to the client. Two further unguarded integer parses on client input (Content-Length, and the mutation port) were fixed at the same time.

[4.0.0] - 2026-08-04#

Migrating from v3.0.0: replace contextlake init --yes / -y with contextlake init --skip-interactive -- it is a rename, not a new option; the old flags no longer parse.

Changed#

Fixed#

[3.0.0] - 2026-08-03#

Added#

Changed#

Removed#

Fixed#

[2.67.0] - 2026-07-31#

Added#

[2.66.0] - 2026-07-31#

Added#

Fixed#

[2.65.0] - 2026-07-31#

Fixed#

Added#

[2.64.0] - 2026-07-30#

Added#

[2.63.0] - 2026-07-30#

Added#

[2.62.1] - 2026-07-30#

Fixed#

[2.62.0] - 2026-07-30#

Added#

[2.61.0] - 2026-07-30#

Added#

This is an additive-only pass: no command, flag, or alias was renamed, removed, or re-nested -- everything documented in docs/usage.md, the README, and the site keeps working exactly as before. Full proposal, rationale, and what was deliberately deferred (not done here): planning/specs/ spec-cli-simplification.md.

[2.60.8] - 2026-07-30#

Fixed#

Added#

[2.60.7] - 2026-07-30#

Fixed#

Added#

[2.60.6] - 2026-07-29#

Fixed#

[2.60.5] - 2026-07-29#

Fixed#

[2.60.4] - 2026-07-29#

Fixed#

Added#

[2.60.3] - 2026-07-29#

Added#

[2.60.2] - 2026-07-29#

Fixed#

[2.60.1] - 2026-07-29#

Fixed#

Documentation#

[2.60.0] - 2026-07-29#

Added#

[2.59.1] - 2026-07-29#

Changed#

Fixed#

[2.59.0] - 2026-07-29#

Added#

[2.58.3] - 2026-07-29#

Fixed#

[2.58.2] - 2026-07-29#

Added#

Fixed#

[2.58.1] - 2026-07-28#

Fixed#

[2.58.0] - 2026-07-27#

Changed#

[2.57.0] - 2026-07-27#

Added#

[2.56.0] - 2026-07-27#

Added#

Fixed#

[2.55.0] - 2026-07-27#

Added#

Fixed#

[2.54.0] - 2026-07-27#

Added#

[2.53.0] - 2026-07-26#

Added#

[2.52.0] - 2026-07-26#

Added#

Fixed#

[2.51.1] - 2026-07-26#

Fixed#

[2.51.0] - 2026-07-26#

Added#

Fixed#

[2.50.0] - 2026-07-26#

Added#

Fixed#

Changed#

[2.49.0] - 2026-07-26#

Removed#

[2.48.2] - 2026-07-26#

Fixed#

[2.48.1] - 2026-07-26#

Fixed#

[2.48.0] - 2026-07-26#

Added#

Fixed#

[2.47.0] - 2026-07-25#

BREAKING#

[2.46.0] - 2026-07-25#

Added#

Fixed#

[2.45.1] - 2026-07-25#

Fixed#

[2.45.0] - 2026-07-25#

Fixed#

Changed#

[2.44.0] - 2026-07-23#

Added#

Changed#

[2.43.0] - 2026-07-22#

Added#

Changed#

[2.42.0] - 2026-07-21#

Fixed#

Added#

[2.41.0] - 2026-07-21#

Added#

[2.40.0] - 2026-07-20#

Added#

[2.39.0] - 2026-07-20#

Added#

[2.38.0] - 2026-07-20#

Added#

[2.37.0] - 2026-07-08#

Added#

Fixed#

[2.36.0] - 2026-07-08#

Added#

Documentation#

[2.35.0] - 2026-07-08#

Added#

Changed#

Fixed#

Documentation#

[2.34.0] - 2026-07-07#

Added#

Changed#

Fixed#

Documentation#

[2.33.2] - 2026-07-06#

[2.33.1] - 2026-07-06#

Fixed#

Documentation#

[2.33.0] - 2026-07-06#

Added#

Changed#

Added#

Fixed#

[2.30.0] - 2026-07-06#

Added#

Added#

[2.28.0] - 2026-07-06#

Changed#

[2.27.0] - 2026-07-06#

Added#

[2.26.0] - 2026-07-06#

Added#

[2.25.0] - 2026-07-02#

Added#

[2.24.0] - 2026-07-02#

Added#

[2.23.0] - 2026-07-02#

Added#

Changed#

[2.22.0] - 2026-07-02#

Added#

Changed#

[2.21.0] - 2026-07-02#

The product-review hardening release: an end-to-end review as a brand-new pip install user surfaced the gaps between the advertised experience and the real one; this release closes them.

Fixed#

Added#

Changed#

[2.20.1] - 2026-07-01#

Fixed#

Added#

[2.20.0] - 2026-06-30#

Added#

Fixed#

Changed#

[2.19.2] - 2026-06-28#

Fixed#

[2.19.1] - 2026-06-28#

Fixed#

[2.19.0] - 2026-06-28#

Added#

[2.18.0] - 2026-06-28#

Added#

[2.17.0] - 2026-06-28#

Added#

[2.16.0] - 2026-06-28#

Added#

[2.15.0] - 2026-06-28#

Added#

[2.14.0] - 2026-06-28#

Added#

[2.13.0] - 2026-06-28#

Added#

[2.12.0] - 2026-06-28#

Added#

[2.11.0] - 2026-06-28#

Changed#

[2.10.0] - 2026-06-28#

Added#

Changed#

[2.9.1] - 2026-06-26#

Changed#

[2.9.0] - 2026-06-26#

Added#

Changed#

Fixed#

Security#

[2.8.0] - 2026-06-26#

Added#

[2.7.0] - 2026-06-26#

Added#

[2.6.0] - 2026-06-26#

Security#

Added#

[2.5.1] - 2026-06-26#

Fixed#

Changed#

[2.5.0] - 2026-06-26#

Added#

Changed#

Added#

[2.4.0] - 2026-06-25#

Added#

Changed#

[2.3.0] - 2026-06-24#

Added#

Changed#

Fixed#

[2.2.0] - 2026-06-23#

Added#

[2.1.6] - 2026-06-23#

Fixed#

Added#

Changed#

[2.1.5] - 2026-06-23#

Added#

[2.1.4] - 2026-06-22#

Changed#

[2.1.3] - 2026-06-22#

Changed#

[2.1.2] - 2026-06-22#

Added#

Changed#

[2.1.1] - 2026-06-22#

Added#

[2.1.0] - 2026-06-22#

Added#

Changed#

[2.0.1] - 2026-06-22#

Changed#

[2.0.0] - 2026-06-22#

Changed#

Note#

[1.18.1] - 2026-06-22#

Changed#

[1.18.0] - 2026-06-22#

Added#

[1.17.1] - 2026-06-22#

Changed#

[1.17.0] - 2026-06-22#

Changed#

[1.16.0] - 2026-06-22#

Added#

Changed#

[1.15.0] - 2026-06-22#

Added#

Changed#

[1.14.0] - 2026-06-22#

Added#

[1.13.0] - 2026-06-22#

Added#

[1.12.0] - 2026-06-22#

Added#

[1.11.0] - 2026-06-22#

Added#

[1.10.0] - 2026-06-21#

Added#

[1.9.1] - 2026-06-21#

Fixed#

Changed#

[1.9.0] - 2026-06-21#

Added#

[1.8.0] - 2026-06-21#

Added#

[1.7.0] - 2026-06-21#

Added#

[1.6.0] - 2026-06-21#

Added#

[1.5.0] - 2026-06-21#

Added#

Fixed#

[1.4.0] - 2026-06-21#

Adds an optional knowledge layer (gitlab_sync.kb, the [kb] extra, Python ≥ 3.10) that turns the mirrored repositories into a queryable knowledge graph served to AI agents over MCP. The core sync tool is unchanged and the extra is entirely opt-in. Everything is generic and config-driven, no private data lives in the package.

Added#

[1.3.0] - 2026-06-21#

This release stabilizes the core and makes the tool installable. It repairs several regressions introduced by the earlier modularization and fixes a critical configuration bug.

Fixed#

Added#

Changed#

[1.2.0] - 2026-06-16#

Added#

Changed#

[1.1.0] - 2026-05-24#

Added#

Changed#

[1.0.0] - 2026-05-10#

Added#

Next steps