[2.8.3] - 2026-08-16
Security
-
qmd updateno longer runs a project-local.qmd/index.yml'supdate:
commands without approval (#886). That file arrives with agit cloneand is
adopted automatically for any command run inside the tree, so cloning a
repository and runningqmd updateexecuted shell commands chosen by whoever
wrote it. On a terminal QMD now lists the commands and asks; with nobody to
ask it skips them and keeps indexing. Approvals are recorded per config file
and per command set in<config dir>/trusted.json, so editing a command — or
agit pullthat rewrites one — asks again. Newqmd trust,
qmd trust listandqmd trust revokemanage approvals, and
QMD_TRUST_UPDATE_HOOKS=1opts unattended runs back in. Commands in your own
~/.config/qmd/*.yml, including anythingqmd collection update-cmdwrites,
are unaffected. -
The same project-local trust gate now covers collection
pathvalues that
resolve outside the project and non-defaultmodels.embed/models.rerank
/models.generateURIs (#889). In-project paths still index unattended;
out-of-project directories are skipped untilqmd trust, and custom model
URIs are not loaded or downloaded.QMD_TRUST_LOCAL_CONFIG=1opts unattended
runs back in (andQMD_TRUST_UPDATE_HOOKS=1still does).qmd collection addrecords trust as it writes, the same wayupdate-cmddoes. -
Indexing no longer follows file symlinks or glob
..// absolute patterns
out of the collection directory.fast-globalready skipped symlinked
directories, but a file symlink (or a mask like../**/*.md) still resolved
viarealpathand ingested the target.qmd://filesystem resolution uses
the same containment check, soqmd://collection/../../../etc/passwdno
longer produces a path outside the collection. -
qmd mcp --httpnow validates theOriginandHostheaders on every
request and answers403when they name anything but a loopback address
(#881). Binding to localhost is no defence against the user's own browser:
a page can re-point its hostname at127.0.0.1(DNS rebinding) and read
the indexed corpus throughPOST /queryorPOST /mcp. Requests with no
Origin(curl, MCP clients, editors) are unaffected. Extend the allowlists
withQMD_ALLOWED_ORIGINS/QMD_ALLOWED_HOSTS, or set
QMD_ALLOWED_ORIGINS=*behind your own authenticating proxy. A wildcard
bind (--host 0.0.0.0) skips the host check and warns at startup.
Changed
-
Dependencies:
node-llama-cpp3.18.1 → 3.20.0 (llama.cpp b8390 → b10361, 2026-08-11). Also safe patch/minors:picomatch4.0.4 → 4.0.5,web-tree-sitter0.26.8 → 0.26.12,tsx4.21.0 → 4.23.12,vitest3.2.4 → 3.2.7. No zod/vitest major;@modelcontextprotocol/serverstays 2.0.0 (no 2.x patch).flake.nixFOD hashes are not updated here. -
generateand query expansion now awaitLlamaContextSequence.dispose()before disposing the parent context. node-llama-cpp 3.20 made sequence dispose async; the library's context-onDispose path does not wait. -
MCP server now speaks protocol revision 2026-07-28 via the official
TypeScript SDK 2.x (@modelcontextprotocol/server). HTTP is sessionless
(noMcp-Session-Id, no initialize handshake, no idle-session TTL / #816
reaper). Clients send version and capabilities in_meta;server/discover
is implemented; Streamable HTTP POST requiresMcp-Method/Mcp-Name
(mismatch →-32020);tools/listis deterministic and carriesttlMs/
cacheScope. 2025-era stdio clients still work (serveStdiodual-speak);
2025-era HTTPinitializeis answered per-request without minting a
session. Existing tools (query/get/multi_get/status), stdio
EOF shutdown, and named-index daemon PIDs are unchanged. No release. -
qmd pull(and implicit model downloads inembed/query) no longer print
node-llama-cpp's download progress bar. The bar redraws every few kilobytes
and flooded agent transcripts with thousands of tokens (#776). Pass
qmd pull --progressto show it on an interactive terminal. -
--full-pathno longer degrades silently when a result cannot be resolved on
disk (#785). A fallback there means the file moved or was deleted since the
last index, sosearch,query,getandmulti-getnow print a notice to
stderr naming how many results fell back and suggestingqmd update; stdout
stays machine-readable. -
search/querynow decide per result whether to show the docid under
--full-path, matchingmulti-getandget: a result that resolved shows
its on-disk path and no docid, one that did not keeps itsqmd://URI and
its docid, so it is still addressable. Previously the docid was dropped for
every row whenever the flag was set, leaving unresolved rows with neither a
usable path nor an identifier. -
search --format csvalways emits thedocidcolumn, empty for rows that
resolved to an on-disk path. Under--full-paththe header previously
dropped the column entirely — which also disagreed with the empty-result
header, always printed withdocid. Column positions are now stable across
runs and formats.
Fixed
-
Concurrent first-open of a cold index no longer fails with
table documents_fts already existson Bun/macOS. FTS5
CREATE VIRTUAL TABLE IF NOT EXISTSis not atomic across WAL
connections: two processes can both see a missing table on their
schema snapshot and the loser throws. Table create and legacy-schema
repair now use the sameBEGIN IMMEDIATE+ double-check as the FTS
sync triggers, and treat a concurrent "already exists" as success
when the table is present. -
Nix flake
qmd-node-modulesFOD hashes updated for x86_64-linux and
aarch64-darwin after the MCP SDK 2.0 bump.nix build/ Nix GHA was
failing with a fixed-output hash mismatch. -
CJK FTS rebuild no longer skips leftover
fts5(name, body, content='documents')
tables whenfts_cjk_normalized_versionis already stamped, and schema
repair now checks live FTS columns (PRAGMA table_info) as well as
sqlite_master.sql. The MCP HTTP test helper still seeds that legacy
table;startMcpHttpServer/createStoreon it must not throw
no such column: T.name(#792 regression). -
qmd collection add --globis no longer silently ignored. parseArgs ran
withstrict: false, so OpenClaw's--glob memory.md(and any other
--glob) fell through, the default**/*.mdwas used, and a second
collection on the same path collided as a duplicate instead of indexing
the requested mask (#536).--globis now an alias for--mask. -
The
bin/qmdtrampoline now execsprocess.execPathinstead of
re-resolvingnodefrom PATH. Native addons (better-sqlite3) are
compiled for the Node that installed qmd; a version manager (nvm, fnm,
mise) selecting a different major in the working directory used to spawn
that other binary and fail withNODE_MODULE_VERSION/ERR_DLOPEN_FAILED
(#577 leftover; #319).bun bin/qmdstill resolvesnodefrom PATH so
Node-ABI addons are not loaded into bun. -
qmd update/qmd collection addno longer swallow unreadable files
silently (#460).readFileSyncfailures (ETIMEDOUT on APFS compressed
files, EAGAIN, EACCES, …) still skip the file so the rest of the collection
indexes, but the CLI now warns with the path and error code and reports
the skip count. The SDKupdate()result includesskipped. -
The architecture diagram no longer draws Vec expansions into BM25 search
(#680).lexexpansions are FTS-only;vecandhydeexpansions are
vector-only. The original query still goes to both backends. -
qmd benchno longer runs to a wall of 0.00 when the fixture collection is
missing or empty (#716). It errors up front with the same "Collection not
found" / index hint asqmd search -c, and if every backend still scores
zero it warns on stderr to checkqmd ls. -
qmd cleanupnow reclaims the content and FTS space left behind after a
wrong-directoryqmd update. Deactivating files (the next update in the
right directory) only tombstoned thedocumentsrows; cleanup deleted those
rows and vacuumed, but never dropped the unreferencedcontenthashes and
never ran FTS5optimize, sodocuments_fts_datakept the old bodies
(#550). Cleanup now deletes inactive docs, then orphaned content, then
compact FTS, then vacuum.--dry-runreports the content hashes too. -
Concurrent
querycalls withrerank: trueon a cold MCP server no longer
raceensureRerankContexts(). Embed already serialized context creation;
rerank did not, so two overlapping first queries both saw an empty pool,
both created ranking contexts, and the inactivity timer disposed the loser
(Object is disposed, #682). Callers now await the in-flight create. -
Embedding-context pool size no longer assumes every GGUF costs 150 MB of
VRAM (the nomic-embed figure). Larger models such as Qwen3-Embedding-0.6B
are ~1190 MB per 2048-token context; opening 8 of those exhausted an 8 GB
card soqmd queryfailed withFailed to create any rerank contexteven
though the reranker itself was fine. The pool is now sized from the weight
file, and 1 GB is reserved for the reranker (#799). Default
embeddinggemma/nomic throughput is unchanged.QMD_EMBED_PARALLELISMstill
overrides. -
Multi-collection
-c A -c B(and SDK/MCPcollections: [A, B]) no longer
searches globally then post-filters. A large unrelated collection could fill
the FTS/ANN top-k so the requested collections vanished, yielding false-empty
results even though each collection matched on its own.searchFTS/
searchVecnow search each requested collection, then merge by score
(#775). Single-collection exact-scan (#791, #803) is unchanged. -
Query expansion no longer consumes caller
intent, and a cached expansion
whose sub-queries all miss is dropped instead of replaying forever (#818).
Intent still steers reranking and snippet/chunk selection; it just no longer
enters the expansion prompt or cache key, where the model copied meta-language
("so I can compare spend settings") into lex/vec terms that matched nothing. -
Files whose names differ only in the characters the legacy slug collapsed to
-(spaces, underscores) no longer evict each other from the index (#717).
The handalized-path migration now skips any row whose path is still owned by
a file in the current scan, so it only adopts genuinely stale pre-2.6 rows.
qmd getandqmd ls <prefix>also match_and%in paths literally
instead of as SQLLIKEwildcards, soqmd get 2026_06_16.mdno longer
returns a sibling2026-06-16.md. -
CLI
multi-getand SDK/MCPmulti_getnow share one comma-list resolver.
Collection-prefixed paths (qmd/docs/SYNTAX.md) work in both transports,
unanchoredLIKE '%name'no longer silently fetches a different document
for a filename fragment (NTAX.md≠SYNTAX.md), and ambiguous names
across collections error with the candidate list instead ofLIMIT 1(#759). -
The Nix flake wrapper now seeds the same pre-import env as
bin/qmd.
Nix installs execbun src/cli/qmd.tsdirectly, so they previously skipped
the launcher:qmd mcpcould leak llama/ggml native logs onto JSON-RPC
stdio, and Darwin CLI exits dumped a ggml Metal residency-set stack trace
after an otherwise successful query. The wrapper now quiets those logs for
mcpand setsGGML_METAL_NO_RESIDENCY=1on Darwin unless
QMD_METAL_KEEP_RESIDENCY=1(#723). -
Rerank context creation no longer swallows the real failure. A VRAM OOM or
corrupt model previously produced onlyReranker unavailable — skipping reranking(and a dead identical retry whose comment claimed it disabled
flash attention, which ranking contexts never supported). The warning now
includes the underlying message so the two cases are distinguishable (#782). -
The Nix flake package now ships
skills/next tosrc/in$out/lib/qmd/.
findPackageRoot()walks up from the wrappedsrc/cli/qmd.tslooking for a
siblingskills/directory; without it,qmd skill showandqmd skills list
always failed with "QMD skill not found" on Nix-installed binaries (#722). -
/releasestep 1 no longer points at a missing script.skills/release/scripts/release-context.sh
now exists: it silently installs git hooks and prints version info, working-tree
status, commits and files since the last tag,[Unreleased], and the previous
changelog entry. The skill's process list also drops the duplicate step 7 and
checks dependency updates before cutting the release (#796). -
store.searchVec()(and SDKsearchVector()) now embed the query with the
store's pinned embed model instead of the globalQMD_EMBED_MODEL. A store
created with a non-defaultmodels.embedpreviously failed with
Dimension mismatch ... Expected N ... received Mand loaded the wrong
(often much larger) model at query time. Hybrid/precomputed/session search
paths were unaffected and stay unchanged (#690). -
qmd collection add --mask "a.md,*.txt"now indexes the union of each
pattern. The comma-separated form was documented and commonly guessed, but
the joined string was passed to fast-glob as one literal glob, so it
matched zero files with no error. Brace form{a.md,*.txt}is unchanged.
The same split applies onqmd updatefor stored comma-list masks (#557). -
NixOS / immutable-root installs no longer crash
qmd embedwith EACCES
when node-llama-cpp tries to compile llama.cpp into a read-only
node_modules. The flake wrapper puts Nix's glibc and libstdc++ on
LD_LIBRARY_PATHso prebuilt binaries candlopenthem, and
getLlama()usesbuild: "never"when the llama directory is not
writable (#574). -
CJK FTS rebuild no longer raises "database is busy" when flushing insert
batches. The streaming.iterate()cursor stayed open across
BEGINon the same connection; the scan now uses keyset-paginated
LIMITbatches so each SELECT finalizes before the insert transaction
starts (#797). -
Quoted FTS phrases containing dotted tokens (e.g.
"1.0.21") now match the
indexed document. The porter unicode61 tokenizer stores dotted strings as
adjacent parts, but phrase sanitization stripped the dots into a single
token (1021) that could never hit. Dotted tokens inside quotes are split
into adjacent phrase terms, matching the bare-term rewrite from #563 (#757). -
scripts/build.mjsno longer passesshell: truetospawnSyncon Windows.
With the default Node install path (C:\Program Files\nodejs\node.exe),
cmd.exesplit the unquotedprocess.execPathat the space, thetsc
spawn failed, andpreparecould still report success with nodist/—
leavingbin/qmdat "not built". The helper always receives a real binary
path plus an args array, so no shell is needed. A spawn error now prints
the missing binary path instead of failing silently. (#681) -
Case-sensitive collections no longer collapse distinct document identities that
differ only by path casing. The implicitCOLLATE NOCASElegacy migration was
unsafe for filesystems that contain bothREADME.mdandreadme.md; case-only
legacy migrations must now be explicit and operator-reviewed (#801). -
cleanupOrphanedVectorsnow runs its orphan count and both DELETEs in a
single immediate transaction. An interruption between the two DELETEs
(crash,SQLITE_BUSY) could desyncvectors_vecfromcontent_vectors,
leaving stale metadata rows that make a later reactivation of the same
content hash look already-embedded — soqmd embedskips it and the
document becomes silently unsearchable by vector, with no orphan left to
clean up (#766). -
qmd embedno longer splits a UTF-16 surrogate pair (emoji, etc.) across a
chunk boundary. A chunk ending or starting mid-pair produced an unpaired
surrogate in the chunk text, which some remote embedding APIs reject as
invalid JSON — permanently failing that chunk on every retry, since the
boundary calculation is deterministic. This covers both the character-based
chunker andchunkDocumentByTokens's recursive re-splitting for
astral-plane-dense content, which could previously drive the char budget
low enough to reproduce the same split (#777). -
insertContextlooks upstore_collectionsby name. #754 retargeted the
query from the droppedcollectionstable but leftWHERE id = ?, and
store_collectionshasname TEXT PRIMARY KEYwith noidcolumn — the
call threwno such column: id. MatchesdeleteContext/
updateStoreContext(#853). -
qmd collection addwith no path argument now errors with usage instead of
silently indexing the current working directory (#684). Pass.to index
CWD, matching the documented examples. -
qmd statusreports orphaned embedding chunks,qmd updatehints when they
exceed 10% of vectors, andqmd cleanup --dry-runpreviews what would be
removed. Incremental update still does not auto-prune vectors (a transient
empty mount would otherwise force a full re-embed) (#768). -
qmd --index <name> mcp --http --daemonnow scopes PID/log files per index
(mcp-<name>.pid) and passes the resolved database path to the child, so a
named-index daemon no longer collides with the defaultmcp.pidor opens
the default store (#772). -
Opening a store no longer throws
SQLiteError: no such column: T.namewhen
documents_ftsis still the legacyfts5(name, body, content='documents')
schema.CREATE VIRTUAL TABLE IF NOT EXISTSleft that table in place, and
the CJK FTS rebuild'sDELETE FROM documents_ftscompiled against
documents.name, which does not exist (#792). -
Rerank cache keys now include the resolved
models.rerankURI, so swapping
the configured reranker no longer serves the previous model's cached scores
(#764). -
vsearch -c <collection>no longer returns empty results for small
collections crowded out of the global ANN candidate pool.searchVecnow
exact-scans the collection's vectors withvec_distance_cosinewhen the
set is within 20k rows (ANN + post-filter cannot see collections that never
enter global top-k, and sqlite-vec capskat 4096 so a larger multiplier
alone is not enough). Larger collections still use capped ANN over-fetch
(#791, #803). -
multi-get --format filesnow emits the docid as its own CSV field
(#docid,path,...) instead of prepending it into the path field with a
space (#docid path,...), matchingsearch --format filesand keeping
naive comma-splitting usable (#760). -
qmd embednow takes an exclusive process lock (.qmd-embed.locknext to
the index DB) so concurrent invocations no longer race onvectors_vec
and fail withUNIQUE constraint failed: vectors_vec.hash_seq. A second
embed exits early withAnother embed process is already running. Skipping.
Stale locks from crashed processes are recovered via PID identity checks
(#825). -
qmd mcp(stdio) now shuts down gracefully when stdin reaches EOF instead
of orphaning to PID 1 when the parent MCP client dies (#751): the server
closes its transport, gives in-flight request handlers a bounded window to
settle, closes the store (which disposes its llama.cpp instance), and lets
the process drain viaprocess.exitCode(no forcedprocess.exit(), which
has caused exit-time native crashes before). -
qmd --versionno longer reports an unrelated repository's commit (#787).
The commit was discovered at runtime withgit -C <installDir> rev-parse,
andgit -Cwalks up, so any install nested inside another checkout
reported that checkout's HEAD — a global npm install under a git-managed
prefix such as Homebrew's/opt/homebrewclaimed Homebrew's commit as
qmd's. Identical tarballs reported different "commits" depending only on
where they were installed, which is why one issue can collect three distinct
hashes for the same published build.scripts/build.mjsnow stamps the
commit it built from intodist/cli/build-info.json(suffixed-dirtywhen
built from a modified tree), and the runtime prefers that. Source checkouts
still resolve their own HEAD, but only after confirming the enclosing
repository is qmd's; anything else reports no commit rather than a
misleading one. The lookup also no longer interpolates the install path into
a shell string, so a path containing a space stops silently dropping the
commit, and--versionfrom a build runs no subprocess at all. -
qmd doctorno longer false-positives.etagHTTP sidecars (written by
qmd pullnext to each download) as invalid GGUF models. The model-cache
check now only inspects real.gguffiles, so a sidecar that happens to
sort before the blob no longer poisons the report. #812 -
qmd mcp stopandqmd mcp --http --daemonnow verify that a pidfile PID still belongs to a qmd process before signalling it or refusing to start. Recycled PIDs (common after reboot) are treated as stale: the pidfile is unlinked instead of SIGTERM'ing an unrelated process or blocking daemon start with a false "Already running" error (#806). -
qmd collection addnow rejects missing paths and regular files before
creating collection configuration or index state. The error reports both the
received and resolved path so malformed shell arguments can be corrected. -
Claude Code plugin: scope the plugin
sourceto./skillsso installs
copy just the skills (~50 KB) instead of the entire repository. Previously a
canonical install materialized ~230 MB / 9,000+ items into
~/.claude/plugins/cache/— including a full npm dependency install
triggered by the repo-rootpackage.json. Both skills (qmdandrelease)
still ship, unchanged. (#790) -
Claude Code plugin: releases now bump the plugin version in
.claude-plugin/marketplace.jsonin lockstep withpackage.json. The
plugin cache is keyed on this version, and it had been stuck at0.1.0
since February — so installed plugins never received skill updates
(users who installed in February are still being served that snapshot
today, despite the qmd skill nearly tripling in size since). Also bumps
the plugin to2.6.3as a one-time catch-up so existing installs pick
up the current skill on their nextclaude plugin update. (#789)
Changed
--full-pathno longer degrades silently when a result cannot be resolved on
disk (#785). A fallback there means the file moved or was deleted since the
last index, sosearch,query,getandmulti-getnow print a notice to
stderr naming how many results fell back and suggestingqmd update; stdout
stays machine-readable.search/querynow decide per result whether to show the docid under
--full-path, matchingmulti-getandget: a result that resolved shows
its on-disk path and no docid, one that did not keeps itsqmd://URI and
its docid, so it is still addressable. Previously the docid was dropped for
every row whenever the flag was set, leaving unresolved rows with neither a
usable path nor an identifier.search --format csvalways emits thedocidcolumn, empty for rows that
resolved to an on-disk path. Under--full-paththe header previously
dropped the column entirely — which also disagreed with the empty-result
header, always printed withdocid. Column positions are now stable across
runs and formats.