Improve qmd doctor diagnostics

This commit is contained in:
Tobi Lutke 2026-05-19 12:48:16 -04:00
parent 596198c2ba
commit 5cda3cf54c
No known key found for this signature in database
4 changed files with 664 additions and 163 deletions

View File

@ -5,7 +5,7 @@ import { execSync, spawn as nodeSpawn } from "child_process";
import { fileURLToPath } from "url";
import { basename, dirname, join as pathJoin, relative as relativePath, resolve as pathResolve } from "path";
import { parseArgs } from "util";
import { readFileSync, readdirSync, realpathSync, statSync, existsSync, unlinkSync, writeFileSync, openSync, closeSync, mkdirSync, lstatSync, rmSync, symlinkSync, readlinkSync, copyFileSync } from "fs";
import { readFileSync, readdirSync, realpathSync, statSync, existsSync, unlinkSync, writeFileSync, openSync, readSync, closeSync, mkdirSync, lstatSync, rmSync, symlinkSync, readlinkSync, copyFileSync } from "fs";
import { createInterface } from "readline/promises";
import {
getPwd,
@ -69,6 +69,7 @@ import {
DEFAULT_EMBED_MAX_BATCH_BYTES,
DEFAULT_EMBED_MAX_DOCS_PER_BATCH,
DEFAULT_RERANK_MODEL,
DEFAULT_QUERY_MODEL,
DEFAULT_GLOB,
DEFAULT_MULTI_GET_MAX_BYTES,
createStore,
@ -100,6 +101,7 @@ import {
listAllContexts,
setConfigIndexName,
loadConfig,
saveConfig,
setConfigSource,
findLocalConfigPath,
getLocalDbPath,
@ -127,15 +129,14 @@ function getStore(): ReturnType<typeof createStore> {
store = createStore(storeDbPathOverride);
// Sync YAML config into SQLite store_collections so store.ts reads from DB
try {
const activeModels = ensureModelsConfiguredForCli();
const config = loadConfig();
syncConfigToDb(store.db, config);
if (config.models) {
setDefaultLlamaCpp(new LlamaCpp({
embedModel: config.models.embed,
generateModel: config.models.generate,
rerankModel: config.models.rerank,
}));
}
setDefaultLlamaCpp(new LlamaCpp({
embedModel: activeModels.embed,
generateModel: activeModels.generate,
rerankModel: activeModels.rerank,
}));
} catch {
// Config may not exist yet — that's fine, DB works without it
}
@ -392,6 +393,39 @@ function formatBytes(bytes: number): string {
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
}
function isForceCpuEnabled(): boolean {
const value = process.env.QMD_FORCE_CPU;
return !!value && !["false", "off", "none", "disable", "disabled", "0"].includes(value.trim().toLowerCase());
}
function configuredGpuModeLabel(): string {
return isForceCpuEnabled()
? "CPU forced (QMD_FORCE_CPU)"
: (process.env.QMD_LLAMA_GPU?.trim() || "auto");
}
function summarizeDeviceNames(names: string[]): string {
const counts = new Map<string, number>();
for (const name of names) {
counts.set(name, (counts.get(name) || 0) + 1);
}
return Array.from(counts.entries())
.map(([name, count]) => count > 1 ? `${count}× ${name}` : name)
.join(", ");
}
function sanitizeDiagnosticMessage(message: string): string {
const home = homedir();
return message
.replaceAll(home, "~")
.replaceAll(process.cwd(), ".")
.split("\n")
.map(line => line.trim())
.filter(Boolean)
.slice(0, 3)
.join("; ");
}
async function showStatus(): Promise<void> {
const dbPath = getDbPath();
const db = getDb();
@ -551,9 +585,7 @@ async function showStatus(): Promise<void> {
// incompatible GPU drivers (for example Vulkan loader present but no usable driver).
// Keep the native probe opt-in, but always show how QMD is configured and how to probe.
console.log(`\n${c.bold}Device${c.reset}`);
const configuredGpuMode = process.env.QMD_FORCE_CPU && !["false", "off", "none", "disable", "disabled", "0"].includes(process.env.QMD_FORCE_CPU.trim().toLowerCase())
? "CPU forced (QMD_FORCE_CPU)"
: (process.env.QMD_LLAMA_GPU?.trim() || "auto");
const configuredGpuMode = configuredGpuModeLabel();
console.log(` Mode: ${configuredGpuMode}`);
if (process.env.QMD_STATUS_DEVICE_PROBE !== "1") {
console.log(` Status: ${c.dim}not probed${c.reset} (set QMD_STATUS_DEVICE_PROBE=1 to test GPU/CPU backend)`);
@ -565,15 +597,7 @@ async function showStatus(): Promise<void> {
if (device.gpu) {
console.log(` GPU: ${c.green}${device.gpu}${c.reset} (offloading: ${device.gpuOffloading ? 'yes' : 'no'})`);
if (device.gpuDevices.length > 0) {
// Deduplicate and count GPUs
const counts = new Map<string, number>();
for (const name of device.gpuDevices) {
counts.set(name, (counts.get(name) || 0) + 1);
}
const deviceStr = Array.from(counts.entries())
.map(([name, count]) => count > 1 ? `${count}× ${name}` : name)
.join(', ');
console.log(` Devices: ${deviceStr}`);
console.log(` Devices: ${summarizeDeviceNames(device.gpuDevices)}`);
}
if (device.vram) {
console.log(` VRAM: ${formatBytes(device.vram.free)} free / ${formatBytes(device.vram.total)} total`);
@ -586,7 +610,7 @@ async function showStatus(): Promise<void> {
} catch (error) {
console.log(` Status: ${c.dim}probe failed${c.reset}`);
if (error instanceof Error && error.message) {
console.log(` ${c.dim}${error.message}${c.reset}`);
console.log(` ${c.dim}${sanitizeDiagnosticMessage(error.message)}${c.reset}`);
}
}
}
@ -1804,38 +1828,44 @@ function parseChunkStrategy(value: unknown): ChunkStrategy | undefined {
throw new Error(`--chunk-strategy must be "auto" or "regex" (got "${s}")`);
}
export function resolveEmbedModelForCli(): string {
function ensureModelsConfiguredForCli(): { embed: string; generate: string; rerank: string } {
try {
return resolveEmbedModel(loadConfig().models);
} catch {
return resolveEmbedModel();
}
}
export function resolveGenerateModelForCli(): string {
try {
return resolveGenerateModel(loadConfig().models);
} catch {
return resolveGenerateModel();
}
}
export function resolveRerankModelForCli(): string {
try {
return resolveRerankModel(loadConfig().models);
} catch {
return resolveRerankModel();
}
}
function resolveModelsForCli(): { embed: string; generate: string; rerank: string } {
try {
return resolveModels(loadConfig().models);
const config = loadConfig();
const models = resolveModels(config.models);
const current = config.models ?? {};
if (current.embed !== models.embed || current.generate !== models.generate || current.rerank !== models.rerank) {
saveConfig({
...config,
models: {
...current,
embed: models.embed,
generate: models.generate,
rerank: models.rerank,
},
});
}
return models;
} catch {
return resolveModels();
}
}
export function resolveEmbedModelForCli(): string {
return ensureModelsConfiguredForCli().embed;
}
export function resolveGenerateModelForCli(): string {
return ensureModelsConfiguredForCli().generate;
}
export function resolveRerankModelForCli(): string {
return ensureModelsConfiguredForCli().rerank;
}
function resolveModelsForCli(): { embed: string; generate: string; rerank: string } {
return ensureModelsConfiguredForCli();
}
async function vectorIndex(
model: string = resolveEmbedModelForCli(),
force: boolean = false,
@ -3235,12 +3265,343 @@ function doctorCheck(label: string, ok: boolean, details: string): void {
console.log(`${mark} ${label}: ${details}`);
}
function formatCount(n: number): string {
return n.toLocaleString("en-US");
}
function shortModelName(model: string): string {
if (model.startsWith("hf:")) {
return model.split("/").pop() || model;
}
return model.length > 56 ? `${model.slice(0, 53)}...` : model;
}
function normalizedDoctorNextSteps(steps: string[]): string[] {
const unique = Array.from(new Set(steps));
const hasForceEmbed = unique.some(step => step.includes("qmd embed --force"));
if (!hasForceEmbed) return unique;
return unique.filter(step => !step.includes("qmd embed") || step.startsWith("Run `qmd embed --force`"));
}
function shortHashSeq(hashSeq: string): string {
const idx = hashSeq.lastIndexOf("_");
if (idx < 0) return hashSeq.length > 18 ? `${hashSeq.slice(0, 18)}...` : hashSeq;
return `${hashSeq.slice(0, 12)}_${hashSeq.slice(idx + 1)}`;
}
type DoctorVectorSampleResult = {
ok: boolean;
details: string;
};
function decodeStoredEmbedding(bytes: Uint8Array): Float32Array {
return new Float32Array(bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength));
}
function cosineDistance(a: ArrayLike<number>, b: ArrayLike<number>): number {
if (a.length !== b.length || a.length === 0) return Number.POSITIVE_INFINITY;
let dot = 0;
let normA = 0;
let normB = 0;
for (let i = 0; i < a.length; i++) {
const av = a[i] ?? 0;
const bv = b[i] ?? 0;
dot += av * bv;
normA += av * av;
normB += bv * bv;
}
if (normA === 0 || normB === 0) return Number.POSITIVE_INFINITY;
return 1 - (dot / (Math.sqrt(normA) * Math.sqrt(normB)));
}
function isGgufFile(path: string): boolean {
if (!existsSync(path)) return false;
let fd: number | null = null;
try {
fd = openSync(path, "r");
const header = Buffer.alloc(4);
readSync(fd, header, 0, 4, 0);
return header.toString("utf-8") === "GGUF";
} catch {
return false;
} finally {
if (fd !== null) closeSync(fd);
}
}
function findCachedModelPath(model: string): string | null {
if (model.startsWith("hf:")) {
const filename = model.split("/").pop();
if (!filename || !existsSync(DEFAULT_MODEL_CACHE_DIR)) return null;
const entries = readdirSync(DEFAULT_MODEL_CACHE_DIR, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isFile() || !entry.name.includes(filename)) continue;
const candidate = pathJoin(DEFAULT_MODEL_CACHE_DIR, entry.name);
if (isGgufFile(candidate)) return candidate;
}
return null;
}
return existsSync(model) && isGgufFile(model) ? model : null;
}
type EnvOverride = {
name: string;
value: string;
consequence: string;
};
function envValueForDisplay(value: string): string {
const sanitized = sanitizeDiagnosticMessage(value);
return sanitized.length > 96 ? `${sanitized.slice(0, 93)}...` : sanitized;
}
function collectEnvironmentOverrides(activeModels: { embed: string; generate: string; rerank: string }): EnvOverride[] {
const configModels = loadConfig().models ?? {};
const overrides: EnvOverride[] = [];
const add = (name: string, consequence: string) => {
const raw = process.env[name]?.trim();
if (!raw) return;
overrides.push({ name, value: envValueForDisplay(raw), consequence });
};
const addModel = (name: string, key: "embed" | "generate" | "rerank", active: string) => {
const raw = process.env[name]?.trim();
if (!raw) return;
const configured = configModels[key];
const consequence = configured && configured !== raw
? `set but ignored because index models.${key} is configured as ${configured}`
: `sets the active ${key} model to ${active}; changes embedding/search semantics and may require \`qmd pull\` plus \`qmd embed\``;
overrides.push({ name, value: envValueForDisplay(raw), consequence });
};
add("INDEX_PATH", "overrides the SQLite index path; QMD reads/writes a different database");
add("QMD_CONFIG_DIR", "overrides the QMD config directory and takes precedence over XDG_CONFIG_HOME");
add("XDG_CONFIG_HOME", "moves QMD config to $XDG_CONFIG_HOME/qmd when QMD_CONFIG_DIR is not set");
add("XDG_CACHE_HOME", "moves the default index cache, model cache, and MCP daemon PID files");
addModel("QMD_EMBED_MODEL", "embed", activeModels.embed);
addModel("QMD_GENERATE_MODEL", "generate", activeModels.generate);
addModel("QMD_RERANK_MODEL", "rerank", activeModels.rerank);
add("QMD_FORCE_CPU", "forces llama.cpp to bypass GPU backends; embeddings/query will be slower but GPU crashes are avoided");
add("QMD_LLAMA_GPU", "selects llama.cpp GPU backend (metal/cuda/vulkan) or disables GPU when set to false/off/0");
add("QMD_DOCTOR_DEVICE_PROBE", "controls qmd doctor native device probing; 0/off skips GPU probing");
add("QMD_STATUS_DEVICE_PROBE", "controls qmd status native device probing only; qmd doctor probes independently");
add("QMD_EMBED_PARALLELISM", "overrides embedding parallel context count; too high can exhaust RAM/VRAM");
add("QMD_EXPAND_CONTEXT_SIZE", "overrides query expansion context size; larger values use more memory");
add("QMD_RERANK_CONTEXT_SIZE", "overrides reranker context size; larger values use more memory");
add("QMD_EMBED_CONTEXT_SIZE", "overrides embed context size; larger values use more memory");
add("QMD_EDITOR_URI", "overrides clickable editor link template in terminal output");
add("QMD_SKILLS_DIR", "overrides where qmd skills are discovered from");
add("QMD_DISABLE_DARWIN_QUERY_JSON_SAFE_EXIT", "disables macOS JSON-query safe exit workaround; may re-expose Metal finalizer crashes");
add("NO_COLOR", "disables colored terminal output");
add("CI", "disables real LLM operations inside QMD's LlamaCpp wrapper");
add("HF_ENDPOINT", "changes Hugging Face download endpoint used when pulling models");
add("QMD_WRAPPER_CAPTURE", "test/debug hook for the qmd shell wrapper; should not be set in normal use");
add("WSL_DISTRO_NAME", "enables WSL path handling heuristics");
add("WSL_INTEROP", "enables WSL path handling heuristics");
return overrides;
}
function checkEnvironmentOverrides(activeModels: { embed: string; generate: string; rerank: string }): void {
const overrides = collectEnvironmentOverrides(activeModels);
if (overrides.length === 0) {
doctorCheck("environment overrides", true, "none");
return;
}
doctorCheck("environment overrides", false, `${overrides.length} set`);
for (const override of overrides) {
console.log(` - ${override.name}=${override.value}: ${override.consequence}`);
}
}
function checkModelDefaults(activeModels: { embed: string; generate: string; rerank: string }, _nextSteps: string[]): void {
const configModels = loadConfig().models ?? {};
const checks = [
{ role: "embedding", key: "embed", active: activeModels.embed, configured: configModels.embed, defaultModel: DEFAULT_EMBED_MODEL, envName: "QMD_EMBED_MODEL", envValue: process.env.QMD_EMBED_MODEL },
{ role: "generation", key: "generate", active: activeModels.generate, configured: configModels.generate, defaultModel: DEFAULT_QUERY_MODEL, envName: "QMD_GENERATE_MODEL", envValue: process.env.QMD_GENERATE_MODEL },
{ role: "reranking", key: "rerank", active: activeModels.rerank, configured: configModels.rerank, defaultModel: DEFAULT_RERANK_MODEL, envName: "QMD_RERANK_MODEL", envValue: process.env.QMD_RERANK_MODEL },
] as const;
const notes: string[] = [];
for (const check of checks) {
const envValue = check.envValue?.trim();
if (envValue && check.active === envValue) {
notes.push(`${check.role}: env ${check.envName}=${check.active} (default ${check.defaultModel}; might be ok)`);
} else if (check.configured && check.configured !== check.defaultModel) {
notes.push(`${check.role}: index ${check.configured} (default ${check.defaultModel}; might be ok)`);
} else if (envValue && check.active !== envValue) {
notes.push(`${check.role}: ${check.envName} is set to ${envValue} but index config uses ${check.active}`);
}
}
if (notes.length === 0) {
doctorCheck("model defaults", true, "using QMD codebase defaults");
return;
}
doctorCheck("model defaults", false, `non-default model configuration: ${notes.join("; ")}`);
}
function checkModelCache(activeModels: { embed: string; generate: string; rerank: string }, nextSteps: string[]): void {
const models = [
["embedding", activeModels.embed],
["generation", activeModels.generate],
["reranking", activeModels.rerank],
] as const;
const unique = new Map<string, string[]>();
for (const [role, model] of models) {
unique.set(model, [...(unique.get(model) ?? []), role]);
}
const missing: string[] = [];
const cached: string[] = [];
for (const [model, roles] of unique) {
const label = `${roles.join("+")}: ${model}`;
const path = findCachedModelPath(model);
if (path) {
cached.push(label);
} else {
missing.push(label);
}
}
if (missing.length === 0) {
doctorCheck("model cache", true, `${cached.length} active ${cached.length === 1 ? "model is" : "models are"} downloaded`);
} else {
doctorCheck("model cache", false, `missing ${missing.length}/${unique.size}: ${missing.join("; ")}. Next: run \`qmd pull\``);
nextSteps.push("Run `qmd pull` to download missing embedding/generation/reranking models before `qmd embed` or `qmd query`.");
}
}
async function checkEmbeddingVectorSamples(db: Database, model: string, fingerprint: string, sampleSize: number = 3): Promise<DoctorVectorSampleResult> {
const activeDocs = (db.prepare(`SELECT COUNT(*) AS count FROM documents WHERE active = 1`).get() as { count: number }).count;
if (activeDocs === 0) {
return { ok: true, details: "no active documents indexed" };
}
const vecTableExists = db.prepare(`SELECT 1 FROM sqlite_master WHERE type='table' AND name='vectors_vec'`).get();
if (!vecTableExists) {
return { ok: false, details: "no vector table to test; please run qmd embed again" };
}
const samples = db.prepare(`
SELECT cv.hash, cv.seq, c.doc AS body, MIN(d.path) AS path
FROM content_vectors cv
JOIN documents d ON d.hash = cv.hash AND d.active = 1
JOIN content c ON c.hash = cv.hash
WHERE cv.model = ? AND cv.embed_fingerprint = ?
GROUP BY cv.hash, cv.seq, c.doc
ORDER BY random()
LIMIT ?
`).all(model, fingerprint, sampleSize) as { hash: string; seq: number; body: string; path: string }[];
if (samples.length === 0) {
return { ok: false, details: "no current embedded chunks to test; please run qmd embed again" };
}
const threshold = 0.0001;
const mismatches: string[] = [];
await withLLMSession(async (session) => {
for (const sample of samples) {
const hashSeq = `${sample.hash}_${sample.seq}`;
const chunks = await chunkDocumentByTokens(sample.body, undefined, undefined, undefined, sample.path, undefined, session.signal);
const chunk = chunks[sample.seq];
if (!chunk) {
mismatches.push(`${shortHashSeq(hashSeq)}: chunk no longer exists`);
continue;
}
const title = extractTitle(sample.body, sample.path);
const result = await session.embed(formatDocForEmbedding(chunk.text, title, model), { model });
if (!result) {
mismatches.push(`${shortHashSeq(hashSeq)}: embedding failed`);
continue;
}
const stored = db.prepare(`SELECT embedding FROM vectors_vec WHERE hash_seq = ?`).get(hashSeq) as { embedding: Uint8Array } | undefined;
if (!stored) {
mismatches.push(`${shortHashSeq(hashSeq)}: stored vector missing`);
continue;
}
const distance = cosineDistance(result.embedding, decodeStoredEmbedding(stored.embedding));
if (distance > threshold) {
mismatches.push(`${shortHashSeq(hashSeq)}: stored vector distance ${distance.toFixed(6)}`);
}
}
}, { maxDuration: 10 * 60 * 1000, name: "doctorEmbeddingVectorSample" });
if (mismatches.length > 0) {
return {
ok: false,
details: `${mismatches.length}/${samples.length} sampled chunks differ from stored vectors (${mismatches[0]}). Rebuild with \`qmd embed --force\``,
};
}
return {
ok: true,
details: `${samples.length} sampled ${samples.length === 1 ? "chunk" : "chunks"} reproduce stored vectors`,
};
}
async function runDoctorDeviceChecks(nextSteps: string[]): Promise<void> {
const mode = configuredGpuModeLabel();
doctorCheck("device mode", true, mode);
const skipProbe = ["0", "false", "off", "no", "skip"].includes((process.env.QMD_DOCTOR_DEVICE_PROBE ?? "").trim().toLowerCase());
if (skipProbe) {
doctorCheck("device probe", false, "skipped by QMD_DOCTOR_DEVICE_PROBE=0. Next: unset it and rerun `qmd doctor` to verify GPU/CPU acceleration");
nextSteps.push("Unset `QMD_DOCTOR_DEVICE_PROBE` and rerun `qmd doctor` when you want to verify llama.cpp device acceleration.");
return;
}
const crashHint = "Probing native llama backend now. If qmd crashes here, rerun with `QMD_FORCE_CPU=1 qmd doctor` (or `QMD_DOCTOR_DEVICE_PROBE=0 qmd doctor` to skip this probe).";
if (process.stdout.isTTY) {
process.stdout.write(`${c.dim}${crashHint}${c.reset}`);
}
try {
const device = await getDefaultLlamaCpp().getDeviceInfo({ allowBuild: false });
if (process.stdout.isTTY) {
process.stdout.write(`\r${" ".repeat(crashHint.length)}\r`);
}
if (device.gpu) {
const gpuLabel = device.gpu === "metal" && process.platform === "darwin"
? "metal (macOS Metal backend)"
: String(device.gpu);
const parts = [`GPU ${gpuLabel}`, `offloading ${device.gpuOffloading ? "enabled" : "disabled"}`];
if (device.gpuDevices.length > 0) parts.push(`devices: ${summarizeDeviceNames(device.gpuDevices)}`);
if (device.vram) parts.push(`VRAM ${formatBytes(device.vram.free)} free / ${formatBytes(device.vram.total)} total`);
parts.push(`${device.cpuCores} CPU math cores`);
doctorCheck("device probe", device.gpuOffloading, device.gpuOffloading
? parts.join("; ")
: `${parts.join("; ")}. Next: check QMD_LLAMA_GPU and llama.cpp backend support`);
if (!device.gpuOffloading) {
nextSteps.push("GPU was detected but offloading is disabled; check `QMD_LLAMA_GPU=metal|cuda|vulkan` and rerun `qmd doctor`.");
}
} else {
doctorCheck("device probe", false, `running on CPU (${device.cpuCores} math cores). Next: install/configure Metal, CUDA, or Vulkan for faster embeddings, or set QMD_FORCE_CPU=1 to make CPU mode explicit`);
nextSteps.push("Vector operations are running on CPU; install/configure Metal, CUDA, or Vulkan if embedding/query performance is too slow.");
}
} catch (error) {
if (process.stdout.isTTY) {
process.stdout.write(`\r${" ".repeat(crashHint.length)}\r`);
}
const message = error instanceof Error ? sanitizeDiagnosticMessage(error.message) : sanitizeDiagnosticMessage(String(error));
doctorCheck("device probe", false, `probe failed: ${message}. Next: run with QMD_FORCE_CPU=1 to bypass GPU probing, or set QMD_LLAMA_GPU=metal|cuda|vulkan and retry`);
nextSteps.push("GPU probe failed; try `QMD_FORCE_CPU=1 qmd doctor` to confirm CPU fallback, then fix GPU drivers/backend if acceleration is expected.");
}
}
async function showDoctor(): Promise<void> {
const storeInstance = getStore();
const db = storeInstance.db;
const pkg = readPackageJson();
const embedModel = resolveEmbedModelForCli();
const activeModels = resolveModelsForCli();
const embedModel = activeModels.embed;
const fingerprint = getEmbeddingFingerprint(embedModel);
const nextSteps: string[] = [];
console.log(`${c.bold}QMD Doctor${c.reset}\n`);
console.log(`Index: ${getDbPath()}`);
@ -3254,7 +3615,7 @@ async function showDoctor(): Promise<void> {
}
const betterSqliteVersion = pkg.dependencies?.["better-sqlite3"] ?? pkg.devDependencies?.["better-sqlite3"] ?? "not declared";
doctorCheck("better_sqlite version", true, String(betterSqliteVersion));
doctorCheck("better-sqlite3 package", true, String(betterSqliteVersion));
try {
const row = db.prepare(`SELECT vec_version() AS version`).get() as { version: string };
@ -3263,6 +3624,12 @@ async function showDoctor(): Promise<void> {
doctorCheck("sqlite-vec", false, error instanceof Error ? error.message : String(error));
}
checkEnvironmentOverrides(activeModels);
checkModelDefaults(activeModels, nextSteps);
checkModelCache(activeModels, nextSteps);
await runDoctorDeviceChecks(nextSteps);
try {
const adoption = await maybeAdoptLegacyEmbeddingFingerprint(storeInstance, embedModel);
if (adoption.checked || adoption.adopted > 0) {
@ -3274,7 +3641,10 @@ async function showDoctor(): Promise<void> {
try {
const pending = getHashesNeedingEmbedding(db, undefined, embedModel);
doctorCheck("embedding freshness", pending === 0, pending === 0 ? "all active documents match current fingerprint" : `${pending} active documents need embedding`);
doctorCheck("embedding freshness", pending === 0, pending === 0 ? "all active documents match current fingerprint" : `${formatCount(pending)} active documents need embeddings. Next: \`qmd embed\``);
if (pending > 0) {
nextSteps.push(`Run \`qmd embed\` to generate ${formatCount(pending)} missing/stale document embeddings.`);
}
} catch (error) {
doctorCheck("embedding freshness", false, error instanceof Error ? error.message : String(error));
}
@ -3289,30 +3659,45 @@ async function showDoctor(): Promise<void> {
const uniqueFingerprints = new Set(rows.map(row => row.fingerprint));
const offCurrent = rows.filter(row => row.model === embedModel && row.fingerprint !== fingerprint);
const ok = rows.length === 0 || (uniqueFingerprints.size === 1 && rows[0]?.fingerprint === fingerprint && offCurrent.length === 0);
const currentDocs = rows
.filter(row => row.model === embedModel && row.fingerprint === fingerprint)
.reduce((sum, row) => sum + row.docs, 0);
const otherDocs = rows.reduce((sum, row) => sum + row.docs, 0) - currentDocs;
const groups = rows.map(row => {
const label = row.fingerprint === fingerprint ? "current" : (row.fingerprint || "legacy");
return `${shortModelName(row.model)}:${label} ${formatCount(row.docs)} docs/${formatCount(row.chunks)} chunks`;
}).join("; ");
const details = rows.length === 0
? `none yet; current ${fingerprint}`
: rows.map(row => {
const label = row.fingerprint === fingerprint ? "current" : (row.fingerprint || "legacy");
return `${row.model}:${label} ${row.docs} docs/${row.chunks} chunks`;
}).join("; ");
? `no vectors yet; current fingerprint ${fingerprint}`
: ok
? `${formatCount(currentDocs)} docs on current fingerprint (${fingerprint})`
: `${formatCount(currentDocs)} docs current, ${formatCount(otherDocs)} docs legacy/stale. ${groups}. Next: \`qmd embed\``;
doctorCheck("embedding fingerprints", ok, details);
if (!ok) {
nextSteps.push("Run `qmd embed` to migrate active documents to the current embedding fingerprint; use `qmd embed --force` if vector samples still fail afterward.");
}
} catch (error) {
doctorCheck("embedding fingerprints", false, error instanceof Error ? error.message : String(error));
}
const sample = db.prepare(`
SELECT c.hash, c.doc
FROM documents d
JOIN content c ON c.hash = d.hash
WHERE d.active = 1
ORDER BY random()
LIMIT 1
`).get() as { hash: string; doc: string } | undefined;
if (sample) {
const rehashed = await hashContent(sample.doc);
doctorCheck("content hash sample", rehashed === sample.hash, `${sample.hash.slice(0, 12)} ${rehashed === sample.hash ? "matches" : `!= ${rehashed.slice(0, 12)}`}`);
} else {
doctorCheck("content hash sample", true, "no active documents indexed");
try {
const vectorSample = await checkEmbeddingVectorSamples(db, embedModel, fingerprint);
doctorCheck("embedding vector sample", vectorSample.ok, vectorSample.details);
if (!vectorSample.ok) {
nextSteps.push("Run `qmd embed --force` to rebuild existing vectors that no longer reproduce under the current embedding pipeline.");
}
} catch (error) {
const message = error instanceof Error ? sanitizeDiagnosticMessage(error.message) : sanitizeDiagnosticMessage(String(error));
doctorCheck("embedding vector sample", false, `${message}; rebuild with \`qmd embed --force\``);
nextSteps.push("Run `qmd embed --force` to rebuild existing vectors, then rerun `qmd doctor`.");
}
const steps = normalizedDoctorNextSteps(nextSteps);
if (steps.length > 0) {
console.log(`\n${c.bold}Recommended next step${steps.length === 1 ? "" : "s"}${c.reset}`);
for (const step of steps) {
console.log(` - ${step}`);
}
}
closeDb();

View File

@ -1435,51 +1435,50 @@ function resolveEmbedOptions(options?: EmbedOptions): Required<Pick<EmbedOptions
};
}
function contentVectorSchemaRepairFor(error: unknown): "embed_fingerprint" | "total_chunks" | null {
const CONTENT_VECTOR_DESIRED_COLUMNS: { name: string; definition: string }[] = [
{ name: "seq", definition: "INTEGER NOT NULL DEFAULT 0" },
{ name: "pos", definition: "INTEGER NOT NULL DEFAULT 0" },
{ name: "model", definition: "TEXT NOT NULL DEFAULT ''" },
{ name: "embed_fingerprint", definition: "TEXT NOT NULL DEFAULT ''" },
{ name: "total_chunks", definition: "INTEGER NOT NULL DEFAULT 1" },
{ name: "embedded_at", definition: "TEXT NOT NULL DEFAULT ''" },
];
function isContentVectorColumnError(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error);
if (
message.includes("no such column: embed_fingerprint") ||
message.includes("has no column named embed_fingerprint")
) {
return "embed_fingerprint";
if (!/(no such column|has no column named)/i.test(message)) {
return false;
}
if (
message.includes("no such column: total_chunks") ||
message.includes("has no column named total_chunks")
) {
return "total_chunks";
}
return null;
return CONTENT_VECTOR_DESIRED_COLUMNS.some(col => message.includes(col.name));
}
function repairContentVectorColumn(db: Database, column: "embed_fingerprint" | "total_chunks"): void {
try {
if (column === "embed_fingerprint") {
db.exec(`ALTER TABLE content_vectors ADD COLUMN embed_fingerprint TEXT NOT NULL DEFAULT ''`);
} else {
db.exec(`ALTER TABLE content_vectors ADD COLUMN total_chunks INTEGER NOT NULL DEFAULT 1`);
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
// Another caller may have already repaired the column between error and ALTER.
if (!message.includes("duplicate column name")) {
throw error;
function runContentVectorColumnRepairs(db: Database): void {
for (const column of CONTENT_VECTOR_DESIRED_COLUMNS) {
try {
db.exec(`ALTER TABLE content_vectors ADD COLUMN ${column.name} ${column.definition}`);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
// The repair series is intentionally idempotent: most columns should
// already exist, and another caller may have repaired a missing column
// between the failed query and this ALTER series.
if (!message.includes("duplicate column name")) {
throw error;
}
}
}
}
function withLazyContentVectorMigration<T>(db: Database, operation: () => T): T {
const repaired = new Set<string>();
let repaired = false;
while (true) {
try {
return operation();
} catch (error) {
const column = contentVectorSchemaRepairFor(error);
if (!column || repaired.has(column)) {
if (repaired || !isContentVectorColumnError(error)) {
throw error;
}
repairContentVectorColumn(db, column);
repaired.add(column);
runContentVectorColumnRepairs(db);
repaired = true;
}
}
}
@ -2076,7 +2075,7 @@ export async function maybeAdoptLegacyEmbeddingFingerprint(store: Store, model:
return { checked: false, adopted: 0, reason: "no legacy empty-fingerprint embeddings" };
}
const sample = db.prepare(`
const sample = withLazyContentVectorMigration(db, () => db.prepare(`
SELECT cv.hash, cv.seq, cv.pos, cv.total_chunks, c.doc AS body, MIN(d.path) AS path
FROM content_vectors cv
JOIN documents d ON d.hash = cv.hash AND d.active = 1
@ -2085,7 +2084,7 @@ export async function maybeAdoptLegacyEmbeddingFingerprint(store: Store, model:
GROUP BY cv.hash, cv.seq, cv.pos, cv.total_chunks, c.doc
ORDER BY cv.hash, cv.seq
LIMIT 1
`).get(model) as { hash: string; seq: number; pos: number; total_chunks: number; body: string; path: string } | undefined;
`).get(model) as { hash: string; seq: number; pos: number; total_chunks: number; body: string; path: string } | undefined);
if (!sample) {
return { checked: false, adopted: 0, reason: `${legacyCount} legacy docs have no active sample` };
@ -2127,7 +2126,7 @@ export async function maybeAdoptLegacyEmbeddingFingerprint(store: Store, model:
return { checked: true, adopted: 0, reason: `legacy sample differs from current fingerprint (nearest ${nearest.hash_seq}, distance ${nearest.distance.toFixed(6)})` };
}
const update = db.prepare(`UPDATE content_vectors SET embed_fingerprint = ? WHERE model = ? AND embed_fingerprint = ''`).run(fingerprint, model);
const update = withLazyContentVectorMigration(db, () => db.prepare(`UPDATE content_vectors SET embed_fingerprint = ? WHERE model = ? AND embed_fingerprint = ''`).run(fingerprint, model));
return { checked: true, adopted: update.changes, reason: `sample ${expectedHashSeq} matched current fingerprint at distance ${nearest.distance.toFixed(6)}` };
});
}
@ -2232,36 +2231,38 @@ export function cleanupOrphanedVectors(db: Database): number {
return 0;
}
// Count orphaned vectors first
const countResult = db.prepare(`
SELECT COUNT(*) as c FROM content_vectors cv
WHERE NOT EXISTS (
SELECT 1 FROM documents d WHERE d.hash = cv.hash AND d.active = 1
)
`).get() as { c: number };
if (countResult.c === 0) {
return 0;
}
// Delete from vectors_vec first
db.exec(`
DELETE FROM vectors_vec WHERE hash_seq IN (
SELECT cv.hash || '_' || cv.seq FROM content_vectors cv
return withLazyContentVectorMigration(db, () => {
// Count orphaned vectors first
const countResult = db.prepare(`
SELECT COUNT(*) as c FROM content_vectors cv
WHERE NOT EXISTS (
SELECT 1 FROM documents d WHERE d.hash = cv.hash AND d.active = 1
)
)
`);
`).get() as { c: number };
// Delete from content_vectors
db.exec(`
DELETE FROM content_vectors WHERE hash NOT IN (
SELECT hash FROM documents WHERE active = 1
)
`);
if (countResult.c === 0) {
return 0;
}
return countResult.c;
// Delete from vectors_vec first
db.exec(`
DELETE FROM vectors_vec WHERE hash_seq IN (
SELECT cv.hash || '_' || cv.seq FROM content_vectors cv
WHERE NOT EXISTS (
SELECT 1 FROM documents d WHERE d.hash = cv.hash AND d.active = 1
)
)
`);
// Delete from content_vectors
db.exec(`
DELETE FROM content_vectors WHERE hash NOT IN (
SELECT hash FROM documents WHERE active = 1
)
`);
return countResult.c;
});
}
/**
@ -3426,10 +3427,10 @@ export async function searchVec(db: Database, query: string, model: string, limi
params.push(collectionName);
}
const docRows = db.prepare(docSql).all(...params) as {
const docRows = withLazyContentVectorMigration(db, () => db.prepare(docSql).all(...params) as {
hash_seq: string; hash: string; pos: number; filepath: string;
display_path: string; title: string; body: string;
}[];
}[]);
// Combine with distances and dedupe by filepath
const seen = new Map<string, { row: typeof docRows[0]; bestDist: number }>();
@ -3538,30 +3539,32 @@ export function clearAllEmbeddings(db: Database, collection?: string): void {
.prepare(`SELECT 1 FROM sqlite_master WHERE type='table' AND name='vectors_vec'`)
.get();
if (vecTableExists) {
const hashSeqRows = db.prepare(`
SELECT cv.hash, cv.seq
FROM content_vectors cv
WHERE cv.hash IN (${exclusiveHashesQuery})
`).all(collection) as { hash: string; seq: number }[];
withLazyContentVectorMigration(db, () => {
if (vecTableExists) {
const hashSeqRows = db.prepare(`
SELECT cv.hash, cv.seq
FROM content_vectors cv
WHERE cv.hash IN (${exclusiveHashesQuery})
`).all(collection) as { hash: string; seq: number }[];
const delVec = db.prepare(`DELETE FROM vectors_vec WHERE hash_seq = ?`);
for (const row of hashSeqRows) {
delVec.run(`${row.hash}_${row.seq}`);
const delVec = db.prepare(`DELETE FROM vectors_vec WHERE hash_seq = ?`);
for (const row of hashSeqRows) {
delVec.run(`${row.hash}_${row.seq}`);
}
}
}
db.prepare(`
DELETE FROM content_vectors
WHERE hash IN (${exclusiveHashesQuery})
`).run(collection);
db.prepare(`
DELETE FROM content_vectors
WHERE hash IN (${exclusiveHashesQuery})
`).run(collection);
const remaining = db
.prepare(`SELECT COUNT(*) AS n FROM content_vectors`)
.get() as { n: number };
if (remaining.n === 0) {
db.exec(`DROP TABLE IF EXISTS vectors_vec`);
}
const remaining = db
.prepare(`SELECT COUNT(*) AS n FROM content_vectors`)
.get() as { n: number };
if (remaining.n === 0) {
db.exec(`DROP TABLE IF EXISTS vectors_vec`);
}
});
}
/**
@ -3601,23 +3604,25 @@ export function insertEmbedding(
}
function removeIncompleteEmbeddings(db: Database, expectedChunksByHash: Map<string, number>, model: string): number {
let removed = 0;
const rowsStmt = db.prepare(`SELECT seq FROM content_vectors WHERE hash = ? AND model = ?`);
const deleteContentStmt = db.prepare(`DELETE FROM content_vectors WHERE hash = ? AND model = ?`);
const deleteVecStmt = db.prepare(`DELETE FROM vectors_vec WHERE hash_seq = ?`);
return withLazyContentVectorMigration(db, () => {
let removed = 0;
const rowsStmt = db.prepare(`SELECT seq FROM content_vectors WHERE hash = ? AND model = ?`);
const deleteContentStmt = db.prepare(`DELETE FROM content_vectors WHERE hash = ? AND model = ?`);
const deleteVecStmt = db.prepare(`DELETE FROM vectors_vec WHERE hash_seq = ?`);
for (const [hash, expectedChunks] of expectedChunksByHash) {
const rows = rowsStmt.all(hash, model) as { seq: number }[];
if (rows.length === 0 || rows.length === expectedChunks) continue;
for (const [hash, expectedChunks] of expectedChunksByHash) {
const rows = rowsStmt.all(hash, model) as { seq: number }[];
if (rows.length === 0 || rows.length === expectedChunks) continue;
for (const row of rows) {
deleteVecStmt.run(`${hash}_${row.seq}`);
for (const row of rows) {
deleteVecStmt.run(`${hash}_${row.seq}`);
}
deleteContentStmt.run(hash, model);
removed += rows.length;
}
deleteContentStmt.run(hash, model);
removed += rows.length;
}
return removed;
return removed;
});
}
// =============================================================================

View File

@ -15,7 +15,7 @@ import { spawn } from "child_process";
import { setTimeout as sleep } from "timers/promises";
import { buildEditorUri, termLink, resolveEmbedModelForCli } from "../src/cli/qmd.ts";
import { openDatabase } from "../src/db.ts";
import { DEFAULT_EMBED_MODEL_URI } from "../src/llm.ts";
import { DEFAULT_EMBED_MODEL_URI, DEFAULT_GENERATE_MODEL_URI, DEFAULT_RERANK_MODEL_URI } from "../src/llm.ts";
// Test fixtures directory and database path
let testDir: string;
@ -472,9 +472,57 @@ describe("CLI Status Command", () => {
expect(stdout).toContain("QMD Doctor");
expect(stdout).toContain("SQLite runtime");
expect(stdout).toContain("sqlite-vec");
expect(stdout).toContain("environment overrides");
expect(stdout).toContain("INDEX_PATH");
expect(stdout).toContain("overrides the SQLite index path");
expect(stdout).toContain("QMD_CONFIG_DIR");
expect(stdout).toContain("overrides the QMD config directory");
expect(stdout).toContain("model defaults");
expect(stdout).toContain("model cache");
expect(stdout).toContain("device mode");
expect(stdout).toContain("device probe");
expect(stdout).toContain("embedding freshness");
expect(stdout).toContain("embedding fingerprints");
expect(stdout).toContain("content hash sample");
expect(stdout).toContain("embedding vector sample");
expect(stdout).toContain("please run qmd embed again");
const configText = readFileSync(join(testConfigDir, "index.yml"), "utf-8");
expect(configText).toContain("models:");
expect(configText).toContain(DEFAULT_EMBED_MODEL_URI);
expect(configText).toContain(DEFAULT_GENERATE_MODEL_URI);
expect(configText).toContain(DEFAULT_RERANK_MODEL_URI);
});
test("qmd doctor warns when configured models differ from code defaults", async () => {
const env = await createIsolatedTestEnv("doctor-custom-models");
await writeFile(join(env.configDir, "index.yml"), `collections: {}\nmodels:\n embed: hf:example/custom-embed/custom.gguf\n generate: ${DEFAULT_GENERATE_MODEL_URI}\n rerank: ${DEFAULT_RERANK_MODEL_URI}\n`);
const { stdout, exitCode } = await runQmd(["doctor"], { dbPath: env.dbPath, configDir: env.configDir });
expect(exitCode).toBe(0);
expect(stdout).toContain("model defaults");
expect(stdout).toContain("non-default model configuration");
expect(stdout).toContain("index hf:example/custom-embed/custom.gguf");
expect(stdout).toContain("might be ok");
expect(stdout).toContain("qmd pull");
});
test("qmd doctor says when models are overridden by env", async () => {
const env = await createIsolatedTestEnv("doctor-env-models");
await writeFile(join(env.configDir, "index.yml"), "collections: {}\n");
const customEmbed = "hf:example/env-embed/custom.gguf";
const { stdout, exitCode } = await runQmd(["doctor"], {
dbPath: env.dbPath,
configDir: env.configDir,
env: { QMD_EMBED_MODEL: customEmbed },
});
expect(exitCode).toBe(0);
expect(stdout).toContain("model defaults");
expect(stdout).toContain(`env QMD_EMBED_MODEL=${customEmbed}`);
expect(stdout).toContain("might be ok");
expect(stdout).toContain("environment overrides");
expect(stdout).toContain(`QMD_EMBED_MODEL=${customEmbed}`);
expect(stdout).toContain("sets the active embed model");
});
test("qmd doctor flags mixed embedding fingerprints", async () => {
@ -1564,6 +1612,16 @@ describe("status and collection list hide filesystem paths", () => {
expect(pathLines.length).toBe(0);
});
test("doctor does not show full filesystem paths", async () => {
const { stdout, exitCode } = await runQmd(["doctor"], { dbPath: localDbPath, configDir: localConfigDir });
expect(exitCode).toBe(0);
expect(stdout).toContain("QMD Doctor");
const lines = stdout.split('\n').filter(l => !l.includes('Index:'));
const pathLines = lines.filter(l => l.includes('/Users/') || l.includes('/home/') || l.includes('/tmp/'));
expect(pathLines.length).toBe(0);
});
test("collection list does not show full filesystem paths", async () => {
const { stdout, exitCode } = await runQmd(["collection", "list"], { dbPath: localDbPath, configDir: localConfigDir });
expect(exitCode).toBe(0);

View File

@ -380,6 +380,59 @@ describe("Store Creation", () => {
await cleanupTestDb(store);
});
test("content_vectors column repair runs the full ALTER series and retries the failed operation", async () => {
const dbPath = join(testDir, `legacy-no-seq-${Date.now()}-${Math.random().toString(36).slice(2)}.sqlite`);
const model = "hf:test/embed-model.gguf";
const legacyDb = openDatabase(dbPath);
legacyDb.exec(`
CREATE TABLE content (
hash TEXT PRIMARY KEY,
doc TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE TABLE documents (
id INTEGER PRIMARY KEY AUTOINCREMENT,
collection TEXT NOT NULL,
path TEXT NOT NULL,
title TEXT,
hash TEXT NOT NULL,
created_at TEXT NOT NULL,
modified_at TEXT NOT NULL,
active INTEGER NOT NULL DEFAULT 1,
FOREIGN KEY (hash) REFERENCES content(hash) ON DELETE CASCADE,
UNIQUE(collection, path)
);
CREATE TABLE content_vectors (
hash TEXT NOT NULL,
model TEXT NOT NULL,
embed_fingerprint TEXT NOT NULL DEFAULT '',
total_chunks INTEGER NOT NULL DEFAULT 1,
embedded_at TEXT NOT NULL
)
`);
legacyDb.close();
const store = createStore(dbPath);
let columns = store.db.prepare(`PRAGMA table_info(content_vectors)`).all() as { name: string }[];
expect(columns.map(col => col.name)).not.toContain("seq");
expect(columns.map(col => col.name)).not.toContain("pos");
store.ensureVecTable(3);
store.insertEmbedding("hash1", 1, 42, new Float32Array([1, 2, 3]), model, new Date().toISOString(), 2);
columns = store.db.prepare(`PRAGMA table_info(content_vectors)`).all() as { name: string }[];
const columnNames = columns.map(col => col.name);
expect(columnNames).toEqual(expect.arrayContaining(["seq", "pos", "model", "embed_fingerprint", "total_chunks", "embedded_at"]));
expect(store.db.prepare(`SELECT seq, pos, model, total_chunks FROM content_vectors WHERE hash = ?`).get("hash1")).toEqual({
seq: 1,
pos: 42,
model,
total_chunks: 2,
});
await cleanupTestDb(store);
});
test("createStore sets WAL journal mode", async () => {
const store = await createTestStore();
const result = store.db.prepare("PRAGMA journal_mode").get() as { journal_mode: string };