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]#
[9.2.0] - 2026-09-07#
Added#
- A networked server now records which key called which tool, and
contextlake kb keys usagereads it back.kb keys list'sLAST USEDcolumn, frozen at-since it shipped, is filled from the same file.
Usage: 140 calls (140 timed) /home/you/.contextlake/kb/mcp-usage.jsonl
KEY CALLS ERR THR DENY P50 P95
k_4f2a91 120 0 0 0 75ms 142ms
k_9c01de 20 20 500 0 423ms 843ms
Refused requests (never reached a tool)
throttled 500
unknown 30
identity_unset 12
total 545
A row has six fields and there is nowhere to put a seventh: the minute, the key id,
the tool name, the outcome, the tool time in whole milliseconds, and how many events the
row stands for. No query text, no symbol, no repository, no file path, no client address,
and nothing about the credential a refused caller presented. The recorder takes keyword
arguments only, with no free-text parameter and no **kwargs, so that is structural
rather than a sanitiser somebody has to remember to run.
Counts, not lines, for traffic the server never admitted. The eight refusal outcomes
and the identity fault are counted into one row per key, tool and minute; 500 refused
requests are one line reading 500. An unauthenticated flood would otherwise evict every
real row inside a minute. Calls from an issued key keep one row each, because a
percentile needs the individual values.
A refused call is recorded too. The row is written in the tool wrapper's outer
finally, so a call refused by the tool grant, a call the rate limiter never admitted and
a call that raised are all in the file. Percentiles are nearest-rank; a refusal above the
concurrency slot carries no duration and prints - rather than 0ms.
Rows buffer in memory and are written every ten seconds and on shutdown, so a tool call
does no disk I/O. The file grows to 22,000 rows and is then trimmed back to the newest
20,000, so the rewrite happens once per 2,000 rows instead of once per append. A line the
reader cannot score, from a truncated write or a newer contextlake, is skipped and
counted, and kb keys usage says how many rather than quietly reporting a short total.
Three things it deliberately does not measure, each stated on the surface that prints it:
one ask counts once, as ask, since it reaches its eight siblings below the wrapper;
tools/list and the handshake cross no wrapper, so CALLS counts tool calls and never
HTTP requests; and kb://stats resource reads are not recorded.
Off with --no-usage or [serve] usage = false. [serve] usage_max_lines and
usage_flush_seconds tune it, read only from a config you named, the same gate
[serve] keys_file and the quota defaults go through.
stdio is unchanged, byte for byte. It builds no recorder, reads no ContextVar and does not load the usage module at all.
--rate,--burstand--cost-budgeton a key are now enforced over the network. They were recorded and read by nothing. Measured on a livekb serve --transport http --keys-onlyserver: a key created--rate 3/min --burst 4answered four calls and then
HTTP/1.1 429 Too Many Requests
content-type: application/json
retry-after: 20
{"jsonrpc":"2.0","id":null,"error":{"code":-32000,"message":"rate limit exceeded for this key: 3/min. retry in 20s"}}
Two buckets per key, filled lazily from two floats each. --rate and --burst bound
requests; --cost-budget bounds tool TIME, as a duration per period (30s/min), and
each call is charged how long its body ran. A duration rather than a count because a
count misprices ask by 8x: it is one request and eight tool bodies, since ask
reaches its siblings below the wrapper that could have counted them.
The refusal is at the gate, before the request reaches any tool. So it costs one
header parse rather than a worker thread, and it covers tools/list and the
kb://stats resource, which cross no tool wrapper at all. A caller with no valid key
gets 401 and is never counted against a quota: identity resolves first, which is
what keeps the bucket map keyed by ids this server minted rather than by anything a
caller can forge.
Values are validated now. kb keys create --rate 60 is refused at the flag, naming
the string, so a typo cannot be minted onto a key that then reads as limited. The same
parser runs over every stored value when a key file is loaded for serving: a bad value
exits 1 before the socket binds, and a bad value introduced by a live edit is rejected
with one warning while the previous keyring keeps serving.
none on any axis means no limit there, and beats a server default. --burst needs
--rate: on its own it is the capacity of a bucket that does not exist. The minimum
burst is 4, because an MCP client spends three requests on the handshake before its
first tool call.
Not persisted and not shared between processes: a restart refills every quota, and two
server processes give each key twice its quota. On the sse transport the 429 message
is lost and the session closes, which is a defect in that client, not in this server.
docs/mcp-transports.md carries all three.
stdio is unchanged, byte for byte. It builds no limiter, opens no timer and does not load the rate-limit module at all.
[serve] default_rate,default_burstanddefault_cost_budgetinkb.toml, for a quota that applies to every key that names none of its own. All three are unset out of the box, so an upgrade starts limiting nobody. Read only from~/.contextlake/kb.tomlor a file passed to--config: a.contextlake.kb.tomlfound by walking up from the current directory is ignored with one line saying so, because a rate limit a repository checkout can rewrite is not a limit.
A shared token is bounded by default_rate and has no per-credential opt-out, since it
has no key record to write none on.
-
Unknown keys in
[serve]are warned about. The table was known but its keys were never checked the way[kb]keys are, sodefault_rat = "60/min"was a silent way to leave every key unlimited. It now prints one line naming the key and the known set. -
--toolsand--ownerson a key are now enforced over the network. They were recorded and read by nothing. Measured on a livekb serve --transport http --keys-onlyserver: a key created--tools none --repos nothing-matches/*used to get the full tool list and its calls all ran; the same key on the same server now gets an empty tool list and a refusal that names the group which would grant the call.--reposand--externalstill bind nothing and still print(recorded, not enforced);--rate,--burstand--cost-budgetwent live in the same release, below.
Enforced at three surfaces, because a gate on one is a gate the caller walks around
by using another: the tool wrapper, tools/list, and the kb://stats resource, which
answers the counts graph_stats answers and crosses no wrapper at all.
--tools takes comma-separated groups (graph, search, docs, stats, owners,
semantic) plus all, read and none. read is every group except semantic. A
group this server does not know is refused at create, so a typo cannot be minted
onto a key that then reads as scoped. In a hand-edited key file the same value is
denied rather than refused: it narrows the key and never widens it.
ask is refused unless every tool it routes to is granted. It calls eight siblings
directly, below the wrapper that checks a grant, so a key granted ask and denied
blast_radius would otherwise reach blast_radius through the impact route.
--owners real allows who_knows; pseudonymous and hidden refuse it, and refuse
ask with it. There is no anonymiser on the network path, so a key that asked for
pseudonyms gets no names rather than real ones.
--repos is deliberately not enforced. It cannot be decided from a call alone: a
node id does not carry the repository it came from, and repo_dependencies,
repo_flow and repo_event_flow take a required repo and return rows naming other
repositories. Correct scoping needs a filter inside the store.
stdio is unchanged, byte for byte. It reads no key, no policy and no identity, and it loads no grant module.
Changed#
-
The
(recorded, not enforced)label moved from per line to per axis. One label after all three scope axes claimed the same thing about all three, so enforcingtoolsalone would have made the line sayreposandownerswere live too.kb keys shownow marks each axis on its own, and an unset axis carries no marker at all, because it records no scope for a marker to qualify. -
kb keys showandlistprint the EFFECTIVE quota and where each value came from. The limits line used to read a bareunsetfor a key that named no rate. With[serve] default_rateset, such a key is limited, and an operator readingunsethands it out believing it is not. It now renders one of four states per axis:rate=60/min (enforced),rate=unset -> 60/min from [serve] default_rate (enforced),rate=unset (no limit), orrate=none (enforced: no limit, set on the key). Theratecolumn inlistshows the effective value for the same reason.
Each per-record --json document gains effective_rate, effective_burst,
effective_cost_budget (strings or null) and limits_source, an object mapping each
of the three axes to key, config or unset. No field is removed and none changes
type.
policy_enforcedin every--jsondocument is derived rather than a fixedfalse. It answers whether every axis the document renders is enforced, so a key scoped only on--toolsreadstrueand the same key with--reposadded readsfalse. Aburstrecorded beside no rate does not count as enforced: it is the capacity of a request bucket that does not exist, so listing it would make a key that limits nothing read as limited. A key with no policy at all readsfalse, not a vacuoustrue: the fact an operator needs is whether anything limits the key, and for the key a barekb keys create alicemints the answer is no. Each key also carries a newenforced_axeslist. The field is not removed and does not change type.
Fixed#
- The graph's empty state named a flag that does not exist on the command the reader
is running. "Widen the seed, raise
--max-nodes, or clear filters" is right when the page is rendered standalone bykb graph, and wrong inside the dashboard, where the same template is embedded in an iframe andkb dashboardhas no such flag: following the advice answers'--max-nodes' isn't a flag on 'dashboard'. It now names the node cap rather than a spelling that is correct on one of the two surfaces, which is the rulekb.languagesalready follows atparse.py:1467.
[9.1.0] - 2026-09-06#
Added#
--jsonon all sevenkb keysverbs.create,revoke,rotate,pruneandchecknow emit a document, joininglistandshow.
9.0.0 made those five refuse the flag at exit 2. That was the honest interim state: the
release before it let them take --json, print their ordinary log lines and exit 0, so a
script that asked for machine-readable output got prose and no error. Refusing was better
than lying about it, and answering is better than refusing.
Standard output carries the document and nothing else, on every exit path. A failure is a
document too, carrying "error" with a snake_case code, which is what kb query,
kb owners, kb impact and kb eval already do.
Three fields exist because an exit code could not carry the answer:
changedonrevoke,rotate,pruneandcreatesays whether the key file was written. Revoking a key somebody else already revoked exits 0 and changes nothing, which read the same to a script as revoking it.reasononcheckis one ofmalformed,unknown,revokedorexpired. All four exit 1, so a CI gate that warns on one and fails on another had nothing to read.last_used_stateisnot-recordedin this release.last_used_atisnullfor two different reasons and the sibling is the only thing that separates them.
create --json and rotate --json keep the key on stderr and report
"key_shown_on": "stderr". --json > out.json would otherwise write a live credential
into a file at the caller's umask. --print-key moves the key into the document's key
field, and it already refuses a terminal.
rotatehonours--print-keyand--out. Both flags parsed onrotateand both were ignored, exiting 0. The new key exists nowhere else, so a rotation script had no route to it but scraping stderr.
Fixed#
kb keys create --out <existing path>minted a key and lost it. The record was written to the key file, and only then was the--outpath refused for already existing. The command exited 2 saying nothing had worked while a live record sat in the file whose plaintext had never been shown to anybody. The output file is now opened before the key is minted, and removed again if the mint fails.
If you ran that command on 9.0.0, look at kb keys list. An orphaned record is
indistinguishable from a key you hold: same live state, same empty LAST USED, and no
field on the record says whether the key ever reached anybody. Find the records matching
the names you tried to create, and kb keys revoke them. Each retry of the failing
command minted another one, so there may be more than one per name.
-
kb keys show --jsonon an unknown id emitted no JSON. The not-found branch ran ahead of the--jsoncheck, so it printed prose to stdout and exited 1. It now emits{"error": "unknown_id", ...}at the same exit code, as dorevokeandrotate. -
--overlaphelp said "default 0". The default is7d.
[9.0.0] - 2026-09-06#
Added#
contextlake kb keys, the command every key-file refusal already named. Seven verbs:create,list,show,revoke,rotate,checkandprune.
kb serve refuses to start on several key-file states, and each refusal told the operator
to run contextlake kb keys create <name>. That command did not exist. A server that
refuses and names a way out the reader cannot take is worse than one that starts wrongly,
because the operator has nothing to do next. This is that command.
The key is shown once. It goes to standard error at creation and never appears again,
because the file stores a SHA-256 digest rather than the key. It is deliberately never sent
through the logger: the console handler always writes to stdout, a --log-file run adds a
5 MB rotating file with three backups that outlives the process, and the redactor rewrites
workspace paths and repo names, so it would scrub a key on neither. A lost key is rotated,
never recovered, and rotate keeps the old key working for --overlap so the holder can
swap without an outage.
Two other paths can carry the key out, and both are narrower than they look. --print-key
writes the bare key to stdout for a pipe and refuses a terminal, where it would land in the
scrollback instead of a secret store. --out FILE writes it at mode 0600, with the mode set
at creation rather than chmod-ed afterwards, and with O_EXCL so an existing path is
refused rather than overwritten.
check reads the key from standard input only. A key on a command line lands in shell
history and shows in ps to every account on the machine. A terminal with nothing piped
in is refused too, rather than waiting for end-of-file behind a blank screen: it does not
prompt, because a typed key lands in the scrollback. It opens no socket and sends no
request, which is what lets it answer when the server is the thing that is down, and it
says so rather than implying it verified anything against a server.
No verb opens the store database. Every one of them runs on a machine that has never
built an index, which is the machine an operator is on when a server has just refused to
start. kb keys list is the first command they run.
Two refusals split by verb rather than collapsed into one rule. A key file carrying group
or other bits, or sitting in a directory anyone can write to, is a policy fault: write
verbs refuse, and list warns and prints the table anyway, because blocking the operator
from seeing what exists is the wrong failure when list is how they diagnose the refusal
they just hit. A file that cannot be read at all is not that: nothing was read, so every
verb fails and names the path.
The scope flags are recorded and enforced by nothing, and every surface says so.
--tools, --repos, --owners, --rate, --burst and --cost-budget are written onto
the key and rendered back by create, list, show and check. No code reads them. A
key created with --tools none --repos nothing-matches/* was presented to a live
kb serve --transport http --keys-only server and tools/list answered with all 23
registered tools, one of which then ran and returned a result.
So the values print with (recorded, not enforced) beside them and three lines saying
what that means, show --json and list --json carry "policy_enforced": false, and an
unset axis reads unset rather than none, which read as a denial for the key a bare
create makes. Telling an operator their key is scoped when it is not is worse than not
offering the flags, because they hand the key out on that reading.
Scope is per tool, not per repository once it does work: a key allowed a tool will read
every indexed repo through it. --rate and --cost-budget are stored as typed and
validated by nobody, because their parser ships with the rate limiter. The LAST USED
column reads never until the usage file it reads from exists.
- Per-request identity on the network MCP transports, and the frame that will carry access control. Groundwork only: nothing is enforced yet and nothing changes for a local run.
build_http_app now resolves a Principal per request and the tool wrapper reads it, so
two callers holding two different credentials are two different identities inside a tool
body rather than one anonymous caller. Until now there was no place to put that fact, which
is why access control, rate limiting and usage accounting could not be built: each would
have had to invent its own notion of who was asking.
The wrapper's whole try/finally/except structure lands here, once, deliberately. Four
planned stories each need to add a line to that twelve-line function, and when they were
specified separately their orderings contradicted each other. Landing the frame first turns
each of them into an insertion at a named anchor instead of a restructure, so whoever lands
second does not have to unpick whoever landed first.
It fails closed. Whether identity is required is a build-time decision made by
build_http_app, never inferred from whether an identity happens to be present. A stdio run
does not read the value at all, and on a network run a missing identity refuses the call
rather than answering it unscoped. Those two states used to be the same value, which meant a
server whose identity plumbing broke would have answered every request as if unauthenticated
access were intended, with every test still passing.
What it does not do. It detects a MISSING identity. It cannot tell a WRONG one, and on
the SSE transport the plausible failure is substitution rather than absence. Closing that
needs a per-connection token compared at the boundary, which is specified and not built.
ask calls its sibling tools directly, so those legs never cross the wrapper and an access
check placed there will not cover them; the anchor comment says so. And the run outcome the
wrapper records has no reader until the usage recorder lands.
stdio, the default, is unchanged: same tool output, no identity lookup, no new file, config field or dependency for anyone who never serves over the network.
contextlake kb source wizard. It lists every configured source with a reachability mark, then offers to add another and loops until you answer no (pressing enter is no). The survey reads the sameverify_sourcepathkb doctorandkb source testuse, so "is this source reachable" has one answer across all three. The add step iskb source addrun interactively, so the prompts, the literal-secret refusal and the write target are the same ones. It needs a terminal: a prompt written to a pipe hangs, so a non-interactive run is refused with exit 2 and the flag form to use instead.
Changed#
kb enrichreports edges to code, not only documents stored. The linking step already ran: every enrichment document was matched against the repo's symbol names and the matches were stored asdocumented_byedges. The count was then discarded, so the run could report only how many documents came back. A document with no edge to any symbol cannot answer a question about the code, and it read as a success.
The run now prints, per repo, the terms tried, the documents returned and the edges attached to code, and closes with a line that puts every targeted repo in one of five buckets: enriched, nothing returned, returned but unattached, failed, skipped. The five add up to the number of repos the run planned to touch, on every exit path.
"Returned but unattached" is a state, not a failure. The matcher is whole-word with a
three-character floor, so a ticket that discusses a repo in prose without naming a symbol
correctly attaches to nothing. That run still prints ✓.
A repo whose store or shard write fails is now counted and reported instead of aborting the whole run. A run where every repo failed that way exits 1.
API change: run_enrich_repo returns an EnrichCounts(terms, documents, edges) triple
instead of the document count alone.
kb doctor's per-source line now separates three answers that used to render as one⚠. A source that was dialled and did not answer keeps⚠. A source of a type with no reachability probe (gitlab,zendesk) now draws⊘, matching whatkb source testalready prints for the same case. A source withenabled = falsealso draws⊘and is not dialled at all, matchingkb connectandkb ingest, which both skip disabled sources. None of the three changes doctor's exit code, which is unchanged and still deliberate.
What this loses: doctor no longer reports a broken path or an unreachable endpoint on a disabled source. Nothing reads that source, so the round trip bought nothing, but the line used to be there. Re-enable the source to have it dialled again.
- The bundled
--sampledemo fleet moved to a new domain, and two of its repos changed shape. Repo ids and symbol names changed, so a script that names one has to be edited. The old fleet's domain read as a real production estate rather than as an obvious invention, which is what demo data has to be. It now models a weather-station monitoring network: stations report readings, a forecast service runs a model over them, an ingest pipeline normalises raw readings, an alerts service fans out severe-weather notices, and a console UI shows it.
acme/auth-service -> acme/station-registry
acme/catalog-api -> acme/forecast-api (rebuilt, not renamed; see below)
acme/payments-api -> acme/sensor-ingest (call edges redirected; see below)
acme/web-ui -> acme/console-ui
acme/notifications -> acme/alerts
acme/shared-lib -> acme/shared-lib (unchanged)
demo/app -> demo/app (id unchanged; its two symbols changed)
acme/forecast-api is a rebuild. The old repo was a four-layer controller / service /
repository / validator chain in C#. It is now a scheduled model run in Go:
RunCycle -> ForecastRunner -> GridSampler -> ModelGrid, with no controller, no repository and
no validator. acme/sensor-ingest keeps its nodes and redirects its call edges so readings
flow one way: SensorGateway -> Ingest -> ReadingProcessor, plus
Backfill -> ReadingProcessor. Nothing downstream calls back into the gateway, and the two
triggers converge on one processor instead of forming another straight line.
What moved and what did not, measured on the fixture. The acme org, the repo count (7),
the node total (44), the edge total (36) and all 11 cross-repo edges are the same, edge for
edge, under the mapping above. Two counts did move: languages went from 4 to 5 (Go added, C#
down from 10 nodes to 5), and node kinds from 7 to 8 (struct added, class 12 to 11,
method 3 to 2).
Every symbol inside those repos changed with the ids, so a golden-query file, an MCP call or
a dashboard bookmark naming an old one returns nothing until it is updated. The new node and
edge lists are in src/contextlake/kb/dashboard/fixtures/sample-dashboard.json. Nothing in
the product changed. This is demo data and the docs that quote it.
[embeddings] base_urlnow defaults per provider. Check yours before upgrading if you point it at a local server. The field was declared as the literalhttp://127.0.0.1:11434, and one declared literal wins for every provider. So[embeddings] provider = "openai"with nobase_urlline sent each batch of indexed code toPOST http://127.0.0.1:11434/embeddings, with the value ofOPENAI_API_KEYin theAuthorizationheader. The field is now unset by default and resolved when it is read:https://api.openai.com/v1foropenai,http://127.0.0.1:11434for everything else.[llm]already worked this way.
This changes where your requests go. If you run an OpenAI-compatible server on port 11434
and reach it with provider = "openai" and no base_url line, that traffic stayed on your
machine before the upgrade. After it, embedding requests go to
https://api.openai.com/v1/embeddings and your indexed code leaves the machine. One line keeps
it where it was:
[embeddings]
provider = "openai"
base_url = "http://127.0.0.1:11434"
Nothing changes for provider = "ollama", "builtin" or "auto", or for any config that
already writes a base_url line.
Fixed#
-
Five
kb keysverbs accepted--json, printed prose and exited 0. The flag is declared once on thekeysparser, because the verb is a positional, so argparse accepted it on all seven. Onlylistandshowbuild a JSON document. The other five wrote their ordinary log lines and exited zero, so a script that asked for machine-readable output got prose with nothing in the result to tell it apart from success.create,check,rotate,revokeandprunenow refuse the flag and name the two that honour it, exiting 2, the usage code, without touching the keystore. The refusal goes to stderr, following the rule the two emitters already keep: once--jsonis asked for, stdout carries the document and nothing else, so a refusal cannot land inside a caller's> out.json. -
kb serveprinted a green success banner for a server that never started. A network start that was about to refuse printed
✓ MCP server on http://127.0.0.1:8765/mcp (Ctrl-C to stop)
--keys-only refused: no key file with a live key was found ...
and exited 1 with nothing listening. The banner sat above the block that reads the key file, so all five key-file refusals printed second, and an operator reading their terminal top to bottom saw a running server. A code comment on that block claimed the opposite, that every refusal ran before any banner.
The two banner lines are printed last now, right before the server call, with nothing
between them that can return. A start that refuses prints no banner at all. The refusals
covered: a key file that cannot be trusted, --keys-only with $CONTEXTLAKE_MCP_TOKEN set,
a file this reader cannot account for, a file whose keys have all expired or that holds no
records, and --keys-only with no live key anywhere. The --host refusal already ran before
the banner and is unchanged. Output on a start that goes ahead is unchanged.
-
kb serve --keys-fileand--keys-onlyare documented. They decide which key file a network start reads and whether it may mint a shared token, anddocs/cli-reference.mddescribed neither. It carries the four-tier resolution order now, why an absent file at a named path is refused rather than read as a first start, and the two cases--keys-onlyrefuses. -
A first start named no way to stop using the shared token. Every key-file refusal told the operator to run
contextlake kb keys create <name>. The one route a new operator walks, a first start on a machine with no key file, printed the unscoped token and named nothing, so the person who most needed a scoped key was the only one never told the command exists. That start now adds one line saying the token is shared and namingkb keys create. The all-revoked start, which already named it for its own reason, is unchanged and does not say it twice. -
[serve]was reported as an unknown config table while the same run refused a start over[serve] keys_file. Onekb serverun printedconfig: unknown config table 'serve' (ignored)on stdout andKey file refused: [serve] keys_file names ...on stderr. The table is known:keyfile._serve_keys_fileopens the same TOML files and honours the value, which is how the second line came to name it. The warning was the wrong line, andserveis in the known-table set now. Keys inside the table are still not checked the way[kb]keys are. -
The reason written down for one fail-closed refusal was false. A key file holding no records is refused, and
kb/keyfile.pyjustified that with "kb keys pruneon a schedule empties a file whose keys lapsed, so the calendar reaches this state too". Nothing schedulesprune; it runs when a person types it. The refusal is unchanged, because it never depended on who emptied the file: a file holding no record admits nobody and reads on stderr like a first start, so minting on it turns a deployment that asked for scoped keys into an open one. The same false premise was restated inkb/cmds/serve.py's table of zero-live states and in a test docstring; all three are corrected, and a test now fails if the sentence returns to any of them. -
kb enrichterm selection: the cap is now spent on searchable symbols. The term builder readrepo_brief'stop_symbols, which ranks every node in a repo and then caps the result, and filtered that capped list down to the embeddable kinds. So files, packages, modules and config keys consumed the budget before one searchable symbol was considered. Measured on 48 of a 56-repo store (the 8 largest shards left out to bound the measurement's own memory): 24 of the 29 repos holding 9 or more embeddable nodes got fewer than the 10 terms asked for, and the largest of them, at 5,494 embeddable nodes, produced 5.
repo_brief now carries a second ranked list, top_embeddable_symbols, filtered to the
embeddable kinds first and capped after, one row per distinct name. The term builder reads
that. On the same 48 repos all 29 with enough symbols now reach 10 terms, and the total rises
from 191 terms to 321.
Your terms will change, not only grow. The count per repo rises or holds, and no repo ends with fewer, but the ranking that fills the list is different, so the SET moves: 20 of the 32 repos that HELD a symbol term lose at least one they used to get, and 41 of the 143 previous symbol terms are gone. A term drops out when a higher-degree definition displaces it. (The denominator is 32, not the 48 measured: the other 16 held no symbol term to lose, so counting them would understate the churn among repos this can affect.)
field and endpoint names can vanish from a repo's terms entirely. There is no per-kind
floor, so the ranking alone decides. On those 48 repos, field reached no term in any of the
4 repos holding field nodes, including one where 103 of its 178 embeddable nodes are fields;
endpoint reached none in 12 of the 15 repos holding one. Widening the budget barely helps:
re-measured at 25 terms a repo instead of 10, all 4 field repos stay empty, and endpoint
recovers in at most 2, leaving 10 of 15 still empty. So a bigger budget is not the fix for
either kind, and for field it changes nothing at all. This is deliberate. A repo gets 9 symbol names by default, across 19 embeddable kinds,
so a floor could reserve at most one slot each, and a per-kind floor is half of what starved
the old path. The full reasoning sits beside the ranking in wiki/generate.py.
top_symbols is unchanged, and so is every wiki page: its all-node candidate set is
deliberate, the new list stays out of the grounded_count/coverage_total ratio and out of
hubs/dispatchers, and no wiki, dashboard or MCP surface reads it. The 16 repos in that
measured set whose symbols were never extracted still get one term; that is indexer coverage,
and this change does not claim it.
Security#
- A config file found by walking up from the current directory may no longer choose an endpoint,
a credential variable, or a credential-carrying provider. The provenance gate covered the keys
that become a subprocess argv (
[llm] command/args/provider = "cli",[[sources]]command/args/mcp_command). It now also covers[llm]/[embeddings]base_urlandapi_key_env, and[[sources]]mcp,token_envandauth_dir.[[sources]] scopesis strengthen-only: a discovered file may narrow the OAuth grant and may not widen it.
On top of the per-key refusal, a discovered file may not aim a tier that carries a credential.
When the provider that wins the merge for [llm] or [embeddings] is openai or anthropic
and that value came from a discovered file, the tier is off for that run, rather than falling
back to built-in defaults nobody chose.
This breaks an honest project-local block that names openai or anthropic in a
.contextlake.kb.toml. Three things clear it: delete those keys from that file and set them in
~/.contextlake/kb.toml; pass --config PATH naming that file; or, for [llm] only, pass
--llm PROVIDER to kb wiki, kb docs or bootstrap. Adding the block to
~/.contextlake/kb.toml while the discovered file keeps its own provider line does not clear
it, because the discovered file is merged last and its provider still wins. Set
CONTEXTLAKE_NO_LOCAL_CONFIG=1 to skip ancestor discovery entirely.
[8.13.0] - 2026-09-02#
Changed#
- The ambiguity fanout cap is 10, not 6, and stores re-index to pick it up. A call reference naming a symbol defined in more than the cap's many places produces no edge at all, so the caller is simply absent from "who calls X". Measured with the cap removed on the two largest ambiguity contributors in a 717,381-node store, the old cap was dropping 29.6% and 37.0% of resolvable call references. The comment beside it claimed 21.6% and "no knee in the distribution"; both were wrong. There is a knee, and 10 sits on it: 76.2% and 80.6% of references for 1.22x and 1.52x the ambiguous edges.
Admitting everything is still wrong, for a sharper reason than the old note gave. The uncapped cost is one or two pathological names per repository: 2,864 sites naming a symbol with 1,432 definitions produced 4.1M edges by themselves, 92% of that repository's uncapped total.
PARSER_VERSION moves to 12, so existing stores re-index rather than keeping edges
built under the old cap.
Added#
- Zoom in and zoom out buttons on the graph toolbar. Zoom level could only be chosen by wheel or pinch. Pinch is a multipoint gesture, and WCAG 2.5.1 (Level A) wants a single-pointer alternative; "Fit to view" sets a zoom level but does not let you pick one. Each press steps by a fixed ratio about the viewport centre.
Wheel zoom got more responsive alongside them: wheelSensitivity was 0.2, so reaching
a readable scale took a dozen notches and the canvas read as stuck. It is now 1.
Fixed#
-
Every graph and dashboard page requested a favicon that did not exist, producing a 404 in the console on each load. Both now carry an inline SVG icon, which costs no request and survives an offline export.
-
kb dashboard --site <dir>overwrote files it did not write. The export writesindex.htmlunconditionally, so pointing it at a directory holding anything else -- a docs site, a hand-maintained landing page -- replaced that content silently, with the command reporting success. It now refuses any directory containing files the export does not itself produce, naming what it found. Re-running into a previous export stays idempotent and needs no flag.
[8.12.0] - 2026-09-02#
Added#
- A node in the architecture graph now opens the wiki for its repo, and lands on the subsystem page covering that node's file. The graph page had no wiki link of any kind. Inside the dashboard the graph runs in an iframe, so the node's control asks the dashboard to route; the dashboard resolves the file to the narrowest generated subsystem page that contains it and opens that, falling back to the repo's own page when no subsystem page covers it. Opened through the dashboard's Fullscreen link the same page has no parent, so it links that route directly. A static export links the sibling wiki page it already writes.
The control appears only for a repo that has a generated wiki. The prefix match is
anchored on a path segment, so src cannot claim srcutil/helper.py.
There is no jump to a heading, and that is a limit of the data rather than an omission. Generated pages carry page-level headings only (Overview, Setup & Run, Architecture, Dependencies, Gotchas), the model is told to omit any it has nothing to say for, and nothing in a page is about a single symbol. A computed anchor would land silently at the top of the page.
- The docs pages that explain the graph now carry the running graph.
asking-the-graph,code-graph-model,indexing-the-code-graphandvisualizing-the-graphdescribed the visualizer in prose while the live page sat one directory away, reachable only from the landing page. The page is 788 KB, so it is not embedded eagerly: the markup ships a screenshot and an IntersectionObserver swaps the iframe in when the reader scrolls near it, carrying the current theme. A reader with no JavaScript, or who never scrolls that far, keeps the screenshot.
Fixed#
-
A static export's graph pages disagreed about which repos had a wiki. The map was filled while the pages were being written, so each page saw only the repos written before it and the fleet overview, written first, saw none. Nothing read the map yet, so nothing failed. It is now built before the first page.
-
RepoTooLargetold you to pass a flag that does not exist. A repository over the memory budget was refused with "narrow it with--languages". There has never been a--languagesflag; the message introduced it, so anyone who followed the advice got "unrecognized arguments" and no way to act on the error. The setting is real and lives inkb.tomlaskb.languages, which the message now names. A guard was added: no string inside araiseor an exception's__init__may name a long flag that no parser registers. -
The command palette's search field removed its own focus ring.
.cl-palette__input:focussetoutline: none, which takes the indicator away from keyboard users rather than only from mouse users. It was safe while the palette held one focusable control and would have become a WCAG 2.4.7 failure the moment a second one was added, silently. Narrowed to:focus-visible, with a stylesheet-wide guard.
[8.11.0] - 2026-08-31#
Fixed#
-
The dashboard rendered twice on every load, and fetched
/api/overviewtwice with it.boot()attaches ahashchangelistener and then gives the page a default hash when it opens without one. Assigninglocation.hashfireshashchange, and the listener is already attached, so the page rendered once from the explicit call and once from the event. The two requests overlap, so neither can serve the other from cache. Measured against a 961,633-node store: 2,769 ms and 4,005 ms, 35 ms apart, while the page shell was ready in 114 ms.history.replaceStatewrites the same URL and dispatches nothing. It also keeps the default hash out of the history stack, so Back no longer returns to the hash-less URL and straight back again. -
The dashboard reserved layout columns for a sidebar and drawer that were not there. The rail becomes
position: fixedbelow 768px and the drawer below 1280px, so both leave the grid, but the rules naming their columns applied at every width. Specificity is resolved before any media query is considered, so those rules won regardless of source order. On a 700px viewport the main content measured 64px wide with the rail collapsed. Three of the four rail and drawer combinations were wrong, not the one that was reported. All four now use the full width, and 1000px and 1400px were re-checked. -
kb wikiunder-reported how much of a run did not happen. When a repository's whole-repo page fails, the run skips that repository's module pages rather than trying each one. Those pages were counted nowhere, so the four totals added up to less than the run planned and six missing subsystem pages read the same as a repository that had none. They are now reported as "N not attempted", kept separate from failures because a page nobody tried is a different fact from a page that broke. The all-failed line also called a page count "repo(s)", so a run that lost three module pages of one repository announced it had failed for three repositories.
Added#
-
A truncated graph view now names the node kinds it dropped. "500 of 3,200" says a view is partial without saying that the part you came for is the part that is missing: a repository with 412
tablenodes that renders none of them has an empty ER diagram, and the old message could not tell that apart from dropping 412 low-value nodes. The four worst losses are logged with the count each kind had, and the full breakdown reaches callers asdropped_by_kind. Both sides are counted rather than estimated. The key is absent on a complete view, so nothing can render "0 dropped" over a view that dropped nothing. -
A
forced-colorsblock for the dashboard, where there were none. Most of it needed nothing: the health chips carry the words Fresh and Stale, the confidence chips carry a border style and a clipped glyph, and cards, the rail and the drawer have borders that survive a forced palette. The trust bar does not. Its segments are sized by flex and told apart by background colour alone, so a forced palette merged them into one bar; they now carry a divider, and focus uses the system highlight colour.
Documentation#
- The
--repospattern syntax is documented in full, in Mirroring repositories. The page said patterns are globs and are anchored; it did not say which wildcards are available. It now lists all four (*,?,[abc],[!abc]) with an example each, and states the four rules that govern every pattern: comma-separated, anchored, case-insensitive, and matched against both the group-qualified and the local path.
It also covers the one case the anchoring does not reach. There is no escape character,
so odd*name matches a repo literally named odd*name and matches oddXname as well,
and cannot select the first on its own. A one-character set does:
--repos "odd[*]name". * and ? are legal in a path on Linux and macOS, not on
Windows.
Every claim on that page is now pinned by tests/test_repos_pattern_syntax.py.
[8.10.1] - 2026-08-31#
Fixed#
kb index --workspaceno longer breaks its worker pool. The cause wasRepoTooLarge, the exception 8.10.0 added for the memory budget: it could not be unpickled. A worker that refuses a repository sends the exception back to the parent, and Python rebuilds an exception ascls(*args), whereargsheld only the formatted message this class passes toException.__init__. The rebuild was three arguments short and raisedTypeErrorinsideProcessPoolExecutor's manager thread, which has no future to attribute a failure to, so it broke the whole executor: every healthy worker was sent SIGTERM and every pending repository failed withA process in the process pool was terminated abruptly. One refused repository ended a 656-repository run in about a minute.
pickle.dumps succeeded on the exception throughout. Only the parent's unpickle
failed, which is why nothing caught it. The same 17-repository cluster that broke the
pool twice now completes at --workers 4 with 0 failures, the over-budget repository
reported as a skip, and all four workers exiting 0.
-
Five more exceptions had the same defect and are fixed with it.
GrammarNotInstalledis raised on the same indexing path and would break a pool the same way when an optional grammar is absent.McpToolError,CircuitOpenError,StoreBusyandRunBusyare off that path today. Every exception in the package that defines its own__init__now defines__reduce__, and a test walks the package to assert each one survives a round trip, so the next one cannot ship without a sample. -
A broken worker pool now falls back to serial indexing. That fallback was written for this failure and was unreachable:
BrokenProcessPoolsubclassesRuntimeError, so the per-repositoryexcept Exceptioncaught it, counted one failure against whichever repository raised it, and continued, and the handler that re-runs the work-list serially never ran once. The failure counters are reset before the serial pass, so a repository that genuinely failed before the break is not counted again by it. -
The reason a pool broke is now reported.
str(BrokenProcessPool)is a fixed sentence naming no cause, and the real reason is attached as__cause__, which was discarded. Reporting only the fixed sentence is why three candidate causes stayed unseparated across several investigations.
[8.10.0] - 2026-08-31#
Upgrading#
This release re-indexes every repository on your next kb index. PARSER_VERSION moves
from 10 to 11 because .config and four sibling extensions now carry settings into the graph,
and no commit moves when an extractor starts reading a file type it previously ignored. Without
the bump an existing store would report every repository "unchanged" and never gain a single
setting.
What that costs, measured on a 660-repository fleet:
- The rebuild is automatic.
kb indexreportsolder parser (10 -> 11)and re-indexes rather than skipping; the pass after that is quiet again. - The new extensions add roughly 61,000
config_keynodes fleet-wide, dominated by 1,023.configfiles at about 57 settings each. - The store-size change was not measured and no figure is given for it here. A full re-index of that fleet has not been completed on this machine, and a number nobody measured is worse than an absent one.
Known issues#
kb index --workspace can break its worker pool at --workers above 1, failing every
still-pending repository with A process in the process pool was terminated abruptly. On a
656-repository run this failed 640 of them in about a minute. --workers 1 is unaffected.
Fixed in 8.10.1, which describes the cause. It is RepoTooLarge, the exception this
release added, failing to unpickle in the pool's manager thread. No worker runs out of memory
and the max_repo_memory estimate is not involved. The wording in this section before 8.10.1
attributed the break to that estimate and to a dying worker, and both were wrong.
Added#
-
.config,.props,.targets,.settingsand.plistnow reach the XML config extractor, andPARSER_VERSIONmoves to 11 so existing stores get them. Without the bump the extraction reaches nobody who already has a store:kb indexgates re-indexing on the parser stamp, so an already-indexed repository reports "unchanged" and never gains a setting. Re-indexing happens automatically on the nextkb index. Only.xmlreached the extractor before, so the canonical .NET settings file was contributing nothing: measured across 660 repositories, 1,023.configfiles produced zero nodes while being exactly the files "where is this setting defined" is asked about..resxis deliberately still excluded, being localisation rather than settings and worth roughly 91,000 nodes fleet-wide; so are project files, which the manifest extractor owns, and.svg, which is XML-shaped graphics. -
[kb] max_repo_memory, a per-repository memory budget checked before any file is parsed.max_file_bytesbounds one file and cannot bound a repository that is wide rather than deep. The repository that took a 15.4 GB machine down had a largest file of 3.57 MB against a 5 MB cap, so that cap never fired once, while 1,432 XML files averaging 0.42 MB added up to 671 MB. The new budget estimates a repository's cost from a stat-only pass, weighting each file kind by measured peak memory per byte (code 19.6x, SQL 5.0x, XSD 4.3x, XML 3.5x), and skips the repository with its name and the dominant kinds if it would exceed the budget. It defaults to 3 GB, taken from the fleet rather than chosen: across 660 real repositories the median estimate is near zero and p99 is 1.69 GB, with three outliers at 6.09, 6.76 and 7.35 GB. Set it to 0 to disable. The estimate is linear while the real cost is not, so it runs low on the largest repositories; it is a coarse guard, and the existing shard-item check remains the second layer.
[8.9.0] - 2026-08-30#
Added#
-
AWS and Azure adapters.
--platform awscreates an EventBridge Scheduler schedule firing an ECS task;--platform azurecreates a Container Apps Job on a cron trigger. Both shell out to an already-authenticatedawsoraz, so contextlake still ships no cloud SDK. On EKS and AKS use--platform k8sinstead: both are Kubernetes, so theCronJobadapter serves them and bringsconcurrencyPolicy: Forbidwith it. Neither cloud service has an equivalent ofForbid, so two runs there can overlap and the second skips on the store's advisory lock rather than never starting, which both adapters report. They round differently by design: EventBridge takesrate(N minutes)and rounds to whole minutes, while a Container Apps Job trigger is a cron expression and rounds the way cron does. Registered but never auto-detected, for the same reason as the Kubernetes adapter. Verified by asserting the rendered request documents and the exact CLI arguments: there is no account here, so neither is verified by execution. -
A Kubernetes adapter, covering OpenShift as well. Renders a
CronJoband applies it withkubectl, falling back tooc. One adapter serves both, because OpenShift is Kubernetes with a stricter default security context and the manifest satisfies the stricter one: norunAsUser, since the restricted SCC assigns an arbitrary UID and rejects a pinned one, plusrunAsNonRoot, a dropped capability set and anfsGroupso the mounted state directory stays writable.concurrencyPolicy: Forbidgives single-writer semantics from the cluster, so the second of two overlapping runs never starts. State mounts aPersistentVolumeClaimrather than anemptyDir, because an ephemeral store re-indexes the whole fleet every run. The schedule is a cron expression and rounds through the same function the cron adapter uses. Nothing is patched in the cluster on its own: changing an interval means re-runningschedule install, since a background rewrite would need cluster-write rights for the life of the schedule. Reachable with--platform k8sand never auto-detected:kubectlon a PATH does not mean a schedule belongs in that cluster. -
A Windows adapter, so
contextlake scheduleworks through Task Scheduler. Creates a task withschtasks /SC MINUTE /MO nunder a\contextlakefolder. Two limits are reported rather than hidden./MOcounts whole minutes, so an interval is rounded the way cron's is, down above a minute and up below it, andinstallsays when it rounded.schtaskscannot set StartWhenAvailable, so a run missed while the machine was off is lost; the adapter reports that with the same phrase cron uses, which is what stopsstatusprinting the fact twice. The command is quoted with Windows rules rather than POSIX ones, because a venv path containing a space is the ordinary case there. Verified by asserting the exactschtasksarguments: the development machine is Linux, so this backend is not verified by execution. -
A launchd adapter, so
contextlake scheduleworks on macOS. Renders a LaunchAgent plist withStartIntervalin seconds, installs it withlaunchctl bootstrap gui/$UID(not the deprecatedload, which can return 0 while doing nothing), and reads the interval back off the installed plist rather than reporting what was requested. launchd replays a run missed while the machine was asleep, like systemd and unlike cron.schedule statusreports no next-fire time for it, because launchd exposes none for an interval agent and a computed guess would drift from what it actually does. Verified by asserting the rendered plist and the exactlaunchctlarguments: the development machine is Linux, so this backend is not verified by execution. -
schedule listreports units whose job record is gone.state()can only answer "is job X installed?", which can only be asked about a job that still has a record. The reverse had no reader: delete a record and its unit keeps firing on schedule, is absent fromlist, anduninstallcannot reach it, because it resolves a job name through the record that is gone. Adapters gaininstalled_names(), implemented by reading the unit directory for systemd and the marked crontab blocks for cron.listnames each orphan and its platform, and says how to remove it. It also names any platform it could not enumerate: skipping a platform and finding nothing on it both produce an empty result, so reporting only the empty one would let "never looked" read as "checked, clean". With--jsonthe two arrive as_orphaned_unitsand_unchecked_platforms, added alongside the jobs rather than nesting them: a script reading this output keeps working. The leading underscore is what makes that safe, since a job name must start with an alphanumeric and so can never collide. -
schedule recommendsays when the activity bound was never measured. The freshness half of the interval formula needs a count of how many repositories changed, which only the index stage records. On an install without thekbextra nothing records it, so the bound never engages and the interval rests on the duty-cycle floor alone. Theactivity floorline was omitted entirely in that case, which read the same as the bound being switched off. It now states that it was not measured and what records it.--jsongains anactivityfield readingnot-measured,no-changeormeasured:floor_activity_secondsis null for the first two of those, so the number alone could not tell them apart. -
A memory-budget guard on
kb index. One repository in a real fleet needed more than 9.3 GB in a single worker and never finished; no worker count survives a repository that size. A repository whose parsed shard exceeds 2,000,000 combined nodes and edges (well above the largest repository known to index successfully, at roughly 356,000) is now skipped with a named, explicit log line instead of being persisted, and the run continues with the rest. This is a guard, not a fix: it stops one pathological repository from taking a whole run down, it does not make that repository indexable, and the exit code reflects that the run was not fully clean. -
--workers Nonkb indexandbootstrap. Caps how many repositories the index stage parses in parallel._index_workspacealready accepted and honoured aworkersvalue, but nothing wired a flag to it, so the default (one fewer than the CPU count, capped at 8) could not be overridden anywhere, including onbootstrap, the default scheduled job. Lower it to cut peak memory on a large fleet or a small machine.
Fixed#
-
Every scheduled job read every other job's run history. All jobs append to one history file and nothing in a record said which job wrote it. Two things followed.
decide_kindasks whether a successful full rebuild is older thanschedule_full_every, so a rebuild run by one job answered that question for a job that had never run one and postponed its rebuild by a whole cycle. The recommender's median run duration mixed every job's durations, so a two-minutekb indexand a forty-minutebootstrapproduced one interval that fitted neither. Records now carry the job name, passed to the child inCONTEXTLAKE_SCHEDULE_JOB, and reads are scoped to one job. Records written before this carry no job name and count as the default job's, so no existing install loses the measurements it has earned. If you created a named job withschedule intervalon 8.8.0, its earlier records are reattributed todefault, so that job starts from an empty history and runs one extra full rebuild on its next cycle. Every record it writes after that is tagged. -
kb index's parallel path leaked every completed repository's parsed graph for the whole run. The worker pool'sfutsdict was keyed byFuture, andfut.result()does not clear a future's cached result, so a completed repository'sGraphShardstayed reachable throughfutsuntil the pool'swithblock exited. Measured by A/B on the same 45 repositories at the same worker count: 2,130 MB retained versus 95 MB released, 22x apart on identical work, growing with repos indexed rather than with worker count. Each future's dict entry is now dropped once its(repo_id, path, head)is read off it, both on the fast path and the serial fallback after a broken pool. -
A timed-out scheduled run orphaned its worker pool.
contextlake schedule runspawned its child withsubprocess.run(..., timeout=...), which on a timeout kills only the direct child. The child is usuallybootstraporkb index, which runs aProcessPoolExecutorof up to 8 workers, so a timed-out run left the whole pool running, reparented to init and still holding memory. Measured on a real machine: one killed run left 8 orphaned workers holding 12.4 GB. On a schedule, every timed-out run leaked another pool. The child now starts in its own process group and, on a timeout, the whole group is signalled (SIGTERM, then SIGKILL if it does not exit within a few seconds), so nothing below it survives. Windows has neither process groups norSIGKILL, so there the timeout falls back totaskkill /F /T, which walks the child tree and reclaims the pool the same way.
[8.8.0] - 2026-08-27#
Added#
contextlake schedule: a self-scheduler. Measures how long a run takes and how often your repositories change, works out an interval from the two, and installs a systemd user timer or a crontab entry that keeps the mirror and the knowledge layer current on its own, with no unit file or cron line to write by hand. Core tier: works without the[kb]extra.recommend,statusandlistread only;install,interval,resetanduninstallwrite. Ad-hoc jobs run any contextlake command on their own interval:
contextlake schedule interval 6h run -- kb wiki --force
Ten schedule_* config keys, an honest split between what systemd and cron can each do, and a
container refusal for state that will not survive a restart. Discarding measured history
(--purge, reset --history) renames the history file to a .discarded sidecar instead of
deleting it, so a mistaken discard is recoverable. See Scheduling runs.
branch_map: per-repository branch pins.branchputs the whole fleet on one branch, which is the common case and not the only one.branch_maptakes comma-separatedpattern=branchpairs using the same globs asrepo_filter, so one team can trackdevelopwhile a legacy tree sits onmaintenanceand everything else follows the most-active selection. First match wins, so a specific entry can precede the glob that would otherwise catch it. It beatsbranch, falls back to it, then to the selection. A repository whose mapped branch does not exist is reported asunpinned, exactly asbranchalready does, rather than being switched to something else.
branch_map = team/api=develop, legacy-*=maintenance
Changed#
-
bootstrapgained--force. Rebuilds everything instead of only what changed: every repository re-parsed, every node re-embedded. It did not exist as a flag before; it is whatschedule's periodic full cycle runs. -
configuration.md's example config now shows the scoping keys.repo_filter,branchandbranch_mapwere documented in the settings table but absent from the example anyone copies, so the two that already existed were easy to miss.
[8.7.0] - 2026-08-25#
Four defects, none of which the unit suite could see. Three were found by running the CLI against itself and against a live GitLab group. The fourth was found by reading a CI line that had been red for eight runs while every local suite passed, because the MCP SDK is floored without a cap and a new release changed how tool errors surface.
Two of the four are commands that reported success while doing nothing. One withheld the reason for a refusal it was right to make. One is a documentation example that does not run.
PARSER_VERSION, SCHEMA_VERSION and EMBED_CONTENT_VERSION are untouched, so nothing
re-parses and nothing re-embeds. One stored value does change: see Migration.
Fixed#
-
kb index --sourcefiled repositories under their directory name instead of their remote id.--workspacehas always used the canonical id from theoriginremote; the single-source path used the directory name. Every connector matches on the repo id, sokb connectsilently found nothing for anything indexed the zero-config way, which is the way the tool advertises:cd my-repo && contextlake kb index. Measured on one repository with 100 open merge requests, same clone and same store both times: 0 external links via--source, 285 via--workspace. A repository with nooriginnow gets the documentedname@root-commitfallback instead of a bare directory name, so two clones of one history collide correctly and two unrelatedapidirectories do not. -
MCP clients stopped being told why a call was refused.
mcp2.1.0 changed its tool runner to preserve the message of a deliberately-raisedToolErrorand collapse every other exception to a bareError executing tool <name>, keeping the text server-side. contextlake raisedValueErrorfor argument validation, soblast_radiuswithhops: -1answeredError executing tool blast_radiusinstead ofhops must be 0 or greater, not -1. The refusal itself was never in doubt (is_errorstayed true); the reason was lost. Validation now raisesToolError, which restores the message on 2.1.0 and is unchanged on 2.0.0. -
kb index --watchdid nothing unless--workspacewas given. The flag was read only inside the workspace branch. With--source PATH, or the zero-config current directory, it parsed, ran one pass and exited 0 without watching and without saying so, while its own help promises "keep re-running ... on an interval (Ctrl-C to stop)". -
The CLI advertised an example that does not run.
contextlake kb graph --serveappeared inkb graph's help epilog and exits 2: the command requires one of--node / --name / --search / --repo / --overview. That requirement is deliberate and stays; the example is nowkb graph --overview --serve. All 63 runnable examples across every command's epilog were executed to find it, and one was broken.
Migration#
Automatic. No action required. A store written by an earlier version holds
directory-name ids for anything indexed with --source. Re-indexing the same checkout
re-files that row under its canonical id and clears the stale one, the same migration
--workspace has always run, scoped to the single path being indexed. The rename is
logged, for example oldname -> gitlab.example.com/acme/widgets.
The consequence worth knowing: a repository indexed before this release is invisible to
kb connect. Re-index it once and the connectors will see it.
Added#
tests/test_cli_examples_parse.pyruns every--helpepilog example on every release, so the CLI cannot advertise a command that does not parse.
[8.6.1] - 2026-08-24#
A documentation-structure release. No behaviour changed, no schema moved,
EMBED_CONTENT_VERSION untouched, so nothing re-embeds.
Fixed#
- Two strings the CLI prints at users named documentation files that no longer exist.
kb serve --helppointed atdocs/serve.mdand the unsupported-language error pointed atdocs/contributing-languages.md; both were renamed in 8.6.0's documentation pass and the strings were not. Anyone following the tool's own instruction was sent to a missing file.
Changed#
- Five documentation pages were split, so a page is one type rather than a task and a
lookup table braided together.
indexing-the-code-graphgave up its 337-line node and edge model to a new The code graph model;connecting-and-enrichinggave up document source configuration to Document sources and RAG;serving-over-mcpgave up transports and concurrency limits to MCP transports and limits;generating-documentationgave up its output description to What the generator produces; andsearching-semanticallygave up the embedding and model reference to Embeddings and models.
Nine pages were candidates. Five cleared the bar: the off-type block had to be contiguous, at least 100 lines, and at least 40% of the page. The published set is now 36 pages.
-
docs/embedding-reference.mdsaid 17 embeddable kinds against a set of 19.schema_elementandschema_typehad joinedEMBEDDABLE_KINDSand the total was never updated. -
57 em-dashes removed from prose that the guard did not cover.
Internal#
-
The em-dash guard now derives its file set from
git ls-filesrather than a hand-kept list, after being re-broken a third time in the part it did not cover: three files held 31 em-dashes while every run stayed green. 52 files covered, up from 33. -
A tool count written in a wording no pattern recognises now fails. The existing gate declares an expected value per phrase, which is what made it blind to a sentence nobody had written yet; a new page reading "The server exposes 99 tools." passed it.
-
A third kind-count gate covers
EMBEDDABLE_KINDS, which neither the node-kind nor the language gate could see.
[8.6.0] - 2026-08-24#
Three ways to read what the graph already knew. Presentation-layer throughout: no schema
change, no parser change, EMBED_CONTENT_VERSION untouched, so nothing re-embeds.
Added#
-
A treemap layout for the fleet, alongside Cards, List and Table. Repository size spans thousands-fold across a real fleet, and a list gives every repository the same visual weight regardless. The treemap sizes each one by its node count, so where the graph actually lives is the first thing you see. Squarified layout, hand-rolled, no new dependency. A repository that has not been indexed cannot be sized, so it is listed beside the map and said out loud rather than drawn as a zero-area tile that would simply vanish.
-
An unresolved-references review surface, in Knowledge health. When the parser cannot pin a reference to one definition it says so, and that verdict has been in the graph as
AMBIGUOUSedges with full provenance without any surface that let you read it. The panel now ranks the names that could not be resolved, with how many places reference each one, how many definitions each could have meant, and afile:lineyou can open.
Ranked by name, and counted by reference site rather than by edge, because both matter at fleet scale. The parser emits one edge per candidate, so edge counts overstate the work by the average candidate count; on a large store a third of a million ambiguous edges were a fifth as many actual reference sites. Grouping by name concentrates it further, so a single disambiguation can cover thousands of sites. A flat list of the raw rows would not be a review surface.
Read-only by design: it reports and cites, it never repairs. Resolving a name is a judgement about intent, and the parser has already said it cannot make it.
- Hovering a connection row previews that node on the graph. Clicking one already moved the camera to it; hovering did nothing, so finding the right neighbour in a long list meant clicking each candidate and losing your place when it was wrong. The preview rings the node without moving the camera and without disturbing the selection you are working from. It fires on keyboard focus as well as hover, because the rows are buttons and a hover-only affordance does not exist for someone not using a mouse.
[8.5.0] - 2026-08-23#
The graph page becomes something you drive. Every change is presentation-layer: no schema
change, no parser change, and EMBED_CONTENT_VERSION is untouched, so nothing re-embeds.
Added#
-
Depth and edge-direction controls on the graph page. Clicking a node to expand it used to bring back exactly one hop of neighbours in both directions, with no way to ask for more or to follow only what a node calls (or only what calls it). The two controls sit under the layout row and apply to the next node you expand; expanding is additive, so they never remove what you have already explored.
/neighborsnow readshopsas well as therelationanddirectionit already parsed, clamped to a maximum of 3 to match the slider. -
Trace downstream on the graph page. Selecting a node highlights its immediate neighbours in both directions, which answers "what is next to this" but not "what does this eventually reach". The inspector now offers a transitive walk that follows edge direction as far as it goes, and reports how many nodes it found. It appears only on nodes that have an outgoing edge. It walks the graph currently on the canvas, which the button says, because in
--servemode that is whatever has been expanded so far. -
Copy a node's
file:linefrom the inspector. Edges have offered this since provenance shipped; nodes carried the same location and gave you no way to take it anywhere. Both controls now confirm what happened, including when the clipboard is unavailable, which is the normal case for a page opened straight off disk rather than served.
Changed#
- The graph page's node legend is grouped by the kind vocabulary's ten bands
(Symbols, Containers, Service surfaces, Data model, Infrastructure, Presentation,
Configuration, Documents, Cross-source, Boundary) instead of one flat run of up to
52 pills. The grouping is projected from the kind registry, not retyped, so it
cannot drift from the colours beside it. Bands with nothing in them are not drawn,
and any kind the projection does not cover falls through to a visible
Otherband rather than disappearing from the legend.
Fixed#
- Folded leaves are now visible in the count, not only absent from the picture. Leaf
folding writes a tally onto each container and onto the page meta, and nothing read either
of them, so on a
--sitepage the majority of a large repository's nodes left the graph with no surface saying so. The status bar now states the total, and selecting a container shows its own tally with the kinds that make it up. The graph is unchanged; what changed is that it says what it did. - The graph page's status bar said "1 edges".
[8.4.0] - 2026-08-23#
Additive throughout. No re-embed, no schema change, no breaking surface: every new capability arrives behind an optional extra or a file you do not have to write.
Added#
- Images are ingested, read by a local OCR engine.
filesgained*.png,*.jpg,*.jpeg,*.webpand*.bmpbehind the new[kb-ocr]extra. The engine's models ship inside its wheel, so a first run downloads nothing and no image leaves the machine.
That property is the whole reason this exists in this shape. The obvious way to read an image
is to send it to a vision model, and that would have made ingestion the first path to leave
local-first. The offline claim is asserted rather than stated: the live test blocks
socket.getaddrinfo and socket.create_connection before calling the engine.
An image the engine reads no words in -- a logo, an icon, a photograph -- is reported and
stored as nothing, never as an empty document that would look like knowledge. OCR'd documents
carry ocr = true, because OCR misreads and a reader should not have to infer that from a
file extension.
- Video is ingested, in two layers, because they promise different things.
[kb-video]decodes and reads on-screen text from sampled frames; it bundles its own ffmpeg, so there is no system package to install and nothing is fetched at runtime.[kb-transcribe]adds the spoken track, and its speech model is fetched once on first use the way[kb-local]'s embedder is. Folding them together would have sold the weaker offline promise as the stronger one, so you can take slides without speech.
The work is bounded rather than the file. max_bytes gates every other type because size
predicts how much text a document contributes; for a video it predicts resolution and length,
so a 1 MB cap would reject every real recording while admitting nothing. Capped instead: one
frame every 5 seconds, at most 60. Repeated on-screen lines are said once, since a slide holds
still across many samples.
Three outcomes are stated rather than implied: no transcriber installed (the video is still
read from its frames, and the document says transcribed = false), no audio track at all (a
screen recording with no microphone is ordinary, not a failed transcription), and nothing
readable either way (reported, stored as nothing).
-
A Zendesk connector, and it is the only one that needs no network. It claims
*.zendesk.comlinks found in a repository's docs and classifies them to a ticket (discussed_in) or a Help Center article (documented_by). Zendesk's API needs a per-instance token and the link already states the association, so fetching the body would buy a subject line at the cost of credentials and an egress exception. Node ids carry the instance subdomain, because ticket numbers restart at 1 per instance. -
A repository can steer its own wiki page, with
.contextlake/wiki.toml.notesis free text quoted into the page and attributed in those words, bounded, and never absorbed into the page's own voice: everything else there is derived from the graph, and a reader weighs those differently.pagesreplaces the automatic choice of which subsystems get their own page, and cannot invent one -- names are matched against modules the graph actually found. -
A "Getting started" section in the generated wiki page. The rest of the page is reference tables and not one of them says what to do first. This is an ordered path: install what the repository declares, run its entry point, read the symbol everything routes through, run the tests, ask the largest recent contributor. It opens by saying it was assembled from the graph, because nobody wrote that procedure down.
-
The dashboard can see generated documents.
kb docshas been writing an API reference and design notes per repository, both reachable over MCP, and the one human UI over the store could not show them. Adds a Documents tab andGET /api/repo/<id>/docs?kind=api|design. -
Every rendered heading carries a stable, unique anchor, so a section of a wiki page can be linked to at all. Repeated headings are disambiguated rather than duplicated, which matters because duplicate
ids are a WCAG 4.1.1 failure and make any link to them land non-deterministically.
Fixed#
-
The sqlite-vec backend was tested in no CI job at all.
ci.ymldid not installkb-vec, so every test guarding that backend skipped, andsecurity.yml-- the only workflow carrying the extra -- runs pip-audit and CodeQL rather than pytest. Measured either side of the fix on one job: 3803 passed / 66 skipped becomes 3866 / 3. Sixty-three tests that had never executed in CI now do. -
A generated document's machine-readable stamp rendered as visible text in the dashboard. The marker is an HTML comment, which a Markdown reader hides and this renderer escapes, so every page opened with a line of angle-bracket noise.
-
The static dashboard export no longer offers a Documents tab it can never fill.
Changed#
- A hanging test is now a red test: the suite sets a timeout. Be precise about what that buys -- it catches hangs that do not allocate, and would not save a run from a fast runaway allocation.
retrieval-quality.yml's recorded measurements were stale and are refreshed and dated.
[8.3.0] - 2026-08-22#
⚠ Re-embed: your stored document vectors are rebuilt once#
EMBED_CONTENT_VERSION moves 4 -> 5. Both halves of the staleness rule apply at once: the
text a vector is built from changed, and every stored key changed shape. kb embed notices
the mismatch and re-embeds everything once; kb ingest rewrites document vectors and sweeps
the partition first, so an old whole-document vector cannot linger beside the new chunks.
Nothing is required of you beyond the next run costing more than usual.
Changed#
- A document is embedded as several chunks instead of one vector. An ingested document
used to be
texts.append(doc.text)-- one vector over the whole page. For a 14 KB document that vector is an average of everything the page discusses, so a question about one paragraph in it matched poorly or not at all. There was no chunking anywhere in that path; thechunk_sizein the vector store is a sqlite-vec KNN tuning parameter and has never had anything to do with text.
So the question was never "which chunking strategy", it was "chunking versus none".
Measured with kb eval on 29 real documents (424 KB, mean 14.6 KB) and 53 queries selected
by position rather than by hand:
| one vector per document | chunked, ~1200 chars | delta | |
|---|---|---|---|
| hit rate | 71.7% | 94.3% | +22.6pp |
| MRR | 45.2% | 80.4% | +35.2pp |
| tokens/query | 276 | 291 | +15 |
13 queries fixed, 1 lost. The control holds: queries were stratified by depth before indexing, the unchunked arm is already worse deep in a document (62.5% against 79.3%) which is the dilution the hypothesis predicts, and chunking helps depth (+29.2pp) more than the surface (+17.2pp). The obvious confound -- each chunk repeats the document title, which helps retrieval by itself -- was ruled out on evidence rather than waved away: all 13 queries chunking fixed share zero words with their document's title.
What this does not establish, because a number without its limits is worse than no
number: one embedder (model2vec, which averages; a transformer would truncate instead,
plausibly worse for a 14 KB document, unmeasured), verbatim-sentence queries so the
absolute figures are optimistic and only the delta is sound, one single-topic corpus, and a
chunk size that was never tuned. The full write-up is in docs/semantic-search.md.
The splitter packs whole paragraphs rather than cutting at a fixed width, and never drops a short trailing chunk -- the last chunk of a document is usually its conclusion. Defaults are 1200 characters with 200 of overlap, which are the values that were measured.
-
Chunking is invisible to everything that reads the store. A document is one node and several vectors, so the stored key gained a chunk suffix and
search()collapses a document's chunks to its best-scoring one before returning. All four call sites still receive node ids and never learn that chunks exist. The sqlite-vec backend over-fetches its KNN window by a fixed factor, because rows and nodes stopped being the same thing and one talkative document could otherwise fill ak-wide window on its own. -
A hanging test is a red test now. The suite had
pytest-timeoutas a dev dependency and never set a timeout, so a test that never returns had no ceiling at all. Be precise about what this buys: it catches hangs that do not allocate. It does not save a run from a fast runaway allocation, which reaches the machine's memory limit long before any timeout fires -- only the code being correct does that.
[8.2.0] - 2026-08-20#
Added#
get_fleet_doc: the fleet page can be read now.contextlake kb docshas been generatingdocs/fleet/design.md-- which packages more than one repository requires, which of those are pinned differently, and which repositories declare no runtime dependency at all -- and no MCP tool could return it.get_generated_docacceptsapianddesignand refuses everything else, so the one document that answers a fleet-wide question was reachable only by opening a file. On a tool whose job is serving a knowledge graph to an editor, that is the same defect as a node with no incident edges.
It is a separate tool rather than a third kind, because the fleet page has no repository:
it is one file for the whole store, so the tool takes no repo argument rather than
accepting one it would have to ignore.
- The fleet page carries provenance. It stamped nothing, and explained in prose that it "spans many commits" -- honest to a human and useless to a program, which is exactly the gap the stamp module exists to close. It now carries a fingerprint of every member's commit and parser version, so it answers the same yes/no a per-repo page answers with its commit.
Three states, not two. stale=true with a fingerprint means the store moved. stale=true
with no fingerprint means the page predates stamping and nothing is known about
whether it is current. A caller that cannot see the store cannot tell those apart unless
told, so it is told. The parser version is in the key because a page can go stale without
a single commit moving.
This also catches something a per-repo commit stamp cannot express at all: a new repository joining the store makes the page wrong without any existing member changing, because its populations count a fleet that grew.
Fixed#
-
The cluster page and the fleet page now share one fingerprint rule. Both ask "have the member commits or parser versions moved", and two copies of that would have to be kept in step by hand -- after which the two documents could disagree about whether the same store had changed.
-
A comment stated a checked fact that had expired.
docs/design.pyrecorded that the MCP server "exposes the store'swiki/directory, and nothing yet returns anything underdocs/". That stopped being true whenget_generated_docshipped. Corrected rather than left standing, and the part worth keeping is kept: the marker was not added because something reads it.
Changed#
-
The fleet-page renderer takes a
membersargument. Empty stamps the pageunknownrather than leaving it unstamped: an absent marker reads as "nothing to report" and a presentunknownreads as "checked, could not tell", and a consumer defaults to fresh on the first and stale on the second. -
--branch NAME: put the whole fleet on one branch.branch_strategyexisted as a config key with no flag to set it, and there was no way at all to say "everything onrelease/24.1". Both are flags now:--branchon the mirror commands, and--branch-strategyfor how the most active branch is picked when no branch is named.
What needed deciding was not the pin but the miss. A release branch usually exists in a handful of repositories out of hundreds, so a repository without the requested branch is its own outcome, counted separately and listed in the summary, rather than an ordinary switch to something else:
✓ Branch switch complete: 4 switched, 0 already, 0 skipped, 0 empty, 0 dry-run, 396 unpinned, 0 errors
396 repo(s) have no branch 'release/24.1'; each stayed on its most active branch
Folding that into "switched" would leave several hundred repositories reading as though
they had done what was asked. The outcome is not an error either, so --branch on a real
fleet still exits 0.
The name is matched exactly: --branch release/24 does not select release/24.1. A
prefix test on an identity question is a hole this project has closed three times already.
Fixed#
- A misspelled
branch_strategyran a different selection and said nothing. The selector falls through tohybridfor any name it does not recognise, sobranch_strategy = "recentcy"silently scored branches a way nobody asked for. It is validated now, against the three real strategies, and rejected with a message naming them. Checked where the config is assembled rather than by an argparsechoiceslist, so a bad value from the config file is caught as well as one from the command line.
[8.1.0] - 2026-08-20#
Added#
- XML Schema (
.xsd) support. The extension was routed nowhere, so a repository whose data contracts live in schemas carried none of them. Every global component is a node now and every name one component gives another is areferencesedge, resolved across files:tns:PartyTypein one file and the<xs:complexType name="PartyType">that defines it in another land on one node, because the namespace prefix is a per-file alias and is stripped before matching.
Two node kinds. schema_element for a global xs:element, which is the name a message or
a document root actually carries and therefore the name a person searches for.
schema_type for a global complexType, simpleType, group, attributeGroup or
attribute, with which one recorded as an attribute rather than as five kinds.
They are not a reuse of struct and typedef, and that is the load-bearing decision
here rather than a naming preference. Reference resolution is by name across the whole
repository, narrowed only by target kind, so sharing a kind with C++ would let
type="Address" resolve onto an unrelated struct Address -- confidently, with nothing
reporting that a guess had been made.
.xsd is also matched ahead of, and never falls through to, the XML config scanner:
name is one of that scanner's key attributes, so a schema sent there would file every
component as a settings key.
- XSLT (
.xsl,.xslt) support. A stylesheet is a program with a real call graph --<xsl:call-template name="X"/>is a call by name -- and none of it was in the graph. Named templates, match templates andxsl:functiondeclarations arefunctionnodes; top-levelxsl:variableandxsl:paramdeclarations areglobal_variablenodes;$namereads becomeusesedges attributed to the template they sit in. A match template has no name to be called by, so its match pattern is its node name, with the pattern and anymodekept as attributes.
This mints no new kinds, and the contrast with the schema work above is deliberate
rather than inconsistent. Schema references resolve on name alone, so they needed kinds of
their own. Calls and variable reads are filtered by language family first, and xsl is its
own family, so an xsl:template named format cannot reach a Python format. The
isolation was already there; the test asserting it is what proves so.
<xsl:import>/<xsl:include> and XPath calls to an xsl:function are not extracted,
said here rather than left to be discovered. The first would need an edge to a file node
that may not exist; the second would need an XPath parser.
- Pro*C (
.pc) support, and the noise nodes that made the mask necessary. A.pcfile is C withEXEC SQLwritten into the source. Handed straight to the C grammar it parses, but so does the SQL: measured on a short realistic file,EXEC SQL INCLUDE SQLCA;andEXEC SQL BEGIN DECLARE SECTION;producedglobal_variablenodes namedSQLCA,SQLandSECTION-- names of things that do not exist, in a kind bare identifiers elsewhere in the repository resolveusesedges onto.
So the file is read twice. The C parse sees every EXEC SQL statement blanked, preserving
length and every newline so the line numbers it cites stay real, and blanking only the
statements so the host variables declared between the declare-section markers survive as
the ordinary C they are. The dataflow pass sees the file intact, because which tables it
reads and writes is what an EXEC SQL statement is there to say.
That pass already normalises table names through the same recipe kb/sql.py gives its
table nodes, so an EXEC SQL SELECT ... FROM CUSTOMERS and a
CREATE TABLE dbo.[Customers] in another file land on one node -- with no second copy of
that rule existing anywhere to drift out of sync.
.pc follows C for language filtering rather than carrying a flag of its own.
Changed#
PARSER_VERSIONis"10". A repository holding.xsd,.xsl,.xsltor.pcfiles carries strictly more than it did and no commit-keyed check would say so, sokb indexrebuilds it. One bump covers the whole language batch rather than one per language: the cost of this constant is a re-index, and there is no reason to charge it three times for work that lands in one release.
[8.0.0] - 2026-08-20#
The compatibility promise starts binding with this release, and this release deliberately
breaks nothing. A major bump permits breaking changes; announcing "stability begins here" in
a release that breaks things would read exactly as badly as it sounds. Nothing you wrote
against 7.x stops working. One action is worth taking: this release moves PARSER_VERSION,
so kb index rebuilds any repository holding PL/SQL or the newly routed shell suffixes, and
those repositories carry less than they should until it does.
README.md states what counts as breaking on four surfaces: CLI verbs and flags, store
layout, MCP tool contracts, and config keys. A change is breaking when it would stop
something you wrote from working, so adding a flag is not, and tightening a flag's validation
is not either. PARSER_VERSION is deliberately outside that list: bumping it stops nothing
working, it means a repository indexed by an older parser carries less than the current one
would extract, so kb index rebuilds it.
The milestone this closes#
Eight gates defined what "complete" meant, written so each could be checked rather than
asserted. Seven are closed, with the evidence committed rather than described. The eighth is
parser stability, and this release reopens it itself: it closed on PARSER_VERSION "8"
holding across eight releases, and the PL/SQL and shell work below moves it to "9". It
re-closes once two releases hold at "9". Saying "all eight" here would have frozen a false
sentence into an immutable release, so it says seven and names the eighth.
Closed:
- Each of the six output types is proven generated from source rather than emitted after it, by changing one thing in a pinned public tree and asserting the specific movement that implies. 7 of 7 bars.
- A clean-room install of the published wheel does the whole thing on the minimum and newest supported Python: 10 of 10, including a second index that rebuilds nothing, an offline refusal, and a repository with no manifest.
- Every number the documentation states about the build is checked against the build, by a gate that discovers its own files rather than reading a list somebody maintains.
- The published artefacts correspond: the release verifies what the index actually serves against the bytes CI built, both distributions, and reports "could not check" as its own state rather than as a pass.
- Zero open scanner alerts, and all three workflows green, each read where it actually runs.
Added#
- PL/SQL objects, and the Oracle spelling that made a whole dialect invisible.
.sqlfiles were read forCREATE TABLE,VIEWandPROCEDURE, and the procedure pattern accepted only the T-SQLCREATE OR ALTER. An Oracle codebase writesCREATE OR REPLACE, so its procedures produced no nodes at all while the files appeared to be indexed.
Packages, package bodies, functions, types and triggers are extracted now, both
redefinition spellings are accepted, and a trigger records the table it fires on. The
ON search is bounded to the trigger's own statement, because ON introduces a join
everywhere else in SQL and an unbounded search hands a truncated trigger the next
statement's table.
-
The per-object PL/SQL extensions:
.pks,.pkb,.plb,.prc,.fnc,.trg,.pls. Oracle tooling splits one object per file by convention, and those files were routed nowhere. -
The remaining shell dialects:
.ksh,.zsh,.bats,.command, alongside the.shand.bashalready read. The bash grammar handles all of them for the constructs that matter here, and a script that went unindexed because of its suffix is the same script with a different name. -
Two node kinds,
db_packageandtrigger. A database package is deliberately not the existingpackagekind: that one is a shared cross-repo node built from manifests, and reusing it would put Oracle packages into the fleet page's shared-dependency count.
Changed#
PARSER_VERSIONis"9". Any repository containing PL/SQL or the newly routed shell extensions now carries strictly more than it did, and no commit-keyed check would ever say so.kb indexrebuilds those repositories;doctorreports the staleness as an advisory rather than a fault.
The decision was taken and written down before the code, because this reopens the parser
stability gate that closed earlier the same day on "8" holding across eight releases.
That is recorded in the gate status as a reopening, not quietly left as closed.
- The milestone language names 8.0.0 rather than "1.0" everywhere it appears, and the test that guards it now DERIVES the release from the package version instead of pinning the phrase. The charter was written calling this "1.0" while the version already sat at 7.x, so every document that spelled it out disagreed with the number on the tin. A reset to 1.0.0 was considered and rejected: it would sort below everything already published, so nobody on 7.x would ever be offered it.
Fixed#
- A registry-parity test carried a hand-written list of the kinds one module produces.
It read
{"table", "view", "procedure"}for the SQL extractor, so adding a kind there made the test report the new kinds as "registered but produced by nothing" while they were being produced on every run. The module now declares what it emits and the test imports that declaration.
[7.32.0] - 2026-08-19#
Fixed#
- The derivation harness counted an arrow inside a node label as an edge. A scanner
flagged the bare
-->match as HTML comment handling, which it is not: the file is Mermaid. But looking at it found a real defect underneath the misclassification. A node label carries arbitrary text, including a symbol name or a docstring with an arrow in it, and every one of those was counted as a rendered edge.
That mattered because the diagram bar compares the count the command announced against the count rendered in the file, and reports a disagreement as the product drawing a different graph than it described. An overcount there is a false accusation waiting for the first repository whose code contains an arrow. Measured on a four-line fixture: three counted where there are two. The pattern is anchored on the edge line now, and the bar still passes on the real tree.
Third time this scanner rule has fired in this project and third time it named something real underneath.
[7.31.0] - 2026-08-18#
Added#
- A clean-room install harness, and its evidence: 10 of 10 on Python 3.10 and 3.13. A
machine with no config and no store installs the published wheel from the index, runs
init, indexes a public repository and produces all six output types. Verified from the published artefact rather than the working tree, because an editable install has masked a version mismatch twice.
One happy path is not a clean room, so it also covers the shapes that have broken before:
a second index over an unchanged tree rebuilds nothing, an --offline run completes with
every proxy variable poisoned so any outbound call would fail loudly, and a repository with
no manifest indexes to a graph rather than an error.
Getting to 10 of 10 took three runs and every failure was the harness, not the product:
it passed init --local where the command's own message named --no-mirror; it installed
[kb], which deliberately carries no embedder, and read the honest "unavailable" as a
missing output; and it looked for the wiki and docs in a directory it had invented while
all three sat under the store_dir that init had written. The same error three ways,
which is measuring against an assumed layout instead of the one the product writes. The
runner now reads that path out of the config init produced.
A review then found six checks that were wrong while the harness reported 10 of 10.
init failing was converted into a pass. "Re-index is quiet" read zero for both runs,
because the regex matched a line the command never prints and the fallback matched the
first run's own "0 unchanged". The offline check ran a command with no network path at all.
"Vector search" accepted the full-text fallback that kb query degrades to. The
no-manifest check only proved a second repository row existed, which the previously indexed
tree had already made true. And a run where nothing executed passed, with a test blessing
it.
All six are fixed and the gate re-run: 10 of 10, with an offline check that now requires
--offline mirror fetch to be refused by the guard rather than by config validation
arriving first. Every one of those six is the defect class this series exists to remove,
which a harness is not exempt from -- and a harness that grades a gate is the worst place
for it to hide.
[7.30.0] - 2026-08-18#
Added#
- The derivation evidence, from a real run against the pinned public tree. All seven bars verified, with real numbers: the graph moved 2,433 to 2,441 nodes and 7,588 to 7,600 edges with nothing dangling; the API reference gained the probe with all five of its real call sites and its symbol count moved 1,824 to 1,830; the design notes recorded the new dependency at its actual line in the manifest; the fleet page moved from nothing shared to one package shared by two repositories; the diagram's announced and rendered counts agree; the wiki's commit stamp advanced.
The pinned commit in the first version of this file did not exist in that repository. It was written from memory while the network was down, so nothing could resolve it. It is now a release tag, resolved from the remote.
Changed#
- The vector-search bar asks whether the symbol is returned, not whether it ranks first. It was written demanding first place, and the first live run measured third of a 1,830-symbol corpus.
Loosening a bar because it failed is how a gate stops meaning anything, so this is recorded in the bar itself, in the check, and in a test rather than quietly applied. The reason it is defensible: the bar's stated purpose is catching a "semantic" search that is really substring matching, and the query shares no word with the symbol or its docstring, so a substring matcher returns it nowhere at all. Appearing in the ranked results already proves retrieval by meaning. First place is a claim about ranking quality against every other symbol in the tree, which is a different question from whether the output is derived from the source. The measured rank is kept in the evidence either way, so a regression in that quality stays visible.
[7.29.0] - 2026-08-18#
Fixed#
get_generated_docgave three different absences one shape. A kind this server does not generate, an indexed repository whose page has not been written yet, and a repository the store does not hold at all all returnedfound: falsewith empty markdown and nothing else. The caller cannot see the store, so it reads any of them as "this repository has no design notes" and reports that as a fact.
Each now says which it is, and each names the move that fixes it: pick a real kind, run
kb docs, or correct the id. The comment in that branch already claimed the kind was
"named, not silently coerced" -- it was named in a field nothing distinguished.
-
A repository with nodes but no repositories-table row was reported as absent. The two populations are not the same: a partition writer adds nodes without a row. Asking only the table told a caller its repository "is not in this store" while that repository's symbols sat in the graph it had just queried. Both are consulted now, the same union the
--reposfilter was corrected to use two releases ago -- the second time this exact split has produced a wrong answer, in a different file. -
Three em-dashes reached
README.mdin the previous release. The repository's own style test catches them, and it did; what let them through was me reading a still-being-written test log and taking a partial result for a complete one. The run had not reached the failures yet. Corrected here rather than by rewriting history, because the commits are unpushed and go out together, so CI runs against a tree that has the fix.
[7.28.0] - 2026-08-18#
Added#
- The compatibility promise is written where a user reads it.
README.mdnow states what SemVer means here and, more usefully, what counts as breaking on each of four surfaces: CLI verbs and flags, store layout, MCP tool contracts, and config keys. A change is breaking when it would stop something you wrote from working -- so adding a flag is not breaking, and tightening a flag's validation is not either, because rejecting a value that silently meant something else was a fix for a promise never kept.
It says plainly that the promise takes effect at 1.0 and that every break before then is named in the changelog. Writing it as though it already bound would have been contradicted by this project's own history two releases ago, when two MCP response shapes changed in a minor -- and a promise the changelog contradicts is worse than no section at all.
PARSER_VERSION is deliberately outside the list, with the reason stated: bumping it stops
nothing working, it means an older-parser repository carries less than the current parser
would extract, so kb index rebuilds it. That is why doctor reports a stale shard as an
advisory rather than a fault, and a test now checks the README's claim against what
doctor actually does -- a promise about behaviour in another file is exactly the kind
that starts lying quietly.
[7.27.0] - 2026-08-18#
Added#
- A committed harness that proves the six output types are generated from source, not
merely emitted after it. Each of them could be satisfied by a fixture, a cached sample,
or by reading a README, so "it appeared after indexing" says nothing about where it came
from.
benchmarks/g2-derivation/changes one thing in a pinned public tree, re-indexes, and asserts the specific movement that change implies.
The bars are written before the test and each names a failure it would catch: a graph whose totals move while the new symbol's edges dangle; a reference that lists a symbol without its real call sites; design notes written from a template rather than read out of the manifest; a diagram whose printed summary describes a different graph than the one it drew; a wiki page regenerated with a stale commit stamp; a "semantic" search that is really substring matching.
The deciding half is separated from the I/O so it can be tested without a network, and every assertion is break-tested to confirm it fails for its own reason. Three states, never two: a bar that could not be tested counts against the run, because the question is whether it was proven and an untested bar has not been.
This does not close that gate on its own. A harness that has never run is not evidence, and the result file is written by a live run.
[7.26.0] - 2026-08-18#
Fixed#
- Three MCP tools returned an empty result with nothing to say why. A probe drove all twenty-two tools over the stdio transport with the real client. Nineteen already distinguished "nothing matched" from "nothing was looked up" through a note field. Three did not, and the caller of these tools is an agent that cannot see the store, so an unexplained empty result gets reported onward as a fact about the codebase.
get_neighbors was the cleanest case: byte-for-byte identical output --
{"edges": [], "total": 0, "truncated": false} -- for a real node with genuinely zero
edges and for a node id that was never indexed. It now names an id the graph does not
hold, and separately says when a relation/direction filter is what emptied the list.
find_definition says whether a name is absent from the graph entirely or is defined and
was excluded by a kind/repo filter. The reasoning that had left it bare -- "no
definition with this name is what an empty result already means" -- was wrong in the case
it did not consider: with a filter, that empty result reads as "X is not defined" when the
truth is "X is defined, and you asked for the wrong kind".
search_code says whether the query's terms are in the index at all, or whether a filter
excluded everything, and now reports total and truncated like every sibling.
Changed#
search_codeandfind_definitionreturn an object, not a bare list. Both now return the{nodes, total, truncated, note}envelope the other list-returning tools use, which is what carries the disclosure above. A client reading the old bare list needs.nodes.
Done now, deliberately, because the MCP tool contract carries no stability promise yet -- that promise is one of the remaining gates for 1.0, and the right time to make a response shape consistent is before it is promised rather than after.
[7.25.0] - 2026-08-18#
Added#
- The release now checks what PyPI actually serves. Three of the four correspondences a
release has to satisfy were already gated inside the workflows: the tag matches the
packaged version, the tag points at a commit whose full CI matrix passed, and the SBOM
describes the shipped wheel rather than the build environment. The fourth was a line of
prose in a runbook --
pip install --upgrade && contextlake --version-- run by a human, asserted nowhere.
That gap is not theoretical. The publish step carries skip-existing: true so a re-run is
idempotent, and the cost of that is an earlier upload under the same version number being
silently kept. Nothing downstream compared bytes, so a wheel that never came from the
tagged commit could have served that version indefinitely with every other gate green.
The build job records the wheel's sha256; a new verify-published job downloads what the
index serves, compares the digest, installs it into a clean environment, and confirms the
tag packages that version. scripts/verify-published-release.py runs the same checks by
hand for any past release.
It reports three states and never two: a check that could not RUN is unverifiable and
exits non-zero, because the one thing a verifier must never do is let "I could not look"
read as "I looked and it was fine" -- which is precisely the defect class this release
series has spent its time removing from the product's own commands.
Review of that first cut found six more, and two would have made the gate worse than useless:
- It gated nothing. The GitHub Release job needed only
buildandpublish, so a mismatch produced a red job beside a release of record that existed anyway. Worse, a failed publish SKIPPED the verifier while the release job still ran. A GitHub Release still appears when publishing FAILED, because nothing was published and the release has value on its own; it no longer appears when the published bytes did not match. - A re-run of an already-published tag would have raised a false tamper alarm. The
archives carry build timestamps, so a rebuild is not byte-identical, and
skip-existingkeeps the original upload. The verifier now reads PyPI's own upload time: a file uploaded before this run began was published by an earlier one, so a digest difference there is a re-publish and is reported as unverifiable rather than as a mismatch. Crying tamper on a routine re-run would have cost this check the only thing it has, which is being believed. - The sdist was never checked -- half of what PyPI serves, and the half anyone building from source gets.
- An EMPTY expected digest reported a mismatch rather than "nothing to compare", which is
reachable from a workflow step whose capture silently produced nothing: an operator
would read a supply-chain incident where the truth is broken plumbing. The step now runs
under
set -euo pipefailand refuses to emit an empty digest. - The tag check sat behind the download, so an unreachable index skipped a check that reads git and needs no network.
-
pipsatisfied the download from its own cache, so a file the index had since replaced would still have verified. It now downloads with no cache. -
The release gate that reads CI could not tell "no such run" from "not indexed yet". Measured on the v7.24.0 tag: the CI run for that commit had already completed green, and the commit-filtered listing still answered with an empty list a minute later, so the gate refused a release that was in fact green. It now asks a few times before believing an absence. A red or cancelled conclusion is still believed immediately, since only absence is the ambiguous answer.
-
scripts/is linted in CI. It was outsideruff check src testsentirely, so the new script's own lint gate was opt-in pre-commit only.
[7.24.0] - 2026-08-18#
Fixed#
Four commands printed a fault and then a success word about the same run. Each was found by a probe of a surface nobody had exercised, and each has the same shape: the summary was written from a variable the reporting did not feed.
-
kb enrichreturned 0 whatever happened. Source methods there are contractually non-raising, so an unreachable source yields nothing rather than breaking the run -- which makes atry/exceptblind to exactly the failure that matters.kb connectreadsresilience.degraded_calls()for that reason, a few files away;enrichnever read it. A run where EVERY source call was written off printed the same green line as a healthy run over repositories with nothing to find. It now states the degradation, and a run that reached no source and stored nothing exits 1. Partial degradation with results still exits 0, which isconnect's existing rule, copied rather than tightened: two sibling commands disagreeing about one event is the defect being fixed, not a place to invent a third rule. -
doctorfolded three of its twenty-odd checks into its verdict, and drew a red ✗ for things documented not to matter. A red ✗ for a repository indexed by an older parser printed on screen, and the bottom line said "OK" in green and the command exited 0 -- two contradictory statements, with the machine-readable one wrong.--helppromises "✓/✗", which a reader takes to mean ✗ is a problem. A check now records its own verdict, so a new one cannot be added and forgotten, and the summary names which check failed.
Three checks then had to become the advisory they were already documented as being, since
a printed ✗ now counts: per-source reachability, glab on PATH ("advisory, not critical"
said the comment beside a red mark), and the optional sqlite-vec ANN index. Without that
the first cut of this change would have failed doctor on a stock install and in CI,
which installs neither -- the fix reproducing the defect class it fixes, caught by review.
The stale-shard check is advisory too, and that resolves a disagreement rather than
picking a side of it. A parser bump makes every existing shard stale, so failing on it
would redden every user's CI on upgrade, which this project had already decided twice. The
wrong part was the red ✗ with "OK" printed underneath. A ⚠ says the same thing without
contradicting the summary. A regression test now runs doctor without the optional
tooling and requires exit 0, so the remaining hand-read call sites have a gate.
-
kb forgetwarned that paths were still on disk and then ticked the operation done. The command is framed as the fix for a bloated store, so a user reading the ✓ to confirm space was reclaimed was told the wrong thing. The glyph and the exit code now follow the outcome, and the wording says "partly forgot" because the graph rows really are gone. -
A
--reposfilter that matched none of a warm cache reported "No projects loaded, runfetchfirst". Advice that cannot help: the filter is the problem, and re-fetching will not change it. "The cache is empty" and "the cache is full and your filter matched none of it" both left the filter as an empty dict, so five callers each printed the same wrong message, and one printed nothing at all. Said once where the filter is applied, with the number of cached projects, in the wordsfetchalready uses for the same event -- and the callers now stay quiet in that case instead of printing the contradicting advice one line below the explanation, which is what the first cut of this change did. -
kb forgetcounted wiki pages it had not checked were gone. The byte figure beside them had already been corrected from a prediction to a measurement; the page count was still a claim. It is measured now, and a page that survives joins the same list the disk artefacts use, so one check decides the verdict.
[7.23.0] - 2026-08-18#
Fixed#
- The gate that keeps every documented number honest was reading a list of files somebody had remembered. Each family of claims named the pages it knew stated its number, which makes the check exactly as complete as that memory: move a sentence to a new page, or write a new page repeating a count, and the claim is verified nowhere while every test stays green. That is the same defect the gate exists to catch, one level up.
Every prose file is now discovered by glob, with CHANGELOG.md the single deliberate
exclusion because a count under an old version heading is TRUE of that version. On its
first run the widened gate immediately found two stale claims on a page no list had ever
included: a language count two years out of date, and beside it a tool count in a wording
no pattern recognised. Both are corrected, and the wording is now covered.
A reviewer then found the third: the glob took .md only, and the site GENERATOR
(site/build_docs.py) holds the published page subtitles, meta descriptions, OpenGraph
text and JSON-LD as Python string literals. "across 14 languages" was sitting LIVE on the
published page against a build of 27, in the one file a markdown glob can never reach.
Corrected, and the generator is now scanned -- the generator rather than its output, so
there stays one authority.
Two more holes in the same gate: the tool-count check required five claims to match in
total, so rewording the one sentence a pattern covered dropped the total to nine, still
passed, and left that claim unchecked -- every pattern must now match something. And the
test guarding the +2 offsets asserted only that the two embedding tools were ABSENT
without embeddings, which says nothing about how many conditional tools exist; it now
builds a server with an embedder and asserts the difference is exactly those two.
kb graph --c4 --repos <matches nothing>wrote a diagram of an empty model. Found by a reviewer forty lines below the--sitefix above, in the same file, on the same flag: it rendered ~600 KB of nothing, announced "0 namespaces, 0 repos", and exited 0. The count was honest, the tick contradicted it, and the file on disk made the contradiction look like a result. Third cycle running that the patch for a defect class has contained a fresh instance of that same class.
--repos also meant two different things inside that one command: --site matched over
repositories with parsed nodes, --c4 over repositories-table rows, so the same spelling
selected one and not the other. Both now decide "does this pattern name anything" from
the union of both populations, while each still generates from its own source, so the
widening cannot make a run produce less.
-
kb dashboard --site --repos <matches nothing>had the identical bug, on the same flag over the same store, and was left behind when the graph side was fixed. It now refuses the same way. -
kb graphanswered a correctly spelled but unknown seed with a syntax banner. A--node,--nameor--searchthat matched nothing exited 2 with usage text, sending the reader to check syntax that was never wrong, while--repoalready exited 1 and named what it could not find. The family now agrees: exit 1 and the seed named, with exit 2 kept for the one real usage error, which is asking for nothing at all. -
kb graph --node <id>returned the id without asking the store whether it exists. A typo produced an empty graph and exit 0, while the identical miss reached through--nameor--searchexits 2 with the usage banner. One event, two verdicts, so a script gating on the exit code could catch a mistyped--nameand never a mistyped--node. -
kb graph --site --repos <matches nothing>printed a green tick over an empty site. It wrote one fleet overview and zero repository pages, logged that zero honestly, and then contradicted its own log with a success line and exit 0. It now refuses before writing anything and names the filter, which is the verdictkb wikiandkb docsalready give an id that matches no repository.
[7.22.0] - 2026-08-18#
Fixed#
kb query --retriever semanticanswered "No matches" on every freshly-initialised workspace. A nearest-neighbour search over an EMPTY table returns[], which is the same value a populated index returns when it finds nothing, and the two mean opposite things: "this search never ran" against "the graph holds nothing like your query". The caller treated the empty list as a real answer and skipped the fts fallback its own--helppromises.
The degrade path was not missing. It was there and correct for the case where
embeddings are DISABLED. What nobody had covered is the case contextlake init
actually creates: [embeddings] enabled = true with no vectors until kb embed runs,
so the embedder builds successfully and reports that the model loaded, which is a
different fact from anything having been embedded. The search now says which of the two
it hit, names the remedy, and shows the fts results.
Four surfaces read the vector store, not one, and all four had it. The MCP
semantic_search and hybrid_search tools now carry the reason in the result's
note; the MCP ask tool routes to full-text and says which search actually ran; the
dashboard's search panel returns lexical results with the reason attached. An agent on
the other end of an empty result with no note reports back that the codebase has no
such concept, which is the worst version of this bug because nobody sees the store.
kb eval --retriever semantic|hybrid REFUSES instead of degrading, and the difference
is deliberate: a query still has a useful answer to give from keywords, while an
eval's entire output is the score of the retriever that was asked for. It used to
report P@k=0 R@k=0 hit-rate=0 and exit 0, which is what --json gates CI on, so an
unpopulated index read as a total retrieval regression.
The check is an existence probe, not a count. It runs before every semantic query, and
the question is "is there anything at all", which the first row answers; an exact
COUNT(*) over the ANN backend's virtual table carries no cheapness guarantee, on
precisely the large stores where per-query work is felt.
-
kb ingestsilently dropped an enabled source whose type this build cannot construct. The refusal added tosource addabove only guards the WRITE path, and a config can predate it, be hand-edited, or be written ahead of installing the plugin it names. The read path filtered those rows out with no output at all, so a run configured to read three sources could read one and print a checkmark. Each is now named, counted as a failed source, and reflected in the exit code; a config where nothing can run fails rather than reporting an empty inbox. A DISABLED source of an unknown type stays silent, because turning one off is how a user parks it. -
--max-retries 0could never work. The retry loop isfor attempt in range(max_retries), so the flag counts total attempts despite its name, and zero runs the body zero times, leaves the error variable unset, and ends atraise last_error-- failing with "exceptions must derive from BaseException" without ever attempting the operation. The bound now starts at 1, the help says so ("1 = try once, no retry"), and the retry primitive refuses a zero budget by name, because a config file sets this too and the flag is not the only way in. -
kb ingest --path <does-not-exist>reported the path as reachable, with a ✓ and exit 0. Output byte-for-byte identical to a real directory holding no matching files. The files source had no failure-recording at all, so the "(source reachable, nothing to ingest)" branch was chosen by the ABSENCE of a recorded failure from a source that could not record one. A missing root, a path that is neither file nor directory, and an unreadable directory are now each recorded and reported, and the run exits non-zero. A genuinely empty directory still reports exactly what it did before. -
kb source add --type <typo>wrote the entry, printed a checkmark, and told the user to run a command that could never pick it up.--typeis an open set because a plugin registers its own name, but "open" means "whatever is installed", and at add time that is exactly enumerable. An unknown type is now refused, nothing is written, and the message lists what this build can run and says a plugin type is discovered automatically once installed. -
kb docs --max-symbols 0silently meant 500, and--max-symbols -5silently dropped the last five symbols. Zero fell throughvalue or 500, which cannot tell an explicit 0 from an unset flag; a negative went straight into a list slice, where it quietly removes from the end while the generated page's own text said "capped at -5 entries". The project already had a bounded-count argparse type carrying a comment about this exact mistake -- the flag had simply never adopted it.--min-workershad the same gap.--max-retriesis bounded too, but keeps zero, because "try once, do not retry" is a real request in a way that "return zero results" is not.
[7.21.0] - 2026-08-18#
Fixed#
- A malformed golden set scored 0.0 instead of being rejected.
kb eval --jsonexists to gate CI on a metric, so a typo in the golden file reportedhit_rate: 0.0and read as "retrieval regressed to nothing" -- blocking a release, with numbers that looked measured rather than meaningless. Two shapes did it: an unrecognisedmatchmode fell through every comparison, andexpectedwritten as a bare string is iterable, so the scorer compared the retrieved ids against its individual CHARACTERS.
Both now raise, the CLI reports bad_golden_set and exits 1, and the message for the string
case names the correction (Write ["Calculator"].) because the reader's next move is to edit
the file. A file with no queries key says what the shape is, where it used to surface a
TypeError about list indices.
Reported by an external review as "kb eval always reports 0.0, the feature is
non-functional". That was not true -- the documented form scores hit_rate: 1.0, verified
-- but the complaint underneath it was: nothing told the reviewer that nine attempted
spellings were being rejected rather than scored, so a working feature looked broken.
- Five more golden-set shapes scored a number instead of being rejected. Found by a reviewer reading the fix above, which is the pattern worth naming: the fix for a defect class is the most likely place to find that same class again.
An empty "queries": [] scored n: 0 with every metric at 0.0 and exit 0, which cannot be
told apart from a set that ran and retrieved nothing. A non-string inside expected (0,
null, a nested list) can never equal a retrieved id or name, so it was a guaranteed miss
dressed as a measurement. An empty query string gives the full-text layer no terms, and no
terms retrieves nothing, scored as a retrieval failure. A query entry that is not an object
raised a bare TypeError naming neither the file nor which entry.
The last one runs the other way and is the dangerous one: a falsy kind or repo (false,
[], {}, "") is dropped by the store's if kind: filter test, so the query ran
UNFILTERED while appearing to carry a filter -- and could therefore score a HIT on a node the
filter would have excluded. All five now raise, and each message says which value is wrong
and what it would otherwise have measured.
Changed#
kb eval --helpcarries the golden-file schema. It is correct indocs/semantic-search.mdand was reachable from nowhere a user reaching for--helpwould look, which is how a reviewer with the tool in front of them tried nine wrong spellings. The help now shows the full shape, saysexpectedis a list, explains what eachmatchmode holds, and points atkb query --jsonas the way to get real ids.
[7.20.0] - 2026-08-18#
Fixed#
kb wiki <repo>ignored its repo filter entirely. It rewrote every repository's structural page, and given a repo id that does not exist it regenerated everything and exited 0 -- wherekb docson identical input reports no match and exits 1. Two commands taking the same argument gave opposite verdicts, so a script could trust neither.
A one-day-old regression with one root cause: the structural stage took args and never
read it. The local-first default is what hid it, because with no LLM configured the command
returns right after that stage, so the correctly-filtered code further down was never
reached. Found independently by two reviewers, one with a control run proving the leak
affects a MATCHING id too, not only a missing one.
kb index --workspacereported "0 failed" and exited 0 for a repository git cannot open. Discovery warned about the directory and then dropped it, returning only the survivors, so the caller counting its own results had no way to learn anything was missing.docs/connect-enrich.mdpromises the opposite verdict in those words.
Discovery now reports what it could not read, the summary names it, and the exit code is non-zero. Deliberately narrow: a vendored tree and a duplicate checkout are also skipped and both are correct decisions taken on purpose, so folding them in would turn clean runs red and teach a reader to ignore the count.
-
Five defects in the three fixes above, found by an adversarial review of the fix commit itself. Every one is an instance of the two classes those fixes were closing, which is the point worth recording: the classes reappear inside their own remedies.
-
kb index --workspace W --repos goodexited 1 over a broken directory the run was told not to touch. The unreadable list was collected during discovery, which runs before--reposis applied, so it was reported unscoped: an aggregate spanning a filter, presented as the run's own result. kb wiki real-id typo-idpassed the new pre-flight, wrote one page and exited 0 without ever naming the id it could not find, because the check asked "did ANY id match" rather than "did every one". A partial run reported as complete.- A polyglot repository with
package.jsonandpyproject.tomlside by side had one ecosystem's dependencies presented as the whole of "Required at runtime": the code picked the alphabetically first shallowest manifest. Every manifest at root depth now counts. - The pseudo-repo filter was applied to one of the overview's two inputs.
repo_node_sizesexcluded them; thelist_repos()half of the same union did not, so a persisted@wiki:*row would still render and still increment the total. A predicate applied to one of two sources is not applied. -
publishesandpackageswere capped with no total, under a docstring promising that every list here carries one. -
Running
kb wikionce doubled the fleet count on the graph overview. A three-repository store rendered "6 repos with a parsed graph", with@wiki:*partitions listed beside the real repositories and each linked to its own page. The predicate excluded the(shared)/(packages)sentinels and stopped, so the partitions written beside a repo --@wiki:,@connect:,@enrich:,@ingest:-- passed straight through.kb lintand the dashboard's owndata.jsonboth said 3, so the correct answer already existed.
The predicate is also renamed to say what it checks. It was _is_sentinel_repo, which is
the name of the narrower (-prefix contract that kb.model owns and kb forget depends
on, so widening it under that name would have quietly changed a shared word's meaning.
- A repository's own published package was listed among its dependencies, and the wiki handed
that to a model as a grounded fact.
repo_brief's package list was built from the node KIND, so everypackagenode near a repository counted as a dependency: the one it publishes, and its lint and docs tooling alongside its real runtime requirements. On a public tree the prompt's facts block therefore readDepends on packages: flask, blinker, ..., ruff, tox, sphinx-- a false statement, in a block whose whole framing is that these came from the graph.
It is built from the EDGES now, so publishes and depends_on cannot collapse into one
claim, and the brief carries three separate facts because they answer three different
questions: requires (what a user needs to run it, with each constraint as written),
dev_requires (what a contributor additionally needs), and publishes (what this repository
offers others). packages survives for the MCP get_repo_brief and dashboard contracts and
is now simply true: the names this repository depends on.
Two scoping rules, each measured against a real tree rather than reasoned about:
- Requirements come from the shallowest manifest only. A repository shipping example applications declares their dependencies too, which put a task queue into the requirements of a web framework that does not use one.
- What a repository publishes is removed from what it requires. Checked whether the manifest rule already subsumed this: it does not. A public HTTP library declares a dependency on its own published name in its root manifest, via a self-referential extra.
Added#
-
The wiki's "Installation and usage" section states what the repository requires, not only which build files exist. It named
pyproject.tomland a README excerpt and stopped, which told a newcomer where to look and never what they need. Both lists carry their totals, so a truncated list cannot read as a complete one, and a package pinned differently by two groups is one entry carrying both constraints rather than two entries that read as a duplicate. -
Every tool count in every doc file is now checked, in any wording. 7.19.0's gate read one phrase in one file and was green while
docs/explained.md's "(21, or 23 once embeddings exist)" anddocs/benchmarks.md's "21 of them on a graph-only store (20 graph tools plus theaskrouter)" were both stale. Worse, a second reviewer skipped numeric claims because it trusted that gate, so one blind spot became two.
Each pattern now carries its OWN expected value rather than sharing one permissive set: the first fix allowed "unconditional minus one" everywhere, which let a stale "21 tools are registered" read as legitimate. All eight claims were confirmed to fail individually.
[7.19.0] - 2026-08-18#
Added#
- Every number the docs state about the build is now checked against the build, on every
push.
tests/test_docs_claims_match_the_build.py: language count, grammar count, node-kind count, both MCP tool counts, and every documented CLI verb, each compared against the single authority for it in the code.
Written as a test rather than a script because a script gets run once and then rots. Adding a language, a node kind or an MCP tool now fails a test that names the doc line to update.
It exists because the claims were caught drifting: docs/explained.md said "21 tools are
registered unconditionally... bring it to 23", and counting from a built server gave 22 with a
tool added that morning -- so 21 had been right only momentarily and 23 dated from an earlier
era. Reading a number tells you nothing about whether it is true.
The rule it enforces: a number in the docs must have exactly one authority in the code, and the test file names it. A claim with no authority cannot be checked and should not be a number. All five checks were confirmed to fail against a corrupted doc before being trusted.
Changed#
- The CLI dispatch table is a module constant,
contextlake.kb.cmds.VERBS. It was a literal insidedispatch(), so nothing outside that function could name the verbs and "is every documented verb real" was unanswerable except by parsing the file as text.
VERBS deliberately includes source, which is dispatched lazily to keep tomlkit off every
other command's import path. The eager handler dict alone under-reports by exactly one and would
look complete, which is the same shape as every other count this release is about.
[7.18.0] - 2026-08-18#
Added#
- A fleet page: what every indexed repository commits to, and where they disagree. Written
to
<store>/docs/fleet/design.mdon any run that covers the whole store.
This is the one generated document no per-repo page can produce. The graph keeps package
nodes global, keyed by ecosystem and name rather than by repo, so two repositories
depending on the same package point at the same node. Disagreement is invisible from inside
either one: a service pinning >=2.5,<4 and another leaving the same package unpinned each
look entirely reasonable on their own page.
Every population is a count of distinct repositories, and manifests are counted separately. Measured on a real four-repository fleet before the renderer was written, one package had 11 dependency edges across 2 repositories, because one of them declares it in eleven manifests: its own plus ten bundled examples. Counting edges would have printed "11 repositories" onto a four-repository fleet. That is absurd at four and perfectly plausible at forty, which is why the two numbers now sit in adjacent columns and are never substituted.
The page names which shared packages are pinned inconsistently and then explicitly declines to recommend anything: a repository may pin tightly because it met a real incompatibility, and nothing in a graph can tell a deliberate split from a drifted one. Agreement is stated too rather than left to inference, because silence reads as "not checked".
Absence is split into its three different causes, because one heading over all of them made a repository with a dev-only manifest indistinguishable from a broken store entry. A repository can be missing from the tables because it declares only development or opt-in dependencies (a manifest was read; nothing in it runs), because it declares nothing this reads, or because its shard could not be loaded at all -- in which case the page knows nothing about it either way and says so, rather than reporting it as declaring nothing. All three are named, not counted, since a count invites the reader to guess which.
The shared-package denominator names the filter it was drawn from. "3 of 15 packages" would silently redefine packages as runtime packages: a fleet with 15 runtime and 200 development packages reads as a 15-package fleet, and nothing else on the page contradicts it. It now reads "3 of 15 packages required at runtime", and states how many appear only as development or opt-in dependencies.
It is written only for a full run. kb docs <repo> skips it and says why, because "3 of
15 packages are shared" is a claim about the whole store and a reader has no way to tell a
scoped page from a complete one. Only runtime and peer dependencies reach it; a dev dependency
disagreeing across the fleet is a lesser finding that would bury the one that matters. The
command's summary line names the fleet page when it writes one, since a summary listing two
outputs while three were written under-reports the work.
[7.17.0] - 2026-08-18#
Added#
- Generated documents now say which commit they describe, and agents can read them.
kb docswrote two documents per repository that nothing could reach: the MCP server exposed the store'swiki/directory and nothing underdocs/. New toolget_generated_doc(repo, kind)returns the API reference (kind="api") or the design notes (kind="design").
The commit stamp had to come first, and it is the reason this is one release rather
than two. A generated page is a claim about source code at a moment. Strip the moment
and a page describing code that changed months ago is indistinguishable from a current
one, while reading exactly as authoritative. The wiki has carried its commit since it was
built, which is how get_wiki reports stale; the API reference and design notes carried
nothing. Serving them without a stamp would have shipped a surface that cannot say whether
it is current.
Every page now carries the fact twice, for the two kinds of reader: a visible sentence
(Generated from \repo` at commit `abc123`.) and a comment marker
(`). The marker is
authoritative; the sentence is what gets reworded.
An absent commit is recorded as unknown, not omitted. Omitting the field makes the
marker unparseable and sends a consumer down the same path as a page that was never
stamped; a present unknown says the thing that is true, which is the difference between
a caller defaulting to fresh and defaulting to stale. stale is therefore true in four
distinct cases: the commits differ, the page has no stamp, the stamp says unknown, or the
repo has no indexed head. Not knowing and being out of date are the same risk to whoever
asked.
An unrecognised kind reports found=false rather than being coerced to the default, so
a caller asking for something that does not exist learns that instead of receiving the API
reference and believing it asked correctly.
[7.16.0] - 2026-08-18#
Added#
- The design notes now carry numbered decision records, for the recorded evidence class only. Each commitment the repository's own manifest makes at runtime becomes an entry: the choice, where it is written, and the reasoning left visibly absent rather than filled with a generated guess.
### ADR-001: Depend on `blinker` at `>=1.9.0`
**Status:** proposed, never ratified.
**Decision.** `pyproject.toml:24` declares `blinker` with the constraint `>=1.9.0`,
required at runtime.
**Context.** *Nobody wrote this down. The repository records the choice and not the
reason, so what was weighed against it is not recoverable from the code.*
That absent Context is the point. A real decision record states what was chosen, what was rejected and why; a graph supplies only the first, so the entry says so instead of inventing the rest.
Only recorded evidence is numbered. A constant read in many places stays a plain table
row, because on a measured public tree three of the seven constants that cleared the
evidence bar were typing constructs, and "ADR-005: T is a repository-wide type variable"
is exactly the invention this page exists to avoid. And only the repository's own runtime
commitments are numbered: a dev dependency is a contributor's convenience, an optional
extra is opt-in, and a nested project's dependencies are that project's decisions. Every
one of those stays recorded in the tables, so the narrower scope costs no coverage.
Entries are ordered by name, because nothing in the graph ranks one dependency above another, and the page states that the numbers are positions in a generated file rather than stable identifiers: adding a dependency renumbers everything after it, so each heading names its package to give a reader something stable to cite. The count is bounded, with the total and the remainder both stated, since one measured application repository declares 112 runtime dependencies in a single manifest.
[7.15.0] - 2026-08-18#
Added#
kb docsnow also writes design notes: what a repository's own files record about how it was built. Written to<store>/docs/design/<repo>.mdbeside the API reference. No model, as before.
The honest scope is narrower than "design document" suggests, and the page opens by saying
so. A graph holds no decision records: it never sees what was rejected or why. It holds two
kinds of evidence, kept apart because they are not equally strong. A manifest dependency is
recorded (somebody wrote blinker>=1.9.0 on purpose, so the package, its constraint
and its line are facts). A constant read in many places is inferred evidence that a
value is load-bearing, and no evidence at all that anybody decided anything.
So the page states counts and refuses to explain them, the rule the wiki's gotchas prompt already carries. Measured on a mature public library before any of it was written: seven constants clear a defensible evidence rule and about four point at something a human would call a decision, the rest being typing constructs. Nothing in the graph can tell those apart, and a generated sentence calling a type variable a core architectural decision is worse than no sentence.
Three properties keep it honest, each present because its absence produced a real wrong
answer: coverage is always stated as N of M, since filters drop candidates silently
and a short list with no denominator reads as "there is little here"; an ambiguous
reading is never counted, because a name with several definitions has each use attributed
to all of them, so one name defined three times carried an identical 41 sites on each and
summing reports 123 uses of 41; and an empty list names what was read, since "declares
no dependencies" and "declares them in a file not yet read" otherwise render identically.
Dependencies get one table per manifest, the repository's own first, so a bundled example
that depends on this project does not read as a dependency of it.
The page carries a machine-readable marker as well as the prose, because whoever reads the
file receives bytes rather than a rendered page, and a status stated only in a paragraph is
a sentence a summariser can drop:
<!-- contextlake:document=design status=proposed-never-ratified evidence=derived-from-code -->
- A dependency now records what was actually written: the constraint, the group, and the
line. A
depends_onedge carried a package name and nothing else. The manifest saidblinker>=1.9.0; the graph saidblinker. Every dependency in a file cited line 1 of it, whatever the file said, so a citation named the file and stopped there while every other citation in the product names a line. And runtime, dev, peer and optional groups were folded into one relation, which made an extra a user opts into indistinguishable from a dependency the package cannot start without.
Each edge now carries attrs["constraint"] (the remainder of the spec as written, so
>=1.9.0, ^4.17.1, [redis]>=5.0 or a whole environment marker survive; nothing is parsed
or interpreted, and the key is absent rather than empty when the manifest pinned nothing),
attrs["group"] (runtime, dev, peer or optional:<extra>), and a real declaring line.
All four ecosystems: pyproject, package.json, csproj and pom.xml, each mapped onto the same
group vocabulary so a consumer does not need to know which ecosystem it is reading.
- A project that declares its dependencies in PEP 735
[dependency-groups]reported having none. That table is a sibling of[project]rather than a key inside it, and only[project]was read, so a project using the modern spelling produced an empty list. Measured on a public Django application: 0 dependencies before, 137 after. A zero is the worst possible failure here, because an empty list reads as "this project made no choices" rather than as "this was not read". Groups land under their owngroup:<name>prefix rather than being folded intooptional:, since an extra is published in the package's metadata while a dependency group is local to the checkout. An{include-group = ...}entry names a group and is correctly not treated as a package.
Four defects fell out of doing this, three of them the same shape: a wrong answer that looks like a right one.
- A bare package name matched inside a longer name, so a project called
demo-example-workerthat depends ondemocited its ownname =line rather than the dependency. Found by reading generated output beside the file it describes, since a "the line is not 1" assertion passes happily on the wrong line. - A package listed in two groups resolved to the first group's line twice.
- A NuGet version written as a
<Version>child element rather than an attribute was not read, so a pinned dependency arrived with no constraint recorded, which this changelog defines as "the manifest pinned nothing": confidently wrong rather than missing. - The NuGet pattern was anchored on
Include, so aPackageReferencewrittenVersionfirst lost its version silently while still producing an edge.
Each is covered by a test confirmed to fail against the previous behaviour.
This bumps PARSER_VERSION and therefore re-indexes every store, closely after the
previous bump, which is not ideal for anyone who just re-indexed. The alternative is worse: a
manifest that has not changed since the last index would keep the thinner edges forever, and
no commit-keyed check would ever say so.
Fixed#
- The same two strip helpers missed one more spelling of an end tag:
</script foo=1>. A browser closes a script there, because attributes on an end tag are ignored while the tag still ends. Both helpers required either a bare>or whitespace before it, so an end tag carrying anything else left the whole block in place. Now they accept any run of non-bracket characters, which is both what the rule asks for and what HTML actually does.
This supersedes the 7.14.0 entry below, which described the same helper as fixed after two
passes. It was better, not finished. The scanner re-fired on the corrected line and named a real
case, as it had the previous two times: <SCRIPT> first, then plain lowercase </script >,
which measuring found and no alert had ever named, now the attribute-carrying form. Four new
parametrised cases cover end tags with attributes and with embedded tabs and newlines, and each
one was confirmed to leak under the previous pattern before the fix landed. Neither helper is a
sanitizer or guards a security boundary: one keeps accessibility assertions from matching a
string quoted inside a script, the other keeps script bodies out of the docs-site search index.
[7.14.0] - 2026-08-17#
Added#
- A constant now records what it is set to, and every place that value is read. Two gaps that were measured, not assumed: the graph knew a constant's name, kind and location and nothing about its value, and no edge recorded a use of one, so neither "what is the retry limit" nor "what breaks if I change it" was answerable from the graph even though both answers sit in the syntax tree.
Every constant carries attrs["declaration"], the declaration as written, collapsed to one
line and capped: MAX_RETRY = 3, #define TIMEOUT 30. It is called a declaration and not a
value because nothing has been parsed out of it. Each read becomes a uses edge from the file
to the constant citing its own line, stored once per occurrence like calls, so "where is
this read" is exhaustive rather than one edge with an arbitrary line attached. uses joins
impact's default relations, so contextlake kb impact MAX_RETRY now answers.
This bumps PARSER_VERSION and therefore re-indexes every store. Nothing about it is
visible to a commit-keyed check, which is exactly what that version exists to signal.
What is deliberately not a use: the declaration itself, a write (TOTAL += 1, global TOTAL),
an import, and an attribute, since cfg.MAX_RETRY reads an attribute of cfg. A bare name is
also never matched against a class field: a data member is reached as self.x or this->x,
so a bare x is a local. That distinction was not theoretical. Allowing fields attributed 588
reads of a loop counter to a class member of the same name on one public C++ tree, confidently
and wrongly rather than flagged as ambiguous, and removing them cut that tree's shard from
24.8 MB to 14.5 MB. Where a name has several definitions the edge is marked ambiguous rather
than pointed at a guess, so anything counting uses can filter on confidence.
Cost, measured on two public trees: read edges came to +11% of all edges on a small Python package and +63% on a macro-heavy C++ one, where one test-assertion macro is read from 22 different files. Shard bytes stay reproducible: two independent indexes of one tree are byte-identical with the new stream, checked before and after.
Fixed#
- A test helper that stripped
<script>and<style>blocks missed two spellings, so four accessibility assertions could have run against text the helper exists to remove, passing while checking nothing. It is not a sanitizer and guards no security boundary; it guards those assertions, which is why it now matches case-insensitively and tolerates a space before the closing bracket.
Raised by CodeQL as py/bad-tag-filter for missing <SCRIPT>. Measuring it found a second miss
the alert did not mention: plain lowercase </script >. So re.I alone would not have closed it,
and the new test proves both halves independently, one case per spelling.
The shipped package does not share this defect. It defends that boundary by escaping <, > and
& where data enters the page, rather than by pattern-matching for dangerous tags, which is the
approach that fails. The same two weaknesses did exist in the docs-site search indexer, where the
input is this repository's own rendered Markdown and every index field is escaped again before
display; fixed there too, as correctness rather than as a vulnerability.
[7.13.0] - 2026-08-17#
Added#
kb docs: generated documentation, with no model involved. The first document it writes is an API reference per repository: every symbol of a documentable kind, and the real places the codebase calls it, each one a file and a line read off the graph's per-occurrencecallsedges.--max-symbols Nbounds the document, default 500. Output lands in<store>/docs/api/<repo>.md. Separate fromkb wikion purpose: a reference is looked things up in, a wiki page is read start to finish, and one document that tries to be both serves neither.
Five things it will not do, each because a draft did and rendering against public trees showed it. It does not call a row count a caller count, so a symbol called twelve times from one place reports twelve sites and one caller. It does not name a file as a caller when a call carries no enclosing definition; it says so, and leaves it out of the count. It does not drop a caller that is not itself documented, because a test function calls things: the rule reads the kind registry's own container group rather than the documented-kind set, which had discarded 270 real callers on one C++ tree and reported a symbol with twelve call sites as having none. It does not claim to be ordered by call count while grouping by filename. And where the cap fell inside a tie, so that which symbols were dropped came down to their filenames, the page says so rather than implying a ranking.
Symbols carry their recorded scope, so a header-heavy C++ library does not produce several identical headings in a row; where the graph recorded no scope, the bare name is shown rather than a guessed one.
-
The API reference quotes the source at each call site, which is what makes an entry an example rather than a pointer. A line is quoted only where it can be proved to be the line that was indexed, meaning the file has not been written since; otherwise the cell says
changed since indexinginstead of showing today's line at that number, and where nothing in a repository can be quoted the page states the reason once. Samemtimeagainstindexed_atrule the stale-slice guard uses, deliberately, so freshness has one definition. -
bootstrapwrites the API reference, with--no-docsto skip it. It is the cheapest of the outputs this product promises (no model, no network, one pass over shards already on disk), so leaving it out of the one command that goes from nothing to a wired workspace meant the output nobody has to configure was the one nobody got by default.bootstrap's own one-line description had also drifted: it listed neither the diagram stage nor this one.
Fixed#
-
A table cell built with
mdwrite.code()was escaped twice, so a C++operator|overload rendered with a visible backslash.tableescapes every cell it is given, which makes it the one place that knows a value is becoming a row, socodeno longer escapes at all. -
A stored path could read a file outside its repository. The snippet reader joined a graph-recorded path onto the repository root without checking the result stayed inside it, so a
..component, an absolute path, or a symlink pointing out of the tree resolved to a file the document had no business quoting. Now resolved and checked for containment, never by comparing path strings.
The check itself moved to kb/paths.py and is shared with the dashboard, which already had
one. That version also catches ValueError, which resolve() raises rather than OSError
for a path carrying an embedded NUL byte, so the copy written for the documentation generator
would have ended a run with an uncaught exception on such a path.
[7.12.1] - 2026-08-17#
Fixed#
- Four defects in the structural page, all found by reading one a real install produced. The suite was green and every one of these was visible in the first page a fresh 7.12.0 install wrote for a four-file repository.
"Installation and usage" reported nothing on a repository with a Makefile.
setup_signals is a flat list of filenames; the renderer unpacked it as a three-tuple,
which is a shard-only helper's internal return rather than what the brief carries. Worse,
the unpacking was written "defensively" with type checks, so it read the first FILENAME as
if it were the list, failed the check, and rendered an empty section over data it was
holding. Defensive unpacking that degrades silently is worse than a shape assumption that
fails loudly. The test fixture had the same wrong shape, so both were wrong in the same
direction and neither revealed the other.
The public surface listed files. A file reached that table only because it is a node with a degree, which is a fact about the graph's shape rather than about the repository's API.
An entry point could appear twice. Section 4 excluded entry-point kinds from one of
its two passes, so a make target with an incoming edge arrived through the other and was
listed under both "Entry points" and "The public surface".
A column labelled "Callers" did not count callers. The rank behind it is in-degree
over every relation, so a symbol its own file contains already counted one. It reads
"Incoming references" now: the vaguer word is true, and the precise one was not.
The install section also quotes the README excerpt when there is one, which the brief was already carrying and nothing displayed.
[7.12.0] - 2026-08-17#
Added#
kb wikinow produces a page for every indexed repository, with no LLM configured. It used to print "LLM tier disabled" and write nothing, so a user who had not set up a backend got nothing at all from a tool whose identity is local-first.
The new structural page is built entirely from the graph, the manifests and the checkout, and carries six sections: entry points and how to run it, architecture, ownership and activity, the public surface with caller counts, installation, and what the repository contains, including the repositories it depends on and that depend on it. That last pair is a cross-repository answer no single-repo tool can give. An empty section is omitted AND named at the end, because an absence that says nothing reads identically whether the repository has none of that thing or the extractor missed it.
Large repositories get one structural page per module as well.
- One wiki page per scope, and prose must earn its place. A repository has one wiki page at one path; the structural page IS that page until generated prose replaces it, and prose replaces it only when it is accurate about the same names and covers the same sections. Passing the council is not sufficient and cannot be: a council judges a page on its own terms and has never seen the page it would displace. A rejected or failed generation leaves the structural page exactly where it was, and the reason is reported.
Strict deliberately. Expect drafts to fail this, and expect to keep reading the structural page on some repositories even with a strong model configured.
- The structural page is what the LLM is now written FROM. An earlier generated wiki was rejected for thin grounding, and the cause was structural rather than stylistic: the model saw a bounded sample of a repository's symbols and wrote confidently about the whole of it. It is handed the structural document instead, so it writes prose over stated facts.
Because that document carries the ownership section, contributor names now reach whatever
provider is configured. It is the same page written to disk, so it already honours
[kb] anonymize: pseudonyms under "always", real names under the default. A run against
a non-local provider says so once before sending anything.
- The structural page is searchable, stored under the repository's one
@wiki:<repo_id>partition and embedded into the semantic tier. Since it is the page everybody gets without a model, leaving it out would have made the default wiki the one you cannot find.
[7.11.0] - 2026-08-17#
Added#
- How a program is STARTED is now in the graph, as an
entry_pointnode. A list of functions cannot answer "how do I run this", which is the first question anybody arriving at an unfamiliar repository asks.
Two producers, because the fact arrives two ways. In most languages the entry point is
an ordinary definition that something else makes special, so the kind is REFINED from
function/method rather than a second node added beside it, the same call the test
kind already makes. Python's if __name__ == "__main__": is an if_statement and not a
definition at all, and [project.scripts] / package.json bin name a command that may
point into another file entirely, so each of those produces its own node.
Each language needs a SECOND condition, and that condition is the whole feature. Go's
package must be main, because func main() in a helper package is an ordinary
function that Go will not build as a command and that looks identical to the real thing.
Java and C# require static. Rust, Kotlin, C and C++ require the top level of the file.
Without them every helper called main anywhere in a repository is advertised as a way
to run the project.
Covered: Go, Rust, C, C++, Kotlin, Java, C# (Main, the one language that capitalises
it), Python, plus pyproject.toml console scripts and package.json bin. npm run
targets are deliberately NOT read: those are build tasks rather than commands on your
PATH, and treating test and lint as entry points would bury the real one.
This is a parser change, so every existing store is stale and re-indexes itself.
kb index already refuses to skip a repository whose graph an older parser built, and
says how many it is re-indexing and why. Nothing to do by hand.
Fixed#
-
The graph-vocabulary diagram's alt text stated the wrong counts, "40 node kinds in 9 bands" against a registry holding 48 in 10, having drifted several releases earlier. Alt text is the version of that image a screen-reader user receives, so a wrong number there is the whole description rather than a detail beside it. Both counts are derived from the registry by a test now.
-
[kb] anonymize = "always"makes anonymising the standing answer on a machine, covering the served dashboard and every--siteexport without anyone remembering the flag. Default"never", so nothing changes for anyone who does not set it.
Explicit rather than inferred, decided rather than defaulted. The intent was to turn it on automatically when the store holds repos the operator does not own, and the index cannot answer that: a repository record carries id, path, host, branch and commit, and no ownership. The most obvious substitute, whether a repo id sits inside the configured mirror group, inverts on the case that motivated the rule, since mirroring an organisation you contribute to but do not own puts every repo inside the group.
Three deliberate asymmetries, each because this setting guards people rather than
preferences. --anonymize can only raise it and there is no --no-anonymize, so a
standing "always" cannot be lost to a half-remembered flag in a shared shell. An
unreadable value anonymises anyway, with a warning quoting the spelling, because a typo
must not read as permission to show a name. And a .contextlake.kb.toml found by
walking up from the current directory may turn it on but never off, since contextlake
clones repositories into the workspace itself and a checkout could otherwise disable
the operator's own setting.
Fixed#
--anonymizeno longer serves a repo's wiki prose on a second route. The served dashboard's/api/repo/<id>deliberately drops the wiki body under--anonymize, because a generated page can carry author names and internal URLs as live anchors. The dedicated/api/repo/<id>/wikiroute, which the Wiki tab's module picker uses, served the same bytes and took noanonymizeargument at all, so what one route withheld the other returned one request away. Both now drop the prose and keep thefound/staleflags, since a page existing is a fact about the repository rather than about a person.
The regression test drives real HTTP over every route that can carry that prose, and asserts each one DOES carry it with anonymising off, so a route that returns nothing cannot pass the not-present half by accident.
Added#
- Makefiles are indexed, and files can now be routed to a grammar by NAME. Every
language before this reached its parser through a file extension, and a build file has
none, so
Makefilewas not merely unsupported: it was dropped without even being counted among the files whose type had no parser. A second routing table matches on the file's stem, soMakefile,makefile,GNUmakefileandMakefile.amare all indexed andMyMakefileis not, an exact match on a derived key rather than a prefix test.
Make targets become make_target nodes: the names a person types at a shell and a CI
job invokes, which is the shortest honest answer to what a project expects of itself.
Make's own special targets (.PHONY, .SUFFIXES) are not extracted, because a symbol
nobody wrote does not belong in a graph whose claim is that its contents came from
source. Variables are not extracted either, stated rather than left to be discovered.
Included fragments (common.mk, rules.mak) take the extension route to the same
grammar, so a build system split across both spellings is one language and not two.
- Dockerfiles, behind an optional
[kb-dockerfile]extra. A Dockerfile yields its build stages and the external images it builds on, told apart from each other: inFROM builder AS testthe base is a stage declared earlier in the same file rather than an image anybody pulls, and emitting it as a dependency would put a container image nobody uses in the graph looking exactly like the real ones beside it.
Optional for a packaging reason and not a product one. tree-sitter-dockerfile ships
two wheels and no source distribution, so making it a hard dependency would not index
less on Windows, aarch64 Linux and musl: it would make pip install contextlake[kb]
fail there outright. When it is absent the Dockerfiles are skipped, and the run says how
many and names the extra that fixes it. That is deliberately a different sentence from
the one about files with no parser, because those have different fixes and one of them
points at a page that cannot help.
27 languages across 25 grammars.
[7.10.0] - 2026-08-16#
Added#
- CSS, HTML and Nix, with purpose-built node kinds. A stylesheet's class, id and element
selectors, an HTML element's
id, and a Nix attribute name are the things other files refer to by name, so they become nodes:css_class,css_id,css_element,html_id,nix_attr. Purpose-built rather than folded intoclassorglobal_variable, because--kind classreturning stylesheet selectors would cost every existing filter its precision.
These do not go through the definition query, and cannot: in CSS the pseudo-class in
a.nav:hover is the same class_name node as the real class in .nav, so a node type
cannot tell them apart, and a query would invent a CSS class called hover on every hover
rule.
-
An HTML page now resolves to the stylesheet that styles it. Every name in a
class=attribute, and every element's own tag, becomes areferencesedge to the CSS selector of that name, resolved across files exactly like a call into another module. That join is the point of indexing the pair: neither side alone answers "which stylesheet defines the class this page uses", or "which stylesheet styles every button". A name no stylesheet defines resolves to nothing rather than to a node invented to receive it. -
Svelte and Vue single-file components. Each
<script>is parsed as JavaScript and each<style>as CSS, and every symbol is reported at its line in the FILE rather than in the block. Neither grammar parses the embedded blocks, so the outer grammar finds boundaries and the contents go through the JavaScript and CSS grammars; Svelte uses its own grammar and.vueborrows HTML's, since no tree-sitter-vue package exists anywhere. A component with no script and no style still appears in the graph. -
Six languages: Swift, Dart, Zig, Perl, Bash and Elixir. 14 languages become 25, across 23 tree-sitter grammars. Every query was compiled and run against a real snippet of its language before being written, because each of these grammars names things differently from what the language's syntax suggests:
- Swift has no struct node.
struct Boxparses asclass_declaration, so a struct and a class arrive as the same kind rather than being told apart by reading source text back. - Dart splits a top-level function in two. The definition node is
function_signature, and the body is its sibling. - Zig declares a struct as a constant.
const Engine = struct {...}is a variable declaration, so Zig types are not extracted, only functions. Stated here and pinned by a test rather than left to be discovered. - Bash variables are global unless declared
local, so an assignment anywhere is a global, which is deliberately not the module-scope-only rule JavaScript and Python follow. - Elixir has no definition node types at all.
defmodule,defanddefpare ordinarycallnodes, so neither the kind nor the scope can come from the node type the way every other language's does. Two small per-language hooks read the macro name instead, which is what makesEngine.startandOther.starttwo functions rather than one.
Depth is now part of the documented claim rather than hidden behind one number: C, C++,
JavaScript, TypeScript, TSX and Python also yield module-level variables and class fields;
every other language yields definitions, imports and calls. docs/style-guide-reference.md
holds the phrasing and a test derives both counts from the parser, so a page cannot drift
from the code again.
Changed#
- A missing grammar package now names itself and its install command. Grammars come from
a table rather than a fourteen-branch import chain, so a language whose package is not
installed raises with the package name instead of a bare
ImportErrorfrom a module the reader never asked for. Proven equivalent before the swap: every language then supported produced the same grammar through the table as through the chain.
[7.9.0] - 2026-08-16#
Module-level variables and class fields are now extracted for JavaScript, TypeScript and
Python. They were extracted for C and C++ only. The head-to-head benchmark in
benchmarks/head-to-head/ is what put a number on that: on a small public JavaScript tree the
comparator emitted 461 variable nodes where contextlake emitted none, and both tools had read the
same 141 files.
⚠ Re-index: your existing graphs are rebuilt on the next index#
PARSER_VERSION moves to 5, so kb index re-indexes every repository whose recorded parser
version differs even though its HEAD has not moved, and says so while it does. Nothing is required
of you beyond running it. That mechanism exists because a previous parser bump left Python and
TypeScript repositories stale indefinitely while every surface reported healthy.
Added#
global_variableandfieldnodes for the JavaScript family and Python. JavaScript, TypeScript and TSX: module-scopeconst,letandvarbindings, including those behindexport, plus class fields including#privateones. Python: module-level assignments, including annotated ones, plus class attributes. Measured on the pinned public trees in the benchmark:expresswent from 320 nodes to 783 andflaskfrom 1,959 to 2,207, which movedflaskinto the lead on distinct relationships. Two trees carry Python helper scripts and so moved slightly too, which the results file names rather than glosses.
Destructuring patterns and tuple targets are deliberately not emitted: they bind several names at once, and a node named after the whole pattern would be a symbol nobody wrote. Locals inside functions are not emitted either, which the tests assert as carefully as they assert the positives.
What this does not claim: more nodes is not automatically better. A const holding a
require() alias is a weaker answer to "what is in this codebase" than a function is. The
benchmark measures coverage, not precision.
Fixed#
- The mirror banner names the forge you configured. A GitHub run printed "Mirror
repositories from GitLab", and "Active GitLab projects: 2", two lines above "Enumerating via
the GitHub REST API" proving the behaviour was right. Only the words were wrong, and in the
first minute of a tool whose pitch is precision that reads as "did it use the wrong platform?".
The label machinery already existed and these six sites never called it. The cache filename is
still
gitlab_projects.txt: renaming it would make every existing user re-enumerate, so it needs a legacy fallback and is a separate change.
[7.8.0] - 2026-08-16#
Search stopped burying the answer, and the gate that should have caught it can now see it. The two are one story: a symbol's own definition ranked 32nd of 153 on a live index, and the project's own retrieval harness scored the fix as changing nothing at all.
⚠ Behaviour change: search results come back in a different order#
kb query, and the MCP search_code tool that agents call, return the same matches ordered
differently. No re-index is needed and no result is dropped, but anything that depended on the
previous ordering, including "take the first result", will see different output. The reason is in
Fixed below: the previous order buried a symbol's own definition under files that merely mention
its name.
Changed#
- The retrieval-quality gate now scores MRR as well as hit-rate, and the evaluation
fixture gained the shape it was missing. Discovered while checking that the search
ranking fix below did not regress the gate: the golden-set numbers were byte-identical
before and after it, so the harness meant to catch retrieval regressions could not see
the largest retrieval defect this project has fixed, in either direction. Two reasons,
both now closed. The fixture contained no test files at all, so the competing-noise
shape that buries a definition could not occur in it; and the gate reads hit-rate, which
only asks whether the answer landed inside
k, never where. Measured on the fixture with the shape added: MRR is 0.80 with the current ordering and 0.77 with the bare FTS5rankthat shipped until 7.7.0, while hit-rate reads 0.80 for both. The MRR floor sits at 0.78, between those two measurements, so the pre-fix ordering fails it. That 0.03 spread is the whole range this gate can discriminate, and MRR over 30 queries moves in steps of about 1/30, so read the note beside the floor in the golden set before moving it.
Fixed#
-
A query against an empty store no longer answers like a genuine miss. Querying also creates the store file, so someone who had not indexed yet, or whose
--localconfig pointed somewhere other than the shell they were querying from, got a confidentNo matcheswith a freshly made empty database behind it. It now says the store is empty and prints which store, because the usual cause is the right command against the wrong one. -
Text search results show the qualified name that explains them. Three functions all named
hookcame back for one query and the printed lines differed only by line number; the reason (their qualified names sit inside a test function) was carried by--jsonand by nothing a human saw. -
kb graph --overviewon a single-repository store says what it drew.--overviewis the fleet map, with repositories as nodes, so on a one-repo store it is a single dot, and that was the first picture the quickstart handed a new user. It now names that repository's own symbol view. -
initno longer tells you to install what you are already running. The closing advice printedpip install "contextlake[kb]"even when the extra was importable in the running interpreter. -
The unknown-repo error stopped promising something that does not work. It ended "or pass a path on disk"; measured, no path form resolves, neither
.nor an absolute path to the checkout. It now points atkb indexand atkb doctorfor the ids the store actually holds. -
kb query <Symbol>now ranks<Symbol>'s own definition first. It did not. Measured on a clean 2,086-node index of a small public Python library,kb query Contextreturned 20 test-file hits and the real class ranked 32nd of 153;Commandranked 44th of 191. The same ordering reached an agent through the MCPsearch_codetool, so it was not only a CLI complaint.
FTS5's bare rank weights every indexed column equally and the default tokenizer splits on
_, so test_context_meta in tests/test_context.py matched the term in name,
qualified_name and file while the real Context matched twice. Longer, noisier rows won.
Results are now ordered by exact name, then by name prefix, then by a weighted bm25, so a
related ContextMeta also outranks a test file that merely mentions the word. Weighting alone
was measured and is not sufficient: it moved the real definition from 4th to 3rd and no
further.
[7.7.0] - 2026-08-16#
Turn on what was already built. Four capabilities were present in the code, complete and tested, and unreachable from the path a user actually walks. Nothing here is a new feature; each change connects a built thing to the command that should have been calling it.
A fifth item on the batch list, folding the wiki into the static site export, shipped no code: the whole-repo wiki was already carried there. See "Verified, no change needed" below.
Changed#
-
Semantic search is on by default.
[embeddings] enablednow defaults totrue. Opt-in meant a new user's natural-language questions returned nothing at all, with no hint that a step was missing. Measured on a 39-repo store: every purely conceptual question came back empty, and building vectors lifted name recall from 0.375 to 0.625 at a cost of about 1.7 seconds for 7,719 vectors on CPU. This is not paid at index time;kb indexdoes not embed,kb embeddoes. Local-first is unchanged: the provider chain staysauto -> Ollama -> builtin -> embed nothing, and a run with no embedder available says so and names the fix instead of failing quietly. Pass--no-embeddingsto restore the old default. -
bootstrapdraws an architecture diagram. Architecture drawings are one of the outputs this product exists to generate, and the one command documented as taking a workspace from nothing to a wired knowledge layer never produced one;kb graphwas built and nothing called it. The view is chosen from the store's shape, because--overviewrenders the fleet map with repositories as nodes and is therefore correct and useless on a store holding one repository: a single-repo store now gets that repository's symbol graph instead. Skip with--no-diagrams. Cost measured on a real 39-repo store: 2.4s for the fleet view, 1.4s for the symbol view, both bounded by node caps that report when they truncate.
Added#
init --no-mirror, a local-only setup path. Someone with repositories already on disk and no forge account could not completeinitat all: the group check ran before the knowledge-layer branch, so both the interactive form and the documented all-defaults form exited 2 and wrote nothing, and--no-kbdid not help. That persona is exactly who the quickstart addresses. The local-only path writes a knowledge config and no mirror INI, since a mirror INI would name a forge group that does not exist.
Fixed#
--anonymizenow covers the served dashboard, not only--site. The flag was documented for both and implemented for one: the data layer accepted the argument on every identity-bearing function and the server never passed it, sodashboard --serve --anonymizerendered real author identities. Verified over real HTTP in both modes, because a test calling the data function directly would have passed for the entire life of the bug.
Verified, no change needed#
- The static site export already carries the wiki.
dashboard --siteemits agraph/wiki-<repo>.htmlpage per repository and carries the rendered prose in the snapshot payload. Measured on a real store with planted marker text rather than read from the code. Per-module wiki pages are not exported, but nothing writes them on a real store yet, so exporting them now would be a reader with no writer; it is tracked with the work that adds the writer.
[7.6.0] - 2026-08-15#
There is no 7.5.0. The plan assigned 7.5.0 to the search and first-five-minutes batch and 7.6.0 to this one; the owner reordered them so the honesty work shipped first, and this release kept its planned number rather than moving. The search batch keeps its content and takes the next free number. Recorded here so a reader looking for 7.5.0 finds the reason instead of a hole.
Nothing reports success when it failed. Twelve fixes, one defect: a surface reporting a result as complete when it was partial or failed. It turned up independently in the sources, the ingest summary, the diagnostics, the redaction flags, the dashboard buttons and the parser, so it ships as one release under one convention:
An operation that could not observe its input says so in its summary line AND in its exit code. A count printed as an outcome is measured after the operation, not before.
⚠ Behaviour change: partial failures now exit non-zero#
Several paths that printed a warning and exited 0 now exit non-zero. This will break
automation that was silently passing, which is the point, a content pipeline degrading one
source at a time was previously invisible until the answers got worse. Every affected command
names --exit-zero-on-partial in its own output, and that flag already exists as a global
option, so a scheduled run needs one flag rather than a rollback.
Fixed#
-
An unreachable source was a green tick. Four of nine source types swallowed every fetch exception with no log line, so a wrong URL, an expired token, an HTTP 500, a proxy block and a genuinely empty page all produced the same
0 documents, exit 0.web,api,graphqlandmcpnow record each miss with its target and reason, the mannerssources/files.pyalready had, andkb ingestdistinguishes "reachable, nothing to ingest" from "could not read 3 targets". -
kb source testcould not probe five of the nine types and exited 0 regardless, and the three that swallowed all network errors were three of those five, so the diagnostic confirmed that a broken source was fine.web,apiandgraphqlare now probed by building the source and reading the misses it records, which keeps one definition of "can this be read".filesis probed too: a path typo is that type's expired token. What genuinely cannot be probed prints NOT TESTED instead of(source is configured), which read like a pass. -
A partial embed printed a real count and an OK summary. An embedder dying at batch 3 of 200 gave
128 embedded, exit 0, and no line saying the phase ended early, semantic search then answered confidently over a fraction of the corpus. The number was true; the impression was not. -
apiandgraphqlread page one and reported success. They are the escape hatch people reach for when pointing contextlake at an issue tracker, so a 4,000-issue tracker ingested one page.apinow follows the RFC 8288Link: rel="next"header and an explicitnext_fieldcursor, capped bymax_pageswith the cap reported when hit. The header is parsed rather than substring-matched:prevandnextroutinely share one header, and a naive match walks backwards forever. GraphQL cursors cannot be followed generically, so nothing is faked, a response whosepageInfo.hasNextPageis true says that more exists and was not read, and anerrorspayload is recorded as a failure instead of returning silently. -
kb ingestdefaulted to an orphan partition. Without--for-repothe documents are searchable by name and reachable by no traversal. A legitimate choice, but it was the silent default: the flag is opt-in and was never suggested. It is named once up front with the consequence spelled out, and a run that was given--for-repoand linked nothing says so. -
--redactleaked repository ids from four commands, and never covered module names._open_storeregistered the store under a comment reading "Every kb command funnels through here";cmds/doctor.py,dashboard/server.pyanddashboard/site.pydid not. Measured:kb dashboard --site --redactprinted a raw repository id thatkb lint --redacthad just redacted, on the artefact most likely to be shared. A redacted export also emittedrepo-<hash>::<real-subsystem-dir>, because only repository ids were registered. Both are fixed and pinned by a parity sweep that derives the call sites from the source. -
--redactdegraded silently when a store read failed (except Exception: pass). Redaction stays best-effort, but a run that could not enumerate what it was asked to hide now says so, because an operator who reads a clean-looking log shares it. -
The dashboard's Sync and Add buttons ignored every
[kb]indexing setting. One click permanently replaced a repo's filtered graph with an unfiltered one, in the same store, with no message. Reproduced withlanguages = ["python"]on a two-language repo: the button wrote 6 nodes where the CLI wrote 3. A config that cannot be read now falls back to the documented defaults and says so, rather than inventing a third policy. -
A repository in an unsupported language vanished without a trace. A Swift, Dart or Vue tree indexed to
0 nodes, 0 edges, exit 0, reportingskipped 0 generated, 0 oversized, 0 ignored, every counter truthfully zero and the reason invisible, which reads as "this repo is empty" rather than "this tool cannot read it". Unsupported files are now counted by extension, because the same branch fires for READMEs and lockfiles and a bare total would be noise. -
kb forgetreported bytes it measured before the delete. The figure was summed ahead of ashutil.rmtree(ignore_errors=True)and printed as an outcome, so a removal that failed still reported the full amount as reclaimed. Each path is measured after the attempt, and anything left on disk is named. -
An invalid regex in
[[rules]]disabled the rule with no message, anddoctorthen confirmed the rule was loaded, a configured rule that silently matched nothing, forever. Both compile sites now name the pattern and the error. The rule is still skipped, since one bad pattern must not abort the run.
[7.4.1] - 2026-08-15#
The command you were told to run, runs, and writes where you said it would. Six defects, every one of them a documented path that silently did the wrong thing while reporting success. Two of them wrote into a production knowledge store during the audit that found them.
Fixed#
-
kb refresh --refreshindexed your home directory. This is the SessionStart hookkb steerinstalls, so it was the default experience of the documented "refresh when a session starts" flow. It spawned a barekb indexwithcwd=$HOME, and an index with no target defaults to., so it indexed$HOME, never touched the repos the freshness check had just named as moved, and sat in uninterruptible I/O past 96 seconds holding the store's single-writer lock, refusing every other write. Where$HOMEis itself a git repo it would have indexed your home into the knowledge store. It now names each stale repo explicitly, repairs stale vectors withembedrather thanindex, and does not spawn at all when there is nothing to repair. -
Every integration contextlake installs named a command that does not resolve. The MCP entries, the SessionStart hook and the git
post-commithook all wrote a barecontextlake. Editors and git run hooks with a minimal environment, and on a venv install the console script is not on it, so the hook ran, "command not found" went to a stream nobody reads, andkb hook statusstill reported it present. Machine-local files now use the running interpreter;.mcp.jsonand.vscode/mcp.json, which are committed and cloned, prefer the portable spelling and fall back to it. The git hook also stopped sending stderr to/dev/null, it appends to$(git rev-parse --git-dir)/contextlake-index.log. -
Four commands wrote the store without taking its single-writer lock, while
_guard_store's own docstring promised "two writers never interleave".ingest,connect,enrichandforgetall wrote rows and shard files unguarded. Proved by running it: with a live lock held,indexrefused,ingestwrote, andforgetdeleted. The realistic trigger is contextlake's own detached background index. Read-only verbs stay unguarded deliberately. -
--configcould send the knowledge stages to a store you never named, by two doors, both walked into accidentally during the audit. Onbootstrap,--configis the mirror INI and the knowledge stages take--kb-config; passing akb.tomlparsed TOML as INI and left the kb stages on the default store. It now refuses and prints the corrected command. Separately, a--configthat exists but sets no[kb] store_diris merged over the global config and inherits the global store, the resolved store and the file that chose it are now both printed.load_kb_configalready hard-errored on a missing--configfor exactly this reason; these are the same hazard by quieter routes. -
A refused
bootstrapexited 0._bootstrap's return value was discarded at the dispatch, so a refusal printed its message and still reported success. -
One unreadable directory discarded a good graph.
cmd_indexfails the run if any repo failed, and bootstrap aborted on that exit code, so 4 of 6 repos indexing perfectly still meant connect, embed, wiki and steer never ran. The abort now asks whether there is a graph to build on rather than trusting the exit code of the stage that built it. -
--groupcould not silence the "no group found" warning. The flag is merged afterload_configruns, so the warning fired on every--groupinvocation of every mirror command, telling a user who had just supplied the group that no group was found.
Fixed#
- "This is a timeout, not an empty history" was printed for things that were not timeouts.
The ownership walk can fail three ways, and all three collapsed into one boolean; the caller then
narrated every one of them as the slowest. A path that is not a git repository exits
128in three milliseconds, and the warning claimed a 30-second timeout that never happened. The sentence exists specifically to stop one misreading, and it was creating another.
Found by reading the output of a real site deploy, where it fired seven times over the bundled sample fleet, which is not a git repository at all. Had one of those been a genuine timeout it would have read identically.
The three causes are now distinct. A real timeout and an unrunnable git each say so in their
own words; "not a repository" is silent, because it is the ordinary state of an indexed tree that
was never a clone, it is already visible as an empty owners list, and warning per repository per
request is what buried the other two. A pair of tests pins both directions, and they capture at
the log seam rather than through caplog or capsys: logging_setup sets propagate = False,
so caplog.text is always empty here, and a capsys assertion passes or fails on whether an
earlier test installed a stream handler. Both were written and both were wrong before this landed.
[7.4.0] - 2026-08-13#
Added#
- Every cited node now says whether the file moved under it. The staleness this package tracked
was keyed on the head commit and the parser version, right for the graph as a whole, and blind to
the case that bites hardest: an agent editing files between index runs, inside the same commit.
The graph says
line 88, twenty lines get inserted above it, and the answer still says 88. A confidently wrong citation is worse than a miss, because the agent goes and reads it.
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.
- PDFs are ingested, so the design docs stop being the part the graph cannot see. Decision
records, RFCs and architecture write-ups arrive as PDFs more often than as markdown, and until now
the
filessource read a PDF as a binary, failed to decode it, and skipped it without a word. It now reads a PDF's text layer throughpypdf, behind a new optionalkb-pdfextra imported lazily, so nothing changes for anyone who never ingests one.
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.
- A recipe for adding a language, so the work stops being the maintainer's. Language coverage is
contextlake's clearest measurable gap, 14 grammars against a competitor's 23 and another's 40, and
the architecture was never the obstacle: a grammar is a handful of table entries. What was missing
was a written path.
docs/contributing-languages.mdis the nine numbered edits in the order a contributor makes them, what each optional step costs if skipped, the verification commands with the output that proves the grammar works, and a full worked example from a language already in the tree.
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#
-
The release checklist's own first step could not pass.
docs/releasing.mdopened withruff check ., whileci.yml,release.ymland.pre-commit-config.yamlall lintsrc tests. The repo-root launcher tripsS606by design, so anyone following the checklist literally hit a red gate CI does not have. Found by running the documented command instead of the remembered one. A checklist whose first step fails gets skipped, which is worse than not having one. -
A Scala file's node had no language glyph, and nothing noticed. The lettermark map in the graph viewer drifted from the parser's own language table:
scalaparses.scala/.scand had no entry, so its nodes rendered with no language marking at all, which reads as "this has no language" rather than as a missing entry. Exactly the silent shape as the kind-colour drift fixed in 7.3.0, found the same way, by comparing two lists by hand.
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.
-
A comment in the parser claimed
xmlwas a node kind. It is not. It is a file kind; the XML extractor emitsconfig_key, andxmlappears nowhere in the kind registry. The comment was describing a real collision risk in the file-kind registry and used the word "kind" for both, which had already misled one reader into believing a kind existed. -
The dashboard's health panel re-walked every edge in the store on every request.
lint_resultparses each repo's shard and resolves both endpoints of every edge to find dangling ones, and the panel called it per page load. Measured on a 39-repo store: 26.54 seconds, for 1,613,081 edges, repeated on every refresh of a page nothing had changed behind. It is now cached on the same(path, mtime_ns, size)identity the shard cache already uses, so a warm request is stats only: 0.00s.
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.
- Nothing checked that a documentation link still pointed at anything, so they rotted silently.
site/build_docs.pyvalidated its own navigation and the Next-steps targets; the body text of every page went unchecked. That is how a pointer the CLI printed at users survived a restructure that had moved the section it named.
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.
- Forty-three documentation defects, most of them a stated fact the code contradicts. An audit of
every page found 53; these are the 44 that needed no decision from anyone. The worst were the ones a
reader would act on:
docs/install.mddocumented parser version3when the shipped value is4, and its justifying paragraph explained what version 3 had changed, so the page arguing for a re-index described the wrong reason for it. The MCP tool count was wrong in four lines across two files while a third file had it right.README.mdtaught two flags as global options that exit2on most commands.
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 dashboard's repo page re-parsed the whole graph on every request, even when its answer was already cached. The derived-brief cache was keyed on the shard file's identity, and that identity was obtained from the parse, so a hit saved the aggregation and never the parse. On a large graph the parse is the request.
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#
- Repository content handed to a language model is now explicitly framed as untrusted data. contextlake reads other people's source and feeds parts of it to a model when generating wiki pages and answering dashboard chat. A comment in an indexed repo is therefore untrusted input that can carry instructions aimed at the model. Labels were already sanitised on the way out and the config trust boundary was hardened in the 6.x line; the prompt path had no equivalent.
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#
- Thirteen accessibility defects in the dashboard, fixed against measured contrast rather than judgement. Seven Level A and six Level AA, from a WCAG 2.2 AA audit of the whole UI.
--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.
- Eleven accessibility defects in the graph viewer, including a canvas a screen reader could not use
at all. Its accessible name was the single string "Knowledge graph" and its measured
innerTextwas empty, so a non-visual user got a node graph with nothing in it.
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.
-
The CLI pointed users at a documentation anchor that no longer exists.
contextlake completionand the unrecognised-shell warning both printeddocs/usage.md#shell-completion; the docs restructure moved that section todocs/cli-reference.md, anddocs/usage.mditself now links there. Anyone who followed the instruction the tool printed landed on a page without the steps. -
The published vocabulary diagram documented 16 of 40 node kinds, and the embeddable graph page 17 of 40. 7.0.0 consolidated sixteen drifted kind vocabularies into one registry because that drift had caused real invisible harm: kinds with no colour got no legend button on the graph page, so they could not be isolated or filtered. The parity tests added then covered the vocabularies inside Python and stopped at the boundary. Everything generated, the committed SVG diagram, its PNG raster, both site copies, and the colour map inlined into
site/graph-embed.html, was free to drift on, and had: the diagram still showed the verbatim pre-registry taxonomy, 5 bands of 9.
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#
- "A recoverable condition never raises" is now a tested guarantee, not an emergent habit. Ask a
code-knowledge tool about a symbol that does not exist and it should say so; if it raises instead,
an agent stops using that tool for the rest of the session, one error teaches abandonment. All 23
server tools were probed across an empty store, a populated store asked about things it does not
hold, a store whose clone directory is gone, and a store with no path at all: none of them raised,
so the property held and needed no repair. It is now pinned by
tests/kb/test_server_contract.py, which reads the live tool list off the wire rather than a hand-kept copy, so a newly added tool is covered the day it appears and a vanished one fails loudly. The near-miss is asserted in the same file: genuinely invalid arguments must still be refused, so the test cannot pass by everything simply never erroring.docs/serve.mdstates what callers may rely on and what remains an error.
Fixed#
-
askreported four established negatives as answered. The callers, subclasses, impact and owners routes leftansweredat its default when they could not resolve the target, so a question the graph had proved it cannot answer came back labelled as answered, with the miss stated only in the prose.answeredexists precisely so a caller does not have to read the prose. The dependents route beside them already got this right, andAskOut.answereddocuments "no such definition / no such repo" as False cases, so this was an omission rather than a distinction. It matters most on impact: that question is asked to decide whether a change is safe, and "answered, nothing found" is the reading that green-lights it. Found by the contract test above. -
An empty marker in the vector store was indistinguishable from a missing one, and a repo could re-embed itself forever.
set_embedded_headandset_embedded_parser_versionwrote""when there was nothing to record, and the getters folded""back intoNone, so "nobody wrote this yet" and "what was written is empty" gave the same answer. Both markers exist only to be compared for equality against what is current, so collapsing them decides a staleness question wrongly rather than merely losing detail.
"" 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.
kb indexcould delete repositories it was never going to index. The repo-id migration walked every repo in the store, and deleted any whose stored id no longer matched the canonical id its checkout resolves to, rows, nodes, edges, shard files and vectors. That is safe only under the assumption stated in its own docstring, that "the caller's normal discovery+incremental-index loop does that immediately after". The migration ran before discovery, so it could not know whether the assumption applied.
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.
- A
git logtimeout in the owner ranking was retried with a bigger walk, then re-paid on every request. 7.1.0 bounded that walk and cached it on HEAD, and left a hole exactly where the walk is slowest: the code readrows = _walk(bounded) or _walk(unbounded), andorcannot tell "succeeded, found nothing" from "timed out". So a repository that had just failed to finish the cheap walk was immediately asked to do the expensive one, and the empty result returned before the cache write, so the dashboard paid it again on every repo-detail request. Measured across three real clones: 0.4s, 2.1s, and 60.1s.
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#
--offlinemakes "it does not phone home" checkable rather than merely claimed. There is no telemetry in this project and never has been, which is exactly the kind of statement every project makes. This is the switch that lets somebody verify it:contextlake --offline <command>(orCONTEXTLAKE_OFFLINE=1) refuses every outbound connection at the socket, so it covers not just this package's nineurlopencall sites but every library in the process -- including the model downloaders inside the embedding and LLM stacks, which are the requests nobody here writes. A flag checked at each call site would only have held for the sites somebody remembered.
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.
kb refreshsays whether the graph still describes the code on disk, andkb steerinstalls it as a session-start hook. Between a nightlybootstrapand the post-commit hook there was a gap nobody covered: you sit down to work against a store that is quietly behind, and nothing says so.indexskips a repo whose head has not moved -- correct, and the reason staleness here is invisible rather than loud.
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.
kb eval --verify-citationschecks that a citedfile:lineactually contains the symbol. Every retrieval metric here answered one question -- did the right node come back? None asked whether the citation attached to it still points at that symbol, and the citation is the product: an agent is not handed a node id, it is told to go readsrc/thing.cpp:412. A wrong citation is worse than a miss, because it looks like an answer.
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#
- A real golden query set for retrieval quality, gated on every change instead of weekly. Retrieval quality was measurable but barely measured: the set held 3 queries over a 2-node, 2-kind fixture, and only a weekly scheduled workflow scored it, so a set that had drifted apart from its fixture could sit green for a week.
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:
- Every query matches on name, not node id. Ids are a slug plus a digest of the symbol's identity, so an id-scheme change (7.0.0 changed all of them) would have silently invalidated an id-matched set while reading as a retrieval regression.
- The floor lives in one place, a field in the golden set that both the per-PR test and the weekly workflow read. A threshold written in two files drifts.
- A separate test asserts the fixture still produces each kind the set queries, so a parser change that stops emitting one fails as itself rather than as a mysterious retrieval drop.
Changed#
- Data members, macros, typedefs, enum constants and file-scope variables are now embedded, so semantic search can reach them. Before this they were at recall exactly zero, not poorly-ranked, unreachable. No semantic or hybrid query could return a macro or a data member, because none had a vector.
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#
- The dashboard's repo panel spent 30 seconds in a git subprocess on every single request.
Profiled on a 131,603-node repository: the panel took 41s, and 30 of those were one
git log --no-merges --numstatwalk over the whole history (36,290 commits), run synchronously, uncached, on every request. Three separate callers each paid it, the dashboard panel,kb owners, and the MCPwho_knowstool.
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.
- The wiki's per-kind floors could consume the whole ranked list. Hub, dispatcher and top-symbol lists reserve a slot per kind present, so a structurally low-degree kind (a SQL table calls nothing) still gets represented. That reservation had no ceiling, and once a repository held about as many kinds as the list is long, the degree ranking the lists exist to present had effectively stopped running.
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#
- Five symbol kinds that were never emitted at all: data members, macros, typedefs, enum constants and file-scope variables. Measured on a large legacy C/C++ tree, these are 83,052 named symbols -- more than the entire rest of that graph. Asking where a constant is defined, or what a class actually holds, had no answer.
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:
- 235,010 declarations in that tree are function-local. Emitting
declarationwithout checking for file or namespace scope would have produced 235,010 "globals" instead of 5,965 -- more nodes from that one mistake than the whole intended addition. - 8,970 member-function declarations parse as
field_declaration, exactly like a data member does. Treating them as fields would have invented 8,970 members that are really methods. The name walk returns nothing when it meets a function declarator, which is what tells them apart -- while still capturing pointer, array and reference members, whose names sit further down the same chain.
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.
- An index run now says how many call references it could not resolve. A reference naming a symbol defined in more than six places is deliberately left unresolved rather than pointing at six guesses, but until now nothing said so above debug level, and a caller missing for that reason looks exactly like a caller that does not exist.
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.
- Three more verbs now cite the edge they travelled. A provenance audit of every answering verb
found three that discarded it. The worst was
find_dependents, whose own response text tells the caller "INFERRED from manifests, verify against the cited file" and then did not cite the file, although thedepends_onedge's provenance is the manifest and its line. The subclasses walk dropped where the inheritance is declared.shortest_pathasserted a route while citing none of the adjacencies that make it a route, so a reader had to grep every hop by hand to check it was real.
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).
- CUDA files are indexed.
.cuand.cuhwere absent from the extension table, so a CUDA source file contributed zero nodes. Measured on a large legacy C++ tree: 2 files, 8,793 lines, nothing in the graph. They now parse through the C++ grammar, which CUDA is a superset of, and yield 135 nodes and 141 edges from those same two files.
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.
- XML configuration is indexed.
.xmlwas absent from the extension table, so a repository's configuration contributed zero nodes and "where is this setting defined" had no answer in the graph. Each identified setting is now aconfig_keynode carrying its value, its element path asqualified_name, and a real file and line.
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:
- A line scanner, not a stdlib XML parser.
xml.etreeandxml.domexpand entities, and contextlake parses whatever a mirror happened to clone, so a hostile file would be an indexer hang. Nothing is expanded here; a&b;stays four characters. It also means malformed XML degrades to a partial extraction instead of raising and yielding nothing for the whole file, which matters on hand-edited trees, and it is the only way to get real line numbers at all. - Credential-shaped values are withheld, and the node still says so. A value is dropped when
the setting's name looks like a secret, when the value contains an embedded
password=-style assignment, or when it has the shape of a token or key blob. The node remains withvalue_redacted, so "a password is configured here, at this line" is still answerable while the password itself never enters a store that gets written to disk and served over MCP.
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#
--repospatterns are anchored, and--repos-exactis gone. BREAKING:--repos apinow selects a repo named exactlyapi, where it previously also selectedforecast-apiandapi-gateway. For a substring match, glob it:--repos "*api*".
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 built-in wiki LLM moved from
llama-cpp-pythontoopenvino-genai. BREAKING for anyone scripting the install: the wheel index, the--only-binarypin and the C++ toolchain are all gone.pip install "contextlake[llm-local]"is now the whole instruction, andcontextlake doctor --fix llm-localno longer attaches an index.
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.
-
CVE-2025-69872 is resolved by removal, and CI carries no vulnerability suppressions.
diskcache5.6.3 was reached only through[llm-local]→llama-cpp-python, had no fixed upstream version, and was held insecurity.ymlas an explicit "disposition pending" ignore. Replacing the backend removes the package, so the ignore list is now empty and a red audit means a genuinely new advisory. Verified against the resolved closure, not the advisory text. SeeSECURITY.md. -
callsedges are stored once per call site, not once per caller/callee pair. BREAKING for anyone counting edge rows. A function invoked three times from the same caller was one edge citing the first invocation; it is now three edges, each citing its own line, so "where is this called" can be answered exhaustively rather than with one representative site.
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.
semantic_searchandhybrid_searchreturn an envelope instead of a bare list. BREAKING: both now return{nodes, total, truncated, note}, the same shape every other node-returning verb uses.
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.
qualified_namedrops its file prefix for C and C++ external-linkage symbols. It was stored aspath/to/f.cpp::NS.Box, so a header and its.cppcould never match on it -- the other half of the same defect the id change addresses. For those symbols the namespace chain is the fully qualified name, so it is now stored asNS.Box.put.
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.
- Node ids no longer contain a file path or a line number. BREAKING: every code-symbol id changes, so a re-index is required, and the embedding store must be rebuilt (see below).
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:
- The signature is part of the key, because overloads share everything else. Measured on a large
legacy C++ tree, 1,038 qualified names occur more than once in a single file, and until now only
the line told them apart. It uses the whole declarator, so
at(int) constandat(int)stay distinct -- trailingconstand&/&&sit outside the parameter list. - The file leaves the key only for C and C++ external-linkage symbols. That is the one case where
one symbol legitimately spans two files. Everywhere else the file is the identity: Python, JS,
Go, Java and the rest put one module per file, so
class Widgetin two modules are two classes.staticfunctions keep their file too, because internal linkage is file-scoped by language rule. - Constructors and destructors are marked in the slug. Id normalisation folds
~away, soC::CandC::~Cwere previously indistinguishable outside the digest.
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.
- One node-kind registry, and every kind vocabulary is now projected from it. The vocabulary was sixteen hand-maintained lists across twelve files -- a colour map, three glyph tables, an embeddable set, two impact sets, four name-resolution target sets, several diagram gates and a doc taxonomy -- and nothing checked that a new kind reached all of them.
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#
- A parser bump now invalidates the artefacts built on top of the graph, not just the graph.
PARSER_VERSIONmoves to4, and this release is the reason the gap mattered: node ids are now file- and line-independent, so every id changed.
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.
- Anonymous-namespace and
staticsymbols no longer merge across files, and a caller can no longer reach another translation unit's private symbol. Both halves of internal linkage, which had to ship together.
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.
-
C and C++ nodes never carried a signature.
_doc_siglooked for the parameter list as a field on the definition node, and in C/C++ it hangs off the declarator instead, so every C/C++ node reportedsignature: None-- in the UI, the wiki andget_repo_briefalike. This function's own docstring admitted it by listing only py/js/ts/c#. It now walks into the declarator. -
A stale embedding store answered silently, and
askvouched for it. A vector row is keyed by node id, so anything that changes how ids are built leaves every stored key pointing at a node the graph no longer holds. The retrieval paths dropped those hits with a bareif n:and returned a shorter, entirely plausible, non-empty answer.doctorreported a healthy row count throughout, and the half-migrated case was worst of all: re-index some repositories and the surviving hits are silently biased toward whichever ones were re-embedded.
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.
kb.toml'slanguageskey did nothing. It was validated as a known key, shown in the dashboard settings view, and documented as a filter -- and it was never passed to the parser, so every install indexed all supported languages whatever the file said. A setting that silently ignores the user.
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.
- Registering a new kind for the dashboard made its icon worse, not better.
kindIconresolves toKIND_GLYPHS[kind] ? kind : "file", so adding a kind to that table is precisely what disables the generic file fallback. Addingconfig_keyandtestto it earlier in this cycle without adding their sprite symbols therefore replaced a working file icon with<use href="#g-config_key">pointing at a symbol that does not exist: a blank box.
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.
- An out-of-line method could be attached to a class its qualifier excludes. Resolution keyed on
the qualifier's LAST segment and gave up whenever that bare name matched more than one class. So
NS::Box::putcould land on an unrelatedOther::Box, and a tie the qualifier already settles was discarded. The first is the worse half: a fabricated parent reads as a fact, while a missing edge reads as a gap.
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 qualifier segment could vanish, silently moving a method to a different class. The scope-name
walk tested each segment against three plain name types with no
else, so any other shape was dropped without a trace.template_typeis the common one: inNS::Box<T>::puttheBox<T>segment disappeared, leavingNSas the final qualifier, and the resolver then attachedputto whateverNSmatched. A fabricated parent is worse than a missing edge, because it reads as a fact.
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.
- Every test in a C++ repo was invisible by name. A test macro with a body parses as a function
definition whose name is the macro, so
TEST(TimerSuite, HandlesMinutes)produced one node calledTESTand the case nameMinuteswas discarded. Measured on a large legacy C++ tree before the fix: 2,820 nodes, 6.8% of every function and method node, namedTEST_F(1,855),TEST(962) orTEST_P(3). Asking "where is the invalid-input test for that module" had no answer, and becauseTEST_Falone spanned 118 files it was also the single largest source of duplicate names in the graph.
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.
- Config, SQL and ADR nodes were islands, and their files were missing from the graph entirely.
The code path builds a
filenode and parents every definition to it. The bespoke extractors never did, so their output had no way in: measured on a large legacy C++ tree, 12,991 of 12,991config_keynodes and 142 of 207tablenodes had zero incident edges, against 0 of 28,274 functions. Worse, there were 0filenodes for.xmland 0 for.sql, so a file-level view of that repository silently omitted every config file and every schema file it contained.
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.
- Two module-level dicts were both named
_PARSERS. One caches tree-sitter parsers by language, the other maps a file kind to its extractor; the later definition rebound the name, so_parser()was insertingts.Parserobjects into the extraction registry. It worked only because no language happened to share a name with a kind. Addingxmlas a kind made that collision reachable, sincexmlis precisely what a future tree-sitter XML grammar would be called, and_parser("xml")would then have returned the extraction callable instead of a parser. The cache is now_TS_PARSERS.
[6.7.0] - 2026-08-11#
Added#
find_callersnow tells you which line the call is on. It used to answer with the caller's definition line, so "who calls this" gave you a function name and left you to grep its body for the call. Every edge in the graph already carried the call site; the response model dropped it. Each result now carriescall_fileandcall_linealongside the caller's ownfileandline_start, so the answer is quotable as evidence rather than a lead to chase.
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.
find_callees, the other half of the call graph.find_callersanswers "who depends on this"; this answers "what does this reach", which is the question you have when reading a function you did not write. Same arguments, same budgeting, same call-site provenance. The traversal already existed (get_neighborshas accepteddirection="out"all along), so this exposes reachable data rather than computing anything new.
Fixed#
- Edges cited a file with no line.
EdgeOutcarriedsource_filebut notsource_line, althoughProvenancehas always held both, so every verb returning edges named a file and left the line behind.get_neighborsand everything built on it now report both.
Changed#
find_callersno longer de-duplicates its results by caller. This is a no-op on today's graphs and is called out only so the change is not mistaken for a behaviour change: the parser already keeps just onecallsedge per (caller, callee) pair, retaining the earliest call site, so there was never a second row for the response layer to drop. Removing the redundant filter means that when the parser does retain every call site, these verbs surface all of them with no further change.notenow discloses the distinct-caller count whenever it differs from the number of entries, so a count of calls can never be read as a count of callers.
[6.6.0] - 2026-08-10#
Added#
- Every documentation page that teaches a structure now draws it. 20 diagrams across 16 pages,
where the set previously had none. Each shows the shape of its own subject rather than a generic
pipeline: the directory-shape decision
kb indexmakes, the three isolated partitions thatconnect,ingestandenricheach write, the gate a wiki page clears before it is published, the ancestor walk config discovery performs, and the tiers on disk with the shards as the source of truth and everything else derived from them. "contextlake, explained" carries five, since its job is to say why the thing is built the way it is.
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.
- CodeQL now scans the JavaScript as well as the Python. The stored-XSS fixed in 6.2.0 lived in the generated graph page, and a Python-only scan could not have found it however long it ran. The dashboard, the graph viewer and the command palette are all first-party JavaScript this project ships, and none of it was being analysed. The two languages run as a matrix so neither hides the other's findings. A config file excludes the four vendored bundles: they are third-party code we cannot fix here, and 4 MB of minified library source would bury a real finding in noise from that library's own generated patterns.
Changed#
- Each release now publishes a CycloneDX SBOM, and each standalone binary carries a build provenance attestation. The container images already had both; the wheel, the sdist and the three launchers had neither, which is the half of the supply chain most people actually install.
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.
- Every GitHub Actions step is pinned to a commit, not a tag. All 34
uses:across the five workflows referenced a moving pointer:@v5means "whatever that owner points it at today", and these jobs hold a checkout of the source, a container-registry login, and the OIDC token that publishes to PyPI under this project's name. Each is now a 40-character SHA with the version in a trailing comment, which Dependabot rewrites along with the SHA, so the pins do not go stale.
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.
-
ruff is capped at
<0.17. It was>=0.4with no ceiling, which was harmless while the lint only reported. Now that it gates, a minor ruff release that adds a rule to a selected group would turn CI red with nobody having changed a line, andSis a large group that grows. The cap makes that arrive as a Dependabot pull request to read instead. -
The security lint now blocks, because its backlog is gone.
ruff --select S(flake8-bandit) ran as its own reporting-only job withcontinue-on-error: trueover 4893 untriaged findings. A job that cannot fail says nothing, and one carrying that much noise was never going to be read.
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#
-
A DOM helper turned a caller's mistake into a rendered error carrying the caller's data. The dashboard's
appendsent anything oftypeof "object"straight toappendChild. A plain object is not a node, so that throws, and the browser's exception message embeds the value it refused. The dashboard then renders the message into its error block, so a bad argument came back out as page content. It now appends only what is provably a node, tested withinstanceof Node, and anything else becomes text. -
The landing page built an
iframesrcout of two values it read back from the DOM. The theme came fromdata-theme, the path fromdata-embed, and both went intosrcas they were found. Neither is attacker-reachable on a static page, but aniframesrcis where ajavascript:URL would land, and a value narrowed where it is used cannot become one. The theme is now one of two literals, and the path has to resolve to a same-origin page: it goes throughnew URLand its scheme is checked, which is what makes the check real, rather than being pattern-matched as a string, which is not. Verified in a browser thatjavascript:,data:and an off-origin URL are all refused while the embed still loads. -
kb wiki --namespacescrashed on a FIPS-enabled host. The cluster freshness check hashed its member commits with SHA-1, and a FIPS build of OpenSSL refuses SHA-1 outright rather than returning a weak digest, so the command raised before writing a page. The call now passesusedforsecurity=False, which is accurate: the value is a cache key answering "have the member commits moved", nothing trusts it, and its collision resistance is irrelevant to that question. -
The published site served its images from an external host, and they failed behind a proxy. The markdown references them by absolute GitHub URL, which it must keep, since that is what makes an image appear when the file is read on github.com or on PyPI. The built page inherited it, so the site made one external request per image, and behind a TLS-inspecting corporate proxy every one of them failed. 36 images on a site that otherwise depends on no external host. The built pages now point at the site's own copies.
-
Four retired pages were still being published.
bootstrap,ownership,storageandcomparisonwere removed from the documentation, but the generated site directory kept their old HTML and the deploy copied it wholesale every time. They were unreachable from the navigation, absent from the sitemap, and impossible to correct, since the source they were built from no longer existed. The build now removes a page whose source has gone, and the deploy removes it from the published branch. -
Docs: a wiki page's cost no longer counts a rewrite that does not happen. "How much does the model matter?" described one page as the draft, plus a review per council lens, plus a rewrite on rejection. There is no rewrite: a rejected page is reported and skipped. The sentence carries the argument about whether a slow local model can finish a run, so the per-page cost was overstated.
-
Docs: the gate that rejects a page before the council is now documented. A draft that reproduces its own instructions, or repeats one span, is refused by a structural check that runs ahead of the council and makes no model call. A reader could hit
rejected: prompt leakagewith nothing in the documentation explaining it. "Generate the wiki" now names both reasons, says why they are decided without a reviewer, and states that a rejected page is skipped rather than rewritten. -
The copy button no longer lands on a diagram. It attached to every code block in the prose, and a rendered diagram is one, so it offered to copy a picture.
[6.5.0] - 2026-08-07#
Changed#
-
An ingested document is named by its own title, not by its filename. A page headed
# Payments runbookwas stored, listed and cited asrunbook.md. The identifier stays the path, since that is what a re-ingest matches on, but the name a reader sees now comes from the document. Only a level-one heading near the top counts, after any front matter: a deeper heading is a section rather than the subject, and a file with no heading keeps its path. -
The default
max_file_bytesis now 5 MB everywhere, which lowers it for indexing. The parser used 5 MiB (5,242,880) and the config used 5 MB (5,000,000), so whether a file in that 242,880-byte window was parsed or skipped depended on which code path reached it. The documented figure is 5 MB, so that value wins and both now read from one constant.
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#
-
kb embedno longer deletes vectors it was never going to write. It clears a repository's vectors before writing the new ones, which is right when it is replacing its own work and wrong when the shard holds kinds it does not embed.connect,enrichandingesteach embed their own nodes as they write them, and none of those kinds (document,design,file,repo) is oneembedhandles, so a single pass over one of those partitions emptied it and reported "0 written" as though that were the answer. A shard whose content it skips entirely is now left alone. A repository that genuinely lost all its nodes still loses its vectors, which is the case the clear exists for. -
A negative
limiton an MCP tool no longer returns a confidently wrong answer. Nothing in the tool schemas constrainedlimitto a positive number, so a negative one reached the query layer, where two different things went wrong and neither was visible to the caller. Python's slicing tookitems[:-3]and dropped three items off the end while still reporting the result as truncated, and SQLite reads a negativeLIMITas no limit at all, sosearch_codereturned every matching node with no signal that anything had been capped.limitis now clamped at each of the four sites, so asking for a negative number returns nothing and says so. -
kb forgetnow sweeps up the shared nodes it leaves stranded. A package identity, an HTTP route or an event topic belongs to no single repository, so it is stored once under a sentinel ((packages),(shared)) with per-repository attribution carried on its edges. Nothing removed those. Measured on a real store, forgetting the only repository in it left 734 such nodes behind, still listed and still searchable, describing packages and routes that now belong to nothing.
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.
kb forgetnow reclaims the repository's files, not only its database rows. It removed the nodes, edges, vectors and wiki pages and leftgraph/<id>.jsonandhistory/<id>/on disk, so a store measured in hundreds of megabytes gave back none of it -- on one store a single retained shard was 173 MB against a few MB of rows. The files are the large half of a repository, and reclaiming them is the whole point of the case this command was written for: forgetting a pseudo-repository created by a mis-index. It also compacts the index afterwards: deleting rows frees SQLite pages but not file space, so on one real store the index sat at 197 MB of which 188 MB was free list, and the largest file in the store did not move. End to end, forgetting one repository from a real store now takes it from 529 MB to 4.5 MB in about two seconds. The command reports the space it reclaimed, and--dry-runreports without deleting or compacting anything.
[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#
kb index --bundle, to index a directory that holds git repositories as one repository anyway. It is the opt-in half of the refusal below, and it is read before the directory's shape is measured at all, so it always works.
Changed#
kb index <dir>now refuses a directory that holds git repositories, instead of warning and indexing it anyway. The warning was correct and it printed the right command, and it was still not enough: a warning is one keystroke from being scrolled past. On one real store it was scrolled past, and the result was a pseudo-repository holding a duplicate copy of every mirrored repository -- 63% of all nodes in the store, and every symbol in the graph present twice under two identities that could not be told apart.kb embedthen wrote 91% of its vectors into the duplicate.
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#
kb index's bundling advice now names the directory you actually gave it. The remedy it prints when a directory holds git repositories was the hardcoded stringcontextlake kb index --workspace ., but the directory being indexed comes from the positional path or--sourceand only falls back to.when neither was given. Sokb index /srv/fleetwas told to run--workspace .-- the shell's current directory, not the one just named. Followed verbatim it indexes the wrong tree.
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#
- Dashboard: the fleet-wide Architecture "Overview" graph now has a real text/table
equivalent (WCAG 1.1.1 Non-text Content). A single repo's Architecture view already
had one -- a genuine tabbed table of Dependencies/HTTP flow/Event flow, not a token
gesture -- but picking no repo (the Overview scope, showing every repo and their
cross-repo edges at once) rendered only an invitation to go pick one, with no
equivalent for the fleet-wide picture itself. A screen-reader user could reconstruct
it by visiting each repo's own tab in turn, but never got the sighted user's
one-screen overview. The same three edge categories are now available unfiltered by
repo -- sourced from the same underlying edge scan the graph itself uses, capped at
500 rows per category with a banner if truncated -- reachable the same way the
per-repo tables are (a "Skip past graph" link, then a tabbed,
columnheader/rowheader-marked table with a working provenance button per row). A static--siteexport built before this shipped simply has no data for this table and falls back to the original "pick a repo" invitation rather than an error. - The dependency-vulnerability scan now covers the dependencies that actually ship. The
CI audit installed every extra except
llm-local, because that one compiles from source and made the job flaky. The gap was larger than it looked:llm-localis what the publishedfull/latestcontainer image and the release binaries are built with, and Dependabot could not compensate the way the workflow claimed -- it reads declared dependencies frompyproject.tomlwith no lockfile, so a transitive dependency of that extra was invisible to it too. Both scanners reported clean, and both were right about the narrower thing they were pointed at.
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#
- Dashboard: keyboard focus no longer jumps into main content on the very first page
load (WCAG 2.4.3 Focus Order). The router moves focus to
#appon a genuine route change so a keyboard user navigating between lenses (Fleet, Architecture, Chat, ...) lands where the new content starts -- a deliberate, correctly-motivated fix for a real problem. But the guard that decides "is this a route change" compared the new route against anullstarting value, so the very first render (page load, before the user has tabbed anywhere) always satisfied it too, and focus jumped to#appbefore the skip link could ever be used -- inverting the effective tab order so the header and primary navigation, which come first visually, came last in the sequence a forward-tabbing user actually experiences. Reloading the dashboard now leaves focus at the top of the document (document.activeElementis<body>, matching a fresh page load), while an actual navigation still moves focus into the new panel exactly as before, and an in-view re-render (a filter toggle, a trust-bar click -- same route, no navigation) still does not steal focus. The skip link is no longer dead on arrival. - Dashboard: repo health is no longer colour-only, and its dot now clears the
non-text contrast minimum (WCAG 1.4.1 Use of Color, 1.4.11 Non-Text Contrast). The
Fleet page's Cards, List and Table layouts all rendered a repo's health as a small
solid dot with no text -- the only other information was a native
titletooltip (mouse-hover only, not available to touch or keyboard users). Every health chip now also carries a visible short label ("Fresh"/"Stale"), matching the pattern the repo page already used elsewhere ("HEAD moved", "no checkout") and the one the confidence chips use correctly (fill + border-style + glyph + a visible label, never colour alone). The dot's own fill colour was also measured at 2.40:1 against its row background -- under the 3:1 a graphical state indicator needs even for a sighted user who can perceive colour -- and is now a darker, more saturated teal that clears 3:1 against both themes' backgrounds (measured rendered: light 3.5-3.8:1, dark 3.4-3.5:1), not just estimated from the source values. - Dashboard: the confidence trust-bar's clickable segments now meet the WCAG 2.5.8
minimum pointer-target size (24x24 CSS px), including on the narrow/zoomed viewports
where the only other way to reach the same filter is hidden. The segments were
14px tall subdivisions of a continuous track with no gap between them -- under the
minimum, with no rescuing "equivalent control" or "enough surrounding space"
exception available once the header's confidence-filter buttons are hidden below
768px (the same breakpoint zooming to 200% on a typical laptop display reaches). The
track is now tall enough that each segment's real hit box -- not just its painted
colour -- clears 24px, verified by checking that
elementFromPointat the very top and bottom of the reported box still resolves to the segment (a naivemin-heighton a clipped ancestor can report the right number while the actual hit area stays small); the bar's visual height is otherwise close to unchanged. - Dashboard: primary navigation links keep their accessible name when the rail is
collapsed, the viewport narrows below 1280px, or the page is zoomed to 200% (WCAG 4.1.2
Name/Role/Value, 2.4.4 Link Purpose). Each nav link paired a decorative,
aria-hiddenicon with a visible text label, and nothing else -- correct as long as the label stayed on screen. All three of those states hide the label withdisplay:none(a one-click "more screen space" toggle, a normal laptop-width viewport, and a standard accessibility accommodation WCAG 1.4.4 exists to require support for), which left the icon contributing nothing and the label removed from the render tree entirely -- ten unlabelled links in the primary nav, indistinguishable from each other to a screen reader. Each link now also carries anaria-labelmirroring its own visible text exactly (the same pattern already used correctly by the neighboring "Collapse navigation" button), so an accessible name survives every one of those states. Verified with real Chromium accessibility-tree snapshots at each trigger, not by reading the CSS: all ten links are named at the desktop width, all ten stay named immediately after collapsing the rail (no viewport change at all), at 320px, and at 640x450 (a standard way to simulate 200% zoom on a 1280px display). - Dashboard: the Fleet table's rows have a visible keyboard-focus indicator again
(WCAG 2.4.7 Focus Visible, 1.4.11 Non-Text Contrast). The row's
:hoverand:focus-visiblestates shared one rule that also setoutline: none, so a keyboard user tabbing through the Table layout landed on a row that looked identical to its neighbors -- measured at the time as roughly a 1.05:1 background-colour shift, far under the 3:1 minimum a UI-component focus indicator needs, and with no border or shadow standing in for the removed outline. Every other interactive surface in the dashboard (.cl-repocard,.cl-reporow, and the global:focus-visiblerule these rows now fall back to) already keeps the ordinary 2px outline; this was the one selector overriding it to nothing. Removing the override lets the global outline apply, and its colour clears 3:1 against the row background in both themes (measured viagetComputedStyleon a really-tabbed-to row, not computed from source: ~4.6:1 light, ~4.9:1 dark). kb indexnow sees nested repositories at any depth, not just direct children. The bundling check askedsrc.glob("*/.git"), which matches one level down, so a fleet mirrored under a subdirectory was invisible to it: on a real workspace it reported "contains 1" where the truth was 20. That count is the whole point of the message -- "1" reads as an edge case worth skipping past, "20" is a stop sign -- so undercounting by 95% muted the warning at exactly the moment it needed to be loudest. The scan now sharesiter_repo_dirswithdiscover_repos, so the number it reports and the set--workspacewould actually walk cannot drift apart, and it names how deep the repositories sit.- SECURITY:
[[sources]]ingest could be pointed at a local file instead of a URL. Theweb,apiandgraphqlsources passed their configuredurlstraight tourllib, which speaksfile:,ftp:anddata:as readily ashttps:. Aurl = "file:///…"therefore read that file off disk and ingested its contents as a document -- afterwards visible in the graph, the wiki, the dashboard and every connected MCP client.
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#
kb forget <repo>removes one repository from the store. contextlake could already tell you a stored repo was wrong -- the id-migration pass says "git can't find a repository here at all ... re-clone or remove it", andlintreports unreadable repos -- while offering no way to act on it: nineteenkbsubcommands and not one could remove a repo, so the only supported repair for a mis-indexed store was deleting the whole store and re-indexing every repo in it. It clears all three tiers (graph, vectors, wiki pages) and, deliberately, the@connect:/@enrich:partitions too -- those hold connector output under a separate id, and leaving them behind means orphaned rows still answering queries for a repo that no longer resolves.--dry-runprints the counts and removes nothing.
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#
- SECURITY: generated graph pages could execute HTML/JavaScript taken from an indexed
repository (stored cross-site scripting).
GHSA-fwx4-9qvg-98qc,
Critical (CVSS 3.1 9.3), affecting
>= 2.2.0, < 6.2.0. The graph payload was embedded in the page's inline<script>with no escaping, so a</script>sequence anywhere in indexed content closed the element early and the browser parsed the rest of the payload as markup. The reachable inputs are ordinary repository data -- a symbol name, a file name, commit context, or a connector/web-page title -- so anyone able to land a string in a repository you index could choose what ran in your browser when you opened the page. It mattered most onkb dashboard --serve, which serves those pages on the same origin as the script carrying the per-process mutation/LLM token: injected code there could read the token and drive the mutation and chat endpoints.kb graph's standalone HTML file,kb graph --c4,kb graph --serve, and thebuild_sitepage set were all affected, since all four render through the same function.
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#
kb index --sourceaccepts an indexed repository id, not just a path.kb lintreports a repository by its logical id, which is derived from theoriginremote and has no relation to where the clone sits on disk, so the id it printed could not be acted on: every path spelling of it answered "No such file or directory", and the reader was left holding an identifier with nothing to do with it.--sourcenow resolves an id (or a unique tail of one, sowidgetsworks forexample.com/team/widgets) to that repository's recorded checkout and re-indexes it under the id it was already filed as. When the id is unknown the error names near-miss ids from the store; when it is known but its checkout is gone, the error names the path it was indexed from.
Changed#
- A workspace holding several groups no longer reads as full of anomalies. With group A already
cloned,
mirror sync --group Breported every group-A repo asExtraand sent it through the branch-switch pass, which could only answerNot in GitLab list. Local paths are relative to the group, soA/team/apiandB/team/apiboth land atteam/apiand the path cannot say which group a clone came from;status,verifyand the branch pass now read each clone'soriginremote and count out-of-group repos under a newOther groupsrow instead. The scoping is one-sided: only a repo whose origin positively names a different group drops out, so a clone with no origin, or one whose config cannot be read, is still reported exactly as before. Nested-repo detection is deliberately left unscoped: nesting is a property of the disk layout rather than of the group being synced, and a clone from another group sitting inside this group's working tree is precisely the corruption that check exists to report. kb lintno longer calls a repository with no commits "stale". Two repositories on a real fleet were reported stale on every run with "HEAD moved or never finished, re-run index", and re-indexing never cleared them, because they have no commits: there is no HEAD to move, so the staleness test matched permanently and the instruction it printed could not work. A repository with no commits is now reported asempty, one imported from a graph-shard JSON asshard, and one whose path is gone (or that git will not answer for) asunreadable, each with wording that says what it is.emptyandsharddo not count against a clean lint, since nothing a reader can do clears them;unreadablestill does, because nothing can be cited from it.graph_healthand the dashboard health API carry the same new fields,stalein all three is now genuine staleness only, and the dashboard's health panel gained anUnreadabletile and list so that fault is still visible there.
Fixed#
kb query --retriever semantic|hybridapplies the same relevance floor the MCP tools do.semantic_searchandhybrid_searchhave refused a query with no anchor in the index since 6.0.0, but the CLI called the retriever factories directly and skipped the check, so the same store answered the same question two ways: an empty list over MCP, and k confident unrelated hits on the terminal. The predicate now lives in one module both surfaces use. The CLI does not merely go quiet: it names the terms the index has never seen, so the refusal is checkable and retryable. The exit code stays 0, and--jsonstill prints a bare (empty) array with the reason on stderr. The floor applies where[embeddings]is enabled, which is exactly the condition under which the MCP server exposessemantic_search/hybrid_searchat all; without embeddings the query degrades to keyword search, which has its own notion of "no match".- A commit git cannot decode as UTF-8 no longer kills the command. Every place contextlake read a
child process's output decoded it strictly, so one byte git could not map raised
UnicodeDecodeErrorout ofsubprocess.runitself, before any of the surrounding error handling could run.kb connectover a 20-repository fleet died on'utf-8' codec can't decode byte 0x96 in position 99486and stored nothing. Git output is bytes: commit subjects, author names and file paths carry whatever encoding their author's machine used, and 0x96 is the cp1252 en-dash that older Windows tooling writes constantly. All 26 captured subprocess calls now decode witherrors="replace", and a guard test fails the build if a new one does not. - One bad repository no longer aborts
kb connectfor the whole fleet. Only the connector calls were guarded; reading a repo's branches and commit subjects, blaming its files, and writing its partition were not, so a single unreadable repository stopped the run before the other nineteen were reached. Each repository is now contained: it is named, skipped, and the rest are enriched. A run with any skipped repository exits non-zero, matching whatkb indexalready does for a repo that failed to parse.
[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#
- Python 3.10 is now the minimum for the whole tool. The mirror core previously claimed 3.9
while the knowledge layer needed 3.10, so the project had two floors and the documentation had
to explain which half you were using. There is one floor now.
pipdeclines cleanly on an older interpreter, so nothing breaks silently. --tool-concurrency 1is a supported setting, and the bound works differently. It used to hang the stdio transport outright, with no error and no timeout: the SDK wraps stdin and stdout with no limiter of its own, so its blocking reader borrowed from the very thread limiter the flag shrank, and at one token it never gave the token back. The bound now sits on the tool bodies themselves and the worker pool is sized to the bound plus a reserve for transport I/O, so a limit of one is safe on stdio, streamable HTTP and SSE alike. The default is unchanged at 2.-
semantic_searchandhybrid_searchreturn each hit'sscore, and return nothing when a question has no anchor in the index. A nearest-neighbour index has no concept of "no match": it returns its k nearest however far away they are, so both tools answered every query with k confident, correctly cited, entirely unrelated hits. They now refuse when not one term in the question appears anywhere in the index, and every hit carries the number it was ranked by, so a caller can judge the ranking instead of trusting it. -
kb servesays when the store it is serving has never been indexed, andgraph_healthreportsindexed. An empty store started, printed its banner and served every tool, with no line anywhere saying the graph was empty;graph_healththen answered zero stale, zero dangling and zero parser-stale, which is the exact output of a perfectly healthy fleet. The counts were not wrong, they were unqualified. Startup now warns and names the command to fix it, the same way an unconfigured embeddings tier already does, andindexed=falsesays the zeros mean "nothing to check" rather than "nothing to fix". A store with no filesystem path, which can read no local HEAD and open no shard, also reported zero repositories rather than zero checks; it now counts the repositories it holds and leavescheckedat zero, which is the part that says the checks did not run. shortest_pathreturns an envelope instead of a bare list. It was the only tool whose output shape could not express a miss, so a typo'd node id and a genuine "these two are unconnected" were the same empty list -- and the docstring's "empty if none" described only the second. It now returnsnodes,found,hopsand agapnaming which of the two misses occurred. Callers reading the old top-level list need to readnodesinstead. A route running through a node an edge names but the graph no longer holds also used to drop that node quietly, leaving two nodes that were never adjacent side by side; the length reported is now the route's real one andgapsays what is missing from the list.- Five repository-scoped MCP tools now report
found.who_knows,get_repo_links,repo_dependencies,repo_flowandrepo_event_flowechoed the caller's own string back with an empty payload, so a mistyped repository id was indistinguishable from a known repository with no data: five confident "nothing here" answers instead of one "no such repository".get_wiki,get_readmeandget_repo_briefalready carried the field; the rest of the family now matches.who_knowsalso stops reporting an unindexed repository as one with no local clone, which asserts it is indexed.
Fixed#
get_wikireported a cluster page as fresh without checking.stalewas hardcoded to false on that path, so an agent filtering on the field treated a page nothing had verified as verified, and a cluster page whose members were long gone read as current. The page already carries the freshness stamp its generator skips on -- the fingerprint of its members' commits -- so it is now recomputed and compared, exactly as a repository page's commit is, and a page with no stamp fails closed rather than open.blast_radiusanswered for symbols it had never heard of. An unresolvable name was used as the seed anyway, so the tool returned a well-formed, non-error, bounded impact analysis of a symbol that does not exist: "nothing depends on this, safe to change" and "no such symbol is indexed" were the same answer to a question about whether a change is safe. Bothblast_radiusandfind_callersnow say which one it is, in the same wordsfind_dependentsalready used. A negativehops, which walked nowhere and reported a reassuring empty reach, is refused;hops: 0is a real request and still answers.- An invalid
directionwas answered by three MCP tools and refused by a fourth.repo_dependencies,repo_flowandrepo_event_flowmatched no branch for a value outsidein/out/bothand returned an empty edge list, so a typo read as "this repository has no dependencies / no HTTP flow / no event flow" -- a positive architectural claim produced by an argument the tool had in fact rejected.get_neighborsraised for the same input. All four now declare the three legal values in their input schema and refuse anything else, naming them. askignored thekit advertises on its impact route. Every other route honoured it; the impact route dropped it and letblast_radiusfall back to its own default of 100, so an agent asking for one result could be handed a hundred. It is threaded through now, and because a smallkmakes truncation ordinary rather than rare, the answer says when the count it reports is the first slice rather than the whole reach.ask's dependents answer ignored the repository you asked about.find_dependentshad no repository parameter at all, soaskacceptedrepoin its schema and then answered across the whole fleet: a scope leak, not merely a missing filter. The route also never resolved its target and reported "INFERRED from manifests" over an empty result. It now resolves the target, honours the scope, and says plainly when it found nothing.find_callersandblast_radiushid name collisions thatfind_definitiondisclosed. When a name resolved to several distinct symbols, one was silently chosen. All three now say so.- Numeric options accepted values that could not work.
--porttook anything, and--tool-concurrencyaccepted 0, negatives and absurdly large values; both are now range-checked and refused with the bound named.dashboard --serve --siteasked for two mutually exclusive outputs and picked one silently; it now refuses. TheCONTEXTLAKE_MCP_TOOL_CONCURRENCYenvironment variable deliberately keeps its lenient path: a stale value in a shell profile should not stop an editor from starting. - Every
kbfailure that was not a config error escaped as a raw traceback, at any verbosity. The mirror side of the CLI has long had a top-level guard that reports the error on one line and re-raises only under--verbose; the kb side caughtConfigErrorandKeyboardInterruptand nothing else. Measured on a full disk, where a write failure duringkb indexreached the user assqlite3.OperationalError: disk I/O errorand a stack, with no-vpassed. The two sides now behave the same. doctorwrote nothing to--log-file. Measured at zero lines. doctor renders its aligned report itself rather than through the logger, because the console formatter appends a right-edge clock that suits a progress stream and ruins a report read as a block, and nothing carried that output into the audit file. The console rendering is unchanged, and every line now also reaches--log-file, formatted and scrubbed there like any other.doctor --fixoutput goes the same way, which also closes a second gap: it was printing paths and commands with no redaction at all while plaindoctorwas scrubbing them.kb wikipublished a confident page for a repository with nothing behind it. A one-file repo that indexed to 0 symbols still produced a 119-line page, scored 0.987 by the council, presenting the forge's boilerplate README as the project's own setup and architecture. A page grounded in nothing is now not generated at all: a repo whose shard holds no symbols, or a scope with no file-backed symbol, is refused before the model is called, counted as a rejection and named in the log. The refusal counts grounding exactly as the provenance footer does, so the two can never disagree, and it runs ahead of the freshness check, so an ungrounded page already on disk stops being backfilled into the search index.initomittedplatformwhenever it equalled the default, and a config above it then supplied a different one.init --local --platform gitlabwrote noplatformkey at all, so a global~/.contextlake.inisayingplatform = githubfilled the gap andmirror cloneenumerated the GitHub API and 404'd.initnow writes the platform always. Omitting a key does not mean "use the default" when config layers: it means whatever file sits above this one gets to answer, and a generated config should state what the workspace is rather than depend on its surroundings.- A failed enumeration named a forge the run never called. The same 404 above reported that it
"could not enumerate GitLab projects", while the banner said
Github groupand the URL wasapi.github.com: three different answers to which forge this was. All of them now derive from one resolved name, and the missing-glabadvice is raised only by a run that actually reached forglab. --reposwas silently inert whenever the project cache was warm, andmirror statusreported a filtered count as the group total. The cache holds the filtered project list, and every command that reads it answered from it regardless of which filter produced it. Somirror clone --dry-run --repos <no-match>planned the repositories the previous--reposmatched,clone --repos <one-name>over an unfiltered cache planned the whole group, andmirror statusreported a 40-repo group as 2 with no mention that a filter had shaped the number. A.filtersidecar guard already existed but only fired on thefetchpath.
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#
kb connectwith a GitLab source could never have worked, and reported success anyway. Repo ids became canonicalhost/namespace/project, but the connector still prepended the configured group and encoded the whole thing, requestingprojects/group%2Fgitlab.com%2Fns%2Fproj. Every call 404'd. The host segment is now dropped, andgroupacts as a filter rather than a prefix, since the namespace it used to add is already in the id. A repo with nooriginremote carries thename@root-commitfallback id, which names no GitLab project, and is now skipped rather than requested.
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#
contextlake doctor --fixresolves missing optional dependencies instead of only naming them. With no argument it installs what your resolved configuration actually calls for, so a setup using Ollama is never handed a local-LLM wheel it will not use;--fix <capability>overrides that.--dry-runprints the plan and stops.
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#
- The full container image no longer compiles
llama-cpp-python, and no longer installs a C++ toolchain to do it. It now takes the same prebuilt wheel the standalone binary does. The Dockerfile's stated reason for compiling ("no portable prebuilt CPU wheel for every platform this targets") was wrong: the CPU index carries apy3-none-manylinuxwheel that is ABI-agnostic and satisfies any Python 3 on the base image. The runtime image is unchanged in size, since the toolchain was already discarded by the multi-stage split; what this removes is build time and the build stage's own CVE surface, and it stops the container and binary channels disagreeing about whether a compiler is required. - The standalone binaries now bundle the built-in local LLM (
llm-local) alongsidekb-full, and install it from a prebuilt wheel rather than compiling. The binary points at the CPU index viaPYAPP_PIP_EXTRA_ARGS, so first run needs no C++ toolchain. The--only-binaryconstraint names that one package deliberately rather than:all:, which would forbid a source fallback for every other dependency and let one missing wheel break the whole binary.
Fixed#
- The container image kept its knowledge store outside the volume you mounted. The documented
docker run -v "$PWD:/work" ... kb indexbuilt a store under the runtime user's home, inside the container's writable layer, and the layer went with the container on exit. The run took minutes, reported success, and left nothing on the host.HOMEnow followsWORKDIRinto the mount, so everything contextlake persists lands in the directory you mounted. Without-vthe run is ephemeral exactly as before, since/workis now handed to the runtime user at build time rather than left owned by root.
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
- Run
contextlake kb index --force. The parser version moved to2, so every existing shard is stale, and nothing detects that on its own:needs_reindexcompares only the repo HEAD. - 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. contextlake.pyat the repo root is nowrun-contextlake.py. Only affects running the launcher from a clone; the installedcontextlakecommand and the standalone binaries are unchanged.kb serve --transport httpandssenow require a bearer token, and refuse a non-loopback--hostwithout--allow-remote. stdio is unaffected and needs no token, which is the default and what every documented editor integration uses.
Security#
- Security (breaking for
kb serve --transport http/sse): the MCP network transports now require authentication. They previously had none, no Origin validation, and no warning, sokb serve --transport http --host 0.0.0.0published every indexed symbol, file path, docstring and owner identity to the network. A bearer token is minted at startup and printed once to stderr; requests without it get401, a hostileOrigingets403, a hostileHostgets421. SetCONTEXTLAKE_MCP_TOKENto pin a stable token for a client config. A non-loopback--hostis refused unless--allow-remoteis passed. There is no TLS: the transport is meant for loopback or a tunnel, and says so at startup.
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#
CONTEXTLAKE_NO_LOCAL_CONFIG=1skips ancestor config discovery entirely, for both.contextlake.iniand.contextlake.kb.toml; only the global file and an explicit--configare read. Intended for CI, containers, and anywhere untrusted checkouts are handled in bulk, where opting out of the whole tier is simpler to reason about than the per-key gate above.- CI now enforces a coverage floor (
--cov-fail-under=88) on the full-suite job, so a silent drop from the current 92% can no longer pass green. The floor is deliberately only on that one job: putting it inpyproject'saddoptswould make every narrowpytest -k ...run fail on its own partial number, and the core job measures the whole package while skippingtests/kb, so its honest total is ~23% and no shared floor can fit both. - A
slimcontainer image alongside the full one, for users who do not need the local-LLM extra or the baked GGUF. Both are published on release and signed. - Dependabot now watches the
dockerecosystem too, so the Dockerfile's pinned base digest gets moved forward. A digest pin buys reproducible builds but silently ages out of security updates in a way a floating tag does not. - Structured logging and metrics, so the systemd timer this repo ships is actually observable.
--log-format jsonemits one object per line carrying a run id that correlates every line of a run across the index/connect/embed/wiki pipeline;--metrics-file PATHwrites Prometheus textfile format (run duration, repo counts by status, node and edge totals, last-success timestamp) for node_exporter's textfile collector. Timestamps are UTC. --redacthashes repo paths and group names in log output. It defaults to on for the log file and off for the console: the console is yours and needs real paths, the file is what gets attached to a bug report.SECURITY.mdpreviously told you to scrub logs by hand. Note this is obfuscation for sharing, not a cryptographic guarantee: a short repo name can be confirmed by anyone who guesses it.--access-logturns on request logging for the local servers, which previously had none even optionally.--verbosenow surfaces the traceback on an unexpected failure. It printed onlyError: {e}, so a user's crash report could not be diagnosed without asking them to reproduce under a debugger.- Connectors and model providers are guarded by a circuit breaker with jittered retry. A slow or unreachable endpoint used to cost its full timeout on every call, so the pain scaled with the fleet: measured 160.9s across 40 repos against a blackholed MCP server, versus 12.1s with the breaker, and the guarded cost is constant rather than per-repo. At the shipped 120s MCP timeout over a 480-repo fleet that is roughly 32 hours down to roughly 12 minutes. An open circuit says so in the log rather than returning empty results that read like "nothing found".
- Retrieval quality and SQL-parser accuracy are now measured, not just measurable. A weekly
workflow runs
kb evalagainst the bundled sample graph and fails if the hit rate regresses below a floor set at the current measured value. Only the lexical retriever runs there: the semantic and hybrid ones need an embedder that is not available offline in a public runner. - The SQL parser's accuracy is quantified and published in the code-graph docs against a small
hand-labelled corpus: precision 0.90, recall 0.69. Every edge it emits is marked
INFERRED, and until now nobody could say what that was worth. Measuring it turned up a real false positive (aREFERENCESinside a comment was being matched) alongside two already-known gap classes. kb evalgains--json, matching the conventionownersandimpactalready use.- Property-based tests (
hypothesis) for the invariants that were only ever example-tested:normalize_id's idempotence,make_id's part handling,sanitize_label's guarantee that no control character and nothing over the length cap ever escapes it, and that_fts_querycannot emit a string that makes SQLite's FTS5 raise. Plus a pathological-input corpus for the four regex-based extractors, each bounded bypytest-timeout, since they consume untrusted repository content. Three real defects fell out and are recorded asxfailwith measurements rather than quietly passing. All three are fixed below; the tests that found them now guard them. - Combinatorial test coverage for the places where options interact rather than act alone: a
provider-resolution matrix (embedder/LLM/vector-store builders across every provider, backend
and enabled/disabled combination), a serve matrix (transport x embedder-present x
vector-store-present, asserting the actually-registered MCP tool set), and a boundary matrix
(every
limit/hops/max_*at zero, one, either side of the default and very large, plus empty and single-file repos andNone-valued node fields flowing through the payload, diagram and MCP-model layers). The provider names are discovered from the source rather than hardcoded, so a new provider is covered automatically. This is the class of gap that let the--llm-chatnon-loopback hole exist: the vulnerability was an untested cell of a flag matrix. - Supply-chain scanning: a
securityworkflow runningpip-auditover the full dependency surface (dev plus everykbextra), aruff --select Ssecurity-lint pass, and CodeQL for Python, on push to main, on pull requests, and weekly so newly-disclosed CVEs surface against an untouched tree. The--select Spass is non-blocking for now: it reports ~159 real findings plus ~3,336assert-in-tests hits that are pytest idiom rather than defects, and triaging that backlog is its own piece of work. Deliberately kept out ofpyproject.toml's ruffselectand out ofci.ymlso it stays additive rather than a new gate on every commit. - Dependabot for
pipandgithub-actions, weekly, with minor/patch grouped per ecosystem so a routine tree-sitter point release does not open a dozen PRs. Major bumps stay ungrouped. - Published container images now carry SLSA build provenance and an SPDX SBOM, and are signed keyless with cosign via the workflow's OIDC identity (no key material anywhere). Signing is by digest rather than tag, so the signature is pinned to exactly what was built.
--exit-zero-on-partialon every mirror command (andbootstrap), for anyone whose scripts depend on the old always-zero exit status: the run still reports what failed, it just exits 0.
Changed#
- The container image is rebuilt as a multi-stage, non-root, digest-pinned build. It previously
shipped the compiler toolchain (
build-essential,cmake, needed only to compilellama-cpp-python) into the final image, ran as root, had noHEALTHCHECK, floated on a mutable base tag, and copied the source before installing dependencies so every source edit recompiled the native deps. Measured result: the full image drops from 2.25GB to 1.78GB, and the newslimvariant is 736MB, about 67% smaller than what shipped before. - The no-install launcher is renamed
contextlake.py->run-contextlake.py. At the repo root it shadowed the installed package:python -m ...puts the working directory first onsys.path, sopython -m pytestfrom a clone failed withNo module named 'contextlake.cli'; 'contextlake' is not a packagebefore collecting a single test, the first command many contributors type.CONTRIBUTING.mdhad documented the workaround; it now documents reality instead, and CI runspython -m pytest --collect-onlyso the trap cannot come back. No compatibility shim is left behind, because a file at the old path would recreate the exact problem. The installedcontextlakecommand and the standalone binaries are unaffected: both resolve through the package entry point, never the root file. - All the local HTTP servers now share one base (
kb/http_base.py) carrying the Host check, the JSON error envelope and the exception guard. The three servers had drifted apart, which is the structural reason theGET/POSTgap above existed at all. index_repo_diris decomposed into a file walker, a parser registry and a ref collector; it was the most complex function in the codebase and sits on the critical path of every index run. Shard output is unchanged, proven by a new golden-shard test that also passes against the pre-change code, so it is a genuine regression check rather than a snapshot of the new behaviour.- Breaking: a mirror run that had failures now exits 1.
mirror fetch/clone/update/branches/verify/sync(andbootstrap's mirror stage) exited 0 no matter how much of the fleet failed.mirror syncreported success with a ✓ even when every single clone failed. Nothing unattended could tell a healthy mirror from a dead one: the cron wrapper indocs/usage.mdtests$?and so never fired, and theType=oneshotsystemd unit inexamples/was always recorded as succeeding, leavingsystemctl is-failedand anyOnFailure=hook with nothing to fire on. Each stage now returns its own ok/failed/skipped counts,syncexits on the total across all stages, and the sync finale is a ⚠ rather than a ✓ when anything failed.
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#
- Indexing a hostile or merely corrupted
pom.xmlno longer hangs. The Maven block regexes were quadratic when closing tags were missing: each unclosed opener sent the lazy match scanning to end-of-string before failing. Closing tags are now indexed in one linear pass and paired with their openers. Measured on a 160KB file with 16k unclosed tags: 39.8s to 0.001s for<dependency>, 25.6s to 0.000s for<parent>, 29.0s to 0.001s for<dependencies>. A truncated pom from an aborted download was enough to trigger this; it never took an attacker. - A deeply nested
.tffile no longer hangs indexing.parse_hclwas quadratic in nesting depth, and the cost was ours rather than the grammar's: in the installed py-tree-sitter, bothNode.parentandNode.next_siblingre-descend from the tree root, so walking siblings while resolving a reference was O(depth) per step. The traversal now carries the context it needs instead of re-deriving it. Measured at depth 1250 with a reference per level: 138.2s to 0.027s, and 337.4s to 0.037s for thelocalsshape. -
normalize_idis idempotent again, matching its docstring. The punctuation strip ran beforecasefold(), so a fold that expands a character into a base letter plus a combining mark left the mark behind for a second call to remove. This changes the generated id for exactly 29 code points (established by checking every code point, not by sampling); none are ASCII or Latin-1, so ordinary identifiers are unaffected. It is slightly lossier for those 29:ǰnow normalizes toj, so it would collide with a plainjwhere it previously did not. That is unavoidable while also keeping the existing guarantee that output equals its own casefold. The re-index the shard-reproducibility fix already requires picks these up in the same pass. -
Shard output is now reproducible. Indexing the same commit twice produced different shard bytes every time, because the tree-sitter query cursor returns captures in an order that varies between runs. The set of nodes and edges was always correct (only their order moved), but it made
archive_shard's documented "a repo re-indexed at the same commit overwrites identically" invariant false, and defeated any checksum-based reasoning about whether an index is current. Captures are now sorted at the single extraction site, andPARSER_VERSIONis bumped to2.
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#
- Breaking:
contextlake init's non-interactive flag is now--skip-interactive;--yes/-yare gone, not aliased. Unlike apt/npm/gh's--yes, which only skips a single yes/no confirmation whose answer carries no new information,init's flag drives a whole value-collecting wizard by substituting defaults for prompts that mostly aren't yes/no questions at all (platform, group, work_dir, store_dir all take a typed value) ----yesmisdescribed what it did. No deprecation window: same hard-cutover approach as the CLI namespacing change below, since there are no external users of the flag yet to accommodate.
Fixed#
contextlake initno longer writes a config naming a group/org/workspace that doesn't exist. Previously,init --yes(now--skip-interactive) without--groupsilently wrotegitlab_group = your-org, and the stale-placeholder safety net never caught it because it checked for a different literal (your-gitlab-group). The same placeholder was also reachable interactively by accepting the suggested default.groupnow has no default at all: an empty value (either path) is refused with a clear message and exit code 2, before any file is written.- The mirror
.contextlake.iniside of--confignow hard-fails when the given path doesn't exist, matchingkb.toml's existing behavior -- previously it silently fell through to the next config in the precedence chain (typically~/.contextlake.ini), which can point at a completely different workspace than the one you meant to use.ConfigErrornow lives inconfig.py(re-exported fromkb/config.pyfor compatibility).
[3.0.0] - 2026-08-03#
Added#
- Preview (opt-in, pending visual approval): the graph page's layout dropdown gains a
dagre (preview)option -- a layered/directed dagre layout that also renders nodes as real HTML cards (border-radius, shadow, real typography) instead of canvas circles, and marches ants along the selected node's edges. Selecting any other layout leaves the existing canvas rendering completely unchanged; this is a look to judge before it becomes anyone's default. Card rendering is skipped above 400 nodes (the status bar says so) and on the fleet overview. - The graph page can now be saved as SVG as well as PNG -- a new
SVGbutton beside the existingPNGone. The PNG button is unchanged (still cytoscape's own canvas render), and it keeps working while thedagre (preview)card rendering is on: the capture temporarily reverts the cards to canvas nodes and restores them afterwards. Expect that PNG to look sparse in card mode -- it is the classic circles-and-glyphs picture at the wider spacing dagre laid out for cards. The SVG is the format that keeps the card look: it embeds each card as real HTML in aforeignObject, which browsers render but Inkscape/Illustrator ignore. Hand-rolled, no new vendored library. - Vendored
cytoscape-dagre4.0.0 andcytoscape-dom-node2.1.0 (both MIT, ~46 KB + ~11 KB) alongsidecytoscape.min.js, so the preview above works offline like the rest of the page.cytoscape-dagrebundles dagre itself, so there is no separate dagre file.app.jsfeature-detects both and drops the preview option if they did not load. They load on every graph page (a ~57 KB inline cost, or one shared sibling file each in a--sitebuild); neither does anything until the preview layout is selected. contextlake kb servenow accepts--transport sse, the legacy HTTP+SSE transport, alongside the existingstdio/http(Streamable HTTP) options -- for MCP clients that only support SSE and haven't moved to Streamable HTTP yet. See docs/serve.md.- Ingested documents now link to the code symbols they mention by name, via a new
kb ingest --for-repo <repo>flag (per-source equivalent:for_repoon a[[sources]]entry) that says which indexed repo the documents are about. Without it, ingest behaves exactly as before and links nothing. - Enrichment results (
kb enrich) now link to the code symbols they mention by name, instead of being stored as isolated document nodes with no edges at all. - Generated wiki pages (whole-repo and per-subsystem) now link each section to the code symbols it names, closing the last of the four zero-edge pipelines the audit found. Only the symbols are linked, not the repo as a whole: a repo's external-knowledge links (Jira / Confluence / Figma / GitLab) stay free of contextlake's own generated pages.
- GitLab merge requests now link directly to the code files their diff touches (not just their
repo), via a new
fetch_changes/match_files_to_nodespair. The edge istouched_by, read code-first (pay.py -> MR #42) like thedesigned_in/discussed_in/documented_byedges beside it. - Figma designs now link directly to code symbols whose name matches a frame or component name, when Figma metadata is available (MCP-configured).
connectnow discovers Figma and Slack links in docs by default (built-in URL patterns) -- no[[rules]] type="link_scrape"config required, matching how GitLab sources were already default-on.- New shared text-mention matcher (
connectors/text_match.py), reused by Slack and by ingested/enriched/wiki content to find which code symbols a piece of text is actually about. - Slack connector can now fetch channel message text (previously it only ever parsed Slack links found in docs) -- lays the groundwork for linking discussions to the code they're about.
- Slack channels now link to specific code symbols mentioned in their message text, using the same text-mention matcher shared with ingest/enrich/wiki content.
- Connectors can now link external content directly to the code it's about via a new shared
link_to_codeprimitive (the existing repo-level edge is kept alongside it, except for wiki pages). - The Slack MCP tool used to read a channel's messages is configurable as
history_toolon a[[sources]]entry (defaultconversations_history), alongside the existingverify_tool. Slack MCP servers don't agree on a tool name, so without this a non-default server silently produced no message links at all. - A public, read-only live demo of the dashboard is now linked from the project homepage and
docs footer. It's the existing
contextlake kb dashboard --site DIR --samplestatic export (bundled fictional "acme" fleet, no real data) generated intosite/demo/bysite/deploy.shon every deploy, no new tooling. - The wiki council can now review with a different (stronger) model than the one generating the
pages, via two new
[llm]keys:review_providerandreview_model. Until now a single client served both roles, so a local-only setup had the tiny built-in 0.5B grading its own drafts, a near-constant rubber-stamp. Settingprovider = "builtin"+review_provider = "anthropic"keeps generation local and free while a real model decides what actually gets published; the inverse split (generate strong, review cheap) works too, sincereview_providerwins unconditionally. The reviewer'smodel,api_key_envandbase_urlare re-resolved for the review provider rather than inherited from the generator. Left unset, the default, the council reviews with the generating client exactly as before. Strictly opt-in and never inferred from an API key that happens to be in the environment: it costs pages ×council_sizeextra calls against the review provider (dropcouncil_sizeto 1 to cut that threefold). The run banner names both models when they differ, andcontextlake doctorstill checks the generation provider only. - Connector-produced nodes (GitLab MRs/issues, Figma designs, Slack channels) are now embedded and
semantically searchable, closing the third leg of the consolidation gap (unified in keyword
search, now-linked in the graph, now embeddable). Each
connectpass sweeps the repo's old connector vectors first, so an MR that closes or a design that's unlinked doesn't leave an orphaned embedding behind. graphexports (GraphML/Cypher/DOT/Mermaid) now include linked external nodes (GitLab MRs, Figma designs, Slack channels, wiki page sections) one hop out from the code they're linked to, not just code, so the edges the earlier consolidation work now creates actually show up in an export instead of being silently dropped.
Changed#
- Vendored cytoscape.js bumped 3.30.2 -> 3.34.0 (bugfix/feature releases within 3.x; the graph
page's existing default rendering is unaffected).
--cdnnow pins the same version. - BREAKING: the commands are now namespaced under
mirrorandkb. The CLI had grown to 29 flat top-level commands doing two unrelated jobs, mirroring git repositories, and building and serving the knowledge layer over them, and--helphad stopped being navigable. Each verb now lives under the noun it belongs to:contextlake mirror fetch|clone|update|branches|verify|status| sync|audit, andcontextlake kb index|source|connect|embed|ingest|enrich|wiki|lint|eval|query| graph|owners|impact|dashboard|serve|steer|hook.kbis the word already user-visible inkb.toml, thecontextlake[kb]install extra, and the knowledge-layer package. Five commands did not move:initandbootstrapspan both tiers (bootstrap runs mirror + index + connect + embed + enrich + wiki + steer, and ships to users as a systemdExecStart),versionandcompletionbelong to neither, anddoctoris the diagnostic you reach for when nothing else works. Thewho-knowsandblast-radiusaliases survive underkb. Shell tab-completion needs no re-registration, it reads the live parser. This is a hard cutover with no compatibility window: the old flat spellings do not parse at all.contextlake fetchfails as an ordinary unknown command, and the existing suggester answers it withDid you mean: mirror fetch?, the same treatment any other unknown command gets, not a special case. - Two post-upgrade steps, both required. contextlake wrote the old flat forms into files it does
not revisit, and there is no grace period covering them: re-run
contextlake kb hook install(or--workspace DIR) so already-installed post-commit hooks are rewritten, otherwise re-indexing stops with no visible error, andcontextlake kb steer --forceso.mcp.jsonandAGENTS.mdpoint atcontextlake kb serve. Both rewrite their managed block in place, andhook installdetects and replaces a block still carrying the old syntax. - Every command string contextlake generates now uses the namespaced form: the post-commit hook
kb hook installwrites (contextlake kb index), the.mcp.json/.vscode/mcp.jsonentry and the AGENTS.md / CLAUDE.md / windsurfrules / Kiro bodieskb steerwrites (contextlake kb serve), the dashboard's own subprocess spawns, and the usage/next-step hints across the knowledge commands. The dashboard UI's own copy-paste commands moved too: every empty/unavailable state's suggested command, the Wiki tab's "Generate wiki" snippet, and the MCP card's--transport httpexample. So didinit's next-step lines, the mirror commands' "narrow to just the failures" retry hints, and the "port already in use" / "[llm]isn't enabled" noticeskb dashboard --serveprints.
Removed#
- The old flat command spellings (
contextlake fetch,contextlake index, …). They are gone outright, along with the deprecation notice and itsCONTEXTLAKE_NO_DEPRECATIONopt-out, the namespaced form is the only one that parses. See the two required post-upgrade steps above.
Fixed#
contextlake kb graph --layout dagrewas unreachable from the CLI. The renderer has supporteddagresince the preview landed, butcli.pyrestates the layout names as two hard-codedargparsechoiceslists instead of importing the renderer'sLAYOUTStuple, and neither was updated -- so the preview was only reachable from the in-page dropdown. Both lists now includedagre.- Neither the dashboard's Links panel nor the
get_repo_linksMCP tool showed a repo's GitLab-diff or Slack cross-links. Both listed onlytracked_by(Jira),documented_by(Confluence),designed_in(Figma),has_merge_requestandhas_issue, so the newertouched_by(a merge request whose diff touches the repo's code),discussed_in(a Slack channel whose history mentions its symbols) andreferenced_in(a Slack channel linked from its docs) edges reached the graph and were invisible on both surfaces. All eight relations now come from one shared list, so a connector adding a relation lights up both doors or neither. - A
repo=-scoped semantic/hybrid search missed a repo's own linked connector/enrichment content.VectorStore.search/SqliteVecStore.searchfilteredrepo_idby exact match only, so a query scoped torepo="team/api"never matched rows written under the@connect:team/api/@enrich:team/apipartitions thatconnect/enrichdeliberately isolate on write (seeconnect_partition/enrich_partition) -- even though that content is directly linked to the repo's own code via real graph edges. Bothsearch()implementations now widen a repo filter to match the literal repo id or either of its connector/enrichment partitions. contextlake kb serve --transport httpprinted a bind URL that 404s. It reported the barehttp://127.0.0.1:8765, but Streamable HTTP is served at the SDK'sstreamable_http_path(/mcp), which contextlake does not override, so a client pointed at the printed URL got a 404 and the root looked like a dead server. The line now printshttp://127.0.0.1:8765/mcp, matching the/ssepath already reported for--transport sse.steer-generated MCP config is unaffected: it wires thestdiotransport by command, never by URL.- A bare
contextlake hookexited 2 withinvalid choice: '==SUPPRESS=='on Python 3.9–3.11 instead of defaulting toinstallas its own--helppromises. The optionalactionpositional pairedchoices=with contextlake's SUPPRESS-default convention, the same argparse trap already documented on thecompletionpositional, where argparse validates the SUPPRESS sentinel itself againstchoiceswhen the positional is omitted.cmd_hook()already rejects an unknown action with a clearer message, so no validation was lost. [llm] provider = "anthropic"(or"openai") with no explicitbase_urlsent its API calls to the local Ollama port instead of the real API endpoint.LlmCfg.base_urlwas a declared field defaulting tohttp://127.0.0.1:11434, so that one literal won for every provider and the per-provider fallbacks inbuild_llmwere dead code.base_urlnow defaults toNoneand is resolved per provider at read time (llm.base.default_base_url, mirroringdefault_api_key_envand its rationale):anthropic→https://api.anthropic.com,openai→https://api.openai.com/v1,ollama/auto→ the local daemon. An explicitly configuredbase_urlstill wins, so proxies and local openai-compatible servers are unaffected.- Dashboard repo-detail requests on a large repo re-parsed and re-aggregated the entire shard from
scratch on every single request, with no caching of any kind.
read_shardnow keeps a small in-memory cache of the parsed shard (validated on every read against the file's own mtime/size, so a re-index, same process or a separatecontextlake kb indexrun whiledashboard --servestays up , is still picked up correctly), andrepo_brief's degree/hubs/dispatchers/top-symbols aggregation over every node and edge is cached the same way. The shard cache is bounded by estimated resident bytes, not entry count: a parsed shard's pydantic objects measured at roughly 13x their on-disk JSON size, so an entry-count cap alone would have risked pinning dozens of large repos' shards in memory on the multi-hundred-repo fleets this targets. Measured against a synthetic 54k-node/261k-edge shard (75 MB on disk), a warm repeat request against an unchanged repo dropped from ~2.5s to ~0.25s with no added memory growth on further repeats; the first, cold-cache request is unchanged. Both caches are correct under a rewrite this process makes itself:write_sharddrops its entries in each, so a re-index at an unchanged commit that happens to emit a same-length shard within one filesystem mtime tick cannot serve a stale aggregation under a freshhead. Andrepo_briefobserves the shard file's on-disk identity exactly once per call (previously twice, one independentstat()each for the shard-parse cache and the aggregation cache) so a rewrite landing between those two observations can't pair mismatched halves either. - A
languagesfilter listing only"c"no longer silently drops all.hfiles..hfiles are parsed with thecppgrammar internally, so a["c"]-only filter previously excluded them entirely..hinclusion is now decided independently of that internal parsing choice: it is indexed whenever either"c"or"cpp"is enabled, since C/C++ headers are shared infrastructure. The old workaround of listing both languages is no longer necessary (docs updated accordingly). - The graph visualizer's
repo_subgraph(path_prefix=...)no longer matches a sibling directory that merely shares a string prefix (e.g.path_prefix="api"incorrectly also matchedapiv2/). It now requires a path-boundary match, the file equalspath_prefixor starts withpath_prefixplus/, the same fix already applied to the wiki'srepo_brief. - An existing store now picks up the "overview names its subsystem pages" feature without a
--forceregeneration. The freshness check asked one question, is the commit unchanged?, and skipped the page before the subsystem-naming field was ever consulted, so a repo already wiki'd at its current commit kept an overview page that said nothing about the subsystem pages sitting beside it, indefinitely. A wiki page's footer now records which subsystem pages it names, and the check asks the two questions separately: a page is skipped only when its commit is unchanged AND it already names the subsystems this run would name. A page that names none (every non-federated repo, and every page written before this existed) records none and is still skipped, so there is no fleet-wide regeneration. - The per-run cap on subsystem wiki pages no longer permanently strands the tail of a very large
federated repo. A repo with more qualifying modules than the cap (20) gave pages to its 20
largest and never reached the rest: a later run with the same head commit re-picked the identical
top 20 and freshness-skipped every one of them, and even a new commit re-picked a top 20 rather
than the stranded tail. Module slots now go to never-yet-paged modules first (each group keeping
the existing largest-first order), so repeated
wikiruns walk the whole repo while any single run stays bounded by the cap. The whole-repo overview page names every module that already has a page or is getting one this run, so the named set accumulates run over run instead of tracking only the current slice, and the truncation log line now says how many modules were deferred to a later run instead of claiming they were skipped outright. - Subsystem wiki pages for modules that no longer qualify are now pruned instead of living
forever. A module that shrank below the module floor, or a repo whose tree was restructured (or
that stopped qualifying as federated at all, orphaning every one of its module pages), left its
page, its
@wiki:{repo}::{module}partition, that partition's shard and its embeddings behind permanently,--forcedidn't remove them either, since it only regenerates what qualifies today. Everywikirun now removes all four for a module that is no longer in the qualifying set; it costs one indexed key-range lookup per repo and no LLM call, so it isn't gated behind--force. Pruning is skipped when the empty module list came from the index not answering (a large repo whose index rows are missing or mid-rebuild) rather than from the repo actually changing shape , the shard and the index are separate layers, and only the second reading is evidence. - A failed whole-repo wiki page no longer drags every one of that repo's subsystem pages through the same failure. The whole-repo page and its module pages share one LLM and one council, so an unreachable backend cost up to 21 round trips per federated repo before anything was reported. The run now skips that repo's module pages and moves on as soon as its whole-repo page fails.
- Each wiki page is now built from one
repo_brief, not two.cmd_wikineeds the brief itself for the council's review prompt, andgenerate_pagethen built a second, identical one internally. The parts of a brief that sit outside its cached shard aggregation are real I/O, the README read, the recursive legacy-build-tooling walk of the live checkout, the enrichment-shard read, so that was a duplicated filesystem pass per page, up to 21 of them for one federated repo in a single run.generate_pagenow accepts a caller-builtbriefand reuses it. - The wiki footer's grounding-coverage ratio is now comparable between a repo's overview page and its subsystem pages. The whole-repo denominator counted every node, including file-less ones (import targets, packages, endpoints, topics) that a module-scoped page structurally cannot contain, so identical grounding depth read as systematically worse on the overview. Both halves of the ratio now count file-backed symbols only, and the footer names the unit ("Grounded in N/M file-backed symbols") so it can't be read against the prompt's own all-nodes symbol count.
- A file-less
#include/import-target pseudo-node is no longer guaranteed a slot in a whole-repo page's top-symbols/hubs/dispatchers lists. The per-kind grounding floor exists so a real but structurally low-degree kind (a SQL table) isn't squeezed out by degree ranking; it was also treatingkind="module"nodes with no file of their own, one per#included name, as a kind deserving that guarantee, putting a row like "module widget.h (?)" in every C/C++ repo's lists. They remain eligible by ordinary degree ranking, so a heavily-included header still ranks in on merit. Other file-less kinds keep their floor slot. - A council rejection now reports how many reviewers abstained (
N reviewer(s) returned nothing parseable) alongside the score. A reviewer that returns nothing, a missing API key, a review CLI not on PATH (CliLlmreturns""on non-zero exit rather than raising), abstains on every lens and so rejects every page at score 0.0, which was previously indistinguishable from a strict but working council. release.ymlandbinaries.yml(both tag-triggered) no longer run independently ofci.yml's full Python 3.9-3.14 test matrix. A newverify-cijob in each checks thatci.ymlactually completed successfully for the tagged commit before building/publishing anything, and fails the release outright if it didn't. This closes the gap that let v2.62.0 ship with a redci.ymlrun live for a full release cycle.
[2.67.0] - 2026-07-31#
Added#
- Large, genuinely federated repos now get one wiki page per subsystem, in addition to the
whole-repo page.
repo_brief()gained apath_prefixparameter that scopes its grounding (symbols, files, dependencies) to one module/subsystem instead of the whole repo, matched on a segment boundary, scoping to a module namedapinever also pulls in a sibling likeapiv2/, since the match requires the prefix to be the whole path or followed by a/.contextlake wikiruns this automatically, no new flag: a repo qualifies once it has at least 5,000 graph nodes AND is genuinely federated (no single top-level module owns more than 60% of them), a single large repo with one dominant source directory still gets just its one whole-repo page, same as before. Generation is capped at the 20 largest qualifying modules per run (deterministic largest-first, ties broken lexicographically) so onewikiinvocation on a very large legacy repo stays bounded; each page's title, prompt framing, and provenance footer all say plainly that it covers only that module, never the repository as a whole. Module pages live underwiki/_modules/and embed into their own@wiki:{repo}::{module}partition (attributed to the real repo id) forask/semantic search. Known limitation: the 20-page cap is not a rotating window, a repo with far more than 20 qualifying modules will only ever get pages for its 20 largest; the rest stay unwritten across runs until a future improvement lets a run prefer never-yet-paged modules when filling slots. - The whole-repo overview page now names its subsystem pages instead of trying to summarize
them. When a repo has qualifying subsystems (above), the overview's Architecture section
explicitly lists and briefly describes each one and points to its own dedicated page, rather than
attempting to compress every subsystem's internals into a single section, which got thinner and
less grounded the more subsystems a repo actually had. This only takes effect the next time the
overview page is actually regenerated: a repo already wiki'd at its current commit has its
overview skipped as unchanged (subsystem pages still generate fresh regardless), so upgrading to
this release doesn't retroactively add naming to an existing overview page, that happens on the
repo's next commit change, or a
--forcerun (the dashboard Regenerate button's force option works too). Known limitation: the named-subsystems list is fixed before subsystem generation runs, so a subsystem that then fails council review or comes back empty (a shard/index mismatch) is still named in the overview as having its own page. A one-off failure self-heals the next time that subsystem's page is generated successfully, but a persistent failure does not: once the overview page's own indexed commit stops changing, its freshness check skips regenerating it, freezing the stale claim indefinitely while the named subsystem keeps being retried (and keeps failing) on every run. - Dashboard: the Wiki tab gained a subsystem picker. A repo with generated subsystem pages now
shows a "Subsystem:" dropdown above the wiki content, letting you switch between the whole-repo
overview and any subsystem's own page without leaving the tab or re-fetching the rest of the
repo's detail panel. The picker only ever lists subsystems that actually have a page written to
disk (checked against the real file on disk, not just "this subsystem qualified for one"), so a
subsystem beyond the 20-page cap, or one whose generation failed, is never offered as a dead
option. New route:
GET /api/repo/<id>/wiki?module=<prefix>.
[2.66.0] - 2026-07-31#
Added#
- Wiki grounding sample size now scales with repo size instead of a flat cap of 15. The
number of symbols sampled into
top_symbols/hubs/dispatchersis nowmax(15, min(80, node_count // 1500)). This only changes behavior once a repo passes about 24,000 graph nodes (below that, the formula still floors to the same 15 as before); the sample size then grows with repo size up to a cap of 80, reached at around 120,000 nodes. top_symbolsnow reserves at least one slot per distinct symbol kind. Previously a pure degree-rank cutoff could squeeze out a structurally low-degree kind entirely (e.g. a SQL table node, which has no in/out call edges) once the sample cap filled up with higher-degree function/method nodes. The zero-degree backfill is applied only totop_symbols, which carries no numeric claim about a symbol;hubs/dispatchersreuse the same per-kind-floor helper but only ever reorder candidates that already have a real (nonzero) caller/callee count -- they never fabricate a "0 caller(s)" row for a kind with no signal.- Wiki pages' provenance footer now states a coverage-ratio fact. It appends
"Grounded in N/M symbols (X%)", where N is the number of distinct symbols appearing across
top_symbols/hubs/dispatcherscombined and M is the repo's total node count, letting a reader judge how much of the repo's surface the grounding sample actually covers. setup_signalsgained per-category counting for legacy C/C++ project/workspace files (.vcxproj,.vcproj,.dsp/.dsw,.pbxproj,.cdtproject), so a large legacy repo is summarized as a count (e.g. "3 legacy MSVC6 project (.dsp) file(s) detected") rather than listed file-by-file. None of these extensions are part of the parsed/indexed language set, so they never become graph nodes -- the count instead comes from a recursive, bounded scan of the repo's live checkout (the samestore-given, degrade-to-nothing path the existing config-file detection uses forpackage.json/Dockerfile), pruning the same vendored/build-output directories the indexer itself skips. The scan stops after 200,000 files visited, so a huge legacy monorepo can't turn an uncached, per-requestrepo_briefcall into an unbounded walk. Any match already present in the graph's node set is merged in without double-counting.repo_briefgained agenerated_paths_detectedflag so the wiki prompt can warn the model off treating derived build output as hand-authored design. It fires for an indexed file living under a directory literally namedgenerated/(e.g.src/generated/widgets.py); it also checks the parser's own generated-filename convention (e.g.Form1.designer.cs), but a file matching that convention is, by default, already excluded from indexing before it can reach the graph -- so that half of the check only has an effect when a repo's[kb] skip_generated = false.
Fixed#
contextlake graph --repo <repo>(and the dashboard's repo diagram) now truncates to--max-nodesby degree rank, not by an arbitrarynode_idorder. When a repo's node count exceeds the cap, the surviving nodes are now the highest-degree ones (ties broken bynode_id) instead of whichever nodes happened to sort first by id, so the most connected/important part of the graph is what a truncated view keeps.- The wiki's Gotchas section now states only the caller-count fact, not a characterization of why. The prompt still tells the model each symbol's caller count and that it's therefore worth extra care/tests when changed, but no longer lets it describe why a symbol has many callers -- wording like "foundational", "core", or "critical infrastructure" is explicitly disallowed, since the caller count is the only fact actually given, not an explanation of the symbol's role.
[2.65.0] - 2026-07-31#
Fixed#
- C++: out-of-line qualified method definitions (
Widget::Draw,App::Gadget::Spin) are no longer lost or misfiled. A method body defined outside its class, at any qualification depth (single- or multi-level::chains), is now captured with its fully-qualified name and resolved repo-wide back to the class it belongs to as amethod, instead of either vanishing or being recorded as a bare, file-containedfunction. Along the way, a real forward-declaration ambiguity was fixed: a class/struct that's only forward-declared (no body) no longer produces a spurious node that a same-named real definition could be silently confused with. - C++/C:
.hheader files were mapped to the C language, not C++. A class declared in a header and defined in a matching.cppwas invisible to the graph -- this affects any codebase with a conventional header/source split, which is most C++ code. Headers are now mapped to C++, matching or improving extraction on 196 of 200 sampled real-world headers; the remaining 4 trace to a separate, pre-existing gap in how template full-specializations are handled, not a regression from this change. Note: alanguagesconfig that listscwithout also listingcppwill no longer index.hfiles at all, since they're now classified ascpp-- addcppto yourlanguageslist if you rely on header-declared definitions. (Follow-up to admit.hunder either language tracked in the project's backlog; not fixed in this release.) - C++:
#ifdef/#elseduplicate definitions no longer cause spurious ambiguous call resolution. The same function or method defined once per preprocessor branch (a common portability pattern) is now de-duplicated only when the two definitions are genuinely the same symbol in different branches of the same#ifdef/#else(or#ifndef/#else) conditional -- not merely behind some conditional anywhere in the file, and never for a bare#ifndefinclude guard with no#else(the single most common header pattern of all, deliberately excluded: a guard alone has only one branch, so there's nothing to collapse). A widened signature comparison (parameter types read from the AST, not just parameter names) keeps genuinely distinct overloads on either side of a branch as separate definitions.
Added#
- Namespace blocks now participate in C++ containment. A
namespace App { ... }block is a real containing node in the graph, the same way a class or file already is, instead of its members appearing to float file-level. contextlake doctorflags C/C++ shards indexed with an older parser version. Each indexed shard now records the parser version it was built with;doctorcompares that against the current version and calls out any C/C++ shard that predates the qualified-method, namespace, and#ifdef-dedup fixes above, so it's obvious which repos need a re-index to pick them up (an advisory check -- it doesn't fail the overalldoctorexit code).
[2.64.0] - 2026-07-30#
Added#
- Wiki: richer per-repo template. Pages gain two new sections, each only written
when the graph actually grounds it (never an empty heading): Setup & Run (from
a README excerpt read off the repo's live checkout, plus which conventional
entry-point/config files are present --
package.json,Dockerfile,pyproject.toml,manage.py, etc.) and Gotchas (the most-depended-on symbols, reframed as a "treat changes here with extra care" signal -- reuses the hubs data already computed, no new extraction). Section order is now fixed: Overview, Setup & Run, Architecture, Dependencies, Gotchas, Decisions, External context.repo_brief()gained an optionalstoreparam (only used for the README read; omit it and the field is simplyNone-- degrades the same waydashboard.data._readme_htmlalready does for a missing/moved checkout). Anonymized--siteexports drop the README excerpt, same rule as the existing README/wiki-body exclusion. - Wiki: same richer template for cluster/namespace pages.
wiki/cluster.py's cluster prompt gets the same fixed-order, nothing-invented treatment, including a "Gotchas" section grounded in a real coupling-risk signal: the highest-weight internal edges (busiest cross-repo coupling) and the member repos with the most boundary edges (widest external blast radius) -- both read directly off data the cluster brief already computes. - Dashboard: the Wiki tab no longer gates content behind a "Reveal wiki" click.
The page renders directly; the one thing the old gate carried that mattered (the
staleflag) is now a persistent badge next to the heading instead of being hidden behind the same click. - Dashboard: live "Regenerate wiki" action, single-repo and fleet-wide. With
--allow-mutations, a repo's Wiki tab gets a scoped regenerate button and the Settings tab gets a fleet-wide one. Both show a real pre-flight estimate ("N of M repos will regenerate, the rest are already up to date") before confirming, with a Force option to bypass the freshness check (the estimate updates to make that cost explicit first). Modeled on the existing MCP-server start/stop lifecycle (non-blocking subprocess + pidfile), not the blocking sync/add-repo pattern -- an LLM-backed run has no safe fixed timeout. Spawns the realcontextlake wikiCLI unmodified, so there's no duplicated generation logic; the dashboard just tails its log and polls whether it's still running.
[2.63.0] - 2026-07-30#
Added#
- Dashboard: recursive module drill-down for oversized repos. A repo whose diagrams
tab used to dead-end at one still-too-large module (auto-scope to the largest
top-level directory, still truncated, no way further down) now keeps narrowing into
that module's own largest child, one level at a time, until the view fits or there's
genuinely nowhere further to go. A breadcrumb trail shows the path taken; any earlier
crumb widens back out, and a "narrow further" control lets you explore a sibling.
kb/visualize/payload.py'srepo_modules()gained awithinparam to enumerate one level below an already-scoped prefix (the underlyingpath_prefixmatching already supported arbitrary depth; only the enumerator was ever depth-1-only) -- additive, no change to existing single-level callers. - Dashboard: Hotspots section on the Anatomy tab. The existing combined-degree "Top
symbols" ranking is now also split into hubs (most depended-on -- worth
protecting with tests) and dispatchers (widest fan-out -- where behavior
branches), each its own ranked table. No new extraction:
repo_brief()already computed this centrality data at index time; it's now split by direction instead of only combined. - Dashboard: Path tab. "How does A reach B" as a single numbered route, not a
diagram -- the existing
shortest_pathMCP tool's BFS finally has a dashboard UI. Accepts a bare symbol name (same id/name/fuzzy resolution and ambiguous-across-repos handling the Blast radius view already has) or a node id.
[2.62.1] - 2026-07-30#
Fixed#
contextlake completioncrashed on Python 3.9-3.11 withargparse.ArgumentError: argument shell: invalid choice: '==SUPPRESS=='when run without an explicit shell argument. Root cause: theshellpositional combinednargs="?"+choices=[...]+ aSUPPRESSdefault (this project's standard subparser-default convention) -- older argparse (fixed by 3.12) validates theSUPPRESSsentinel itself againstchoiceswhen the positional is omitted. Fixed by validating the shell value incmd_completion()instead (matchinginit's existing--platformpattern), not via argparsechoices=. Missed in v2.62.0's own release gate (release.yml/binaries.yml, both green) because that gate doesn't run the full Python version matrix -- onlyci.ymldoes, and it wasn't checked after tagging. Reproduced and confirmed fixed against real Python 3.9 and 3.10 (not just the maintainer's own, newer, unaffected interpreter).
[2.62.0] - 2026-07-30#
Added#
- Shell tab-completion now registers itself automatically, the first time any command runs in a
real interactive terminal -- no
initrun required first. Apip install/uv tool install/pipx installhas no post-install hook to do this at install time (a deliberate Python packaging limitation, not a gap here), so this is the closest achievable equivalent: a one-time, TTY-gated check, logged plainly before it writes anything, that never re-fires and never overrides an explicitcontextlake init --no-completiondecline (tracked by a new~/.contextlake/.completion_setup_donemarker). Opt out of the check entirely withCONTEXTLAKE_NO_AUTO_COMPLETION=1. contextlake completion [bash|zsh|fish]: register tab-completion on demand, for the current shell or an explicit override, without waiting for the automatic first-run check or a fullinit. Works without the[kb]extra (shell completion isn't a knowledge-layer concern).
[2.61.0] - 2026-07-30#
Added#
contextlake --helpnow groups all 29 commands by task (Get started / Mirror a fleet / Build the knowledge graph / Explore & search / Serve to editors) instead of one flat, un-grouped list -- built from an advisor-reviewed CLI-wide audit after direct feedback that the CLI "is not very easy or intuitive to guess without reading the manual." Descriptions are pulled live from each subcommand's own help text (no separate copy to drift out of sync) and wrapped to the terminal width, correctly re-indented under the description column.contextlake <mirror-command> --help-advanced(onfetch/clone/update/branches/verify/status/sync/audit) reveals the ~14 resilience/tuning flags (--max-retries,--backoff-initial/--backoff-max,--adaptive-workers,--protect-working-branches,--safe-branches,--require-clean-workspace,--auto-stash, and their--no-counterparts) that default--helpnow keeps out of the listing -- every one already has a.contextlake.iniequivalent, so hiding them by default removes ~60% of the visible flag surface on 8 commands for zero functional cost. The flags themselves are unchanged and still fully documented in Mirror repositories.Examples:epilogs added tofetch/clone/update/branches/verify/status/audit's own--help, matching the worked examples every other command already carried.
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#
provider = "auto"(embeddings and wiki-LLM tiers) no longer commits to a local Ollama that doesn't have the target model pulled. Root-caused the long-standing "Embedder unavailable , HTTP Error 404: Not Found" by reproducing it live: Ollama running for one model (e.g. a chat model) with the embedding/LLM default (nomic-embed-text/llama3.1) never pulled used to still get picked by "auto" (it only checked the daemon answered/api/tags, not whether this model was in the response), so every real call failed on Ollama's own genuine 404.autonow checks model availability (newollama_has_model()) before picking Ollama, falling through to the builtin CPU model instead. An explicitprovider = "ollama"config that hits a real 404 now gets Ollama's actual reason and arun 'ollama pull <model>'hint, not a bareHTTP Error 404: Not Found.- Fixed a raw traceback fragment on
contextlake serve(stdio transport): a second/third Ctrl-C landing in the brief window while Python joins themcpSDK's background stdio-reader thread during interpreter shutdown printed a harmless but alarming "Exception ignored while joining a thread in_thread._shutdown()". Reproduced directly (3 rapid SIGINTs); fixed with a hard process exit immediately aftercmd_serve's own cleanup runs, skipping the rest of Python's shutdown sequence where the noise originated. - Reworded the "corrupted .git" skip message (
index,repo_migrate) after empirically verifying real submodules/worktrees are NOT false-flagged (already correct), the actual gap was that the wording conflated two distinct situations. It now distinguishes "git can't find a repository here at all" (a dangling submodule/worktree link) from "git resolves it to a DIFFERENT, ancestor directory" (naming that directory), the latter being the actual silent-misattribution case this check exists to catch. - Dashboard Diagrams tab: the default (no module selected) view on a truncated repo now auto-scopes
to the repo's largest module instead of an arbitrary alphabetical node slice, a repo with an
ExternalProjects/-style vendored top-level directory no longer shows vendored code ahead of real source by default. An explicit pick from the module dropdown (including "Whole repo") still overrides this and sticks across format-tab switches.
Added#
graph --format graphml: export the bounded subgraph slice as GraphML for Gephi/yEd import, with real typed node/edge attributes (kind, name, repo, file, line, lang / relation, confidence, weight) as GraphML<data>keys.graph --format cypher: export as CypherCREATEstatements for Neo4j/FalkorDB import. Node labels come fromkind, relationship types fromrelation, both backtick-quoted since contextlake's kind/relation vocabularies are open text, not a fixed enum.- CLI: unknown-flag errors now suggest a fix instead of a bare argparse dump. A flag valid on a
different subcommand names where it belongs (
bootstrap --local→ "isn't a flag on 'bootstrap', it's used by: init, source"); a genuine same-command typo suggests the real flag (--worksapce→ "Did you mean: --workspace?"); a value-taking flag immediately followed by another recognized flag names the real problem instead of argparse's generic "expected one argument" (--workspace --open→ "needs a value, but the next token ('--open') is itself a recognized flag").
[2.60.7] - 2026-07-30#
Fixed#
- Dashboard's Mermaid-rendered diagram formats (
mermaid/classdiagram/statediagram/erdiagram/deploymentdiagram) now cap a repo's internal subgraph by edge count, not just node count. A dense repo (heavycontains/callsfan-out -- found on a large real-world C/C++ codebase) could pack well over 500 edges into a 500-node slice, which exceeded Mermaid's own hardmaxEdgesdefault and made the Relations diagram fail to render outright.repo_subgraph()'s newmax_edgesparameter defaults to 400 (safely under Mermaid's 500) for those formats only ----format html/dot/jsonrender via cytoscape/DOT, have no such limit, and are deliberately left uncapped by default (still overridable via the new CLI flag,graph --max-edges N). The dashboard's ownmermaid.initialize()also raisesmaxEdgesto 2000 as a belt-and-braces margin. - Dashboard: fixed a real, reproducible DOM/stylesheet leak. On every FAILED
Mermaid render, the library itself leaves a temporary
<div id="d<renderId>">(holding a full injected stylesheet) sitting directly indocument.body-- it only cleans this up on success. Left alone, every failed render (e.g. the edge-limit error above, before this fix) permanently adds one more global stylesheet the page's CSS engine has to consider on every recalc, so it's a real, unbounded-growth correctness bug worth fixing regardless of how much it costs in practice. Now defensively removed after every failed render.
Added#
- Dashboard Diagrams tab: when a repo's diagram comes back truncated, a "scope
to one module" dropdown appears, populated from the repo's top-level path
segments (largest first, segments under 5 nodes dropped). Lets a huge repo be
explored one directory at a time instead of only ever seeing an arbitrary
(alphabetically-first) slice of 500 nodes -- which, for a repo with a
ExternalProjects/-style vendored directory, meant vendored code crowded out real source in every default view. Newkb/visualize/payload.py::repo_modules() /api/repo/<id>/modulesendpoint.
[2.60.6] - 2026-07-29#
Fixed#
contextlake dashboard --serveon a port already in use dumped a raw traceback instead of a clean, actionable message. Found live: a seconddashboard --serveinvocation while one was already running.serve_dashboardnow catches theOSErrorfrom the socket bind, logs what happened and how to fix it (--port, or stop the existing process), and returns a proper non-zero exit code --cmd_dashboardnow propagates that instead of always returning0regardless of what happened.contextlake index(no--source/--workspace) now warns before silently bundling nested repos into one. Indexing a workspace root that isn't itself a git repo but contains one (the common "cd into your mirrored fleet and just run index" mistake) folded every nested repo's files into a single made-up repo id -- then running the correctindex --workspace .afterward duplicated that data under the nested repo's real identity. The zero-config single-repo behavior is unchanged (it's a legitimate, narrower use case); it now just says so and points at--workspace .when cwd clearly isn't that case.
[2.60.5] - 2026-07-29#
Fixed#
- A def nested directly under an unnamed struct/union/enum could silently drop an entire
C/C++ file from the graph. Found indexing a real legacy C++ codebase: the containment-edge
fallback used
def_node_to_id.get(parent.id)without a default, so a structural parent that matched a def type but was never captured (anonymous structs/unions/enums have noname:field for the query to capture) producedEdge(src=None)-- a pydantic validation error that aborted parsing the whole file, not just that one edge, silently losing every node and edge it would have contributed. Now falls back to the file node, same as every other uncontained definition. - The embed step's error message now includes the exception's class name, not just
str(e). Chasing an "Embedder unavailable, HTTP Error 404: Not Found" report turned into an extended investigation because several unrelated failure modes render similar-looking messages; the class name alone would have settled it immediately. No behavior change, purely diagnostic. - Test-isolation bug (dev-only, not user-facing): two dashboard tests asserted
sources == []while reading the real config precedence chain, so a machine with a populated~/.contextlake/kb.toml(e.g. from manually testinginit) made them fail locally even though CI was always green. Both now isolateHOME.
[2.60.4] - 2026-07-29#
Fixed#
init --localnow actually scopes its own defaults to the workspace it's writing into. Found via live dogfooding:contextlake init --local --yeswrote a project-scoped.contextlake.inicorrectly, but thework_dirvalue inside it still defaulted to a hardcoded~/workregardless of whereinitwas run or that--localwas passed at all -- socontextlake bootstrapwould mirror into a generic~/workdirectory instead of the project you were sitting in. The interactive prompt suggested the same wrong default. Both now default to the current directory (override with--work-dir, or by typing a different answer at the prompt). Confirmed this default was a generic, hardcoded literal, not anything read from your real environment -- it happened to resemble a real workspace name purely by coincidence of casing.initnow also scopes the knowledge-layer store under--local. The generated.contextlake.kb.toml'sstore_dirunconditionally pointed at the global~/.contextlake/kb, so two separate--localprojects on the same machine silently shared one store.--localnow defaultsstore_dirto a.contextlake/kbdirectory next to the workspace; a new--store-dirflag (and an interactive prompt) overrides it either way.
Added#
--store-dironcontextlake init, alongside--work-dir: sets the knowledge-layer store location explicitly instead of taking the (now workspace-scoped, with--local) default.
[2.60.3] - 2026-07-29#
Added#
- Shell tab-completion, on by default.
argcompleteis now a core dependency (pure Python, ~40KB, zero required dependencies of its own), so completion is available the momentcontextlakeitself is installed --pip install contextlakealone.contextlake initthen offers (on by default;--no-completionto skip) to register it with your shell: a one-lineeval "$(register-python-argcomplete contextlake)"appended to~/.bashrc/~/.zshrc(zsh gets abashcompinitline first), or a dedicated completions file for fish -- idempotent, and never touching anything else already in your rc file.contextlake <TAB>then completes every command and every one of its flags, generated live from the same parser that runs the command. Seedocs/usage.md#shell-completionfor the manual one-liner per shell if you skipped it atinittime (or use a shell other than bash/zsh/fish). contextlake versionas a subcommand alias for--version(docker/npm/kubectl all support both spellings;versionpreviously errored as an unknown command, suggestingverify).
[2.60.2] - 2026-07-29#
Fixed#
- Ctrl-C during
init(or any knowledge-layer command) no longer dumps a raw traceback. Only the mirror-pipeline commands (fetch/clone/sync/...) had aKeyboardInterruptcatch incli.py'smain();init's interactive prompts and the entire knowledge-layer dispatch (index,wiki,dashboard,serve,source add's guided prompt, everything routed through_KB_COMMANDS) fell straight through to an unhandledKeyboardInterruptand a stack trace. Both paths now exit clean withOperation cancelled by user(exit 130), matching the mirror commands' existing behavior -- including the lazyfrom .kb import commandsimport itself (tree-sitter/numpy/mcp, the slowest part of a cold start and a very reachable place for a real interrupt to land), not just the dispatch call after it. contextlake servenow reportsStopping MCP serveron Ctrl-C instead of falling through to the generic top-level message, matchinggraph --serve/--siteanddashboard --serve's existing per-command stop messages.
[2.60.1] - 2026-07-29#
Fixed#
- Chat: a failed question now offers Retry. If a chat request fails for any reason (a network blip, the server restarting mid-request, and so on), the error now carries a Retry button that resends the same question in place, instead of leaving you to retype it.
Documentation#
- README now mentions the dashboard's Chat tab (shipped in 2.60.0 but not yet called out there).
docs/dashboard.md§11 documents the new Retry button.site/.gitignorewas missingcomparison.htmlfrom its generated-page list -- the onlybuild_docs.pyoutput not covered, so it kept showing as untracked instead of ignored.
[2.60.0] - 2026-07-29#
Added#
- Chat tab in the dashboard. Ask a question about the fleet in plain language and get an answer, right in the browser. Two layers, always shown together:
- Free graph router (always on, no flag needed). Reuses
contextlake serve's ownaskMCP tool unchanged, in-process -- no logic duplicated. Classifies the question, dispatches to the matching graph tool (find_definition/find_callers/blast_radius/who_knows/get_wiki/semantic search), returns a structured, cited result. Zero LLM cost. - LLM-synthesized prose (
--llm-chat, opt-in at server start). Turns that structured result into a short written answer using whatever[llm]providerkb.tomlalready has configured (the same settingcontextlake wikiuses). The citations the prose was grounded in are always shown alongside it, expandable, so it's checkable rather than just trusted. An LLM failure degrades to the free result rather than erroring out. --llm-chatmints the same per-launch token--allow-mutationsuses, and every chat request while it's active must carry it -- a page other than this dashboard can't silently trigger a paid call. The free layer needs no token, same risk level as any other read-only/api/*route.- See
docs/dashboard.md§12.
[2.59.1] - 2026-07-29#
Changed#
- Upgraded the
mcpSDK dependency to 2.0.0, lifting the<2stopgap pin from v2.58.3. Patched every breaking rename (FastMCP->MCPServer,streamablehttp_client->streamable_http_clientin two files,CallToolResult.structuredContent/isError-> snake_case, the removedmcp.shared.memoryin-memory test helper -> the newmcp.Client).
Fixed#
contextlake servewas completely broken for any tool touching the SQLite-backed store, on either transport. This mcp SDK version dispatches every synchronous tool call throughanyio.to_thread.run_syncunconditionally, so a server-lifetime store sharing onesqlite3connection across calls now crashed with "SQLite objects created in a thread can only be used in that same thread" the moment any tool ran (confirmed live against a real HTTP-served server: works on mcp 1.28.1, broken on 2.0.0).SqliteStore,VectorStore, andSqliteVecStorenow hand out one connection per thread instead of one connection total (WAL mode, already in place, is exactly the mode SQLite recommends for this).
[2.59.0] - 2026-07-29#
Added#
- Directory-scoped config with inheritance.
contextlake init --local(andcontextlake source add --local) writes.contextlake.ini/.contextlake.kb.tomlinto the current directory instead of~/. The "local" tier of both config systems now walks up from the current directory to the filesystem root looking for these files -- the same discovery modelgituses for.git-- so a config at a project's root is inherited by every subdirectory underneath it, not just the exact directory that holds the file. Previously the local tier only ever checked cwd literally, which meant running any command from a subdirectory silently fell straight through to the global config.contextlake source add/remove/enable/disablenow default to the nearest ancestor's local config once one exists, instead of always writing to global.
[2.58.3] - 2026-07-29#
Fixed#
- Pinned
mcpto<2in thekbextra. The v2.58.2 release gate failed in CI (nothing published) becausemcp>=1.28had no upper bound and CI resolved the just-releasedmcp2.0.0, which renamedstreamablehttp_clienttostreamable_http_clientinmcp.client.streamable_http-- breaking every connector/MCP import at collection time. Unrelated to any code in this release; a fresh install yesterday would have hit the same break. Pinned below the major bump untilcontextlake.kb.mcp_clientis deliberately audited and updated for the 2.x API, rather than chasing it mid-release.
[2.58.2] - 2026-07-29#
Added#
contextlake init's data-source prompt now loops ("Connect a data source now?" then "Connect another data source?") instead of collecting exactly one source per run -- found via dogfooding: adding a second source meant re-runningcontextlake source addby hand afterward.initandsource addexplain what "Source name" actually means -- it's a local nickname you pick to reference the connection later (contextlake source test <name>), not your Atlassian site, Figma team, or any other provider-side identifier. Also found via dogfooding: a real user typed their org name as the "source name," reasonably expecting it to be some kind of account identifier.init/source addsuggest each provider's official hosted MCP URL as the default foratlassian(https://mcp.atlassian.com/v1/mcp/authv2) andfigma(https://mcp.figma.com/mcp), verified against each provider's own docs, so the prompt no longer forces you to already know the endpoint yourself.
Fixed#
[llm] provider = "cli"withcommand = "gemini"was broken since it shipped.gemini's-p/--promptflag is a required string value, not a boolean "read the rest from stdin" switch -- the preset sent["-p"]with the prompt only on stdin, which produced a yargs usage dump instead of a completion (found live once a real subscription login was available to test against).CliLlmnow substitutes a{PROMPT}placeholder ingemini's preset args with the actual prompt at call time and skips stdin for that call -- every other command (claude,codex, a user's own CLI) is unaffected and keeps feeding the prompt on stdin. This trades away two properties forgeminispecifically: no moreARG_MAXheadroom for very large prompts, and the prompt is now visible to other local processes viaps//procfor the duration of the call (still never a secret, but no longer stdin-only).- Confirmed live (not just from docs) that
codex's ChatGPT-subscription login is not hijacked by a strayOPENAI_API_KEYin the environment -- ran a realcli-provider wiki generation with the key set and it used the subscription login without complaint, unlikeclaude/gemini. The v2.58.1 entry below called this "not confirmed"; it now is.OPENAI_API_KEYis still stripped defensively forcodex, since a futurecodexversion could change that behavior.
[2.58.1] - 2026-07-28#
Fixed#
[llm] provider = "cli"no longer leaks the CLI's own API-key env var into the child process. The whole point of this provider is reusing a subscription (claude -p/gemini/codex) instead of an API key contextlake would have to hold -- butclaudeandgeminiboth treat their own key (ANTHROPIC_API_KEY;GEMINI_API_KEY/GOOGLE_API_KEY) as an auth override that takes precedence over the subscription login and must be unset to fall back to it. A key set anywhere in the shell for an unrelated reason (testing theanthropic/openaiprovider directly, another tool) silently flippedclionto pay-per-token auth instead -- found live while testing (claude -pfailed with "Credit balance is too low" the momentANTHROPIC_API_KEYwas exported, worked once it was unset; thegeminibehavior is confirmed from its own auth docs, not live-tested here).CliLlm.generate()now strips the matching var(s) from the subprocess environment only, per recognised command (matched by basename, so a path-qualifiedcommandstill strips) -- an unrecognisedcommand(a user's own CLI) strips nothing, since its auth model isn't known.OPENAI_API_KEYis stripped forcodextoo as a defensive precaution, though its docs describe API-key auth as a separate explicitly-opted-into login mode rather than an env-var override.
[2.58.0] - 2026-07-27#
Changed#
- Empty-repo classification: new
notestate, distinct fromskip. A repo with zero commits (update/branchescan't resolveHEAD-- there's no history to read yet) previously reported asskip(⊘), which reads as "something that would normally happen didn't." It isn't that: it describes what the repo is, not something withheld.update_repository()/switch_repository_branch()now return"note"instead of"skip"for this case, with a friendlier message ("New repo -- no commits yet", was"No commits yet (empty repository)"). New neutral glyph•(style.note()), a newupdate/branchessummary bucket (empty, was folded intoskipped). Behavior change for anything parsing CLI output: the returned status string changed from"skip"to"note"for this specific condition.docs/console-output.mddocuments the new glyph and the skip-vs-note distinction.
[2.57.0] - 2026-07-27#
Added#
-
Dashboard mutating routes (
--allow-mutations). The dashboard was strictly read-only (v1).contextlake dashboard --serve --allow-mutationsnow additionally exposes three write actions, each behind an explicit confirm in the browser: Sync now on a repo page (git pull --ff-only+ reindex), Add repo on the fleet overview (clone a URL + index it), and Start/Stop/Restart for a separatecontextlake serve --transport httpprocess from the MCP console tab. Security-reviewed before shipping (aBaseHTTPRequestHandleranswering POST on localhost is a classic CSRF-to-RCE shape): refused outright with--sampleor a non-loopback--host; a random per-launch token (customX-Contextlake-Tokenheader, so a cross-origin POST can't complete the preflight it would trigger) plus aHostheader check (blocks DNS rebinding around the loopback bind) gate every mutating request;git clone's URL is scheme-allowlisted (https:///ssh:///user@host:path, rejecting flag-injection and theext::arbitrary-command transport) and always passed after a literal--; each mutation takes the store's single-writer lock for its own duration only, so a concurrent CLI command sees a clean409instead of an interleaved write. Newkb/dashboard/mutations.py. Verified against a real git repo (not mocks) end-to-end, including a live curl pass against the playground store. See dashboard.md §11. -
Per-symbol ticket attribution.
tracked_byedges could previously only originate from a repo node (a branch name or a doc link, with no way to say which symbol an issue relates to). Two new candidate sources, a symbol's own docstring, and the git-blame commit message on its defining line (one batchedgit blameper file, not per symbol), each a bare-key regex match, so both are AMBIGUOUS candidates fed through the exact same live-JQLverify_issues/reconcilepipeline that already promotes branch-derived keys to INFERRED. Newconnectors/symbol_refs.py(pure logic) +AtlassianConnector.associate_symbols(). Closes the approved-spec divergence flagged when dashboard cross-linking shipped (breadcrumb ends in repo-level "Links" instead of a per-symbol "Ticket"): the symbol/blast- radius page's breadcrumb now gets a real Ticket crumb when a symbol has one, opening the tracker URL directly. Built and tested against a spawned mock/real git repo (no live Jira credentials this session); real- workspace verification is still needed before this is production-proven. - Slack connector. A new
slack.pyconnector (mirroring the Figma/Atlassian shape) classifiesslack.compermalinks in a repo's docs (/archives/<channel>and/archives/<channel>/p<ts>) into channel/message links, wired intoconnect/contextlake sourcealongside the existing three connectors. Reachability is checked best-effort over a configured Slack MCP; there's no single spec-mandated tool name across Slack MCP servers, so the verification tool is configurable (verify_tool, defaultconversations_info) rather than assumed. Built without live Slack credentials this session (tested against a spawned mock MCP server); real-workspace verification is still needed before this is considered production-proven. - Deeper Figma enrichment.
FigmaConnector.fetch_metadata()(used byconnect) now merges a design's real metadata (a name and/or top structural frame/page names, parsed from either a simplified dict or Figma's own XMLget_metadataresponse) into the design node, on top of the URL-slug title that was previously the only source of a name. - Single-binary releases via PyApp. A new
tag-triggered
binaries.ymlworkflow builds a self-contained launcher per platform (contextlake-linux-x86_64,contextlake-macos-arm64,contextlake-windows-x86_64.exe) and attaches them to the GitHub Release. Each binary embedscontextlake[kb-full]'s project metadata and bootstraps a private Python + the package into its own cache on first run (network needed once; every run after is instant), for the audience that has nothing preinstalled, not even Python. Deliberately a separate workflow fromrelease.yml, so a binary-build failure can never block the PyPI publish.uvx/uv tool installremain the recommended path for anyone who already hasuv. Does not bundle the optionalllm-localwiki backend (needs a C++ toolchain to build);ollama/openai/anthropic/cliremain available as wiki LLM providers.
[2.56.0] - 2026-07-27#
Added#
- Dashboard: a "Call sequence" card on the symbol/blast-radius page. The one
graph --formatMermaid diagram the repo-level Diagrams tab (v2.55.0) couldn't offer,sequencediagramneeds a single symbol seed, not a repo-wide view, is now reachable from where a seed already exists: the symbol page. A new/api/impact/diagram?node=<id>endpoint reuses the sameextract_subgraph -> to_payload -> to_sequence_diagrampipelinegraph --node <id> --format sequencediagramalready runs. query --retriever {fts,semantic,hybrid}. Semantic/hybrid search was previously only reachable viacontextlake eval;querynow accepts the same flag and reuseseval's exact retriever factories, degrading to an honest fts fallback (never a crash, never a silent network call) when embeddings aren't configured. Fixed in the same pass:--kindwas silently ignored under--retriever semantic|hybrid(plain fts already filtered by it); it now filters there too.- Dashboard: a "Data flow" tab. Intra-repo
reads/writesedges (extracted since v2.48.0 but never surfaced anywhere, no CLI, dashboard, orvisualize/consumer read them before now) are now visible per repo: which file reads or writes which SQL table/view, each with its file:line and a citation. Deliberately not folded into the existingdependencies/http_flow/event_flowrelationship tables, those are repo→repo aggregates on a node shared across repos by construction; a table/view definition is only ever known within the repo that defines it, so this is a different, honest row shape (file→table, always single-repo). - Docs: typed callouts. Python-Markdown's built-in
admonitionextension (note/warning/important, no new dependency) replaces the handful of existing bold-lead blockquotes that were really a distinct interruption , a risk before you act, an honest limitation, a must-not-skip guarantee , while generic asides stay plain blockquotes. - Docs: per-page-type hero accent. A doc page's hero eyebrow now recolors by its nav group (Get started/Build your knowledge base/Use it/Understand it), from the existing brand palette, a "where am I" signal at a glance, not new illustration work.
- Landing page: the "Get started" terminal card now matches the depth of
the rest of the fog→clarity system, a teal border/glow and a blinking
cursor after the last command (respects
prefers-reduced-motion).
Fixed#
- Dashboard server: a client disconnecting mid-response no longer logs a
traceback.
_send()'swfile.writeis now guarded againstBrokenPipeError/ConnectionResetError, a browser tab closed mid-load orcurlkilled early is normal, not an error. - Empty-repo classification consistency.
core.py'supdate_repository()classified a no-HEAD repo aserror; the branch-switch path (same file, identical condition) already classified it asskip. Both now agree onskip, nothing failed, there's just nothing to sync yet, and error tallies stay meaningful. docs/img/architecture.pngregenerated as transparent RGBA via the existinggen_diagrams.py+ cairosvg pipeline, was the one hand-made diagram on the site that didn't adapt to dark mode.
[2.55.0] - 2026-07-27#
Added#
- Dashboard: a repo page's
Diagramstab. Five of the sixgraph --formatMermaid diagrams (mermaid/classdiagram/statediagram/erdiagram/deploymentdiagram,sequencediagramexcluded, it needs a single symbol seed, not a repo-wide view) are now reachable from the dashboard, not just the CLI, rendered inline as SVG with a raw-source copy card. A new/api/repo/<id>/diagram?format=<fmt>endpoint reuses the exactrepo_subgraph -> to_payload -> rendererpipeline the CLI already runs, no new extraction or rendering logic. The format switcher only enables formats the repo actually has data for (classes forclassdiagram, tables forerdiagram, etc.), read from the same anatomy census the repo page's Kinds card already fetches. Mermaid.js (vendored offline, MIT, ~3.5MB) is lazy-injected into the page only the first time the tab is opened, atsecurityLevel: "strict"(mermaid's own DOMPurify-sanitized mode, diagram text embeds repo-derived symbol/table/resource names, untrusted input). Live-only, same as MCP console/Settings.
Fixed#
deploymentdiagram: a repo's own Python/JS/etc. module nodes no longer leak into the Terraform diagram.kind="module"isn't exclusive to HCL ,kb/parse.pyemitskind="module"package nodes for every code language , so a repo with both Terraform and regular source files was incorrectly drawing unrelated source-module nodes as deployment "module" entries. Now gated onlang="hcl"too. This bug shipped in v2.54.0'sdeploymentdiagramrelease; caught while writing this release's dashboard Diagrams tests against a fixture with an ordinary Python module node alongside a Terraform resource, and fixed here using the same revert-the-fix, watch-it-fail discipline v2.54.0 used for its own data-block categorization bug.
[2.54.0] - 2026-07-27#
Added#
graph --format deploymentdiagram: a Mermaid flowchart of Terraform/HCLresource/data/moduledefinitions grouped by an inferred category (network/compute/storage/database/security/module/other), over datakb/hcl.py's existing extractor already collects (no new extraction pass, same spirit aserdiagram). Category is a keyword heuristic over the resource type prefix (aws_security_group.web-> security); more-specific categories are checked before generic ones so e.g.aws_db_instancelands in database, not compute, on the "instance" substring, caught live before shipping via a real Mermaid render, not just code review. Adatablock's address (data.<type>.<name>) is unwrapped before categorizing, so a data source is grouped by its underlying resource type, not left in "other" on the literaldata.prefix. A single-category view renders flat (no subgraph wrapper);depends_onedges (reconstructed byparse.pyfromvar./module./type-name interpolation references) draw the connections.--c4continues to reject text-diagram formats (deploymentdiagramadded to that list). A repo with no.tffiles renders an honest empty diagram with guidance, not a bug.
[2.53.0] - 2026-07-26#
Added#
graph --c4 --c1: C1 external-system layer. One dashed box per distinct host an indexed repo calls over HTTP that never resolves to any indexed repo's exposed route (kb/arch/resolve.py:repo_external_system_edges), drawn outside every namespace boundary, connected by acalls_externaledge. Deliberately unclassified: a genuine third party and an unindexed internal service look identical here (nointernal_domainsallowlist in V1, seecontextlake-planning/specs/spec-D-c1-system-context.md). No new extraction pass:flow/http.py'scalls_httpedges now carry the raw call-target host (Edge.attrs["raw_host"], newEdge.attrsfield,edges.attrsstore column,SCHEMA_VERSION1→2 with an automaticALTER TABLEmigration for existing stores, verified against a pre-v2.52 store shape, no manual step needed).--c1requires--c4. Verified live end-to-end (host captured → persisted → joined againstexposes→ rendered, confirmed both the resolved and unresolved paths on a real two-repo fleet).
[2.52.0] - 2026-07-26#
Added#
- ADR/decision-record surfacing. A repo's own decision docs under common
conventions (
docs/adr/,docs/decisions/,decisions/,adr/) become first-classadrnodes in that repo's shard duringindex, no separate command, no@enrich:/@ingest:side-channel. Title comes from the file's first#heading, or the filename otherwise. Semantically searchable (adradded toEMBEDDABLE_KINDS) and cited inwikigeneration as a grounded "Recorded decisions" section, distinct from connector-sourced "External context", an ADR is authored, checked into the repo's own git history, so it's presented as a fact, not something to attribute or hedge on. No column data, no edges to other nodes: a decision doc mentioning a class by name isn't a verified reference the way an import is. graph --format erdiagram: a Mermaid ER diagram oftable/viewdefinitions and their foreign-keyreferencesedges, over data the SQL DDL extractor already collects (no new extraction pass). Entities render as bare boxes (the extractor has no column data); aREFERENCESclause always points child-row to parent-row, so cardinality (||--o{) is asserted from FK semantics, not guessed. An ORM-only schema (SQLAlchemy/Entity Framework/ TypeORM, no literalCREATE TABLEtext) renders an honest empty diagram with guidance instead of looking broken.wikinow hints once per run when the builtin model is doing the council review. The builtin 0.5B is a weak reviewer (near-constant high accept scores, mostly rubber-stamping): still functional, but a real backend (--llm anthropic|openai|ollama|cli) gates meaningfully. The note prints once, before generation starts, not per repo.
Fixed#
- Docs house-style: em-dashes stripped from the remaining
docs/*.mdpages (serve.md,cli-reference.md,index-code-graph.md,visualize.md,dashboard.md,connect-enrich.md) that had drifted since the original 2026-07-24 pass, replaced context-aware with colons, semicolons, commas, or parens per the surrounding sentence.
[2.51.1] - 2026-07-26#
Fixed#
- A top-level unrecognized flag followed by a path-looking token no longer
reports a confusing "Unknown command".
contextlake --work-d /tmp doctor(--work-disn't a real flag) used to sayUnknown command: '/tmp',/tmpfell into the<command>positional slot since argparse never learned--work-dexpected a value. Now correctly reportsunrecognized arguments: --work-d, the same message the no-trailing-token and subcommand-scope forms of this mistake already got right. - Dashboard/graph architecture view: repo labels no longer overlap when a
namespace cluster expands. The mindmap drill-in's grid layout only spaced
node shapes apart (
avoidOverlap), not their label text, so adjacent repos with longer names (forecast-api,station-registry,shared-lib) ran into each other. Now accounts for label width (nodeDimensionsIncludeLabels) and uses more generous cell spacing.
[2.51.0] - 2026-07-26#
Added#
- Dashboard: MCP console + Settings surfaces. Two read-only, live-only panels
(not part of a
--siteexport). MCP shows the live tool catalog forcontextlake serveagainst this store, introspected from a realserver.build_server()instance so it can never drift from what's actually exposed, plus copyable.mcp.json/.vscode/mcp.jsonsnippets (reusingsteer.generate.mcp_server_entry, the same entrycontextlake steerwrites). Settings summarizes the activekb.toml: store path/size/schema version, the mirror root (derived from indexed repo paths), configured connectors, and the embedder/LLM tiers, no in-browser editing, editkb.tomldirectly. Connector rows show configured status only, never a live connectivity probe (contextlake source test <name>already does that on demand; auto-probing every connector on every dashboard page load would be a surprising network side effect from a read-only view). Under--sample, both panels use bare config defaults instead of the real precedence chain, so a real~/.contextlake/kb.toml(languages, connectors, embedder/LLM settings) never leaks into the fleet billed as "fictional data, nothing local is read".
Fixed#
index --workspaceno longer silently misattributes a corrupted nested.gitto an unrelated ancestor repo.git -C <path>walks up the filesystem tree past an incomplete/corrupted.gitto find the nearest real one, so a broken checkout could previously be indexed under a completely different repo's remote and commit history, with0 failedreported , found incidentally by the v2.50.0 post-release testing pass. Now verified viagit rev-parse --show-toplevelbefore any identity lookup is trusted; a broken.gitis skipped with a warning instead.query/owners/impact --jsonon an empty argument now emit a structured JSON error ({"error": "missing_argument", "usage": "..."}) instead of a plain-textusage: ...line with a log timestamp, the previous behavior broke--json's "always valid JSON" contract on exactly the case a script piping tojqmost needs it to hold.- MCP
semantic_search/hybrid_search/askno longer crash on an empty query. An empty string embeds to a zero vector, which crashed downstream in the vector store's similarity scoring with a raw, uncaughtTypeError: unsupported operand type(s) for -: 'float' and 'NoneType'instead of degrading gracefully the waysearch_codealready does. All three now return an empty result for an empty/whitespace-only query. contextlake init --config <path>no longer silently ignores--config.initis the one command that writes both generated files (mirror INI + kb.toml) and previously always targeted the fixed defaults (~/.contextlake.ini/~/.contextlake/kb.toml) regardless of--config, every other command honors it correctly.--confignow redirects the mirror INI, with kb.toml written alongside it as a sibling.update/branchesno longer keep a green checkmark on their final summary line when a repo failed.index/embed/wikialready swap to the warning glyph on partial failure;update/branchescalledstyle.ok()unconditionally, so a failed run's summary still visually read as success (the failure detail was only apparent further down).- A malformed
source add --set KEY(no=) now fails cleanly instead of dumping an uncaught Python traceback, the error message itself was already correct, it just wasn't caught. --plain's--helptext no longer overclaims. It strips ANSI color (same asNO_COLOR=1); the unicode status glyphs (✓⚠✗...) are hardcoded literals with no ASCII fallback and always render, which the previous text ("no colour or glyphs") didn't reflect.
[2.50.0] - 2026-07-26#
Added#
- A mistyped subcommand now suggests the closest real one instead of
dumping argparse's raw 30-item choice list (
contextlake fetc→Did you mean: fetch?). Reuses the samedifflib-based fuzzy match already powering unknown-repo-id suggestions. Matches against every command including aliases, then always displays the canonical verb, a typo ofblast-radiussuggestsimpact, matching what--helpteaches (cli.py). --helpnow links to the docs site and the issue tracker (https://sayak.in/contextlake, GitHub issues) so a stuck user isn't a search engine query away from either.-nas a short form of--dry-run, matching the near-universalrm/cp/makeconvention.--plainas a friendlier, discoverable spelling ofNO_COLOR=1, same code path, just a flag instead of an environment variable.contextlake source add --from-stdin KEYreads a connector option's value off a pipe instead of the command line, so a secret never lands in shell history (--set KEY=VALUEalready never echoes it to logs; this closes the other exposure vector). Errors clearly instead of hanging if stdin isn't actually piped.--jsononquery,owners,impact, andlint, the four commands whose entire job is answering a question had no machine-readable output, despite contextlake's whole pitch being "agents answer from real source instead of guessing." Reusesgraph's already-proven pattern: logs move to stderr viause_stderr(), the payload is the only thing on stdout. Error cases (unknown_repo,not_found,ambiguous) are structured JSON too, not just the success path. Exit-code contract:--jsonmirrors the human path exactly, includinglint --jsonreturning 1 on an unclean graph (not just on a malformed request), a CI script piping tojqstill gets valid JSON on a non-zero exit, so check$?deliberately rather than assuming 0 means "ran" instead of "clean".
Fixed#
embedandwiki(both per-repo and--namespaces) silently reported a partial failure as a clean success. If even one repo/namespace out of a batch failed, the summary line kept its✓glyph and simply omitted the failure from the count, indistinguishable from a fully successful run unless you scrolled back through the whole log. Both now show⚠and the failed count wheneverfailed > 0.
Changed#
update,branches,index --workspace,embed, andwikinow name a concrete next step when something failed, instead of ending on a bare summary line.update/brancheslist the failed repos and the exact retry command (contextlake update --repos <name>);indexpoints at the log and notes re-runs are incremental;embed/wikipoint at the log and suggest re-running.updatealso notes how many repos were auto-switched to a new branch. Mirrors_bootstrap's existing ending, which already did this well.-
Flags can no longer be silently abbreviated (
--work-dused to resolve to--work-dirvia argparse's defaultallow_abbrev=True). Disabled on the root parser and every subcommand parser, an unrecognized long flag is now a clear error instead of an undocumented shortcut that breaks the moment a new flag creates an ambiguity. -
contextlake updateno longer just reports a deleted upstream branch and waits for the user to runbranchesby hand. A tracked branch missing on origin almost always means it was renamed, merged, or superseded by another default, not something that needs manual triage.updatenow auto-fetches the full branch list and switches to the most-active remaining branch (the same selectionbranchesmakes), reportingswitchedwith both the old and new branch name. Falls back to the previous clean-skip-with-hint behavior only if the broader reselect fetch itself fails too (e.g. a real network outage), or if no other branch exists (core.py).
[2.49.0] - 2026-07-26#
Removed#
- Dropped the
gitlab-syncbackward-compat layer (the pre-rename project name). This is a breaking change for anyone still relying on it, nothing else changes. Specifically removed: thegitlab-syncconsole-script alias; reading a legacy~/.gitlab_sync.ini/.gitlab_sync.inifile or its[gitlab_sync]INI section; reading a legacy~/.gitlab-sync/kb.toml/.gitlab-sync.kb.tomlknowledge-layer config, and falling back to a pre-existing~/.gitlab-sync/kbstore when~/.contextlake/kbdoesn't exist; recognizing the old<!-- BEGIN gitlab-sync ... -->managed-block marker incontextlake steeroutput. Usecontextlake(notgitlab-sync),.contextlake.ini/~/.contextlake.ini, and~/.contextlake/kb.toml/~/.contextlake/kbgoing forward, migrate any existing legacy config/store to the current names before upgrading past this release.
[2.48.2] - 2026-07-26#
Fixed#
-
The C4 diagram's namespace boundary tagging could mark a real edge as internal when it wasn't, or as crossing a boundary when it wasn't. Two bugs in how repos were bucketed into namespaces for boundary purposes (shared with the dashboard's display-grouping heuristic,
derive_groups, but wrong for boundary tagging specifically): (1) a repo whose id IS exactly a namespace prefix (e.g. a repo literally namedacmealongsideacme/ingest/api) fell into the meaningless catch-all"(ungrouped)"bucket instead of theacmeboundary its own child repo joined, so a real edge between them wrongly rendered as crossing a boundary; (2)"(ungrouped)"was treated as one shared namespace, so two entirely unrelated single-segment repos that only coincidentally had no deeper namespace got a real edge between them rendered as internal, identical to a genuinely related same-namespace edge. C4 boundary tagging now uses its own bucketing rule, independent fromderive_groups: each repo's own path prefix (or its full id, if shorter thangroup_depth) becomes its namespace, so a repo with no real namespace always gets a namespace of exactly itself (kb/c4.py). -
Wiki council review parsing could drop a review's real issues or fabricate a wrong score, on the malformed-JSON recovery path a small local model's output regularly takes. Three related bugs in
_parse_review/_extract_score: (1) the naivetext[first "{" : last "}"]slice broke as soon as any trailing prose contained its own brace, discarding a validly-parsedissueslist along with the JSON, now usesjson.JSONDecoder.raw_decode, which stops at the first complete object regardless of what follows; (2) when JSON parsing failed outright, the fallback score-recovery regex scanned the entire raw text withre.search(first match wins), so an unrelated "...rating is 1 star..." aside earlier in a model's own issue text could win over the real, later"score"field, now the literal JSON-quoted"score": Nform is tried first, since it can't be confused with ordinary prose; (3) the "N out of 10" fallback matched anywhere in prose with no context check, so "3 out of 10 endpoints... are undocumented" (a coverage-gap description) was misread as a 0.3 review score , now rejects a match immediately followed by a noun, the count-phrase shape (kb/wiki/council.py). -
contextlake hook installcould silently wire a hook to the wrongrepo_idand never say so._canonical_repo_idswallowed every exception from opening the store (a bad--config, a corrupt/too-new store) with a bareexcept: passand fell back to the bare directory name, even for an already-indexed repo, and even thoughSqliteStoreitself never raises for a fresh/not-yet- indexed store (it creates one on open), meaning anything landing in that catch was a real problem, not the benign case the fallback was written for. One blast radius: a bad--configproduced a hook install reported as a clean success but permanently inert (its own re-index invocation embeds the same bad config and silently never runs). Another: a transient store error left a duplicaterepo_idrow after the hook did fire. Now logs a warning explaining the fallback instead of swallowing it silently (kb/commands.py). -
contextlake steercould silently corrupt a user's AGENTS.md/CLAUDE.md/etc. on a later refresh, or crash mid-run leaving the workspace half-steered. A repo id or package name (the latter reachable verbatim viamanifest.py's unvalidated package.json dependency-key parsing) carrying a backtick or the literal<!-- END contextlake -->marker text broke out of its markdown code span or smuggled a duplicate marker into the generated body; the next refresh's naive first-occurrence_upsert_blocksplice then truncated/duplicated real content. Now: names are sanitized before interpolation, and_upsert_blockrefuses to splice (warns instead) if a marker pair doesn't cleanly bound a single block. Separately, an existing.mcp.json/.vscode/mcp.jsonwith a non-dictmcpServers/serversvalue (e.g.null) crashed_merge_mcp_entryuncaught after markdown/skill files were already written, now self-heals instead. Also: a relative--configvalue is now resolved to absolute before being embedded in the generated MCP entry, since it may be launched from--out, not the invocation directory (kb/steer/generate.py,kb/commands.py). -
Mermaid diagrams (
to_mermaid/to_class_diagram/to_sequence_diagram) could emit invalid or directive-injecting output from ordinary node/edge text._mermaid_escapeonly escaped",[,]. Confirmed against a real Mermaid parser: a|in an edge'srelationbroke the-->|label|delimiter (invalid diagram); a}in a class member's signature closed theclass X { ... }body early, letting text after it emit as new top-level statements; a newline in a node's name became a genuine new line, rendering as a realNote over ...annotation box rather than inert label text. Now also escapes{,},|, and newlines (kb/visualize.py). -
The dashboard's "Generate wiki" action copied a command that silently no-ops. docs/dashboard.md documents
contextlake wiki <repo-id> --llm builtin; the actual generated/copied command (kb/dashboard/static/dashboard.js, shared by both--serveand--site) wascontextlake wiki <repo-id>with no--llmflag at all, and without--llm(or[llm]enabled inkb.toml), the wiki stage does nothing. Not just a docs mismatch: the copied command didn't work. Now appends--llm builtin, matching the docs. -
ask'sexplain/ownersroutes didn't resolve a repo by its short name, contradicting the docs' own headline example.ask("explain the forecast-api")(verbatim from docs/serve.md) silently fell through tosearchinstead of returning wiki prose or a repo brief: repo ids are always host-qualified (gitlab.example.com/acme/forecast-api), but the router only extracts the trailing "forecast-api" out of the question, which never matched the full stored id viaget_wiki/get_repo_brief/who_knows's exact lookup. A person naturally refers to a repo by its short name, so this needed to be resolved, not documented around. Added_resolve_repo, mirroring the existing symbol-resolving_resolve_id: falls back to matching the repo's last path segment when an exact id lookup misses (kb/server.py). -
contextlake audit --repos PATTERNaccepted the flag but silently ignored it, scanning every repo regardless. docs/usage.md promises--reposworks for "every mirror command" (audit included, per docs/cli-reference.md's own mirror-tier list), butscan_repo_metricscalledget_local_reposdirectly and never consultedrepo_filter, unlike fetch/clone/update/branches/verify/status, which already route through the samematch_repo_filtercheck. Now filters the same way (metrics.py).repo_filter_patterns(core.py) is promoted from_repo_filter_patternsto a public name, reflecting that it was already a cross-module helper (kb/commands.pyalso calls it). -
Wiki review parsing: a JSON
"score": true/falsewas silently accepted as a perfect/zero score.boolis anintsubclass in Python, sofloat(True) == 1.0raises nothing, the primary score-parsing path lacked the samenot isinstance(val, bool)guard its own sibling fallback ladder (_extract_score) already applies. Now abstains (as any other wrong-shaped score does) instead of fabricating a score from a bool (kb/wiki/council.py). - The most-active-branch scan could silently drop a real branch whose name
merely contained the substring "HEAD" (e.g.
release/HEAD-fix), not just theorigin/HEADsymbolic ref it was meant to filter, a substring check ("HEAD" in line) over the wholegit for-each-refline, rather than an exact match on the ref name.select_most_active_branch/branchescould then pick a less-active branch, or (if the affected branch was the only one) report "No branches found" (core.py). contextlake branches' own fetch could report a deleted/access-revoked upstream project as a generic error, inconsistent withupdate. A prior fix classified this condition as a clean skip inupdate_repository's fetch path only;switch_repository_branch'sgit fetch --all(thebranchescommand's own fetch, hitting the same origin) still reported it as an undifferentiated("error", ...). Now applies the sameclassify_error(...) == "project-deleted"check (core.py).
[2.48.1] - 2026-07-26#
Fixed#
statediagramextraction (v2.48.0) could emit a false transition, not just an undercount. An independent review of the guard→assignment regex found five reproducible cases where the lazy gap between a guard and its assignment could cross into anelse/elifsibling branch, a different method, or past a second, unrelated guard on the same field, asserting a transition the code doesn't actually establish, contradicting this module's own "never a false transition" contract. Also fixed:self.status = other.status(a field copy) synthesizing a bogus state literally namedstatus. The guard→assignment span is now rejected if it contains a boundary keyword (else/elif/def/classfor Python, a}/function/classfor JS/C#) or a second mention of the same receiver+field, and a transition value matching the field name itself is dropped (kb/flow/state.py).- Dataflow extraction (v2.48.0) read commented-out and docstring-quoted SQL as a
live
reads/writesedge. A# DELETE FROM ordersleft in a data-access file, or a docstring like"""...INSERT INTO audit_log...""", asserted a data dependency that doesn't exist, the same "never a false edge" contract this extractor documents. Line comments (#,//), block comments (/* */), and triple-quoted strings are now blanked out (newlines preserved, so line numbers on real matches don't shift) before scanning (kb/flow/data.py). Trade-off, by design (honest undercount over false positive): a real query written as a triple-quoted string (query = """SELECT * FROM orders""") is now missed too, same as the existing undercount for ORM/string-concatenation queries. - The fleet architecture map and generated site could render a shared-node
sentinel (
"(shared)","(packages)") as though it were a repo. Since Finding #10 (v2.48.0) gave shared nodes their own stablerepo_id, that id showed up in the rawGROUP BY repo_idnode-count query the fleet overview and site builder use to enumerate repos, and"(shared)"(every module imported fleet-wide) is now the single largest bucket, ranking first and potentially displacing a real repo when the fleet exceedsmax_nodes. A newrepo_node_sizes()helper filters sentinel ids out at the one query all three call sites (overview_subgraph,build_site,build_site_server) and the dashboard's embedded graph pages share (kb/visualize.py,kb/dashboard/server.py)."(packages)"/"(external)"are now named constants (PACKAGES_REPO/EXTERNAL_REPOinkb/model.py) alongsideSHARED_REPO, replacing the bare string literals at their four call sites. - Graph views: a harmless but noisy console warning on every node, on first render. Cytoscape's
style sheet maps node width/height from a
deg(degree) data field, butdegwas only set client-side in a.forEach()that ran after the graph's first style pass, so every node logged "no mapping for property... try a[deg]selector" before silently correcting itself.degis now computed once server-side (kb/visualize.py:_cytoscape_elements, mirroring the existingweight-always-present pattern for edges), present from the very first render. - The release gate never actually ran the knowledge-layer test suite.
release.ymlinstalled only the core package and ranpytest --ignore=tests/kb, mirroring CI's "core (no knowledge layer)" job rather than its "knowledge-layer" job, so a brokentests/kb/*test could tag and ship a release. Found the hard way cutting v2.48.0: atests/kbtest had been misplaced one directory too high (outsidetests/kb/), so it ran under the core-only gate and failed there instead, catching the mistake by coincidence, not because the gate coveredtests/kb. The actualtests/kb/*suite still never ran inrelease.yml. Now installs the[kb]extra and runs the full suite, matching CI's knowledge-layer job.
[2.48.0] - 2026-07-26#
Added#
statediagramgraph format.contextlake graph --repo <repo> --format statediagramrenders a Mermaid entity state machine from guarded assignments to a status/state/stage field,if order.status == Created: order.status = Paidbecomes a labeled transition. Only guarded transitions are emitted (the source state must be established by a preceding comparison on the same field), so a diagram never claims a transition the code doesn't actually establish, an honest undercount, never a guess. Regex-based, Python/JS·TS/C# (kb/flow/state.py), every edgeINFERRED, same stance as the existing HTTP/event flow extractors.- Intra-repo dataflow:
reads/writesedges from code to the tables/views it queries. A literalSELECT ... FROM/INSERT INTO/UPDATE ... SET/DELETE FROMin any file becomes areadsorwritesedge to the matchingtable/viewnode the SQL DDL extractor already found , resolved by name repo-wide, the same mechanism an FKreferencesedge uses, so a query against a table this repo never defines is an honest miss, not a guessed link (kb/flow/data.py).reads/writesare now inimpact's default relation set, socontextlake impact <table>answers "what code touches this table" without needing--relation reads,writesspelled out. - Dashboard: the symbol breadcrumb continues to Diagram / Wiki / Links. Viewing a symbol's blast
radius now shows
repo → symbol → Diagram → Wiki → Links, one click each to that symbol's repo-scoped architecture graph, curated wiki, and connector links, Wiki/Links only appear when the repo actually has one, never as a dead crumb for data that doesn't exist.
Fixed#
- A deleted (or access-revoked) upstream GitLab project is a clean
updateskip, not an error. Previously bucketed into the generic error count alongside real failures; now classified like the existing deleted-branch case, so a project marked for deletion upstream no longer inflatesN errorsin the run summary. - Dashboard: the symbol view's "Cross-repo only" toggle was silently dead in live mode. It
worked only against the static demo snapshot, because
/api/impactnever told the client which repo the seed symbol lived in, the client tried to look it up in a symbol index that only exists in static mode.impact()now returns the seed's ownrepo(kb/dashboard/data.py), fixing the toggle in live mode and, incidentally, a symbol-view provenance chip that was citing the wrong "repo" (actually the raw node id, via a.split(":")on an id that never had a colon in it). - Finding #10: shared nodes (
module/endpoint/topic, and, found while fixing this ,packageand connector nodes) had theirrepo_idsilently overwritten by whichever repo's index run touched them last, and could be deleted out from under another repo entirely. Root cause:SqliteStore.upsert_nodesstamped every node in a shard's batch with that shard's ownrepo_id, ignoring eachNode's own.repofield, so a "requests" module imported by two repos flipped owner on every reindex, andclear_repoon whichever repo it currently pointed at deleted the node (and any other repo's still-live edges into it) as collateral damage.upsert_nodesnow writes each node's own.repo; extractors formodule/endpoint/topicnodes now set it to a new"(shared)"sentinel (kb/model.py), matching the existing"(packages)"/"(external)"pattern for nodes no single repo owns, per-repo attribution for these already lives correctly on their edges (arch/resolve.pyhas always read it from there, never from the shared node itself). No schema migration needed, but self-correction requires an actual re-parse, not just re-runningindex:index's incremental skip (a repo whose HEAD hasn't moved since its last index is left untouched) means a shared node keeps a stale owning repo from before this fix until the repo that currently owns it is actually re-parsed, a new commit landing, orindex --force. Run--forceonce after upgrading if you want existing shared nodes corrected immediately rather than opportunistically. - Dashboard: a shared node's breadcrumb no longer links to a repo that doesn't exist. Search
for a symbol like an imported module, an HTTP endpoint, or an event topic and trace its blast
radius (reachable from any search result's "Blast" button) and its owning "repo" is a pseudo-repo
like
"(shared)"(see the Finding #10 fix above), the breadcrumb previously still tried to linkDiagram/Wiki/Linksto#/repo/(shared), which resolves to nothing. Now treated the same as "repo unknown": those crumbs are omitted, matching the existing rule that an absent wiki/link is never shown as a dead crumb (kb/dashboard/static/dashboard.js). - An explicit
--config/--kb-configpath that doesn't exist is now a hard error, not a silent fall-through.load_kb_configpreviously treated a missing config path exactly like an absent auto-discovered file (empty, keep going down the precedence chain), so a typo'd or not-yet-created--configpath silently landed on the next file in the chain, typically the real~/.contextlake/kb.toml, pointing at a completely different (possibly production) store than the one intended. A missing explicit path now raisesConfigErrorwith a clear message instead; the auto-discovered files in the chain are unaffected (still silently optional, by design).
[2.47.0] - 2026-07-25#
BREAKING#
repo_idis now canonical (derived from the repo's git remote), not the path relative to--workspace. The old scheme meant the same physical repo got a different id from a different index root, duplicate ids, broken path-based owners/graph/impact views. The canonical id survives being re-cloned elsewhere or indexed from a different workspace root. Two local checkouts of the same remote (e.g. a stale pre-reorg clone left alongside its replacement, a real pattern found in this project's own fleet) collapse to one repo, keeping whichever is more recently committed; the dropped checkout is logged, never silently skipped. A repo with no remote falls back to a stabledirname@root-commit-hashid. Migration is automatic: the nextindex/bootstraprun on an existing store detects any repo still under the old id, clears its old row/shard/vectors, and re-derives it fresh under the canonical id, same as a first index, no manual step. This re-parses every existing repo once (their content doesn't change, only their id), and clears embeddings for migrated repos, re-runembedafterward if semantic search is enabled. Verified against this project's real 678-repo store (detection pass, full scale) and a real multi-repo slice (full detect-clear-reindex pipeline).
[2.46.0] - 2026-07-25#
Added#
graphqlsource type foringest. POSTs a query (+ optional variables) to one endpoint and maps records in the response to documents, the same shape as the existingapisource's REST mapping. Auth is a bearer token read from an env var named in config, never stored..vscode/mcp.jsonsteering output.contextlake steernow writes VS Code's own MCP config file (top-levelserverskey, a different schema from.mcp.json'smcpServers) alongside the existing steering files, merging in thecontextlake-kbserver entry without disturbing any other servers already configured there.sequencediagramgraph format.contextlake graph --node/--name/--search ID --format sequencediagramrenders a Mermaid call-order trace from one seed function, walkingcallsedges depth-first and ordering each caller's callees by call-site line. No new extraction was needed, everycallsedge already carried its source line, so this is a renderer over data already collected, not a new parser pass.
Fixed#
- Docs corrected a false claim that Devin reads the same repo-committed MCP config
file as Windsurf. Devin's MCP connections are account/org-level (
mcp.devin.ai, API key + org header); contextlake cannot self-register there the way it can for file-based clients.docs/serve.mdnow says so plainly instead of grouping Devin with Windsurf's wiring instructions. - Deterministic lowest-line dedup for repeated
calls/inheritsreferences. A call site hit twice from the same caller could surface an arbitrary (not necessarily first) line, since tree-sitter's capture order isn't guaranteed to match source order. References are now sorted by line before resolution.
[2.45.1] - 2026-07-25#
Fixed#
- A local config file's
[llm]/[kb]/[embeddings]table no longer wipes out sibling fields set globally. Those three tables are now deep-merged key-by-key across the precedence chain; a.contextlake.kb.tomlsetting only[llm] modelused to silently disable a globally-enabled LLM tier (enabled/providerreverted to their defaults) because the table was replaced wholesale.sources/ruleskeep their documented wholesale-replace behavior (they are list tables, not scalar ones). - Readable terminal output while a long run is in flight. The live progress bar (stderr) and per-item status lines (stdout) share one terminal cursor, so every frame was left on screen with the next status line welded to its right edge. The bar is now erased before each log line and repainted after, and it erases to end-of-line instead of padding out to the terminal width.
- Per-item status lines are clamped to one row on a terminal (long ids elide from the middle, the reason stays whole) so they cannot wrap through the bar. Piped and redirected output is left unclamped, where full ids matter and there is no bar.
- Git's three-line "ambiguous argument 'HEAD'" usage hint is reported as
No commits yet (empty repository)instead of being dumped into the status line. - No ETA is shown until there is enough signal, and it is derived from the cumulative rate: a single early completion used to produce confidently wrong estimates that swung between seconds and tens of minutes.
Update complete:/Clone complete:/Branch switch complete:no longer lose the space before their counts.
[2.45.0] - 2026-07-25#
Fixed#
doctorflags a missing wiki-LLM runtime. When the built-in wiki LLM is configured butllama-cpp-pythonisn't installed,doctornow reports⚠ … runtime not installedwith the install hint, instead of a green✓that only checked for the model file, so the report matches whatwikiwill actually do.- Dashboard favicon. The dashboard shell ships an inline SVG favicon, so browsers
no longer log a
/favicon.ico404 (and the tab gets an icon).
Changed#
- Moonlit-navy dark theme for the dashboard and the graph visualizer.
- Renamed the demo running example to a generic "Catalog" service across the sample fixture, examples, and docs.
- Stripped em-dashes from user-facing copy (house style).
[2.44.0] - 2026-07-23#
Added#
- Composed namespace C4 diagram.
contextlake graph --c4 [--group-depth N]renders a C4-Context/Container view over already-extracted graph data: namespaces as boundaries, repos as containers, and aggregateddepends_on/HTTP/eventflowedges as the labeled inter-service connections (e.g.http x3). Fully offline, no new extraction; output ashtml(default, interactive,<store>/graphs/c4.html),dot(Graphviz clustered), orjson. Mermaid/classdiagram output and--serveare not supported for--c4. - Consistent CLI progress line.
wiki,index,embed, and the mirror-tierclone/update/branchesnow share one progress renderer: a live bar (done/total, percent, elapsed, ETA, rate) on stderr, degrading to periodic summaries when not a TTY, so stdout redirects (e.g.>> run.log) stay clean of bar/\rartifacts.
Changed#
- Consistent CLI presentation across every command. One status vocabulary
(
✓ok,⚠warn,✗fail,⊘skip,=unchanged,↝switched,~dry-run) now covers the mirror tier and every other command;bootstrapandsyncboth show▶ <Phase>section headers; every long-running command ends with a glyph-prefixed summary line.contextlake serve --transport httpnow logs its bind URL, andgraph --overviewon an empty store warns with a "runcontextlake indexfirst" hint instead of silently reporting a written artifact. Per-item detail lines across every long-running command (mirror-tierclone/update/branches,index,embed,wiki,connect,ingest,enrich) no longer flicker a right-aligned clock.
[2.43.0] - 2026-07-22#
Added#
- Fleet / namespace-level wiki.
contextlake wiki --namespace <prefix>(or--namespaces --depth N) generates a cluster wiki page for a whole group of repos, narrating how they fit together: which services call which over HTTP, publish/consume which events, and share which packages, split into coupling within the namespace and coupling to repos outside it. It grounds strictly in the cross-repo edges the graph already resolved (no new extraction), reuses the per-repo wiki's review council + provenance footer (advisory and cited), and says so rather than inventing a link when the graph shows no coupling. Cluster pages are served over MCP by passing a namespace toget_wiki, and shown per group in the dashboard's fleet overview.
Changed#
querynow points at semantic search when a natural-language phrase finds no keyword matches. A multi-word query with no FTS hit gets a one-line hint (runcontextlake embed, then use serve'ssemantic_search/asktools) instead of a bare "No matches"; a single-token symbol lookup stays quiet.
[2.42.0] - 2026-07-21#
Fixed#
- Indexing config keys are now honored.
[kb] skip_generated,max_file_bytes, andindex_workerswere documented but silently ignored (the loader read onlystore_dirandlanguages), so they always used defaults. They are now loaded fromkb.toml. - Vendored nested repos are skipped in discovery. An upstream clone carried inside
the mirror with its own
.gitunder amodule-federationpath segment was indexed as a full repo, flooding the global graph with upstream-demo nodes. Such repos are now skipped, and each skip is logged.
Added#
- Unknown config keys are warned, not silently ignored. An unrecognized
[kb]key or config table (e.g.storeforstore_dir) now logs a warning instead of being dropped without a trace. ownersandgraphsuggest close repo ids when given an id that is not in the store, including the workspace-relative-prefix case (a sub-workspace-indexedteam/gateway/apipoints at the storedacme/team/gateway/api), instead of a bare error or a silently empty view.
[2.41.0] - 2026-07-21#
Added#
- React Router data-router (object form) extraction.
createBrowserRouter,createHashRouter, andcreateMemoryRouterroute arrays now surface asroutenodes, joining the flat JSX<Route>form from 2.39.0. It reuses the tree-sitter AST walk built for Angular, anchored on thecreate*Routercall's array argument so bare{path:...}objects are never mis-read as routes. Nestedchildrencompose into full paths,index: trueresolves to the parent path, and aComponent/elementis captured when it names a plain component. Deferred:loader/lazyandcreateRoutesFromElements.
[2.40.0] - 2026-07-20#
Added#
- Angular route extraction (tree-sitter AST). Angular
Routestables now surface asroutenodes, joining the Next.js and React Router extraction from 2.39.0. It walks the TypeScript AST (not regex) anchored on the route-table container (aRoutes-typed declaration, or an inlineforRoot/forChild/provideRouterarray), so nestedchildrencompose into full paths and a bare{path:...}config object is never mis-read as a route.path: ''index routes fold into the parent,redirectToroutes are skipped,**maps to the catch-all token, and lazyloadChildrencaptures the mount path (the child module is a future release). Only TypeScript files that mention Angular routing are re-parsed.
[2.39.0] - 2026-07-20#
Added#
- Web-topology: frontend route extraction. Indexing now surfaces frontend
routes as embeddable, repo-scoped
routenodes from Next.js App Router page files (theapp/**/page.*path convention, with route groups(name)dropped and dynamic[id]/[...slug]collapsed) and React Router v6 flat JSX<Route path=...>, so "what routes does this app define" and "where is/dashboard" are queryable. Angular route tables, thecreateBrowserRouterobject form, and Luigi navigation configs need AST parsing and are skipped for now rather than mis-captured (a later release adds them). - Next.js API route handlers as endpoints.
app/**/route.tsfiles that exportGET/POST/etc. are now recognized as HTTPendpointnodes (path from the file convention, verbs from the exports) and join the existing cross-repo HTTP flow; previously the HTTP extractor only knew Express/FastAPI/ASP.NET and missed them.
[2.38.0] - 2026-07-20#
Added#
contextlake sourcecommand family for managing connectors.source add|list|remove|test|enable|disablelet you manage knowledge-source connectors (Atlassian, Figma, GitLab) without hand-editingkb.toml. The CLI is guided by default (interactive prompts) and fully flagged for scripting.listandtestshow the effective merged config and per-source reachability;add/remove/enable/disablemutate the config while preserving comments via tomlkit, a new[kb]extra dependency.initcan prompt to connect a source during first-run setup, anddoctorreports per-source reachability as part of its environment check. Hand-editingkb.tomlstill works for power users.- MCP tool-calling connector for external search. An
mcpsource can now declare a search tool (not just read resources) and template codebase-derived terms (repo name, key symbols) into the tool's arguments viatoolandarg_templatekeys. Supports both stdio (command/args) and streamable-HTTP (url) transports. Groundwork for query-driven wiki enrichment in the upcomingenrichstage. contextlake enrich: query connected sources with codebase-derived terms. Derives search terms from each repo's code graph (repo name and top symbols) and queries connected sources (Atlassian Rovo search, or anymcpsource with atoolandarg_template), storing the results in a searchable, embedded@enrich:<repo>partition. Idempotent and re-runnable across the whole fleet. Results are embedded and surface in semantic search (asdocumentnodes tagged with their source), groundwork for connector- enriched wiki pages in the next stage.- The curated wiki now incorporates connector enrichment. After
contextlake enrichcompletes, each repo's wiki page gains an "External context" section drawn from its@enrich:<repo>enrichment documents (Confluence pages, Jira issues, MCP search results). Each external claim is directly quoted and attributed to its source, never presented as a free assertion or undisclosed code fact; the enriched page still passes through the verification council before being written. contextlake bootstrapnow runs theenrichstage, soinitplusbootstraptakes a blank workspace to a mirrored, indexed, embedded, connector-enriched, wiki'd, editor-wired workspace in one command (skip enrichment with--no-enrich). A documented command-composition matrix shows every supported flow (blank-to-enriched, single-repo, add-a-connector-refresh, etc.), so users build exactly what they need by chaining the right stages.
[2.37.0] - 2026-07-08#
Added#
pom.xmlis now indexed into the cross-repo dependency graph (Maven ecosystem): the project'sgroupId:artifactIdbecomes apublishesedge and each<dependency>adepends_onedge, linking Java/Maven repos through shared package nodes, the same waypyproject.toml/package.json/.csprojalready do.- Terraform/HCL is now indexed into an infrastructure dependency graph:
.tffiles indexresource/data/variable/output/module/localdefinitions and resolvevar./module./data./resource references intodepends_onedges (cross-file within a repo).resourcenodes are semantically searchable. The grammar (tree-sitter-hcl) ships in the[kb]extra. - SQL DDL is now indexed into a referential graph:
.sqlfiles indexCREATE TABLE/VIEW/PROCEDUREastable/view/procedurenodes and resolve foreign-keyREFERENCESclauses intoreferencesedges (cross-file within a repo).tableandviewnodes are semantically searchable, and FK dependents surface inblast_radius. It is regex-based (the fleet's T-SQL/PL-SQL defeats a tree-sitter AST), so no new dependency is needed. - Kotlin is now indexed as a tree-sitter code language (
.ktand.ktsfiles): classes, objects, interfaces, enums, functions, methods, imports, and the inferred call graph are extracted; inheritance edges are captured viadelegation_specifier(extending and implementing base classes). The grammar (tree-sitter-kotlin) ships in the[kb]extra.
Fixed#
- Tolerant wiki review-score parsing. The council reviewer now recovers a numeric score from prose or alternate-JSON review responses before abstaining, so capable models whose review text is not strict JSON no longer trigger spurious "unparseable review" rejections. The fallback is scoped to unparseable-JSON responses only; genuinely score-less prose still abstains.
[2.36.0] - 2026-07-08#
Added#
- Selectable LLM backends for the wiki and council tier.
provider = "anthropic"(native Messages API, stdlib-only) andprovider = "cli"(shell out to a localclaude/gemini/codexyou already pay for, no API key held by contextlake). Gemini works today via the existing OpenAI-compatible client (provider = "openai",base_url = ".../v1beta/openai/").doctorreports each backend's key/PATH readiness.
Documentation#
- Install-flag guidance + scenario cheatsheet. QUICKSTART now documents
-U,--only-binary :all:(wheels-only, for compiler-less / brand-new-Python machines), and--extra-index-url, with a "your situation → exact command" table (mirror-only, zero-config kb-full, upgrade, no-compiler Python 3.14, Docker-no-toolchain). The built-in-LLM wheel section gains the--only-binary :all:guard alongside the existing CPU-wheel index. - Config & flag reference completeness. Documented previously example-only keys in the
narrative docs (so they reach the website):
[embeddings] vector_backend+batch_size,[[sources]] auth_dir/mcp_command/group/per_page, thedashboard --group-depthflag, andclone_method/branch_strategyrows in the usage settings table. - "Reading the console output" guide. A knowledge-layer section decoding the runtime
lines users puzzle over: the
▶phase headers,0 nodes, 0 edges(config/doc-only repos), the incrementalalready up to dateembed counts, theFetching 10 files … 0.00Bcached model-load bar, and the✓ written/⚠ rejected by council/unparseable reviewwiki lines.
[2.35.0] - 2026-07-08#
Added#
- Rust, Ruby, PHP, and Scala are now indexed too. Rust (functions, structs, enums,
traits,
useimports, calls); Ruby (classes, modules, methods, calls,<inheritance); PHP (classes, interfaces, traits, enums, functions, methods,useimports, calls,extends/implements); Scala (classes, objects, traits, methods, calls,extends). The parser now covers 13 languages. (.rs .rb .php .scala/.sc.) Kotlin was evaluated but deferred, the available tree-sitter grammar is too inconsistent to index reliably (superseded in 2.37.0 below, which indexes it). - Go, Java, C, and C++ are now indexed. Four more tree-sitter grammars: Go
(functions, methods, struct/interface types, imports, calls); Java (classes,
interfaces, enums, records, methods, constructors, imports, calls, full inheritance);
C (functions, structs, enums, unions,
#includes, calls); C++ (classes, structs, enums, functions, in-class methods,#includes, calls,: public Baseinheritance). Brings the parser to Python, JS/TS(X), C#, Go, Java, C, C++, covering .NET (C#), Node/React/Next/Angular (JS/TS), and native code. (.go .java .c .h .cpp/.cc/.cxx .hpp/.hh/.hxx.) A shared_def_nodenormalization keeps call-attribution and containment correct where a language nests the name under a declarator (C/C++). contextlake hook install, continuous intelligence. A gitpost-commithook that re-indexes a repo into the store after each commit, so the graph never drifts from HEAD without a manualindex/bootstrap.install(single repo or--workspaceacross a whole mirror) /uninstall(restores any pre-existing hook) /status. Re-uses the repo's stored id so it updates the same node, never a duplicate; runs detached so commits don't block.- Store single-writer lock. Two contextlake writers on one store race on SQLite and
can interleave shard writes.
index/embed/wikinow take an advisory lock (<store>/.contextlake.lock) and refuse to run when a live peer holds it, with a clear message naming the holder, while transparently reclaiming a lock left by a crashed process. Override (rarely correct) withCONTEXTLAKE_ALLOW_CONCURRENT=1. - Configurable wiki-LLM
timeout.[llm] timeout(seconds, default 300) is now honored by theollamaandopenaiproviders, so a slow CPU box can raise it instead of every page failing silently at the hardcoded 5-minute per-call limit. Surfaced while measuring wiki quality: a 1.5B–3B Ollama model on a CPU-only host (~0.85–1.7 tok/s, no GPU) exceeds 300s per page.
Changed#
- Quieter, less alarming model downloads. Downloading the built-in model (LLM or
embedder) used to print two Hugging Face notices, a
local_dir_use_symlinksdeprecation and "You are sending unauthenticated requests to the HF Hub…", that can read, on a local-first tool, like outbound data transfer. They are not: the model is downloaded to your cache, nothing is uploaded. Both are now silenced (the real download progress still shows). - More patient, resumable mirror on a network drop. The GitLab enumeration now
retries up to 6 times (≈1+2+4+8+16s of backoff) so it rides out a brief VPN/proxy
reconnect. If it still can't reach GitLab,
bootstrapprints a clear network-drop notice, builds the knowledge layer from the repos already on disk, and tells you the exact idempotent command to re-run once the connection is back, nothing is lost, the mirror just resumes.
Fixed#
- Wiki reviews without a usable score now abstain instead of scoring zero. Small
local models (e.g. the built-in 0.5B) sometimes return a review the council can't score
, either malformed JSON or valid JSON in the wrong shape (no
scorefield). That lens was counted as 0, dragging an otherwise-good page below the accept threshold and rejecting it (rejected by council (score 0.657), "unparseable review"). Any review we can't extract a numeric score from is now excluded from the mean; a page is rejected only if no review scored. Far fewer good pages lost to a flaky reviewer. [llm] council_sizeis now applied. It shipped in the example config and was documented as tunable, butcouncil_gatealways ran all three review lenses. It now trims tocouncil_sizelenses (1–3), so fewer reviews = fewer model calls per page.
Documentation#
- Detailed wiki + LLM-provider docs. knowledge-layer.md now covers: per-provider
[llm]config with a model-id table; why the built-in LLM needs a prebuilt wheel or a compiler (nativellama.cppbindings; PEP 508 can't pin an index; PyPI lags new Pythons); using Ollama for the wiki, including the WSL↔Windows-host networking gotcha (mirrored networking orOLLAMA_HOST=0.0.0.0+ the default-route gateway IP); and a measured model-vs-hardware quality note (built-in 0.5B vs Ollama on CPU vs GPU vs API).
[2.34.0] - 2026-07-07#
Added#
bootstrap --llm PROVIDER(and--llm-model).bootstrapalready ran the wiki stage, but it had no way to turn on the LLM tier, so on a fresh setup the wiki step silently no-op'd unless you had pre-enabled[llm]inkb.toml. Nowcontextlake bootstrap --llm builtinbuilds the whole knowledge layer, graph, vectors, and wiki, in one command (builtin= local CPU model;ollama|openai|autoalso accepted). Pointstore_dirat a workspace folder and everything lands in one place. The pre-command form (--llm builtin bootstrap) kept working throughout; this adds the natural post-command form.
Changed#
- Clearer built-in-LLM install error. When the
llm-localextra is missing, the error now also gives the prebuilt-CPU-wheel fallback (pip install llama-cpp-python --extra-index-url .../whl/cpu) for Pythons without a wheel or a compiler (e.g. 3.14), instead of only re-suggesting thepip install 'contextlake[llm-local]'that just failed. Same fallback documented in QUICKSTART + the knowledge-layer model-providers section.
Fixed#
find_callersandblast_radiusaccept a bare symbol name. Agents call these MCP tools with a name (e.g.ForecastService), but they only accepted an internal node id, so a name silently returned nothing even when the graph had the answer (only theaskrouter resolved names). Both now resolve a name to its first matching definition. Surfaced while benchmarking MCP token cost on a 1M-node fleet.
Documentation#
- Benchmarks page. An honest, measured look at what connecting the contextlake MCP saves (new-code grounding, search, maintenance) with methodology and caveats.
- Benchmarks: generation-token nuance. Refined the "does not reduce generation tokens" claim, a single correct generation is irreducible, but across a whole task contextlake cuts total generation by avoiding failed regenerations and reinvented code. Added a ranked "Does it cut generation tokens?" section, explicitly marked a mechanism argument, not a measured figure.
[2.33.2] - 2026-07-06#
[2.33.1] - 2026-07-06#
Fixed#
initnow recommends the extra that matches your choice. If you enable semantic search duringcontextlake init, the "Next" hint recommendscontextlake[kb-full](which ships the built-in embedder) instead of plain[kb], previously it suggested[kb], so the very nextbootstrapembed step failed for every repo because no embedder was installed.embedfails fast on an unavailable embedder. A whole-environment problem (missingkb-localextra, unreachable Ollama/API) is now detected once by an up-front readiness probe and reported with a single actionable message, instead of repeating the same error for every repo in the fleet.- Empty repositories no longer count as branch-switch errors. A freshly-cloned repo with no commits (git: "ambiguous argument 'HEAD'") is now skipped cleanly as "Empty repo (no commits)" rather than reported as an error.
Documentation#
- Update & uninstall guides. The quickstart and README now document how to upgrade contextlake in place (pipx / pip / uv / Docker) and how to uninstall it and, optionally, remove the local store, config, mirror, and cached models, noting that nothing is ever written inside your repositories.
[2.33.0] - 2026-07-06#
Added#
--repos, mirror and index just a subset. Every mirror command, plusbootstrapandindex --workspace, now accepts--repos PATTERN, a comma-separated glob/substring filter over repo paths (e.g.--repos "team/api,billing,frontend/*").fetchnarrows the cached project list, soclone/update/branches/verify/status/bootstrapall scope to that set;bootstrap/index --workspacealso filter which repos get indexed. Perfect for a demo or a try-before-fleet run,contextlake bootstrap --repos "…"goes from nothing to a wired workspace over just the chosen repos.
Changed#
embednow vectorizes only meaningful nodes, code definitions (class / function / method / interface / struct / enum) and HTTP endpoints, and skips file, module, package, and topic nodes. A file path or a shared package name carries little semantic signal, and the shared cross-repo nodes were being re-embedded once per referencing repo, inflating the "vectors written" count (it now matches the store total) and diluting search results. Eval-gated: no relevance regression on the golden-query harness; semantic search returns cleaner definition hits. Found while dogfooding a full multi-repobootstrap.
Added#
askanswers "what extends X?" A newsubclassesroute makes the inheritance graph queryable in natural language:ask("what extends BaseController"),ask("who implements Store"),ask("subclasses of Embedder")resolve the base type and return the classes/interfaces with an incominginheritsedge, cited graph facts, not a fuzzy search. (Surfaced by dogfoodingaskon a real 800-node codebase, where inheritance questions previously fell through to semantic search.)
Fixed#
- Text-format graph output is no longer log-polluted. Streaming a large graph to
stdout as
--format json/dot/mermaid/classdiagramcould prepend a timestamped log line (e.g. the node-truncation warning) to the payload, producing invalid JSON or a Mermaid diagram that starts with a stray line. Logs now switch to stderr up front whenever a text format is streamed to stdout, so the payload on stdout is always clean. Found by generating a class diagram for a real 800+-node package.
[2.30.0] - 2026-07-06#
Added#
- Class diagrams,
graph --format classdiagram. Now that the graph carries inheritance,contextlake graph --repo <r> --format classdiagramrenders a Mermaid UML class diagram: classifiers (class / interface / struct / enum) with their methods as members (signatures included),<|--for extends and<|..for interface implements, and an<<interface>>stereotype. Files and call/import edges are dropped so it reads as a class view, not the flat relation graph. Paste it straight into a PR or design doc. (Payloads now also carry each node'ssignature.)
Added#
- Inheritance graph,
inheritsedges. The code parser now extracts class inheritance and interface implementation across all four languages (Python bases, JS/TSextends+implements, C# base lists), resolved repo-wide like calls (INFERRED for a unique base, AMBIGUOUS when a base name matches several, external bases dropped). So "what extendsBaseController?" is a singleget_neighborshop, andblast_radiusnow includesinheritsby default, changing a base class surfaces its subclasses as impacted. This is also the extraction prerequisite for class diagrams.
[2.28.0] - 2026-07-06#
Changed#
ask's explain route degrades usefully. When a question like "explain the forecast-api" hits a repo with no generated wiki,asknow returns that repo's grounded anatomy (top symbols, packages, languages) from the graph instead of a blind semantic search, a structuredbriefbeats fuzzy hits for "explain this." (Surfaced by a full end-to-end test sweep of the CLI + MCP server, which otherwise found no defects.)
[2.27.0] - 2026-07-06#
Added#
ask, one MCP tool, natural language, auto-routed. A small-context IDE agent no longer has to pick among twenty graph tools:ask("who calls ingest_reading"),ask("what breaks if I change ForecastService"),ask("explain the forecast-api"). A deterministic, offline classifier maps the question to a substrate (definition / callers / dependents / impact / owners / explain / search), resolves the symbol or repo, and returns one labeled answer, graph facts cited and confidence-tagged, theexplainroute clearly marked advisory. The classifier is its own pure module (kb/router.py), unit- and eval-tested on a golden question set (23/23 route + target) so misroutes are falsifiable. It's a convenience front door over the specific tools, which remain first-class.
[2.26.0] - 2026-07-06#
Added#
contextlake init, guided first-run setup. One command writes a valid mirror config (and, opt-in, the knowledge-layer config) instead of hand-authoring TOML/INI: it detects the platform, tells you which token env var it will use, and prints the next step. Interactive when stdin is a TTY, non-interactive with--yes(plus--platform/--group/--work-dir/--no-kb/--embeddings) for scripting. Never writes a token to disk; refuses to overwrite existing config without--force.
[2.25.0] - 2026-07-02#
Added#
- The wiki is now searchable prose. Accepted wiki pages are split into sections
and stored in an isolated
@wiki:<repo>partition (mirroring@connect/@ingest); with the semantic tier enabled they embed alongside the code vectors, so a natural-language query can land on the wiki's explanation of a subsystem, cited to the page file and labeled advisory (kindwiki), never outranking extracted code facts. Pages written before this existed are backfilled on the nextwikirun with zero LLM calls (freshness-skipped pages included).
[2.24.0] - 2026-07-02#
Added#
- Multi-platform mirroring: GitHub, Bitbucket, and Gitea (Codeberg / Forgejo)
join GitLab. Set
platform = github(orbitbucket/gitea/codeberg/forgejo) andgroup = your-orgin the config and the whole pipeline, fetch, clone, update, branches, verify, status, audit, bootstrap, runs against that platform: every enumerator normalizes to the same project shape, so everything downstream of the fetch cache is platform-agnostic. Auth is the platform's token env var (GITHUB_TOKEN,BITBUCKET_TOKEN,GITEA_TOKEN; public owners work tokenless, rate-limited), carried in headers and the git child environment with each platform's expected basic-auth username, never in URLs or argv. Self-hosted instances pointapi_baseat their endpoint. GitLab behavior is unchanged, including theglabfallback.
[2.23.0] - 2026-07-02#
Added#
- Semantic search now embeds real code content. Each node's vector carries its captured signature and docstring alongside the name/path metadata, so natural-language queries land on the right symbol even when its name is terse. Eval-gated before shipping: on the golden-query harness's natural-language set, MRR doubled (0.50 → 1.00) and hit-rate went from 0.83 to 1.00 versus name-only vectors. Existing stores are detected by a new embedded-text version stamp and re-embedded once automatically (with a message saying why); incremental behavior then resumes.
Changed#
- Built-in embedder guidance is now measured, not assumed. A four-model
bake-off on the enriched text (potion-8M/32M vs ONNX bge-small and quantized
nomic-v1.5) showed the tiny static models winning on both quality and latency;
the docs and config example now name
potion-base-32Mas the one-line quality upgrade and keep the 30MBpotion-base-8Mas the zero-config default.
[2.22.0] - 2026-07-02#
Added#
glabis now fully optional. With aGITLAB_TOKEN(aread_api+read_repositoryPAT),clone_method=autoclones with plaingit, passing the credential as an auth header through the child environment, never on the command line and never in the URL, so it cannot leak intopsoutput or.git/config. Enumeration already used the token-native HTTP client, so the whole mirror now runs with justgit+ a token; without a token the glab-then-git behavior is unchanged.
Changed#
- The share card is built from the approved hero art (Pebble in the wide misty lake) with real typography, Space Grotesk wordmark, Inter tagline, gold Get started button, instead of AI-generated text; the same card is the GitHub social preview.
- Docs polish: heading slugs now anchor correctly on both GitHub and the docs site,
internals links to the branch-safety guide where it actually lives, and the
command reference states the per-command
--help, thewho-knows/blast-radiusaliases, and the dashboard--sampledemo fleet.
[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#
dashboard --sampleworks from a pip install and under--serve. The demo-fleet fixture used to live at the repo root (absent from every wheel, so--samplecrashed withFileNotFoundError), and the--servepath ignored the flag entirely, serving an empty dashboard from the real store. The fixture now ships as package data and--serve --sampleserves the fictional fleet from an ephemeral store, the advertised zero-setup preview actually is one.- A failed enumeration can no longer wipe the project cache.
fetchused to write the partial (often empty) result over a good cache on any mid-paging failure, print a green checkmark, and exit 0. It now raises, leaves both caches byte-identical, andfetch/syncexit non-zero; a genuinely empty enumeration warns instead of celebrating. bootstrap --workspaceis honored (it was silently ignored in favor of the mirror'swork_dir), and the steering files follow it. Indexing a workspace with zero git repositories now exits non-zero with guidance instead of reporting✓ Bootstrap completeover an empty knowledge base.- MCP
serverInforeports contextlake's version instead of the MCP SDK's.
Added#
- Per-command help. Every verb is a real argparse subcommand:
contextlake sync --helpshows only sync's flags with worked examples, barecontextlakeprints the front door (description, command list, getting-started) instead of an argparse error, andcontextlake index PATHworks as a positional. Flags may still appear before the command, so existing scripts keep working. who-knowsandblast-radiusas CLI aliases forowners/impact, matching the MCP tool vocabulary.servesays when the semantic tools are gated. Whensemantic_search/hybrid_searchare not registered (no[embeddings]config, or nocontextlake embedrun yet) the server now states it and why, instead of the tools silently vanishing.- A Docker install block for the published
ghcr.io/sayak-sarkar/contextlakeimage, which now carries OCI source labels linking it back to the repository.
Changed#
- The CLI introduces itself as what it is, a local context layer that mirrors, indexes, and serves real source over MCP, rather than "GitLab Workspace Synchronization CLI Tool".
- One coherent story across the docs: the install leads with
pip install "contextlake[kb]"(with the Python 3.10 floor stated at the point of use), one MCP server name (contextlake-kb), one bootstrap invocation, one canonical tagline tail everywhere, a complete MCP tool list in the serve guide, and a contributor setup ([dev,kb]) that can actually run the suite. - PyPI metadata points back at the product: Homepage is the site, with Documentation/Issues links; the summary carries the anti-hallucination clause; the classifier and keyword sets state the supported Python range and positioning.
[2.20.1] - 2026-07-01#
Fixed#
- README doc links now resolve on the PyPI project page. They were relative
(
docs/….md), which 404s on PyPI (it renders the README but doesn't host the repo files); they're now absolute GitHub URLs. The docs-site build still rewrites them back to local pages.
Added#
- CLI and rendered-wiki screenshots in the docs. The knowledge-layer guide now shows real
terminal output for
doctor,index,query,owners,impact, and a single-repo graph, plus a curated wiki rendered in the dashboard, all captured from a generic demo fleet.
[2.20.0] - 2026-06-30#
Added#
- Dashboard fleet layout switcher, Cards / List / Table. The fleet overview now offers three densities (rich cards, dense rows, an aligned sortable-look table), each with an icon, persisted in localStorage.
- "What am I looking at?" info popover (ⓘ in the header) explaining nodes, edges, the three confidence levels (and that the chips filter by them), and the Live vs. Static data source, plus a visible "Show" label on the confidence filter.
- Actionable empty states. A repo with no wiki offers a "Generate wiki" button (copies
contextlake wiki <repo>); blast-radius / out-of-snapshot views offer "Run live server". --llm <provider>and--llm-model <model>CLI flags forwiki, enable the LLM tier inline (builtin|ollama|openai) without editingkb.toml, e.g.contextlake wiki acme/app --llm builtin.- A guided dashboard tour (docs/dashboard.md), a step-by-step walkthrough with screenshots (fleet layouts, repo anatomy, the architecture graph, blast radius, and generating a wiki), linked from the README and knowledge-layer docs.
Fixed#
wiki/embed/connect <repo>now scope to the named repo(s). The positional repo id was ignored, so these silently ran across the entire indexed fleet; an unknown id now errors cleanly instead of processing everything.- Dashboard: repo names no longer truncate, card names wrap to two lines (basename + a front-clipped namespace path), and the full id is on hover.
- Dashboard: no more page-height jump on hover, card metadata is always visible instead of expanding on hover.
- Dashboard: architecture graph renders fully on first view, the embedded cytoscape graph re-fits when its iframe gets real size, instead of leaving nodes painted off-screen until a manual zoom/click.
- Dashboard: dead-end clicks are graceful, repos beyond the static slice show a "run the live server" state, not a scary error.
- Replaced the crude inline otter illustration in empty states with the Pebble mascot art.
Changed#
- Dashboard stat / confidence numbers are thousands-formatted (
1,013,948). - Static-export per-repo relationships are built from a single bucketed edge scan
(
repo_relationships_bulk) instead of rescanning all edges per repo. - The
--sampleshowcase is now a multi-repo demo fleet (a fictionalacmeorg) rather than a single repo, so the dashboard's sample mode reads like a real fleet.
[2.19.2] - 2026-06-28#
Fixed#
impact <symbol>no longer silently resolves an ambiguous name to the wrong repo. A bare name (e.g.Node,Order) was resolved via a full-text search and the top hit taken blindly, so a common name could seed an unrelated repo's symbol and report a confidently-wrong (often empty) blast radius. Resolution is now exact-id → exact-name → fuzzy: when a name is defined in several repos the CLI lists the candidates and asks you to narrow with--repo, and--reponow actually scopes resolution. The dashboard's change-impact API returnsambiguous+candidatesfor the same case. Shared resolver (impact.resolve_target) drives both the CLI verb and the dashboard so they behave identically.
[2.19.1] - 2026-06-28#
Fixed#
- Dashboard: the command palette (and the provenance drawer and pin chip) no longer render
stuck-open. Their
[hidden]attribute was being overridden by a CSSdisplay:value, so the "Jump to a repo, symbol, or action" palette stayed permanently open as a full-screen overlay that blocked the entire interface. Added[hidden]guard rules so each element is actually removed from layout when closed.
[2.19.0] - 2026-06-28#
Added#
contextlake dashboard, a local knowledge-system dashboard UI. A self-contained, offline-first single-page app over your store: fleet overview (domain-grouped), per-repo anatomy / README / wiki / owners / connector links, repo→repo dependency / HTTP-flow / event-flow (each with confidence + provenance, never shown as ground truth), an embedded interactive architecture graph, a change-impact explorer, health, and search.--serveruns it live against your store;--site DIRexports a staticfile://-safe copy. Privacy: a real-store--sitewarns "review before publishing";--anonymizehashes author identities and drops external URLs + README/wiki prose;--samplebuilds a guaranteed-generic showcase from the bundled fixture. Read-only in v1 (sync/MCP controls planned).
[2.18.0] - 2026-06-28#
Added#
- Built-in
mcpsource foringest. contextlake now connects as an MCP client (stdio viacommand/args, or streamable-HTTP viaurl) to another MCP server, lists its resources, and ingests each into the graph + semantic store. So it both serves a knowledge graph over MCP and consumes other servers' resources, on the same source seam.
[2.17.0] - 2026-06-28#
Added#
- Built-in
apisource foringest. GET a JSON endpoint and map its records to documents,items(dotted path to the record list),id_field/title_field/text_field, and an optional bearer token read from an env var named bytoken_env(the secret never lives in config). Standard library only.
[2.16.0] - 2026-06-28#
Added#
- Built-in
websource foringest. Fetch one or more URLs and ingest their readable text ([[sources]] type="web",urls = [...]) into the graph + semantic store. Standard library only (urllib+html.parser), no new dependency and no headless browser; the network is touched only when awebsource is configured.
[2.15.0] - 2026-06-28#
Added#
contextlake ingest, aggregate external documents (RAG) into the knowledge layer. Documents becomekind="document"graph nodes and, when embeddings are on, their bodies are embedded so semantic search spans code and docs. Zero-config:contextlake ingest --path ./docs.- A source/plugin seam (
contextlake.kb.sources). Common sources are built-in and config-only (thefilessource ships now); anything heavier is a loosely-coupled plugin, a class withiter_documents()registered via acontextlake.sourcesentry point, discovered automatically (a broken plugin is skipped, never fatal). Bake in the common, plugin the rest.
[2.14.0] - 2026-06-28#
Added#
contextlake impact <symbol>, change-impact / blast radius from the shell. Lists what calls or depends on a node (reverse-reachability over the graph,--hopsdeep,--limitcapped), so "what could break if I change this" no longer needs an editor or MCP client. Resolves a node id or falls back to a name search. The walk is shared with theblast_radiusMCP tool (one implementation inkb/impact.py).
[2.13.0] - 2026-06-28#
Added#
- Ownership / SME lookup from commit history. New
contextlake owners <repo>(optionally--path SUBDIR) ranks likely owners / subject-matter experts straight from git history, zero-config, no index needed, using a recency-weighted blend of commit volume and lines changed, so recent active contributors outrank a long-departed prolific author. Exposed to agents over MCP as thewho_knows(repo, path?, limit?)tool.
[2.12.0] - 2026-06-28#
Added#
connect --watchandembed --watch. The live-refresh loop thatindexalready had now covers the connector and embedding passes too,connect --watchre-links andembed --watchre-embeds on an interval (--interval N, default 60s; Ctrl-C to stop), each re-resolving its targets so newly indexed repos are picked up.embed --watchstays cheap by re-using the incremental HEAD gate.- Tunable sqlite-vec chunk size. A new
[embeddings] vector_chunk_sizesetting exposes the sqlite-vecvec0KNN chunk size (default 1024) for tuning large stores. Clamped to a multiple of 8; applied when the vector table is first created (re-embed to change it).
[2.11.0] - 2026-06-28#
Changed#
contextlake indexwith no arguments now indexes the current directory instead of doing nothing, socd my-repo && contextlake indexjust works. Pass--source PATHor--workspace DIRto index elsewhere.
[2.10.0] - 2026-06-28#
Added#
- Incremental
embed.embednow re-embeds only repos whose indexed HEAD has moved since they were last embedded (tracked per-repo in the vector store), so a scheduled embed over a large fleet stays cheap, likeindexalready is.--forcere-embeds everything; a partial--limitrun never updates the gate. .contextlakeignore, drop one at a repo's root to exclude your own paths from indexing (one glob per line;*.lockignores by name anywhere,vendor/prunes a directory). A small, dependency-free subset of gitignore syntax; ignored files are counted and reported, never silently dropped.
Changed#
- Colorful output now reaches
statusandfetch.statusprints a right-aligned, glyph-coded summary (✓synchronized,⚠missing/extra), andfetchstyles its header and final count, matching the existing coloured per-repo output ofclone/update/branches. Still plain andNO_COLOR-friendly when not a TTY.
[2.9.1] - 2026-06-26#
Changed#
- README overhaul (this also fixes the instruction shown on PyPI): corrected the primary install
to
pip install contextlake(the oldpip install .only works from a clone), led with the value prop, a real graph screenshot, the Pebble mascot, and a branded "How it works" architecture diagram, and tightened the prose. Images are committed PNG/JPG with absolute URLs so the README renders identically on GitHub and PyPI (no SVG-only assets). Removed em-dashes across the prose docs.
[2.9.0] - 2026-06-26#
Added#
- Graph readability overhaul, the dense-graph pain points are fixed. Three long-standing
complaints addressed in the shared visualizer (
graph --serve,--site, and every embedded graph): - Zoom floor, "fit" no longer shrinks a big graph into unreadable specks. A clamp keeps any fit at or above a readable zoom (≥0.45); below that it snaps to the floor and re-centres, so you always land somewhere scannable instead of scrolling in 5–10 times.
- Level-of-detail labels, dense graphs no longer pile their text into an illegible smear. Below a readable zoom only the higher-degree hubs keep their labels (degree-gated by zoom tier); hovering or selecting any node always reveals its label, and search/highlight are unaffected.
- Semantic cluster zoom (namespace overview), zoom into a region and the on-screen namespace clusters expand into their repos; zoom back out and they collapse. A hysteresis gap prevents flapping, and the zoom path never re-frames, so it can't feed back on itself.
- Minimap, a custom radar (bottom-right, no new dependency) showing every visible node; click or drag to recentre the main view. Tracks filters and cluster expand/collapse live.
- On-canvas legend key, the node legend now shows each kind's actual glyph (the same icon the node paints), plus a collapsible key for edge-confidence line styles and per-language repo lettermarks, so the iconography is self-explanatory. All still offline/self-contained.
Changed#
- Captured docstrings + signatures now feed the wiki and
get_repo_brief.repo_brief's top symbols carry theirdoc+signature, so the LLM-wiki is synthesized from real docstrings (not just symbol names) andget_repo_briefreturns them per symbol, closing the capture→consume loop for the doc/signature feature (richer, better-grounded wikis and repo anatomy). build_vector_storeandSqliteStore.searchno longer fall back silently. A sqlite-vec load failure now warns that search dropped to brute force; a searchOperationalErroris logged (DEBUG for an expected malformed-FTS query, WARNING for a real DB problem) instead of always returning[].- Deduplicated HTTP/util helpers (
_ollama_reachable,_post_json,_chunks), previously copied across the llm/ and embeddings/ providers and the connector, into one stdlib-onlykb/_util. No behaviour change.
Fixed#
- Safety gate now fails closed on an indeterminate git state.
has_uncommitted_changesand the branch/HEAD reads in the sync core swallowed errors and returned a permissive default, so a failed, timed-out, or non-repo git call read as "clean / safe to modify" or "no change", silently mis-driving the destructive update/stash/merge they guard. They now check return codes + add timeouts and treat any unknown state as unsafe;_rev_parseand_collect_branch_inforaise on a git failure instead of returning an empty string that misreads the update. bootstrapandembed/wiki/connectnow exit non-zero on failure.bootstrapignored every stage's result and always reported success; the three commands returned0even when every repo or source in a non-empty work set failed (embedder/LLM/connector unreachable → zero output, CI green on a broken knowledge layer).bootstrapnow propagates stage failures (and hard-aborts if the foundational index stage fails); the commands return non-zero on total failure.
Security#
.dockerignorenow excludes the gitignored local config/secret files (.gitlab_sync.ini,.contextlake.ini,.contextlake.kb.toml) so a localdocker build .can't bake them into an image. The published image is unaffected (built from a clean checkout).
[2.8.0] - 2026-06-26#
Added#
- Definitions now capture their docstring + signature (on node
attrs:doc,signature), surfaced through the MCPNodeOut(get_node/find_definition/ neighbors etc. now returndoc+signature), so an agent gets a function's purpose and parameters in one call. This is also the additive groundwork for body-aware embeddings, thenode_text()change that would feed bodies to the embedder stays gated on the eval harness (quality measured, not assumed). Best-effort and multi-language: signatures across py/js/ts/c#, and docstrings from Python first-statement strings, JSDoc (/** */), and C# XML (///) leading doc-comments (plain comments are ignored).
[2.7.0] - 2026-06-26#
Added#
- MCP:
repo_event_flow(repo, direction, limit), repo→repo event flow (who publishes events that whom consumes), from the topic two-hop (publishes_event ⨝ consumes_event). Completes the cross-repo flow trio alongsiderepo_dependencies(package) andrepo_flow(HTTP); the SQL already existed (used by the overview) but had no dedicated tool. - MCP:
get_readme(repo), the repo's own README read straight from its local clone (offline). Ground truth (the maintainers' words), distinct from the advisory synthesizedget_wikiprose. - MCP:
get_repo_brief(repo), a repo's "anatomy" from its indexed graph: node/edge counts, kind + language breakdown, top symbols by connectivity, packages, and a file sample. - MCP:
list_repos(include_stats), the repo fleet with per-repo branch, indexed head, last-index time, and node count, the dashboard's repository list. - MCP:
get_repo_links(repo), a repo's cross-links to Jira / Confluence / Figma / GitLab (url, title, status), grouped by relation. Populated byconnect; served offline afterward. - MCP:
graph_health(), knowledge-graph health as data (stale repos + dangling edges, with a sample) for the dashboard's health panel;lint's logic is now a reusablelint_result().
[2.6.0] - 2026-06-26#
Security#
- Local development hygiene. Secret and machine-specific tokens used by local pre-publish checks are read from the environment or a git-ignored file, never committed to the repository.
- Genericized example figures in the docs. The example
statusoutput and the overview-feature notes use illustrative values. - Test-locked the offline boundary (INV-2). A new test blocks all outbound sockets and asserts the
core commands (
index/query/graph/lint/embed) still run, whileconnectdegrades rather than fails, proving contextlake is safe in air-gapped/egress-restricted environments, with enrichment the single opt-in online step. Documented indocs/storage.md.
Added#
evalnow scores any retriever and reports a cost dimension. Retrievers are built by factories (make_fts_retriever/make_semantic_retriever/make_hybrid_retriever) that close over their deps, so semantic and hybrid are scorable, not just FTS (the old fixed call site couldn't pass a vector store + embedder). The harness now also reports estimated tokens per query and precision per 1k tokens, making "route to the cheapest sufficient source" measurable, andeval --retriever fts|semantic|hybridselects which to score. Ships a seed golden set atexamples/fixtures/golden-queries.json.
[2.5.1] - 2026-06-26#
Fixed#
- README logo now renders on PyPI. The header glyph used a repo-relative
src, which PyPI can't resolve (it doesn't host the repo files), so it showed as a broken image on the project page. Pointed it at the absoluteraw.githubusercontent.comURL (correctimage/svg+xmlcontent-type, verified through PyPI's ownreadme_renderer). Badges were already absolute.
Changed#
- Docs reconciled with the shipped MCP surface.
docs/knowledge-layer.mdnow lists the cross-repo tools (repo_dependencies,repo_flow,blast_radius,get_wiki) alongside the existing graph tools, and the README command table documentseval(the golden-query retrieval-quality harness).
[2.5.0] - 2026-06-26#
Added#
[kb-full]one-step install for local semantic search,pip install "contextlake[kb-full]"pulls the knowledge layer + the built-in CPU embedder (kb-local) + the sqlite-vec ANN backend (kb-vec) together, soindex → embed → semantic searchjust works with no Ollama and no API key.- Repo nodes show their primary language, the fleet's tech stack at a glance. In the overview,
each repo node now carries a lettermark (
PY,JS,TS,C#, …) for its dominant language (a single GROUP-BY over data the parser already records), so an architecture map reads its stack without clicking in. Trademark-free white-on-navy lettermarks, inlined offline; unknown languages keep the generic repo glyph. - Architectural edges are now labelled, flows read like a C4 diagram. Dependency / flow edges
(
depends_on,calls_http,exposes,flow,publishes,publishes_event,consumes_event) carry an autorotated label of the relation plus its context where meaningful (depends_on · requests,calls_http · /v1/orders, the event topic). Structural edges (calls/contains/imports) stay unlabelled so the hundreds of them don't bury the diagram in text. - Graph nodes now carry type glyphs, the first step toward architecture diagrams. Every node is
painted with a Lucide-style icon for its kind (file, class, function, package, repo, HTTP endpoint,
event topic, …) so a graph reads by type at a glance instead of by colour alone. Glyphs are inlined
as percent-encoded SVG
data:URIs (no CDN, no sprite fetch, the page stays a single offline file), and each glyph's stroke colour is chosen per node fill at build time (white on the darkreponode, dark on the lightmodulenode) so it never washes out. Flow nodes (endpoint/topic) joined the palette + legend. --sitenow renders the LLM-wiki as cross-linked pages. Each repo with a generated wiki gets awiki-<slug>.html(the index links it, the page links back to the graph), rendered by a tiny dependency-free Markdown→HTML converter (HTML-escaped, the wiki is untrusted LLM output), carrying the same fresh/stale badge asget_wiki. Stays fully offline, zero new deps.- MCP:
get_wiki(repo), serve the LLM-wiki to agents (with a staleness signal). The generated wiki was written to<store>/wiki/but read by nothing; now an agent can fetch a repo's wiki prose (sanitised Markdown), explicitly labelled advisory (verify against cited sources; never outranks EXTRACTED facts) and carryingstale, true when the wiki'shead_commitdiffers from the repo's current indexed head, so prose describing changed code is never cited as current. - MCP:
blast_radius(node_id, hops), "what could break if I change this". Bounded transitive reverse reach over incomingcalls+depends_onedges (configurable), breadth-first, capped byhopsandlimit. Each hit carries its hop distance, the relation, and confidence (EXTRACTED-first,truncatedwhen capped), an impact slice for agents, made correct by the AMBIGUOUS-edge change below so the hottest symbols aren't missed.
Changed#
embed's "disabled" message is now actionable. Instead of the dead-end "Embeddings are disabled", it names the exact next step, installcontextlake[kb-full](when the embedder is missing) and/or set[embeddings] enabled = true, and notes the one-time ~30 MB model download, so the post-bootstrap"Build semantic vectors" stage no longer silently goes nowhere.- Documented and test-locked the no-pollution invariant (INV-1).
docs/storage.mdnow states that every generated artifact lives under the store (~/.contextlake/kbby default) and never inside a synced repo working tree, andtests/kb/test_no_repo_pollution.pyenforces it by driving the generating commands over a temp two-repo mirror and asserting each repo tree is byte-identical. doctornow probes ANN (sqlite-vec) availability. When embeddings are enabled it reports whether the native sqlite-vec KNN index actually loads in this environment, or whether semantic search will fall back to brute-force cosine, so the silent fallback (a known offline/corporate-env failure mode) is visible before you embed, not after.- Wiki generation is now incremental (skip-if-unchanged).
contextlake wikiskips the (expensive) LLM call for any repo whose existing page was already generated from its current head commit, so a no-op fleet re-run drops from O(repos × LLM calls) to ~0.--forceregenerates regardless; the summary reports how many were skipped. - Ambiguous calls are no longer silently dropped. When a call name resolves to 2–6 candidate
definitions, indexing now emits an
AMBIGUOUScallsedge to each candidate (de-duplicated, self-calls excluded) instead of discarding the call, so the hottest symbols aren't lost and blast-radius isn't undercounted. Names matching more than the cap are too generic to be signal and are still skipped. AMBIGUOUS edges render dotted in the visualizer.
Added#
contextlake eval --golden FILE.json, a retrieval-quality harness. Score a labelledquery → expected-nodesset against the index and get precision@k / recall@k / MRR / hit-rate (aggregate + per-query), over any retriever (FTS today; semantic/hybrid pluggable). Makes retrieval changes (embed-bodies, reranking, a futureaskrouter) falsifiable instead of vibes. Stdlib-only; the golden set is plain JSON,matchby node id or name.- Event/messaging flow extraction (Kafka/MSK, SNS, EventBridge). Indexing now detects, per file,
the message topics a repo publishes to and consumes from (literal topics in Kafka
producer/
@KafkaListener/subscribe, EventBridgeDetailType, SNS), asINFERREDedges to a sharedtopicnode. A two-hop join (publishes_event ⨝ consumes_event) yields directionalpublisher --flow--> consumerrepo edges, the direction an event travels, shown in the fleet overview alongside HTTPflowand structuraldepends_on. High-precision (literal topics only); config-variable topics are an honest undercount, never a false link. Re-runindexto populate.
[2.4.0] - 2026-06-25#
Added#
- MCP: repo-level architecture tools
repo_dependencies/repo_flow. Surface the cross-repo wedge to AI agents:repo_dependencies(repo, direction)returns the package two-hop (dependent → publisher, weighted),repo_flow(repo, direction)returns the HTTP endpoint two-hop (caller → exposer, weighted), both INFERRED, weight-ranked, with "undercount, verify" guidance. Previously these edges fed only the visualizer. contextlake graph --site DIR, a cross-linked offline graph site. Emitsindex.html+overview.html+ onerepo-<slug>.htmlper repo with a parsed graph, sharing a singlecytoscape.min.js/app.css/app.js(referenced, not inlined, so the folder stays small). Overview repo nodes link to their repo page (and the inspector gains an "Open this repo's graph →" button); every page has an Index/Overview nav. Fully offline. Scope it with--repos PATTERN(comma-separated glob/substring) to build pages for only a subset of repos.contextlake graph --overview --servenow serves the whole site live, rendering each repo page on demand from the store instead of materialising the fleet up front, so online serving never inlines hundreds of MB. Shared assets are served once (browser-cached);/neighborskeeps click-to-expand inside a repo view.- HTTP/REST flow extraction (the first true cross-repo flow signal). Indexing now detects, per
file, the HTTP endpoints a repo exposes (ASP.NET / Express / FastAPI·Flask routes) and calls
(HttpClient / axios·fetch / requests·httpx), as
INFERREDedges to a sharedendpointnode keyed by a normalised path. A two-hop join (exposes ⨝ calls_http) yields directionalcaller --flow--> exposerrepo edges, which the fleet overview now renders alongside structuraldepends_on(distinct colour, aggregated per namespace). Path matching is deliberately conservative (host/query stripped, params →{}, trivially-generic paths dropped) so unrelated repos don't falsely link. Re-runindex/bootstrapto populate. Event/messaging flow (SNS/SQS/EventBridge/Kafka) is the next slice.
Changed#
- MCP: result budgeting on
get_neighbors/find_callers/find_dependents. They now take alimit(default 50), order EXTRACTED-first, and return{..., total, truncated}instead of an unbounded list, so a hub node can't silently blow up an agent's context, and a clipped result announces itself. - Generated graphs now default to a dedicated
<store>/graphs/directory instead of the current working directory,graphHTML output and--siteland next to the knowledge base, not wherever the command happened to run. Pass--output/--site DIRto override.
[2.3.0] - 2026-06-24#
Added#
- Two interlocking overview views, a
Namespacemindmap and aDependenciesgraph. The fleet overview now has a mode toggle over one graph. Namespace (default) collapses the whole repo fleet into its top-level GitLab namespaces (sized by repo count), with aggregated, weight-labelled namespace→namespace dependency edges; tapping a namespace expands its repos in place as a compact mindmap branch (the rest dims to spotlight it) and tapping again collapses, every repo stays placed and searchable. Dependencies lays the connected repos out as readable hub-and-spoke clusters. Both modes share selection, search, and the inspector. - Inspector lists a node's relationships, each neighbour clickable to navigate to it (in-view hop-to-hop). Tapping a node/edge reframes the canvas onto the selection so it stays legible.
Changed#
- Graph visualizer reworked into an enterprise app shell. The floating translucent cards are
replaced by a real layout, a top bar (brand, mode, search), a collapsible left sidebar (view
controls + Nodes/Relationships legends with live counts), the graph filling the centre, a slide-in
right inspector, and a status bar, on a CSS grid with a tokenised design system. Adds a dark
mode (Deepwater theme; re-skins the canvas, not just the chrome), icon-button controls, empty/
loading states, keyboard shortcuts (
/search,ffit,ttheme,Escclear), and focus-visible rings. Still one self-contained offline HTML, zero new dependencies. - Fleet overview now shows real cross-repo dependencies. Repointed from the raw cross-repo
importsjoin (≈4,800 import-star artifacts from fleet-widemodulenodes) to the package two-hop (publishes ⨝ depends_on), 217 trustworthy, manifest-deriveddepends_onedges, markedINFERRED(a deliberate, honest undercount). Repos are labelled by short name (the full path moves to the inspector + search) so nodes are distinguishable. - Graph-visualizer CSS/JS extracted into
static/app.css+static/app.js(inlined at emit time like the vendored cytoscape), so the source is lint/node --check-able. Output is still one self-contained offline HTML.
Fixed#
- Truncation is now visible in the UI. A bounded subgraph that was clipped used to read as complete; a persistent status-bar banner now says "showing N of M, truncated" (honest counts only).
- Overview readability. Isolated/no-dependency repos, typically the bulk of a large fleet, no longer scatter the connected map into an unreadable speck, they're hidden by default behind a toggle (and revealed by search), and the layout frames the meaningful core. Expanding a namespace no longer triggers a disorienting global re-layout (scoped, position-stable).
- Canvas now reflows/reframes correctly when the inspector or sidebar opens (was leaving the old
zoom/pan). Dark-mode faded opacity and
prefers-reduced-motiongating for JS animations.
[2.2.0] - 2026-06-23#
Added#
-
Post-sync repo audit (
contextlake audit, also auto-runs aftersync/bootstrap). Scans every local clone and reports which repos are effectively empty, empty (no commits / no files), readme-only (just a template README), or boilerplate (only meta files like LICENSE/.gitignore) , plus age/activity: each repo's creation date (GitLabcreated_at, captured during fetch; falls back to the first git commit) and last commit date (from the local clone). Prints an aggregate summary (counts, oldest/newest, how many stale >1y/>2y, repos with no commits) and writes a full per-repo report as JSON + CSV (--report PATH, default<cache_dir>/repo_audit.json). The scan is parallel, read-only, and works offline;--no-auditskips the automatic run. Zero new dependencies. -
contextlake graph, visualize the knowledge graph. Extracts a bounded subgraph (the full graph is far too large to draw) and renders it to an interactive, offline-first HTML page (vendored cytoscape.js, inlined, no network needed;--cdnfor a small online file), or todot/mermaid/json. Seed from a symbol (--node/--name+--kind/--search), a single repo (--repo), or the whole fleet (--overview= repos-as-nodes with aggregated cross-repo edges, the architecture map). Scoping knobs--hops/--max-nodes/--max-fanout/--relation/--directionkeep hub nodes from exploding (truncation is always logged). The HTML is a full mini-explorer: nodes coloured by kind and sized by degree; edge labels hidden until a node is selected; clickable edges with an inspector (relation, a confidence trust indicator, the sourcefile:lineprovenance with copy, context and weight), edges are coloured by relation, styled by confidence, and sized by weight, with a relationship legend that filters by relation; a node search box, a detail panel (kind / repo / qualified-name / file:line), a clickable legend that filters by kind, hover tooltips, a switchable layout (cose/concentric/breadthfirst/circle/grid, default via--layout), and a toolbar (fit / reset / save-PNG), all wrapped in the contextlake brand (inlined lake glyph, wordmark, palette, frosted material cards).--openlaunches the browser;--serveruns a local UI with click-to-expand. Adds zero required Python dependencies. -
Resilient project enumeration behind slow/corporate DNS (e.g. Zscaler). When
GITLAB_TOKEN(aread_apitoken) is set,fetch/sync/bootstrapenumerate a group's projects via contextlake's own GitLab REST client instead of theglabCLI. TheglabCLI imposes a short Go dial timeout that a multi-second corporate DNS lookup trips on every call; the native client uses the system resolver's more generous budget, so enumeration completes whereglabfails. Without a token it transparently falls back toglab(its own auth). Configurable viagitlab_token_env,gitlab_host, andnetwork_timeout; the per-page fetch now retries with backoff on transient errors. Additionally, childgitoperations get a widened per-process DNS budget (RES_OPTIONS=timeout:15 attempts:3, root-free, tunable viadns_timeout/dns_attempts, and skipped if you already setRES_OPTIONS) so slow lookups don't surface asi/o timeout.
[2.1.6] - 2026-06-23#
Fixed#
- Quadratic indexing slowdown at scale (the real fix for "indexing got slower the more repos I
had"). Each node was refreshed in the full-text index with a per-row
DELETE FROM node_fts WHERE node_id = ?; because the FTS5 table has no index onnode_id, every one of those scanned the entire, ever-growing global FTS table, so persisting a repo cost O(repo_nodes × total_store_nodes) and the 600th repo took minutes. Now done with one set-based delete + batchedexecutemanyinserts. Re-indexing a repo into a 23k-node store dropped from 6.5s to 0.11s (≈59×) and is now flat regardless of store size; the FTS contents are byte-for-byte identical.
Added#
- Parallel repository indexing.
contextlake index --workspace(andbootstrap) now parse repositories across worker processes (CPU-bound work), persisting to SQLite serially from the parent. Defaults tocpu_count - 1(capped at 8); tune with[kb] index_workers(set1to force serial). Uses thespawnstart method on every platform for identical behaviour on Linux, macOS and Windows, and falls back to serial automatically if a worker pool cannot start. With the quadratic fix above in place, a full warm re-index of a 33-repo subtree dropped from ~8.8s (serial) to ~3.1s (8 workers, ≈2.9×); the parse speedup grows with both repo count and core count.
Changed#
- Indexing skips generated/derived files and oversized blobs (configurable, logged). The code
graph no longer indexes machine-generated files (
*.designer.cs,*.min.js,AssemblyInfo.cs,@generated/<auto-generated>headers, …) or code files larger thanmax_file_bytes(5 MB default), derived noise that bloats the graph and slows legacy monorepos. Both are reported (no silent gaps) and tunable via[kb] skip_generated/[kb] max_file_bytes. The source the generated files derive from is still indexed, so there's no knowledge loss. On a real 3,230-file legacy repo this dropped ~26% of files / 4k generated nodes (22.5s → 16.6s).
[2.1.5] - 2026-06-23#
Added#
- Built-in, zero-config CPU models for the knowledge base, no Ollama and no API key.
The embeddings and wiki tiers now accept
provider = "auto"(the new default), which uses a reachable local Ollama, else an in-process built-in model, else skips. The built-in embedder ships two engines, model2vec (potion-base-8M, ~30MB, default;pip install "contextlake[kb-local]") and fastembed (ONNXbge-small;[kb-fastembed]), and the built-in wiki LLM runs a smallQwen2.5-0.5B-InstructGGUF viallama-cpp-python([llm-local]). Models auto-download once to~/.contextlake/modelson first use (honoringREQUESTS_CA_BUNDLE/SSL_CERT_FILEbehind a TLS proxy) and load lazily.doctorreports model presence. A new guard refuses to mix embedder models/dimensions in one vector store. - Container image on GitHub Container Registry (
ghcr.io/sayak-sarkar/contextlake), published by the release workflow. It bundles the[kb]+ built-in model extras and pre-downloaded models, sodocker run … contextlake bootstrapworks with zero config / offline.
[2.1.4] - 2026-06-22#
Changed#
bootstrap's "knowledge layer not installed" message is now actionable. It prints the exact Python interpreter in use and flags the common cause, running the bare./contextlake.py(system Python) while the[kb]extra was installed into a virtualenv, with the precise install command for that interpreter and the venv alternative (./.venv/bin/contextlake bootstrap).
[2.1.3] - 2026-06-22#
Changed#
- Sync is far more resilient to flaky networks and moved branches.
updateandbranchesnow retry transient proxy/network drops (e.g.unexpected eof,connection reset) with backoff instead of failing on the first hiccup. Pulls are fast-forward only: a branch that has diverged from origin is reported as a cleanDiverged …, skipped (manual reconcile)(the tool never merges or rebases, and git's multi-line "divergent branches" hint no longer leaks into the output), and a deleted upstream branch is reported asUpstream branch deletedinstead of a fatal error. Net effect: transient blips self-heal, and the remaining "errors" are real and few.
[2.1.2] - 2026-06-22#
Added#
- The release workflow now also publishes a GitHub Release on each
vX.Y.Ztag, with notes pulled from this changelog and the built sdist + wheel attached.
Changed#
- Adopt the SPDX
license = "MIT"form (PEP 639) and drop the deprecatedLicense ::classifier, silences the setuptools deprecation warnings emitted during the build. Building from source now needssetuptools >= 77.
[2.1.1] - 2026-06-22#
Added#
- Maintainer release runbook at
docs/releasing.md(versioning → tag → build → publish to PyPI, with first-token and TLS-proxy troubleshooting) and areleaseextra (pip install -e ".[release]") bundlingbuild+twine. - Automated PyPI publishing via
.github/workflows/release.yml: pushing avX.Y.Ztag verifies the tag matches the package version, runs lint + core tests, builds, and publishes using PyPI Trusted Publishing (OIDC), no stored API token.
[2.1.0] - 2026-06-22#
Added#
- Cleaner terminal output: the timestamp moves to the right edge. On an interactive
terminal each line now shows the message on the left with a dim
HH:MM:SSclock flushed to the right edge, re-flowed to the live terminal width and dropped automatically when a line is too long to fit (never wraps or misaligns). Alignment is ANSI- and wide-character aware, so it lines up uniformly across terminals. Piped/redirected output and the rotating log file keep the full[YYYY-MM-DD HH:MM:SS]prefix unchanged, so the audit trail is untouched.
Changed#
- Branch name alone no longer causes an
updateto be skipped. A repo with a clean working tree is now fetched and fast-forwarded on whatever branch it is checked out on, feature branches included. The only thing that blocks anupdateis a dirty working tree (uncommitted/unstaged/untracked changes), which is still skipped (or stashed with--auto-stash).protect_working_branchesnow applies only to thebranchescommand, where it keeps a repo from being switched off a non-safe branch. Previously a clean repo on any branch outsidesafe_brancheswas skipped outright.
[2.0.1] - 2026-06-22#
Changed#
- Clearer config-not-found warning. When
gitlab_groupis still the placeholder, the warning now lists the exact files searched (absolute paths, with[found]/[absent]) and notes that local.contextlake.iniis read from the current directory, so a config placed next to the example in the repo but run from elsewhere is no longer a silent miss.
[2.0.0] - 2026-06-22#
Changed#
- Renamed the project
gitlab-sync→contextlake. The tool grew from a GitLab mirror into a local context layer for AI tools, and the name now reflects that. This is a rename only, no behavior changes. - The command, Python package, and PyPI project are now
contextlake(contextlake <command>,python -m contextlake,python3 contextlake.py). - A deprecated
gitlab-synccommand alias is kept so existing installs and scripts keep working; it will be removed in a future major release. - Existing config keeps working. The former
~/.gitlab_sync.ini/.gitlab_sync.ini(and the[gitlab_sync]section) and the~/.gitlab-sync/knowledge store are still read; new installs use~/.contextlake.iniand~/.contextlake/. An already-built index at~/.gitlab-sync/kbis reused as-is, no re-index needed. - The MCP server is now named
contextlake-kb, andsteerwritescontextlakeinto the files it generates (.mcp.json,AGENTS.md, …).
Note#
- The GitHub repository and CI-badge URLs point at
.../contextlake; they resolve once the repository is renamed on GitHub (the old URL auto-redirects).
[1.18.1] - 2026-06-22#
Changed#
- Confirmed the mascot's name, Pebble the otter, in
BRANDING.mdand the mascot spec.
[1.18.0] - 2026-06-22#
Added#
- Brand identity,
contextlake. ABRANDING.mdguide establishes the project's name, voice, color palette (cool lake teals + a warm "spark" of fresh context), open-source typography, logo, and otter mascot. Hand-authored SVG assets live indocs/branding/(glyph.svg,wordmark.svg) alongside a mascot spec (mascot.md). The name says what the tool does, a local lake of real context for your AI, and stays source-agnostic so the brand survives growth beyond GitLab. This is the brand kit only; the package/command rename is a separate, later step.
[1.17.1] - 2026-06-22#
Changed#
- Broadened the local pre-publish checks to cover the whole tree (
docs/,examples/,.github/, and every top-level doc), not justsrc/.
[1.17.0] - 2026-06-22#
Changed#
- Documentation refactored for readability. The README is now a lean ~180-line
landing page (down from ~1,300); detailed command, configuration, branch-safety,
and scheduling docs live in
docs/usage.md, and the knowledge layer indocs/knowledge-layer.md. Standardized examples on thegitlab-synccommand, clarified thestatusoutput (what "Missing"/"Extra" mean), and removed the repetitive install/security prose.
[1.16.0] - 2026-06-22#
Added#
- A "Commands at a glance" reference table in the README covering all 17
commands, and
docs/internals.md, a deep-dive on the core-sync internals plus a new knowledge-layer architecture section.
Changed#
- Slimmed the README (~1,320 → ~860 lines): the deep Technical Documentation /
architecture moved into
docs/internals.md, and the inline version history now points toCHANGELOG.md. Fleshed out thelintanddoctordocs. (Docs only, no code or layout changes, which already follow standard src-layout conventions.)
[1.15.0] - 2026-06-22#
Added#
- QUICKSTART.md, a short install →
bootstrap→ wire-your-editor guide.
Changed#
steernow enhances existing files instead of skipping them: an existingAGENTS.md/CLAUDE.md/.windsurfrules/.kiro/steeringkeeps the user's content and gets a clearly-delimited managed block appended (only that block is refreshed on re-runs);.mcp.jsonis merged; a same-named skill file is kept; custom layers like.devin/are never touched. Nothing the user wrote is deleted.
[1.14.0] - 2026-06-22#
Added#
- GitLab knowledge connector: links each repo to its open merge requests and
issues (read through the authenticated
glab), on the same connector seam as Atlassian/Figma. Configure with[[sources]] type = "gitlab"(optionalgroup); it needs no association rules. The command runner is injectable, so the mapping is unit-tested without GitLab. - Scheduling recipe:
bootstrapis incremental and branch-safe, so it doubles as a refresh job, documented cron + systemd-timer examples (examples/gitlab-sync.service,examples/gitlab-sync.timer) keep the mirror and knowledge layer always-fresh without disturbing in-progress work.
[1.13.0] - 2026-06-22#
Added#
- Agent skills/workflows library:
steernow also installs a built-in, generic library of operating skills (investigate-root-cause, plan-before-coding, surgical-change, review-before-landing, ship-safely, use-knowledge-graph) into the workspace in the formats local tools read, Claude Code skills (.claude/skills/) and Windsurf workflows (.windsurf/workflows/), so even a small-context model has a strong operating playbook. Managed/idempotent like the other steering files.
[1.12.0] - 2026-06-22#
Added#
bootstrapcommand, one-command turnkey setup that chains mirror → index → connect → embed → wiki → steer, skipping unconfigured/disabled stages and never aborting on a single stage's failure. Takes--kb-config(separate from the sync INI) and--no-sync/--no-embed/--no-wiki/--no-connecttoggles, so a teammate goes from nothing to a fully-wired workspace in one step.
[1.11.0] - 2026-06-22#
Added#
- Steering-layer generation (
steercommand): writes workspace-specific steering files so local AI tools pick up the knowledge graph natively,AGENTS.md(overview + knowledge tools + guardrails), a thinCLAUDE.mdthat imports it,.windsurfrules,.kiro/steering/, and a merged.mcp.jsonentry for the MCP server. Content is grounded in the indexed repos/languages/ dependencies; it only overwrites files it manages (or with--force).
[1.10.0] - 2026-06-21#
Added#
- OpenAI-compatible providers for the embeddings and wiki tiers: set
provider = "openai"to use any OpenAI-compatible API, a hosted key or a local server (LM Studio, Jan, llama.cpp, vLLM), as an alternative to local Ollama. The API key is read from an env var named byapi_key_env(never stored in config); servers that need no key work with it unset. - MCP integration docs: a README section showing how to use
gitlab-sync serveas an MCP server from Claude Code and Windsurf/Devin (the graph tools need no model; only semantic search needs embeddings).
[1.9.1] - 2026-06-21#
Fixed#
serveover the stdio transport wrote human-facing log lines to stdout, which is the MCP JSON-RPC channel, corrupting the protocol stream (clients saw spurious parse errors). On stdio, logs now go to stderr.
Changed#
index --workspaceis quieter by default: the per-repo "parsed/resolved" detail is now debug-level (show it with-v), leaving the clean per-repo progress bar.- Added a
ROADMAP.mdlisting future good-to-haves.
[1.9.0] - 2026-06-21#
Added#
- Curated wiki tier (
wikicommand): a pluggable, local-first LLM client (Ollama) synthesizes a provenance-stamped Markdown page per repo, grounded strictly in graph facts, and an LLM verification council (accuracy / completeness / clarity reviewers + a chairman threshold) gates what gets written. Off unless[llm] enabled = true. index --watch(--interval): keep re-indexing the workspace incrementally on an interval (Ctrl-C to stop) for a long-running refresh.- Bi-temporal queries: each indexed shard is snapshotted by commit, and
query --repo R --as-of <commit>searches repoRas it was at a previously indexed commit (time-travel) without a schema overhaul.
[1.8.0] - 2026-06-21#
Added#
- Incremental workspace indexing:
index --workspacenow re-indexes only the repos whose git HEAD moved since their last index (skipping unchanged ones), with--forceto rebuild everything. Paired with cron this gives scheduled incremental refresh. lintcommand for the knowledge layer: reports graph-health issues, repos gone stale (HEAD moved since index) and dangling edges (an endpoint node missing from the store).- Colorful CLI: status glyphs, coloured per-repo lines, and a progress bar for
the sync and knowledge-layer commands. Honors
NO_COLOR/FORCE_COLORand falls back to plain text off a TTY (pipes, cron, and logs stay clean). No new dependencies.
[1.7.0] - 2026-06-21#
Added#
- Hybrid retrieval (
hybrid_searchMCP tool): seeds Personalized PageRank with the embedding hits and propagates relevance across the graph (HippoRAG-style), so structurally-related nodes (callers, dependents) surface even when their text does not match the query. PPR runs over a BFS-bounded subgraph to stay tractable. - Optional sqlite-vec ANN backend for the vector store, selectable via
[embeddings] vector_backend(auto|sqlite-vec|brute).autouses sqlite-vec when thegitlab-sync[kb-vec]extra is installed and falls back to the exact pure-Python cosine scan otherwise, same interface either way.
[1.6.0] - 2026-06-21#
Added#
- Semantic-search tier (optional, local-first): a pluggable embeddings
provider (
Embedderinterface + config-driven factory; a stdlib-only Ollama provider ships first), a local SQLite-backed vector store with cosine search, anembedcommand that vectorizes indexed nodes, and asemantic_searchMCP tool exposed byservewhen embeddings are enabled. Off by default;doctorreports embeddings status.
[1.5.0] - 2026-06-21#
Added#
- Figma knowledge connector: links repos to the design files they reference,
classifying
figma.comURLs (file/design/proto/board) to a stable file key and taking the human file name from the URL slug. When a Figma MCP is configured each design is additionally checked for reachability (best-effort, never required). Runs alongside Atlassian sources underconnect. Connector-agnostic helpers were extracted to a shared module so new connectors stay small.
Fixed#
link_scrapeassociation rules expressed as apatternslist (as in the example config) were silently ignored; both a singularpatternand apatternslist are now honored.
[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#
- Knowledge-graph store and CLI:
index,query,serve, anddoctorcommands backed by a SQLite + FTS5 cross-repo index with per-repo JSON shards. Every node/edge is provenance-stamped (source file + verified date) and confidence-tagged (EXTRACTED/INFERRED/AMBIGUOUS). - Code graph via tree-sitter for Python, JavaScript, TypeScript/TSX, and C#:
files, classes, functions/methods, interfaces, imports, containment, and an
intra-repo call graph (the parser registry is pluggable).
index --workspaceindexes every git repository under a directory. - Cross-repo dependency graph from
pyproject.toml,package.json, and*.csprojmanifests through shared package nodes. - MCP server (stdio or streamable-http) exposing
search_code,find_definition,find_callers,find_dependents,get_neighbors,shortest_path, andgraph_stats, plus akb://statsresource. All output is sanitized before it reaches an agent. - Knowledge connectors (
connect): an Atlassian connector links each repo to the Jira issues and Confluence pages it references. Candidate issue keys (from branch/commit names) are confirmed and enriched against live sites with a single batched JQL call (unverified false-positives are dropped); Atlassian URLs in docs are classified into issue/page links. One or more sites are supported, each independently authenticated over MCP. Output is stored in an isolated graph partition so code re-indexing never disturbs external links. - Config (
examples/kb.toml.example→~/.gitlab-sync/kb.toml): store location, languages, knowledge sources, and association rules, all deployment-specific facts live here, never in the package. - CI now runs a separate knowledge-layer job (Python 3.10-3.13) alongside the core job.
[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#
- Critical: repositories were keyed by their full
<group>/...path while local clones mirror the tree below the group, so every repo was misreported as missing-and-extra and a sync would clone duplicates into a bogus<group>/subtree. Paths are now mapped to their group-relative local form (the full path is retained forglabauthentication). - Critical: a
~(or$VAR) in a config-filework_dir/cache_dirwas treated literally, so the tool operated on a non-existent path and saw zero local repositories. Path values are now expanded. - Critical: boolean config settings (
protect_working_branches,require_clean_workspace,clean_corrupted,adaptive_workers,auto_stash) were silently overridden by CLI defaults on every run, which disabled branch protection and the clean-workspace requirement by default. Flags now default to "unset" so config-file values are honoured. --configwas accepted but ignored; the explicit config path is now loaded.- Config precedence corrected to: explicit
--config> local > global > defaults (previously global silently overrode local). AdaptiveWorkerPoolraisedAttributeError/ never actually resized the pool; it now initializes correctly and parallelism adapts to the live error rate.- Retry/backoff existed but was never wired in; clone now retries transient failures (network/timeout) and fails fast on DNS/TLS.
updatereported failedgit pull(conflicts, auth, network) as "Already up to date"; it now distinguishes updated / unchanged / error by comparing HEAD before and after.loadsilently discarded a list-shaped JSON cache; it is now normalized.fetchused a malformedglabinvocation; it now calls the GitLab API with a URL-encoded group path and correct pagination, and restores thepath|ssh|http|default_branch|archivedtext cache.verifyrecovers nested-repository (repo-inside-repo) detection.- Corrupted (non-git) target directories are detected and re-cloned again
(honouring
--clean-corrupted); cloning prefersglabfor authentication.
Added#
- Installable package with a
gitlab-syncconsole entry point,python -m gitlab_sync, and the barepython3 gitlab_sync.pyscript (src layout). --dry-runto preview clone/update/branch actions without changing anything.- Logging via the standard library with
-v/--verbose,-q/--quiet, and--log-file(rotating audit log). clone_method(auto|glab|git) andbranch_strategy(commits|recency|hybrid) configuration; the most-active-branch heuristic is now recency-aware.--versionflag.- A pytest test suite (68 tests) with fakes for
git/glab, and GitHub Actions CI running ruff + pytest on Python 3.9-3.14.
Changed#
- Code modularized into a
gitlab_syncpackage:cli.py,core.py,config.py,safety.py,logging_setup.py.
[1.2.0] - 2026-06-16#
Added#
- Branch safety checks to protect working branches from sync conflicts
- Workspace protection requiring clean workspace before operations
- Automatic stashing support for uncommitted changes
- Configurable safe branches list
- CLI arguments for branch safety control:
- --protect-working-branches / --no-protect-working-branches
- --safe-branches
- --require-clean-workspace / --no-require-clean-workspace
- --auto-stash / --no-auto-stash
- Enhanced error classification for better retry strategies
- Adaptive worker pool for dynamic parallelism
- Comprehensive branch safety documentation in README
Changed#
- Updated README with branch safety section including scenarios and examples
[1.1.0] - 2026-05-24#
Added#
- INI-based configuration file support
- Local and global config file support
- CLI arguments now override config file settings
- Improved security with externalized configuration
- Tilde expansion for home directory paths
- Configurable timeouts and worker counts
- Exponential backoff retry mechanism
- Adaptive worker pool for dynamic parallelism
- Enhanced error classification for better retry strategies
Changed#
- Removed all hardcoded company/personal identifiers
- Configuration files can be excluded from version control
[1.0.0] - 2026-05-10#
Added#
- Full synchronization pipeline
- Branch management with automatic active branch detection
- Structure verification
- Concurrent processing with ThreadPoolExecutor
- Error handling and timeout management
- Timestamped logging
