fix(memory): harden qmd collection recovery

This commit is contained in:
Vignesh Natarajan
2026-02-22 14:39:28 -08:00
parent 5858de6078
commit 82d34b4b06
3 changed files with 316 additions and 71 deletions

View File

@@ -304,29 +304,9 @@ export class QmdMemoryManager implements MemorySearchManager {
const result = await this.runQmd(["collection", "list", "--json"], {
timeoutMs: this.qmd.update.commandTimeoutMs,
});
const parsed = JSON.parse(result.stdout) as unknown;
if (Array.isArray(parsed)) {
for (const entry of parsed) {
if (typeof entry === "string") {
existing.set(entry, {});
} else if (entry && typeof entry === "object") {
const name = (entry as { name?: unknown }).name;
if (typeof name === "string") {
const listedPath = (entry as { path?: unknown }).path;
const listedPattern = (entry as { pattern?: unknown; mask?: unknown }).pattern;
const listedMask = (entry as { mask?: unknown }).mask;
existing.set(name, {
path: typeof listedPath === "string" ? listedPath : undefined,
pattern:
typeof listedPattern === "string"
? listedPattern
: typeof listedMask === "string"
? listedMask
: undefined,
});
}
}
}
const parsed = this.parseListedCollections(result.stdout);
for (const [name, details] of parsed) {
existing.set(name, details);
}
} catch {
// ignore; older qmd versions might not support list --json.
@@ -444,6 +424,22 @@ export class QmdMemoryManager implements MemorySearchManager {
);
}
private isMissingCollectionSearchError(err: unknown): boolean {
const message = err instanceof Error ? err.message : String(err);
return this.isCollectionMissingError(message) && message.toLowerCase().includes("collection");
}
private async tryRepairMissingCollectionSearch(err: unknown): Promise<boolean> {
if (!this.isMissingCollectionSearchError(err)) {
return false;
}
log.warn(
"qmd search failed because a managed collection is missing; repairing collections and retrying once",
);
await this.ensureCollections();
return true;
}
private async addCollection(pathArg: string, name: string, pattern: string): Promise<void> {
await this.runQmd(["collection", "add", pathArg, "--name", name, "--mask", pattern], {
timeoutMs: this.qmd.update.commandTimeoutMs,
@@ -456,6 +452,92 @@ export class QmdMemoryManager implements MemorySearchManager {
});
}
private parseListedCollections(output: string): Map<string, ListedCollection> {
const listed = new Map<string, ListedCollection>();
const trimmed = output.trim();
if (!trimmed) {
return listed;
}
try {
const parsed = JSON.parse(trimmed) as unknown;
if (Array.isArray(parsed)) {
for (const entry of parsed) {
if (typeof entry === "string") {
listed.set(entry, {});
continue;
}
if (!entry || typeof entry !== "object") {
continue;
}
const name = (entry as { name?: unknown }).name;
if (typeof name !== "string") {
continue;
}
const listedPath = (entry as { path?: unknown }).path;
const listedPattern = (entry as { pattern?: unknown; mask?: unknown }).pattern;
const listedMask = (entry as { mask?: unknown }).mask;
listed.set(name, {
path: typeof listedPath === "string" ? listedPath : undefined,
pattern:
typeof listedPattern === "string"
? listedPattern
: typeof listedMask === "string"
? listedMask
: undefined,
});
}
return listed;
}
} catch {
// Some qmd builds ignore `--json` and still print table output.
}
let currentName: string | null = null;
for (const rawLine of output.split(/\r?\n/)) {
const line = rawLine.trimEnd();
if (!line.trim()) {
currentName = null;
continue;
}
const collectionLine = /^\s*([a-z0-9._-]+)\s+\(qmd:\/\/[^)]+\)\s*$/i.exec(line);
if (collectionLine) {
currentName = collectionLine[1];
if (!listed.has(currentName)) {
listed.set(currentName, {});
}
continue;
}
if (/^\s*collections\b/i.test(line)) {
continue;
}
const bareNameLine = /^\s*([a-z0-9._-]+)\s*$/i.exec(line);
if (bareNameLine && !line.includes(":")) {
currentName = bareNameLine[1];
if (!listed.has(currentName)) {
listed.set(currentName, {});
}
continue;
}
if (!currentName) {
continue;
}
const patternLine = /^\s*(?:pattern|mask)\s*:\s*(.+?)\s*$/i.exec(line);
if (patternLine) {
const existing = listed.get(currentName) ?? {};
existing.pattern = patternLine[1].trim();
listed.set(currentName, existing);
continue;
}
const pathLine = /^\s*path\s*:\s*(.+?)\s*$/i.exec(line);
if (pathLine) {
const existing = listed.get(currentName) ?? {};
existing.path = pathLine[1].trim();
listed.set(currentName, existing);
}
}
return listed;
}
private shouldRebindCollection(collection: ManagedCollection, listed: ListedCollection): boolean {
if (!listed.path) {
// Older qmd versions may only return names from `collection list --json`.
@@ -547,26 +629,28 @@ export class QmdMemoryManager implements MemorySearchManager {
}
const qmdSearchCommand = this.qmd.searchMode;
const mcporterEnabled = this.qmd.mcporter.enabled;
let parsed: QmdQueryResult[];
try {
if (mcporterEnabled) {
const tool: "search" | "vector_search" | "deep_search" =
qmdSearchCommand === "search"
? "search"
: qmdSearchCommand === "vsearch"
? "vector_search"
: "deep_search";
const minScore = opts?.minScore ?? 0;
if (collectionNames.length > 1) {
parsed = await this.runMcporterAcrossCollections({
tool,
query: trimmed,
limit,
minScore,
collectionNames,
});
} else {
parsed = await this.runQmdSearchViaMcporter({
const runSearchAttempt = async (
allowMissingCollectionRepair: boolean,
): Promise<QmdQueryResult[]> => {
try {
if (mcporterEnabled) {
const tool: "search" | "vector_search" | "deep_search" =
qmdSearchCommand === "search"
? "search"
: qmdSearchCommand === "vsearch"
? "vector_search"
: "deep_search";
const minScore = opts?.minScore ?? 0;
if (collectionNames.length > 1) {
return await this.runMcporterAcrossCollections({
tool,
query: trimmed,
limit,
minScore,
collectionNames,
});
}
return await this.runQmdSearchViaMcporter({
mcporter: this.qmd.mcporter,
tool,
query: trimmed,
@@ -576,50 +660,61 @@ export class QmdMemoryManager implements MemorySearchManager {
timeoutMs: this.qmd.limits.timeoutMs,
});
}
} else if (collectionNames.length > 1) {
parsed = await this.runQueryAcrossCollections(
trimmed,
limit,
collectionNames,
qmdSearchCommand,
);
} else {
if (collectionNames.length > 1) {
return await this.runQueryAcrossCollections(
trimmed,
limit,
collectionNames,
qmdSearchCommand,
);
}
const args = this.buildSearchArgs(qmdSearchCommand, trimmed, limit);
args.push(...this.buildCollectionFilterArgs(collectionNames));
// Always scope to managed collections (default + custom). Even for `search`/`vsearch`,
// pass collection filters; if a given QMD build rejects these flags, we fall back to `query`.
const result = await this.runQmd(args, { timeoutMs: this.qmd.limits.timeoutMs });
parsed = parseQmdQueryJson(result.stdout, result.stderr);
}
} catch (err) {
if (
!mcporterEnabled &&
qmdSearchCommand !== "query" &&
this.isUnsupportedQmdOptionError(err)
) {
log.warn(
`qmd ${qmdSearchCommand} does not support configured flags; retrying search with qmd query`,
);
try {
if (collectionNames.length > 1) {
parsed = await this.runQueryAcrossCollections(trimmed, limit, collectionNames, "query");
} else {
return parseQmdQueryJson(result.stdout, result.stderr);
} catch (err) {
if (allowMissingCollectionRepair && this.isMissingCollectionSearchError(err)) {
throw err;
}
if (
!mcporterEnabled &&
qmdSearchCommand !== "query" &&
this.isUnsupportedQmdOptionError(err)
) {
log.warn(
`qmd ${qmdSearchCommand} does not support configured flags; retrying search with qmd query`,
);
try {
if (collectionNames.length > 1) {
return await this.runQueryAcrossCollections(trimmed, limit, collectionNames, "query");
}
const fallbackArgs = this.buildSearchArgs("query", trimmed, limit);
fallbackArgs.push(...this.buildCollectionFilterArgs(collectionNames));
const fallback = await this.runQmd(fallbackArgs, {
timeoutMs: this.qmd.limits.timeoutMs,
});
parsed = parseQmdQueryJson(fallback.stdout, fallback.stderr);
return parseQmdQueryJson(fallback.stdout, fallback.stderr);
} catch (fallbackErr) {
log.warn(`qmd query fallback failed: ${String(fallbackErr)}`);
throw fallbackErr instanceof Error ? fallbackErr : new Error(String(fallbackErr));
}
} catch (fallbackErr) {
log.warn(`qmd query fallback failed: ${String(fallbackErr)}`);
throw fallbackErr instanceof Error ? fallbackErr : new Error(String(fallbackErr));
}
} else {
const label = mcporterEnabled ? "mcporter/qmd" : `qmd ${qmdSearchCommand}`;
log.warn(`${label} failed: ${String(err)}`);
throw err instanceof Error ? err : new Error(String(err));
}
};
let parsed: QmdQueryResult[];
try {
parsed = await runSearchAttempt(true);
} catch (err) {
if (!(await this.tryRepairMissingCollectionSearch(err))) {
throw err instanceof Error ? err : new Error(String(err));
}
parsed = await runSearchAttempt(false);
}
const results: MemorySearchResult[] = [];
for (const entry of parsed) {