| Name | Modified | Size | Downloads / Week |
|---|---|---|---|
| Parent folder | |||
| semlith-v0.15.0-aarch64-apple-darwin.tar.gz | 2026-09-14 | 16.1 MB | |
| semlith-v0.15.0-aarch64-unknown-linux-gnu.tar.gz | 2026-09-14 | 16.1 MB | |
| semlith-v0.15.0-x86_64-pc-windows-msvc.zip | 2026-09-14 | 14.5 MB | |
| semlith-v0.15.0-x86_64-unknown-linux-gnu.tar.gz | 2026-09-14 | 17.1 MB | |
| SHA256SUMS | 2026-09-14 | 448 Bytes | |
| README.md | 2026-09-14 | 11.6 kB | |
| Semlith 0.15.0 source code.tar.gz | 2026-09-14 | 858.3 kB | |
| Semlith 0.15.0 source code.zip | 2026-09-14 | 907.5 kB | |
| Totals: 8 Items | 65.6 MB | 0 | |
The graph stops answering questions it cannot support, the search stops sending the thing itself when it was asked where the thing is, and the ledger starts recording the agents it exists to measure.
A study on 2026-09-14 measured all three: the graph was wrong about half the time
on "does A reach B", with nothing in the output to say so; a search reply cost 20
to 60 times the grep it replaced when the agent already knew the term; and the
retrievals table was empty on real installations, because the only thing that
ever wrote to it was the portal's own search box.
A graph that says how much it knows
- An edge's target is resolved, not matched by name.
edges.dstis a name, and a name is not an address: a store of any size holds fourgets and sevenindexes, and every one of them used to come back as its own row, indistinguishable from the call the source actually makes. Each edge is now ranked against its candidates — the calling file's own definition first, then a definition whose file the source pointed at, then one in a file the caller imports, then the corpus holding only one definition of the name. One survivor isresolved; several areambiguous, and every candidate carries the definition count so a renderer can say "4 definitions" rather than print four calls. An edge the syntax tree already settled staysextracted: a fact outranks a ranking. Both new values are computed when a query runs and never stored, so re-indexing a target cannot make them stale. edges.hintrecords what the source said about where a call goes. The module of a scoped call, the receiver of a method call, the object of a qualified Python or TypeScript call. The source almost always says more than the name, and none of it was being kept. Rust's supplementary query also stops emitting the path segment of a scoped call as a secondcallsedge —store::edges_out()is a call toedges_out, not tostore, and that edge invented a call the source does not make and handed the path finder a module to walk through.- A path walks definitions, not names. Refusing ambiguous edges is not enough
on its own: on the 0.14.0 store every hop of
call_tool -> record_retrievalresolved to exactly one definition and the chain was still false, because hop 3 arrived atsearchinlib.rsand hop 4 left fromsearchinroutes.rs. Each hop true, the chain not. A node in the traversal is now a definition, and a chain may only leave from the definition it arrived at. The false chains that are still false —search_inandis_imagetorecord_retrieval— now answer "not connected within 6 hops by resolved edges", and a path that does exist still answers with nothing to qualify it. The other two pairs the study named became genuinely connected in this release, because this is the release that made every surface record a retrieval; the question set carries the measured chains and the reasoning. --all-edgeswalks the old way and says what it found. Both endpoints on every hop with file and line, a seam drawn where the chain changed subject, a trailer whose four confidence counts add up to the hop count and which names the ambiguous names crossed, and one line: "A hypothesis, not a finding."--strictstates the default out loud and wins when both are given. One renderer serves the CLI, the MCP reply and the portal, so the three cannot describe one answer differently.semlith neighborscollapses and hides. Callees to a name with several definitions are one row carrying the count, because four rows sayinggetread as four calls and the symbol makes one. Targets the store holds no definition for are counted rather than silently omitted.--allexpands both: "semlith shows no callees" and "everything this calls is outside the index" are different facts.- Search expansion follows only the kinds that mean "depends on", skips
ambiguous names, and weighs a chunk reached through a resolved edge above one
reached by a bare-name match. Following
definesandcontainspulled in every symbol that shared a file with a hit, which diluted the two lists that had answered the question.
A search that answers where
semlith_searchanswers with locations by default. A locate row is the store-relative path, the line span, the enclosing symbol and its kind, the lists that found it, provenance when the graph reached it, whether the file has changed since it was indexed, and one line of the text — grouped by file and cut to amax_tokensbudget that statestruncated: 8 of 23when it cuts. An agent that already knows the identifier wants the address, not the building: the study measured a warm reply at 4.9–7.2 KB atk=8against 100–300 bytes for the grep it replaced. This changes what an MCP client gets back.format: "excerpt"returns exactly what 0.14.0 returned, and the CLI is unchanged — a person reading a terminal is not paying by the token.- Every hit says whether its file has moved under it. One
statper distinct path, the current size and mtime against what the store recorded. Deliberately conservative, so atouchwith no edit reads as stale: a false "check this" costs a reread and a false "this is current" costs a wrong quotation. tools/listis 3 940 bytes with one store open, from 8 634. Every agent paid the old one once per session before asking anything, and most of it was advice a model either already knows or will not follow from a schema. Descriptions are one sentence each,titleannotations are gone — the portal falls back to the description — and the language list moved to a tenth tool,semlith_languages, because it is a fact about the build and can be stated once rather than read by every agent every session.
A ledger that records the user it exists for
- Every retrieval is recorded, from every surface. The write moved out of
routes.rsintosrc/ledger.rs, and stdio MCP, the daemon's/mcpendpoint, the CLI and the portal all call it — so a search from the command line and the same search from an agent are counted the same way rather than nearly the same way. A row says which client asked, using the name the client gives itself in the MCP handshake (claude-code,cursor,cli,portal), and which session it belonged to. Graph answers are recorded against the honest denominator: the bytes of every file a grep for that name would have made you read. A retrieval that found nothing is recorded and credited nothing, because a ledger that remembers only its successes is a marketing document. - Recording is on by default, and
semlith start --ledgeris removed. This reverses what 0.12.0 through 0.14.0 said, and the reversal is the point. Those releases stated that a local tool which starts logging without being told is not different from one that phones home. What that principle bought was a ledger nobody switched on: it measured nobody, so the savings figure had no denominator and the audit trail had no rows. The principle that actually holds is narrower and every clause of it is checkable — the rows never leave the store they were written into, the daemon says on every start that it is recording and names the flag that stops it, and erasing every row is oneDELETE.--no-ledgerstops a session,SEMLITH_LEDGER=0stops a machine, and the daemon printsledger: recording (local only; --no-ledger to stop)orledger: off for this sessionevery time it comes up. - Both sides of a ratio are counted with the store's own tokenizer. The same
tokenizer.jsonthe embedding model already loads, out of the same cache, under the same pinned digest. Four characters per token remains the fallback for a session that never loaded a model — a graph-only session never does — and the row says which counted it, so rows counted two different ways are never summed.tokenizersis now named inCargo.toml; it was already in the tree under fastembed, on the same precedent asimageandgetrandomthere. semlith ledger --verifyre-walks the chain and exits non-zero on a break, naming the first row that does not verify.semlith statsgains one line: tokens not read, over how many of how many retrievals, with the coverage and ameasuredormodelledtier. It does not deduplicate files across one session's retrievals, so it is an upper bound, and the line names its tier rather than implying precision it does not have.- Four nullable columns on
retrievals—session,tool,stale_hits,tokenizer— added by anALTER TABLEthat runs on open. The hash chain is versioned by the row rather than by the store:toolis NULL on every row written before 0.15.0 and set on every row written since, which is what picks the formula. A store holding rows of both kinds verifies end to end, because a verify that reported every 0.14.0 ledger as broken would be worse than no verify at all.
A number behind every retrieval claim
tests/retrieval.rs,#[ignore]d like the other model-downloading tests. It runs a fixed set of 41 questions with ground-truth spans fromtests/fixtures/retrieval/questions.yaml— the asker who knows the identifier, the asker who knows only the idea, and the asker whose question only the graph can answer — and prints hit@1, hit@3, hit@8, bytes per answer, the graph list's marginal contribution and the wrong-yes count forpath. It asserts wrong-yes is zero andtools/listis under 1 000 tokens. The question set was committed before the work it measures, and its spans are line ranges in the 0.14.0 merge rather than in the tree being edited to answer them. Run it withcargo test --release --test retrieval -- --ignored --nocapture.
Removed
semlith start --ledger. The flag now asks for the default, and it is removed rather than kept as a no-op: a script that passes it fails at parse time and is corrected once, where a flag that silently meant its opposite would go on reading as though it were switching something on.
This is a break in the covered surface. docs/compatibility.md records it,
along with the two changes of default — the ledger recording without being
asked, and semlith_search answering with locations where an MCP client
previously received excerpts.
Notes
- Existing stores open unchanged, and
format_versionstays 2. The five new columns are nullable and added by anALTER TABLEbeside theCREATE TABLE IF NOT EXISTSbatch every open already runs. A 0.14.0 binary opens a 0.15.0 store and never asks for them; a 0.15.0 binary reads a 0.14.0 store with a NULL hint on every edge and resolves it exactly as 0.14.0 did, filling the hints in for a file on the nextindexpass that touches it. The hint is a tie-breaker, so its absence costs the ranking a tier rather than an answer.
Install
macOS and Linux
:::sh
curl -fsSL https://raw.githubusercontent.com/semlith/semlith/main/install.sh | sh
Windows
:::powershell
irm https://raw.githubusercontent.com/semlith/semlith/main/install.ps1 | iex
Or download the archive for your platform below, unpack it, and put
semlith on your PATH. Intel macOS has no prebuilt binary: ONNX
Runtime no longer publishes x86_64 macOS builds.
Verify a download against SHA256SUMS before running it.