Spracherkennung für: .ts vermutete Sprache: Unknown {[0] [0] [0]} [Methode: Schwerpunktbildung, einfache Gewichte, sechs Dimensionen]
import crypto from "node:crypto";
import fsSync from "node:fs";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import readline from "node:readline";
import chokidar, { type FSWatcher } from "chokidar";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { withFileLock } from "openclaw/plugin-sdk/file-lock";
import {
createSubsystemLogger,
resolveAgentContextLimits,
resolveMemorySearchSyncConfig,
resolveAgentWorkspaceDir,
resolveGlobalSingleton,
resolveStateDir,
writeFileWithinRoot,
type OpenClawConfig,
} from "openclaw/plugin-sdk/memory-core-host-engine-foundation";
import {
buildSessionEntry,
deriveQmdScopeChannel,
deriveQmdScopeChatType,
isQmdScopeAllowed,
listSessionFilesForAgent,
parseQmdQueryJson,
resolveCliSpawnInvocation,
runCliCommand,
type QmdQueryResult,
type SessionFileEntry,
} from "openclaw/plugin-sdk/memory-core-host-engine-qmd";
import {
buildMemoryReadResult,
buildMemoryReadResultFromSlice,
DEFAULT_MEMORY_READ_LINES,
isFileMissingError,
type MemoryReadResult,
requireNodeSqlite,
statRegularFile,
type MemoryEmbeddingProbeResult,
type MemoryProviderStatus,
type MemorySearchManager,
type MemorySearchRuntimeDebug,
type MemorySearchResult,
type MemorySource,
type MemorySyncProgressUpdate,
type ResolvedMemoryBackendConfig,
type ResolvedQmdConfig,
type ResolvedQmdMcporterConfig,
} from "openclaw/plugin-sdk/memory-core-host-engine-storage";
import {
localeLowercasePreservingWhitespace,
normalizeLowercaseStringOrEmpty,
} from "openclaw/plugin-sdk/text-runtime";
import { asRecord } from "../dreaming-shared.js";
import { resolveQmdCollectionPatternFlags, type QmdCollectionPatternFlag } from "./qmd-compat.js";
type SqliteDatabase = import("node:sqlite").DatabaseSync;
const log = createSubsystemLogger("memory");
const SNIPPET_HEADER_RE = /@@\s*-([0-9]+),([0-9]+)/;
const SEARCH_PENDING_UPDATE_WAIT_MS = 500;
const QMD_WATCH_STABILITY_MS = 200;
const MAX_QMD_OUTPUT_CHARS = 200_000;
const NUL_MARKER_RE = /(?:\^@|\\0|\\x00|\\u0000|null\s*byte|nul\s*byte)/i;
const QMD_EMBED_BACKOFF_BASE_MS = 60_000;
const QMD_EMBED_BACKOFF_MAX_MS = 60 * 60 * 1000;
const HAN_SCRIPT_RE = /[\u3400-\u9fff]/u;
const QMD_EMBED_LOCK_MIN_WAIT_MS = 15 * 60 * 1000;
const QMD_EMBED_LOCK_RETRY_TEMPLATE = {
factor: 1.2,
minTimeout: 250,
maxTimeout: 10_000,
randomize: true,
} as const;
const MCPORTER_STATE_KEY = Symbol.for("openclaw.mcporterState");
const QMD_EMBED_QUEUE_KEY = Symbol.for("openclaw.qmdEmbedQueueTail");
const QMD_UPDATE_QUEUE_KEY = Symbol.for("openclaw.qmdUpdateQueueState");
const IGNORED_MEMORY_WATCH_DIR_NAMES = new Set([
".git",
"node_modules",
".pnpm-store",
".venv",
"venv",
".tox",
"__pycache__",
]);
function isDefaultMemoryPath(relPath: string): boolean {
const normalized = relPath.trim().replace(/^\.\//, "").replace(/\\/g, "/");
if (!normalized) {
return false;
}
if (normalized === "MEMORY.md" || normalized === "DREAMS.md" || normalized === "dreams.md") {
return true;
}
return normalized.startsWith("memory/");
}
function buildQmdProcessPath(rawPath: string | undefined): string {
const nodeBinDir = path.dirname(process.execPath);
const entries = rawPath?.split(path.delimiter).filter(Boolean) ?? [];
if (entries.includes(nodeBinDir)) {
return rawPath ?? nodeBinDir;
}
return [...entries, nodeBinDir].join(path.delimiter);
}
type McporterState = {
coldStartWarned: boolean;
daemonStart: Promise<void> | null;
};
type QmdEmbedQueueState = {
tail: Promise<void>;
};
type QmdUpdateQueueState = {
tails: Map<string, Promise<void>>;
};
function getMcporterState(): McporterState {
return resolveGlobalSingleton<McporterState>(MCPORTER_STATE_KEY, () => ({
coldStartWarned: false,
daemonStart: null,
}));
}
function getQmdEmbedQueueState(): QmdEmbedQueueState {
return resolveGlobalSingleton<QmdEmbedQueueState>(QMD_EMBED_QUEUE_KEY, () => ({
tail: Promise.resolve(),
}));
}
function getQmdUpdateQueueState(): QmdUpdateQueueState {
return resolveGlobalSingleton<QmdUpdateQueueState>(QMD_UPDATE_QUEUE_KEY, () => ({
tails: new Map<string, Promise<void>>(),
}));
}
function _hasHanScript(value: string): boolean {
return HAN_SCRIPT_RE.test(value);
}
function normalizeHanBm25Query(query: string): string {
const trimmed = query.trim();
// Keep Han/CJK BM25 queries intact so OpenClaw search semantics match direct qmd search.
return trimmed;
}
function parseQmdStatusVectorCount(raw: string): number | null {
const match = raw.match(/(?:^|\n)\s*Vectors:\s*(\d+)\b/i);
if (!match) {
return null;
}
const count = Number.parseInt(match[1] ?? "", 10);
return Number.isFinite(count) ? count : null;
}
function resolveStableJitterMs(params: { seed: string; windowMs: number }): number {
if (params.windowMs <= 0) {
return 0;
}
const hash = crypto.createHash("sha256").update(params.seed).digest();
const bucket = hash.readUInt32BE(0);
return bucket % (Math.floor(params.windowMs) + 1);
}
function resolveQmdEmbedLockOptions(embedTimeoutMs: number) {
const expectedEmbedMs = Math.max(1, embedTimeoutMs);
const waitBudgetMs = Math.max(QMD_EMBED_LOCK_MIN_WAIT_MS, expectedEmbedMs * 6);
return {
retries: {
retries: Math.max(60, Math.ceil(waitBudgetMs / QMD_EMBED_LOCK_RETRY_TEMPLATE.maxTimeout)),
...QMD_EMBED_LOCK_RETRY_TEMPLATE,
},
stale: Math.max(QMD_EMBED_LOCK_MIN_WAIT_MS, expectedEmbedMs * 2),
};
}
function shouldIgnoreMemoryWatchPath(watchPath: string): boolean {
const normalized = path.normalize(watchPath);
const parts = normalized
.split(path.sep)
.map((segment) => normalizeLowercaseStringOrEmpty(segment));
return parts.some((segment) => IGNORED_MEMORY_WATCH_DIR_NAMES.has(segment));
}
type CollectionRoot = {
path: string;
kind: MemorySource;
};
type SessionExporterConfig = {
dir: string;
retentionMs?: number;
collectionName: string;
};
type ListedCollection = {
path?: string;
pattern?: string;
};
type ManagedCollection = {
name: string;
path: string;
pattern: string;
kind: "memory" | "custom" | "sessions";
};
type QmdManagerMode = "full" | "status";
type QmdManagerRuntimeConfig = {
workspaceDir: string;
syncSettings: ReturnType<typeof resolveMemorySearchSyncConfig>;
contextLimits: ReturnType<typeof resolveAgentContextLimits>;
};
type BuiltinQmdMcpTool = "query" | "search" | "vector_search" | "deep_search";
type QmdMcporterSearchParams =
| {
mcporter: ResolvedQmdMcporterConfig;
tool: string;
searchCommand?: string;
explicitToolOverride: true;
query: string;
limit: number;
minScore: number;
collection?: string;
timeoutMs: number;
}
| {
mcporter: ResolvedQmdMcporterConfig;
tool: BuiltinQmdMcpTool;
searchCommand?: string;
explicitToolOverride: false;
query: string;
limit: number;
minScore: number;
collection?: string;
timeoutMs: number;
};
type QmdMcporterAcrossCollectionsParams =
| {
tool: string;
searchCommand?: string;
explicitToolOverride: true;
query: string;
limit: number;
minScore: number;
collectionNames: string[];
}
| {
tool: BuiltinQmdMcpTool;
searchCommand?: string;
explicitToolOverride: false;
query: string;
limit: number;
minScore: number;
collectionNames: string[];
};
export class QmdMemoryManager implements MemorySearchManager {
static async create(params: {
cfg: OpenClawConfig;
agentId: string;
resolved: ResolvedMemoryBackendConfig;
mode?: QmdManagerMode;
runtimeConfig?: QmdManagerRuntimeConfig;
}): Promise<QmdMemoryManager | null> {
const resolved = params.resolved.qmd;
if (!resolved) {
return null;
}
const runtimeConfig =
params.runtimeConfig ?? resolveQmdManagerRuntimeConfig(params.cfg, params.agentId);
const manager = new QmdMemoryManager({
agentId: params.agentId,
resolved,
runtimeConfig,
});
await manager.initialize(params.mode ?? "full");
return manager;
}
private readonly agentId: string;
private readonly qmd: ResolvedQmdConfig;
private readonly workspaceDir: string;
private readonly contextLimits: ReturnType<typeof resolveAgentContextLimits>;
private readonly stateDir: string;
private readonly agentStateDir: string;
private readonly qmdDir: string;
private readonly xdgConfigHome: string;
private readonly xdgCacheHome: string;
private readonly indexPath: string;
private readonly env: NodeJS.ProcessEnv;
private readonly syncSettings: ReturnType<typeof resolveMemorySearchSyncConfig>;
private readonly managedCollectionNames: string[];
private readonly collectionRoots = new Map<string, CollectionRoot>();
private readonly sources = new Set<MemorySource>();
private readonly docPathCache = new Map<
string,
{ rel: string; abs: string; source: MemorySource }
>();
private readonly exportedSessionState = new Map<
string,
{
hash: string;
mtimeMs: number;
target: string;
}
>();
private readonly maxQmdOutputChars = MAX_QMD_OUTPUT_CHARS;
private readonly sessionExporter: SessionExporterConfig | null;
private updateTimer: NodeJS.Timeout | null = null;
private embedTimer: NodeJS.Timeout | null = null;
private watcher: FSWatcher | null = null;
private watchTimer: NodeJS.Timeout | null = null;
private pendingUpdate: Promise<void> | null = null;
private queuedForcedUpdate: Promise<void> | null = null;
private queuedForcedRuns = 0;
private dirty = false;
private closed = false;
private readonly closeSignal: Promise<void>;
private resolveCloseSignal!: () => void;
private db: SqliteDatabase | null = null;
private lastUpdateAt: number | null = null;
private lastEmbedAt: number | null = null;
private embedBackoffUntil: number | null = null;
private embedFailureCount = 0;
private vectorAvailable: boolean | null = null;
private vectorStatusDetail: string | null = null;
private attemptedNullByteCollectionRepair = false;
private attemptedDuplicateDocumentRepair = false;
private readonly sessionWarm = new Set<string>();
private collectionPatternFlag: QmdCollectionPatternFlag | null = "--glob";
private constructor(params: {
agentId: string;
resolved: ResolvedQmdConfig;
runtimeConfig: QmdManagerRuntimeConfig;
}) {
this.agentId = params.agentId;
this.qmd = params.resolved;
this.workspaceDir = params.runtimeConfig.workspaceDir;
this.contextLimits = params.runtimeConfig.contextLimits;
this.stateDir = resolveStateDir(process.env, os.homedir);
this.agentStateDir = path.join(this.stateDir, "agents", this.agentId);
this.qmdDir = path.join(this.agentStateDir, "qmd");
this.syncSettings = params.runtimeConfig.syncSettings;
// QMD uses XDG base dirs for its internal state.
// Collections are managed via `qmd collection add` and stored inside the index DB.
// - config: $XDG_CONFIG_HOME (contexts, etc.)
// - cache: $XDG_CACHE_HOME/qmd/index.sqlite
this.xdgConfigHome = path.join(this.qmdDir, "xdg-config");
this.xdgCacheHome = path.join(this.qmdDir, "xdg-cache");
this.indexPath = path.join(this.xdgCacheHome, "qmd", "index.sqlite");
this.env = {
...process.env,
PATH: buildQmdProcessPath(process.env.PATH),
XDG_CONFIG_HOME: this.xdgConfigHome,
// QMD resolves index.yml relative to QMD_CONFIG_DIR rather than XDG_CONFIG_HOME.
// Point it at the nested qmd config directory so per-agent collections are visible.
QMD_CONFIG_DIR: path.join(this.xdgConfigHome, "qmd"),
XDG_CACHE_HOME: this.xdgCacheHome,
NO_COLOR: "1",
};
this.closeSignal = new Promise<void>((resolve) => {
this.resolveCloseSignal = resolve;
});
this.sessionExporter = this.qmd.sessions.enabled
? {
dir: this.qmd.sessions.exportDir ?? path.join(this.qmdDir, "sessions"),
retentionMs: this.qmd.sessions.retentionDays
? this.qmd.sessions.retentionDays * 24 * 60 * 60 * 1000
: undefined,
collectionName: this.pickSessionCollectionName(),
}
: null;
if (this.sessionExporter) {
this.qmd.collections = [
...this.qmd.collections,
{
name: this.sessionExporter.collectionName,
path: this.sessionExporter.dir,
pattern: "**/*.md",
kind: "sessions",
},
];
}
this.managedCollectionNames = this.computeManagedCollectionNames();
}
private async initialize(mode: QmdManagerMode): Promise<void> {
this.bootstrapCollections();
if (mode === "status") {
return;
}
await fs.mkdir(this.xdgConfigHome, { recursive: true });
await fs.mkdir(this.xdgCacheHome, { recursive: true });
await fs.mkdir(path.dirname(this.indexPath), { recursive: true });
if (this.sessionExporter) {
await fs.mkdir(this.sessionExporter.dir, { recursive: true });
}
// QMD stores its ML models under $XDG_CACHE_HOME/qmd/models/. Because we
// override XDG_CACHE_HOME to isolate the index per-agent, qmd would not
// find models installed at the default location (~/.cache/qmd/models/) and
// would attempt to re-download them on every invocation. Symlink the
// default models directory into our custom cache so the index stays
// isolated while models are shared.
await this.symlinkSharedModels();
await this.ensureCollections();
this.ensureWatcher();
if (this.qmd.update.onBoot) {
const bootRun = this.runUpdate("boot", true);
if (this.qmd.update.waitForBootSync) {
await bootRun.catch((err) => {
log.warn(`qmd boot update failed: ${String(err)}`);
});
} else {
void bootRun.catch((err) => {
log.warn(`qmd boot update failed: ${String(err)}`);
});
}
}
if (this.qmd.update.intervalMs > 0) {
this.updateTimer = setInterval(() => {
void this.runUpdate("interval").catch((err) => {
log.warn(`qmd update failed (${String(err)})`);
});
}, this.qmd.update.intervalMs);
}
if (this.shouldScheduleEmbedTimer()) {
const startPeriodicEmbedTimer = () => {
this.embedTimer = setInterval(() => {
void this.runUpdate("embed-interval").catch((err) => {
log.warn(`qmd embed interval update failed (${String(err)})`);
});
}, this.qmd.update.embedIntervalMs);
};
const initialDelayMs = this.resolveEmbedStartupJitterMs();
if (initialDelayMs > 0) {
this.embedTimer = setTimeout(() => {
this.embedTimer = null;
if (this.closed) {
return;
}
void this.runUpdate("embed-interval")
.catch((err) => {
log.warn(`qmd embed interval update failed (${String(err)})`);
})
.finally(() => {
if (!this.closed) {
startPeriodicEmbedTimer();
}
});
}, initialDelayMs);
} else {
startPeriodicEmbedTimer();
}
}
}
private bootstrapCollections(): void {
this.collectionRoots.clear();
this.sources.clear();
for (const collection of this.qmd.collections) {
const kind: MemorySource = collection.kind === "sessions" ? "sessions" : "memory";
this.collectionRoots.set(collection.name, { path: collection.path, kind });
this.sources.add(kind);
}
}
private async ensureCollections(): Promise<void> {
// QMD collections are persisted inside the index database and must be created
// via the CLI. Prefer listing existing collections when supported, otherwise
// fall back to best-effort idempotent `qmd collection add`.
const existing = await this.listCollectionsBestEffort();
await this.migrateLegacyUnscopedCollections(existing);
for (const collection of this.qmd.collections) {
const listed = existing.get(collection.name);
if (listed && !this.shouldRebindCollection(collection, listed)) {
continue;
}
if (listed) {
try {
await this.removeCollection(collection.name);
} catch (err) {
const message = formatErrorMessage(err);
if (!this.isCollectionMissingError(message)) {
log.warn(`qmd collection remove failed for ${collection.name}: ${message}`);
}
}
}
try {
await this.ensureCollectionPath(collection);
await this.addCollection(collection.path, collection.name, collection.pattern);
existing.set(collection.name, {
path: collection.path,
pattern: collection.pattern,
});
} catch (err) {
const message = formatErrorMessage(err);
if (this.isCollectionAlreadyExistsError(message)) {
const rebound =
(await this.tryRebindSameNameCollection({
collection,
addErrorMessage: message,
})) ||
(await this.tryRebindConflictingCollection({
collection,
existing,
addErrorMessage: message,
}));
if (rebound) {
existing.set(collection.name, {
path: collection.path,
pattern: collection.pattern,
});
} else {
log.warn(`qmd collection add skipped for ${collection.name}: ${message}`);
}
continue;
}
log.warn(`qmd collection add failed for ${collection.name}: ${message}`);
}
}
}
private async tryRebindSameNameCollection(params: {
collection: ManagedCollection;
addErrorMessage: string;
}): Promise<boolean> {
const { collection, addErrorMessage } = params;
if (!this.isSameNameCollectionAlreadyExistsError(collection.name, addErrorMessage)) {
return false;
}
log.warn(
`qmd collection add conflict for ${collection.name}: collection name already exists; recreating managed collection`,
);
try {
await this.removeCollection(collection.name);
} catch (removeErr) {
const removeMessage = formatErrorMessage(removeErr);
if (!this.isCollectionMissingError(removeMessage)) {
log.warn(`qmd collection remove failed for ${collection.name}: ${removeMessage}`);
return false;
}
}
try {
await this.ensureCollectionPath(collection);
await this.addCollection(collection.path, collection.name, collection.pattern);
return true;
} catch (retryErr) {
const retryMessage = formatErrorMessage(retryErr);
log.warn(
`qmd collection add failed for ${collection.name} after recreating same-name collection: ${retryMessage} (initial: ${addErrorMessage})`,
);
return false;
}
}
private isSameNameCollectionAlreadyExistsError(name: string, message: string): boolean {
const lowerName = normalizeLowercaseStringOrEmpty(name);
const lowerMessage = normalizeLowercaseStringOrEmpty(message);
return (
lowerMessage.includes(`collection '${lowerName}' already exists`) ||
lowerMessage.includes(`collection "${lowerName}" already exists`)
);
}
private async listCollectionsBestEffort(): Promise<Map<string, ListedCollection>> {
const existing = new Map<string, ListedCollection>();
try {
const result = await this.runQmd(["collection", "list", "--json"], {
timeoutMs: this.qmd.update.commandTimeoutMs,
});
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.
}
return existing;
}
private findCollectionByPathPattern(
collection: ManagedCollection,
listed: Map<string, ListedCollection>,
): string | null {
for (const [name, details] of listed) {
if (!details.path || typeof details.pattern !== "string") {
continue;
}
if (!this.pathsMatch(details.path, collection.path)) {
continue;
}
if (
!this.patternsMatchForManagedCollection(
collection.path,
details.pattern,
collection.pattern,
)
) {
continue;
}
return name;
}
return null;
}
private async tryRebindConflictingCollection(params: {
collection: ManagedCollection;
existing: Map<string, ListedCollection>;
addErrorMessage: string;
}): Promise<boolean> {
const { collection, existing, addErrorMessage } = params;
let conflictName = this.findCollectionByPathPattern(collection, existing);
if (!conflictName) {
const refreshed = await this.listCollectionsBestEffort();
existing.clear();
for (const [name, details] of refreshed) {
existing.set(name, details);
}
conflictName = this.findCollectionByPathPattern(collection, existing);
}
if (!conflictName) {
return false;
}
if (conflictName === collection.name) {
existing.set(collection.name, {
path: collection.path,
pattern: collection.pattern,
});
return true;
}
log.warn(
`qmd collection add conflict for ${collection.name}: path+pattern already bound by ${conflictName}; rebinding`,
);
try {
await this.removeCollection(conflictName);
existing.delete(conflictName);
} catch (removeErr) {
const removeMessage = formatErrorMessage(removeErr);
if (!this.isCollectionMissingError(removeMessage)) {
log.warn(`qmd collection remove failed for ${conflictName}: ${removeMessage}`);
}
return false;
}
try {
await this.addCollection(collection.path, collection.name, collection.pattern);
existing.set(collection.name, {
path: collection.path,
pattern: collection.pattern,
});
return true;
} catch (retryErr) {
const retryMessage = formatErrorMessage(retryErr);
log.warn(
`qmd collection add failed for ${collection.name} after rebinding ${conflictName}: ${retryMessage} (initial: ${addErrorMessage})`,
);
return false;
}
}
private async migrateLegacyUnscopedCollections(
existing: Map<string, ListedCollection>,
): Promise<void> {
for (const collection of this.qmd.collections) {
if (existing.has(collection.name)) {
continue;
}
const legacyName = this.deriveLegacyCollectionName(collection.name);
if (!legacyName) {
continue;
}
const listedLegacy = existing.get(legacyName);
if (!listedLegacy) {
continue;
}
if (!this.canMigrateLegacyCollection(collection, listedLegacy)) {
log.debug(
`qmd legacy collection migration skipped for ${legacyName} (path/pattern mismatch)`,
);
continue;
}
try {
await this.removeCollection(legacyName);
existing.delete(legacyName);
} catch (err) {
const message = formatErrorMessage(err);
if (!this.isCollectionMissingError(message)) {
log.warn(`qmd collection remove failed for ${legacyName}: ${message}`);
}
}
}
}
private deriveLegacyCollectionName(scopedName: string): string | null {
const agentSuffix = `-${this.sanitizeCollectionNameSegment(this.agentId)}`;
if (!scopedName.endsWith(agentSuffix)) {
return null;
}
const legacyName = scopedName.slice(0, -agentSuffix.length).trim();
return legacyName || null;
}
private canMigrateLegacyCollection(
collection: ManagedCollection,
listedLegacy: ListedCollection,
): boolean {
if (listedLegacy.path && !this.pathsMatch(listedLegacy.path, collection.path)) {
return false;
}
if (
typeof listedLegacy.pattern === "string" &&
!this.patternsMatchForManagedCollection(
collection.path,
listedLegacy.pattern,
collection.pattern,
)
) {
return false;
}
return true;
}
private async ensureCollectionPath(collection: {
path: string;
pattern: string;
kind: "memory" | "custom" | "sessions";
}): Promise<void> {
if (!this.isDirectoryGlobPattern(collection.pattern)) {
return;
}
await fs.mkdir(collection.path, { recursive: true });
}
private isDirectoryGlobPattern(pattern: string): boolean {
return pattern.includes("*") || pattern.includes("?") || pattern.includes("[");
}
private isCollectionAlreadyExistsError(message: string): boolean {
const lower = normalizeLowercaseStringOrEmpty(message);
return lower.includes("already exists") || lower.includes("exists");
}
private isCollectionMissingError(message: string): boolean {
const lower = normalizeLowercaseStringOrEmpty(message);
return (
lower.includes("not found") || lower.includes("does not exist") || lower.includes("missing")
);
}
private isMissingCollectionSearchError(err: unknown): boolean {
const message = formatErrorMessage(err);
return (
this.isCollectionMissingError(message) &&
normalizeLowercaseStringOrEmpty(message).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> {
const candidateFlags = resolveQmdCollectionPatternFlags(this.collectionPatternFlag);
let lastError: unknown;
for (const flag of candidateFlags) {
try {
await this.runQmd(["collection", "add", pathArg, "--name", name, flag, pattern], {
timeoutMs: this.qmd.update.commandTimeoutMs,
});
this.collectionPatternFlag = flag;
return;
} catch (err) {
lastError = err;
if (!this.isUnsupportedQmdOptionError(err) || candidateFlags.at(-1) === flag) {
throw err;
}
log.warn(`qmd collection add rejected ${flag}; retrying with legacy compatibility flag`);
}
}
throw lastError instanceof Error ? lastError : new Error(String(lastError));
}
private async removeCollection(name: string): Promise<void> {
await this.runQmd(["collection", "remove", name], {
timeoutMs: this.qmd.update.commandTimeoutMs,
});
}
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) {
if (typeof listed.pattern === "string" && listed.pattern !== collection.pattern) {
return true;
}
// Older qmd versions may only return names from `collection list --json`.
// If the pattern is also missing, do not perform destructive rebinds when
// metadata is incomplete: remove+add can permanently drop collections if
// add fails (for example on timeout).
return false;
}
if (!this.pathsMatch(listed.path, collection.path)) {
return true;
}
if (
typeof listed.pattern === "string" &&
!this.patternsMatchForManagedCollection(collection.path, listed.pattern, collection.pattern)
) {
return true;
}
return false;
}
private patternsMatchForManagedCollection(
collectionPath: string,
leftPattern: string,
rightPattern: string,
): boolean {
if (leftPattern === rightPattern) {
return true;
}
return this.isEquivalentDefaultMemoryRootPattern(collectionPath, leftPattern, rightPattern);
}
private isEquivalentDefaultMemoryRootPattern(
collectionPath: string,
leftPattern: string,
rightPattern: string,
): boolean {
if (
!this.isDefaultMemoryRootPattern(leftPattern) ||
!this.isDefaultMemoryRootPattern(rightPattern)
) {
return false;
}
try {
for (const entry of fsSync.readdirSync(collectionPath, { withFileTypes: true })) {
if (entry.isSymbolicLink() || !entry.isFile()) {
continue;
}
if (entry.name === "MEMORY.md") {
return true;
}
}
return false;
} catch {
return false;
}
}
private isDefaultMemoryRootPattern(pattern: string): boolean {
return pattern === "MEMORY.md";
}
private pathsMatch(left: string, right: string): boolean {
const normalize = (value: string): string => {
const resolved = path.isAbsolute(value)
? path.resolve(value)
: path.resolve(this.workspaceDir, value);
const normalized = path.normalize(resolved);
return process.platform === "win32"
? normalizeLowercaseStringOrEmpty(normalized)
: normalized;
};
return normalize(left) === normalize(right);
}
private shouldRepairNullByteCollectionError(err: unknown): boolean {
const message = formatErrorMessage(err);
const lower = normalizeLowercaseStringOrEmpty(message);
return (
(lower.includes("enotdir") ||
lower.includes("not a directory") ||
lower.includes("enoent") ||
lower.includes("no such file")) &&
NUL_MARKER_RE.test(message)
);
}
private shouldRepairDuplicateDocumentConstraint(err: unknown): boolean {
const message = formatErrorMessage(err);
const lower = normalizeLowercaseStringOrEmpty(message);
return (
lower.includes("unique constraint failed") &&
lower.includes("documents.collection") &&
lower.includes("documents.path")
);
}
private async rebuildManagedCollectionsForRepair(reason: string): Promise<void> {
for (const collection of this.qmd.collections) {
try {
await this.removeCollection(collection.name);
} catch (removeErr) {
const removeMessage = formatErrorMessage(removeErr);
if (!this.isCollectionMissingError(removeMessage)) {
log.warn(`qmd collection remove failed for ${collection.name}: ${removeMessage}`);
}
}
try {
await this.addCollection(collection.path, collection.name, collection.pattern);
} catch (addErr) {
const addMessage = formatErrorMessage(addErr);
if (!this.isCollectionAlreadyExistsError(addMessage)) {
log.warn(`qmd collection add failed for ${collection.name}: ${addMessage}`);
}
}
}
log.warn(`qmd managed collections rebuilt for update repair (${reason})`);
}
private async tryRepairNullByteCollections(err: unknown, reason: string): Promise<boolean> {
if (this.attemptedNullByteCollectionRepair) {
return false;
}
if (!this.shouldRepairNullByteCollectionError(err)) {
return false;
}
this.attemptedNullByteCollectionRepair = true;
log.warn(
`qmd update failed with suspected null-byte collection metadata (${reason}); rebuilding managed collections and retrying once`,
);
await this.rebuildManagedCollectionsForRepair(`null-byte metadata (${reason})`);
return true;
}
private async tryRepairDuplicateDocumentConstraint(
err: unknown,
reason: string,
): Promise<boolean> {
if (this.attemptedDuplicateDocumentRepair) {
return false;
}
if (!this.shouldRepairDuplicateDocumentConstraint(err)) {
return false;
}
this.attemptedDuplicateDocumentRepair = true;
log.warn(
`qmd update failed with duplicate document constraint (${reason}); rebuilding managed collections and retrying once`,
);
await this.rebuildManagedCollectionsForRepair(`duplicate-document constraint (${reason})`);
return true;
}
async search(
query: string,
opts?: {
maxResults?: number;
minScore?: number;
sessionKey?: string;
qmdSearchModeOverride?: "query" | "search" | "vsearch";
onDebug?: (debug: MemorySearchRuntimeDebug) => void;
sources?: MemorySource[];
},
): Promise<MemorySearchResult[]> {
if (!this.isScopeAllowed(opts?.sessionKey)) {
this.logScopeDenied(opts?.sessionKey);
return [];
}
const trimmed = query.trim();
if (!trimmed) {
return [];
}
await this.maybeWarmSession(opts?.sessionKey);
await this.maybeSyncDirtySearchState();
await this.waitForPendingUpdateBeforeSearch();
const resultLimit = Math.min(
this.qmd.limits.maxResults,
opts?.maxResults ?? this.qmd.limits.maxResults,
);
const requestedSources = opts?.sources?.length ? [...new Set(opts.sources)] : undefined;
const collectionNames = this.listManagedCollectionNames(requestedSources);
const limit = resultLimit;
if (collectionNames.length === 0) {
log.warn("qmd query skipped: no managed collections configured");
return [];
}
const qmdSearchCommand = opts?.qmdSearchModeOverride ?? this.qmd.searchMode;
let effectiveSearchMode: "query" | "search" | "vsearch" = qmdSearchCommand;
let searchFallbackReason: string | undefined;
const explicitSearchTool = this.qmd.searchTool;
const mcporterEnabled = this.qmd.mcporter.enabled;
const runSearchAttempt = async (
allowMissingCollectionRepair: boolean,
): Promise<QmdQueryResult[]> => {
try {
if (mcporterEnabled) {
const minScore = opts?.minScore ?? 0;
if (explicitSearchTool) {
if (collectionNames.length > 1) {
return await this.runMcporterAcrossCollections({
tool: explicitSearchTool,
searchCommand: qmdSearchCommand,
explicitToolOverride: true,
query: trimmed,
limit,
minScore,
collectionNames,
});
}
return await this.runQmdSearchViaMcporter({
mcporter: this.qmd.mcporter,
tool: explicitSearchTool,
searchCommand: qmdSearchCommand,
explicitToolOverride: true,
query: trimmed,
limit,
minScore,
collection: collectionNames[0],
timeoutMs: this.qmd.limits.timeoutMs,
});
}
const tool = this.resolveQmdMcpTool(qmdSearchCommand);
if (collectionNames.length > 1) {
return await this.runMcporterAcrossCollections({
tool,
searchCommand: qmdSearchCommand,
explicitToolOverride: false,
query: trimmed,
limit,
minScore,
collectionNames,
});
}
return await this.runQmdSearchViaMcporter({
mcporter: this.qmd.mcporter,
tool,
searchCommand: qmdSearchCommand,
explicitToolOverride: false,
query: trimmed,
limit,
minScore,
collection: collectionNames[0],
timeoutMs: this.qmd.limits.timeoutMs,
});
}
if (collectionNames.length > 1) {
return await this.runQueryAcrossCollections(
trimmed,
limit,
collectionNames,
qmdSearchCommand,
);
}
const args = this.buildSearchArgs(qmdSearchCommand, trimmed, limit);
args.push(...this.buildCollectionFilterArgs(collectionNames));
const result = await this.runQmd(args, { timeoutMs: this.qmd.limits.timeoutMs });
return parseQmdQueryJson(result.stdout, result.stderr);
} catch (err) {
if (allowMissingCollectionRepair && this.isMissingCollectionSearchError(err)) {
throw err;
}
if (
!mcporterEnabled &&
qmdSearchCommand !== "query" &&
this.isUnsupportedQmdOptionError(err)
) {
effectiveSearchMode = "query";
searchFallbackReason = "unsupported-search-flags";
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,
});
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));
}
}
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) {
const docHints = this.normalizeDocHints({
preferredCollection: entry.collection,
preferredFile: entry.file,
});
const doc = await this.resolveDocLocation(entry.docid, docHints);
if (!doc) {
continue;
}
const snippet = entry.snippet?.slice(0, this.qmd.limits.maxSnippetChars) ?? "";
const lines = this.resolveSnippetLines(entry, snippet);
const score = typeof entry.score === "number" ? entry.score : 0;
const minScore = opts?.minScore ?? 0;
if (score < minScore) {
continue;
}
results.push({
path: doc.rel,
startLine: lines.startLine,
endLine: lines.endLine,
score,
snippet,
source: doc.source,
});
}
opts?.onDebug?.({
backend: "qmd",
configuredMode: qmdSearchCommand,
effectiveMode: effectiveSearchMode,
fallback: searchFallbackReason,
});
let ranked = results;
if (opts?.sources?.length) {
const allow = new Set(opts.sources);
ranked = results.filter((r) => allow.has(r.source));
}
return this.clampResultsByInjectedChars(this.diversifyResultsBySource(ranked, resultLimit));
}
async sync(params?: {
reason?: string;
force?: boolean;
sessionFiles?: string[];
progress?: (update: MemorySyncProgressUpdate) => void;
}): Promise<void> {
if (params?.sessionFiles?.some((sessionFile) => sessionFile.trim().length > 0)) {
log.debug("qmd sync ignoring targeted sessionFiles hint; running regular update");
}
if (params?.progress) {
params.progress({ completed: 0, total: 1, label: "Updating QMD index…" });
}
await this.runUpdate(params?.reason ?? "manual", params?.force);
if (params?.progress) {
params.progress({ completed: 1, total: 1, label: "QMD index updated" });
}
}
async readFile(params: {
relPath: string;
from?: number;
lines?: number;
}): Promise<MemoryReadResult> {
const relPath = params.relPath?.trim();
if (!relPath) {
throw new Error("path required");
}
const absPath = this.resolveReadPath(relPath);
if (!absPath.endsWith(".md")) {
throw new Error("path required");
}
const statResult = await statRegularFile(absPath);
if (statResult.missing) {
return { text: "", path: relPath };
}
const contextLimits = this.contextLimits;
if (params.from !== undefined || params.lines !== undefined) {
const requestedCount = Math.max(
1,
params.lines ?? contextLimits?.memoryGetDefaultLines ?? DEFAULT_MEMORY_READ_LINES,
);
const partial = await this.readPartialText(absPath, params.from, requestedCount);
if (partial.missing) {
return { text: "", path: relPath };
}
return buildMemoryReadResultFromSlice({
selectedLines: partial.selectedLines,
relPath,
startLine: Math.max(1, params.from ?? 1),
moreSourceLinesRemain: partial.moreSourceLinesRemain,
maxChars: contextLimits?.memoryGetMaxChars,
suggestReadFallback: isDefaultMemoryPath(relPath),
});
}
const full = await this.readFullText(absPath);
if (full.missing) {
return { text: "", path: relPath };
}
return buildMemoryReadResult({
content: full.text,
relPath,
from: params.from,
lines: params.lines,
defaultLines: contextLimits?.memoryGetDefaultLines ?? DEFAULT_MEMORY_READ_LINES,
maxChars: contextLimits?.memoryGetMaxChars,
suggestReadFallback: isDefaultMemoryPath(relPath),
});
}
status(): MemoryProviderStatus {
const counts = this.readCounts();
return {
backend: "qmd",
provider: "qmd",
model: "qmd",
requestedProvider: "qmd",
files: counts.totalDocuments,
chunks: counts.totalDocuments,
dirty: false,
workspaceDir: this.workspaceDir,
dbPath: this.indexPath,
sources: Array.from(this.sources),
sourceCounts: counts.sourceCounts,
vector: {
enabled: true,
available: this.vectorAvailable ?? undefined,
loadError: this.vectorStatusDetail ?? undefined,
},
batch: {
enabled: false,
failures: 0,
limit: 0,
wait: false,
concurrency: 0,
pollIntervalMs: 0,
timeoutMs: 0,
},
custom: {
qmd: {
collections: this.qmd.collections.length,
lastUpdateAt: this.lastUpdateAt,
},
},
};
}
async probeEmbeddingAvailability(): Promise<MemoryEmbeddingProbeResult> {
const ok = await this.probeVectorAvailability();
return {
ok,
error: ok ? undefined : (this.vectorStatusDetail ?? "QMD semantic vectors are unavailable"),
};
}
async probeVectorAvailability(): Promise<boolean> {
try {
const result = await this.runQmd(["status"], {
timeoutMs: Math.min(this.qmd.limits.timeoutMs, 5_000),
});
const vectorCount = parseQmdStatusVectorCount(`${result.stdout}\n${result.stderr}`);
if (vectorCount === null) {
this.vectorAvailable = false;
this.vectorStatusDetail = "Could not determine QMD vector status from `qmd status`";
return false;
}
this.vectorAvailable = vectorCount > 0;
this.vectorStatusDetail =
vectorCount > 0
? null
: "QMD index has 0 vectors; semantic search is unavailable until embeddings finish";
return this.vectorAvailable;
} catch (err) {
const message = formatErrorMessage(err);
this.vectorAvailable = false;
this.vectorStatusDetail = `QMD status probe failed: ${message}`;
return false;
}
}
async close(): Promise<void> {
if (this.closed) {
return;
}
this.closed = true;
this.resolveCloseSignal();
if (this.updateTimer) {
clearInterval(this.updateTimer);
this.updateTimer = null;
}
if (this.embedTimer) {
clearTimeout(this.embedTimer);
this.embedTimer = null;
}
if (this.watchTimer) {
clearTimeout(this.watchTimer);
this.watchTimer = null;
}
if (this.watcher) {
await this.watcher.close().catch(() => undefined);
this.watcher = null;
}
this.queuedForcedRuns = 0;
await this.pendingUpdate?.catch(() => undefined);
await this.queuedForcedUpdate?.catch(() => undefined);
if (this.db) {
this.db.close();
this.db = null;
}
}
private async runUpdate(
reason: string,
force?: boolean,
opts?: { fromForcedQueue?: boolean },
): Promise<void> {
if (this.closed) {
return;
}
if (this.pendingUpdate) {
if (force) {
return this.enqueueForcedUpdate(reason);
}
return this.pendingUpdate;
}
if (this.queuedForcedUpdate && !opts?.fromForcedQueue) {
if (force) {
return this.enqueueForcedUpdate(reason);
}
return this.queuedForcedUpdate;
}
if (this.shouldSkipUpdate(force)) {
return;
}
const run = async () => {
await this.withQmdUpdateQueue(async () => {
if (this.closed) {
return;
}
if (this.sessionExporter) {
await this.exportSessions();
}
await this.runQmdUpdateWithRetry(reason);
this.dirty = false;
});
if (this.closed) {
return;
}
if (this.shouldRunEmbed(force)) {
try {
await this.withQmdEmbedLock(async () => {
await this.runQmd(["embed"], {
timeoutMs: this.qmd.update.embedTimeoutMs,
discardOutput: true,
});
});
this.lastEmbedAt = Date.now();
this.embedBackoffUntil = null;
this.embedFailureCount = 0;
} catch (err) {
this.noteEmbedFailure(reason, err);
}
}
if (this.closed) {
return;
}
this.lastUpdateAt = Date.now();
this.docPathCache.clear();
};
this.pendingUpdate = run().finally(() => {
this.pendingUpdate = null;
});
await this.pendingUpdate;
}
private ensureWatcher(): void {
if (!this.syncSettings?.watch || this.watcher || this.closed) {
return;
}
const watchPaths = new Set<string>();
for (const collection of this.qmd.collections) {
if (collection.kind === "sessions") {
continue;
}
watchPaths.add(this.resolveCollectionWatchPath(collection));
}
if (watchPaths.size === 0) {
return;
}
this.watcher = chokidar.watch(Array.from(watchPaths), {
ignoreInitial: true,
ignored: (watchPath) => shouldIgnoreMemoryWatchPath(watchPath),
awaitWriteFinish: {
stabilityThreshold: QMD_WATCH_STABILITY_MS,
pollInterval: 100,
},
});
const markDirty = () => {
this.dirty = true;
this.scheduleWatchSync();
};
this.watcher.on("add", markDirty);
this.watcher.on("change", markDirty);
this.watcher.on("unlink", markDirty);
}
private resolveCollectionWatchPath(collection: ManagedCollection): string {
return path.join(path.normalize(collection.path), collection.pattern);
}
private scheduleWatchSync(): void {
if (!this.syncSettings?.watch) {
return;
}
if (this.watchTimer) {
clearTimeout(this.watchTimer);
}
this.watchTimer = setTimeout(() => {
this.watchTimer = null;
void this.sync({ reason: "watch" }).catch((err) => {
log.warn(`qmd watch sync failed: ${String(err)}`);
});
}, this.syncSettings.watchDebounceMs);
}
private async maybeWarmSession(sessionKey?: string): Promise<void> {
if (!this.syncSettings?.onSessionStart) {
return;
}
const key = sessionKey?.trim() || "";
if (!key || this.sessionWarm.has(key)) {
return;
}
this.sessionWarm.add(key);
void this.sync({ reason: "session-start" }).catch((err) => {
log.warn(`qmd session-start sync failed: ${String(err)}`);
});
}
private async maybeSyncDirtySearchState(): Promise<void> {
if (!this.syncSettings?.onSearch || !this.dirty) {
return;
}
await this.sync({ reason: "search" });
}
private async runQmdUpdateWithRetry(reason: string): Promise<void> {
const isBootRun = reason === "boot" || reason.startsWith("boot:");
const maxAttempts = isBootRun ? 3 : 1;
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
try {
await this.runQmdUpdateOnce(reason);
return;
} catch (err) {
if (attempt >= maxAttempts || !this.isRetryableUpdateError(err)) {
throw err;
}
const delayMs = 500 * 2 ** (attempt - 1);
log.warn(
`qmd update retry ${attempt}/${maxAttempts - 1} after failure (${reason}): ${String(err)}`,
);
await new Promise<void>((resolve) => setTimeout(resolve, delayMs));
}
}
}
private async runQmdUpdateOnce(reason: string): Promise<void> {
try {
await this.runQmd(["update"], {
timeoutMs: this.qmd.update.updateTimeoutMs,
discardOutput: true,
});
} catch (err) {
if (
!(await this.tryRepairNullByteCollections(err, reason)) &&
!(await this.tryRepairDuplicateDocumentConstraint(err, reason))
) {
throw err;
}
await this.runQmd(["update"], {
timeoutMs: this.qmd.update.updateTimeoutMs,
discardOutput: true,
});
}
}
private isRetryableUpdateError(err: unknown): boolean {
if (this.isSqliteBusyError(err)) {
return true;
}
const message = formatErrorMessage(err);
const normalized = normalizeLowercaseStringOrEmpty(message);
return normalized.includes("timed out");
}
private shouldRunEmbed(force?: boolean): boolean {
// Keep embeddings current regardless of the active retrieval mode.
// Search-mode indexing still needs vectors so later mode switches and
// hybrid flows do not inherit an incomplete QMD index.
const now = Date.now();
if (this.embedBackoffUntil !== null && now < this.embedBackoffUntil) {
return false;
}
const embedIntervalMs = this.qmd.update.embedIntervalMs;
return (
Boolean(force) ||
this.lastEmbedAt === null ||
(embedIntervalMs > 0 && now - this.lastEmbedAt > embedIntervalMs)
);
}
private shouldScheduleEmbedTimer(): boolean {
const embedIntervalMs = this.qmd.update.embedIntervalMs;
if (embedIntervalMs <= 0) {
return false;
}
const updateIntervalMs = this.qmd.update.intervalMs;
return updateIntervalMs <= 0 || updateIntervalMs > embedIntervalMs;
}
private resolveEmbedStartupJitterMs(): number {
const windowMs = this.qmd.update.embedIntervalMs;
if (windowMs <= 0) {
return 0;
}
const customCollections = this.qmd.collections
.filter((collection) => collection.kind === "custom")
.map((collection) => `${collection.path}\u0000${collection.pattern}`)
.toSorted()
.join("\u0001");
if (!customCollections) {
return 0;
}
return resolveStableJitterMs({
seed: `${this.agentId}:${customCollections}`,
windowMs,
});
}
private async withQmdEmbedLock<T>(task: () => Promise<T>): Promise<T> {
const lockPath = path.join(this.stateDir, "qmd", "embed.lock");
const queue = getQmdEmbedQueueState();
const previous = queue.tail;
let releaseCurrent!: () => void;
const current = new Promise<void>((resolve) => {
releaseCurrent = resolve;
});
queue.tail = previous.then(
() => current,
() => current,
);
await previous.catch(() => undefined);
try {
return await withFileLock(
lockPath,
resolveQmdEmbedLockOptions(this.qmd.update.embedTimeoutMs),
task,
);
} finally {
releaseCurrent();
}
}
private async withQmdUpdateQueue<T>(task: () => Promise<T>): Promise<T> {
const queue = getQmdUpdateQueueState();
const key = this.qmdDir;
const previous = queue.tails.get(key) ?? Promise.resolve();
let releaseCurrent!: () => void;
const current = new Promise<void>((resolve) => {
releaseCurrent = resolve;
});
const next = previous.then(
() => current,
() => current,
);
queue.tails.set(key, next);
try {
const waitResult = await Promise.race([
previous.then(
() => "ready" as const,
() => "ready" as const,
),
this.closeSignal.then(() => "closed" as const),
]);
if (waitResult === "closed") {
return undefined as T;
}
return await task();
} finally {
releaseCurrent();
void next.finally(() => {
if (queue.tails.get(key) === next) {
queue.tails.delete(key);
}
});
}
}
private noteEmbedFailure(reason: string, err: unknown): void {
this.embedFailureCount += 1;
const delayMs = Math.min(
QMD_EMBED_BACKOFF_MAX_MS,
QMD_EMBED_BACKOFF_BASE_MS * 2 ** Math.max(0, this.embedFailureCount - 1),
);
this.embedBackoffUntil = Date.now() + delayMs;
log.warn(
`qmd embed failed (${reason}): ${String(err)}; backing off for ${Math.ceil(delayMs / 1000)}s`,
);
}
private enqueueForcedUpdate(reason: string): Promise<void> {
this.queuedForcedRuns += 1;
if (!this.queuedForcedUpdate) {
this.queuedForcedUpdate = this.drainForcedUpdates(reason).finally(() => {
this.queuedForcedUpdate = null;
});
}
return this.queuedForcedUpdate;
}
private async drainForcedUpdates(reason: string): Promise<void> {
await this.pendingUpdate?.catch(() => undefined);
while (!this.closed && this.queuedForcedRuns > 0) {
this.queuedForcedRuns -= 1;
await this.runUpdate(`${reason}:queued`, true, { fromForcedQueue: true });
}
}
/**
* Symlink the default QMD models directory into our custom XDG_CACHE_HOME so
* that the pre-installed ML models (~/.cache/qmd/models/) are reused rather
* than re-downloaded for every agent. If the default models directory does
* not exist, or a models directory/symlink already exists in the target, this
* is a no-op.
*/
private async symlinkSharedModels(): Promise<void> {
// process.env is never modified — only this.env (passed to child_process
// spawn) overrides XDG_CACHE_HOME. So reading it here gives us the
// user's original value, which is where `qmd` downloaded its models.
//
// On Windows, well-behaved apps (including Rust `dirs` / Go os.UserCacheDir)
// store caches under %LOCALAPPDATA% rather than ~/.cache. Fall back to
// LOCALAPPDATA when XDG_CACHE_HOME is not set on Windows.
const defaultCacheHome =
process.env.XDG_CACHE_HOME ||
(process.platform === "win32" ? process.env.LOCALAPPDATA : undefined) ||
path.join(os.homedir(), ".cache");
const defaultModelsDir = path.join(defaultCacheHome, "qmd", "models");
const targetModelsDir = path.join(this.xdgCacheHome, "qmd", "models");
try {
// Check if the default models directory exists.
// Missing path is normal on first run and should be silent.
const stat = await fs.stat(defaultModelsDir).catch((err: unknown) => {
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
return null;
}
throw err;
});
if (!stat?.isDirectory()) {
return;
}
// Check if something already exists at the target path
try {
await fs.lstat(targetModelsDir);
// Already exists (directory, symlink, or file) – leave it alone
return;
} catch {
// Does not exist – proceed to create symlink
}
// On Windows, creating directory symlinks requires either Administrator
// privileges or Developer Mode. Fall back to a directory junction which
// works without elevated privileges (junctions are always absolute-path,
// which is fine here since both paths are already absolute).
try {
await fs.symlink(defaultModelsDir, targetModelsDir, "dir");
} catch (symlinkErr: unknown) {
const code = (symlinkErr as NodeJS.ErrnoException).code;
if (process.platform === "win32" && (code === "EPERM" || code === "ENOTSUP")) {
await fs.symlink(defaultModelsDir, targetModelsDir, "junction");
} else {
throw symlinkErr;
}
}
log.debug(`symlinked qmd models: ${defaultModelsDir} → ${targetModelsDir}`);
} catch (err) {
// Non-fatal: if we can't symlink, qmd will fall back to downloading
log.warn(`failed to symlink qmd models directory: ${String(err)}`);
}
}
private async runQmd(
args: string[],
opts?: { timeoutMs?: number; discardOutput?: boolean },
): Promise<{ stdout: string; stderr: string }> {
return await runCliCommand({
commandSummary: `qmd ${args.join(" ")}`,
spawnInvocation: resolveCliSpawnInvocation({
command: this.qmd.command,
args,
env: this.env,
packageName: "qmd",
}),
env: this.env,
cwd: this.workspaceDir,
timeoutMs: opts?.timeoutMs,
maxOutputChars: this.maxQmdOutputChars,
// Large `qmd update` runs can easily exceed the output cap; keep only stderr.
discardStdout: opts?.discardOutput,
});
}
/**
* QMD 1.1+ unified all search modes under a single "query" MCP tool
* that accepts a `searches` array with typed sub-queries (lex, vec, hyde).
* QMD <1.1 exposed separate tools: search, vector_search, deep_search.
*
* This method probes the MCP server once to detect which interface is
* available and caches the result for subsequent calls.
*/
private qmdMcpToolVersion: "v2" | "v1" | null = null;
private resolveQmdMcpTool(searchCommand: string): BuiltinQmdMcpTool {
if (this.qmdMcpToolVersion === "v2") {
return "query";
}
if (this.qmdMcpToolVersion === "v1") {
return searchCommand === "search"
? "search"
: searchCommand === "vsearch"
? "vector_search"
: "deep_search";
}
// Not yet probed — default to v2 (current QMD).
// If the call fails with "not found", markQmdV1Fallback() will retry with v1 names.
return "query";
}
private markQmdV1Fallback(): void {
if (this.qmdMcpToolVersion !== "v1") {
this.qmdMcpToolVersion = "v1";
log.warn(
"QMD MCP server does not expose the v2 'query' tool; falling back to v1 tool names (search/vector_search/deep_search).",
);
}
}
private markQmdV2(): void {
this.qmdMcpToolVersion = "v2";
}
/**
* Build the `searches` array for QMD 1.1+ `query` tool, respecting
* the configured searchMode so lexical-only or vector-only modes
* don't trigger unnecessary LLM/embedding work.
*/
private buildV2Searches(
query: string,
searchCommand?: string,
): Array<{ type: string; query: string }> {
switch (searchCommand) {
case "search":
// BM25 keyword search only
return [{ type: "lex", query }];
case "vsearch":
// Vector search only
return [{ type: "vec", query }];
case "query":
case undefined:
default:
// Full hybrid: lex + vec + hyde (query expansion)
return [
{ type: "lex", query },
{ type: "vec", query },
{ type: "hyde", query },
];
}
}
private isQueryToolNotFoundError(err: unknown): boolean {
const message = formatErrorMessage(err);
const detail = message.match(/ failed \(code \d+\): ([\s\S]*)$/)?.[1];
if (!detail) {
return false;
}
// Match only the specific v2-query missing-tool signatures emitted by MCP.
// The full mcporter command summary includes the serialized user query, so
// parse only the trailing stderr/stdout detail before deciding to pin v1.
return /(?:^|\n|:\s)(?:MCP error [^:\n]+:\s*)?Tool ['"]?query['"]? not found\b/i.test(detail);
}
private async ensureMcporterDaemonStarted(mcporter: ResolvedQmdMcporterConfig): Promise<void> {
if (!mcporter.enabled) {
return;
}
const state = getMcporterState();
if (!mcporter.startDaemon) {
if (!state.coldStartWarned) {
state.coldStartWarned = true;
log.warn(
"mcporter qmd bridge enabled but startDaemon=false; each query may cold-start QMD MCP. Consider setting memory.qmd.mcporter.startDaemon=true to keep it warm.",
);
}
return;
}
if (!state.daemonStart) {
state.daemonStart = (async () => {
try {
await this.runMcporter(["daemon", "start"], { timeoutMs: 10_000 });
} catch (err) {
log.warn(`mcporter daemon start failed: ${String(err)}`);
// Allow future searches to retry daemon start on transient failures.
state.daemonStart = null;
}
})();
}
await state.daemonStart;
}
private async runMcporter(
args: string[],
opts?: { timeoutMs?: number },
): Promise<{ stdout: string; stderr: string }> {
const spawnInvocation = resolveCliSpawnInvocation({
command: "mcporter",
args,
env: this.env,
packageName: "mcporter",
});
return await runCliCommand({
commandSummary: `${spawnInvocation.command} ${spawnInvocation.argv.join(" ")}`,
spawnInvocation,
// Keep mcporter and direct qmd commands on the same agent-scoped XDG state.
env: this.env,
cwd: this.workspaceDir,
timeoutMs: opts?.timeoutMs,
maxOutputChars: this.maxQmdOutputChars,
});
}
private async runQmdSearchViaMcporter(
params: QmdMcporterSearchParams,
): Promise<QmdQueryResult[]> {
await this.ensureMcporterDaemonStarted(params.mcporter);
// If the version is already known as v1 but we received a stale "query" tool name
// (e.g. from runMcporterAcrossCollections iterating after the first collection
// triggered the fallback), resolve the correct v1 tool name immediately.
const effectiveTool =
params.tool === "query" && this.qmdMcpToolVersion === "v1"
? this.resolveQmdMcpTool(params.searchCommand ?? "query")
: params.tool;
const selector = `${params.mcporter.serverName}.${effectiveTool}`;
const useUnifiedQueryTool = effectiveTool === "query";
const callArgs: Record<string, unknown> = useUnifiedQueryTool
? {
// QMD 1.1+ "query" tool accepts typed sub-queries via `searches` array.
// Derive sub-query types from searchCommand to respect searchMode config.
// Note: minScore is intentionally omitted — QMD 1.1+'s query tool uses
// its own reranking pipeline and does not accept a minScore parameter.
searches: this.buildV2Searches(params.query, params.searchCommand),
limit: params.limit,
}
: {
// QMD 1.x tools accept a flat query string.
query: params.query,
limit: params.limit,
minScore: params.minScore,
};
if (params.collection) {
if (useUnifiedQueryTool) {
callArgs.collections = [params.collection];
} else {
callArgs.collection = params.collection;
}
}
let result: { stdout: string };
try {
result = await this.runMcporter(
[
"call",
selector,
"--args",
JSON.stringify(callArgs),
"--output",
"json",
"--timeout",
String(Math.max(0, params.timeoutMs)),
],
{ timeoutMs: Math.max(params.timeoutMs + 2_000, 5_000) },
);
// If we got here with the v2 "query" tool, confirm v2 for future calls.
if (useUnifiedQueryTool && this.qmdMcpToolVersion === null) {
this.markQmdV2();
}
} catch (err) {
// If the v2 "query" tool is not found, fall back to v1 tool names.
// No need to guard on qmdMcpToolVersion !== "v1" here — if the version
// were already "v1", effectiveTool would have been resolved to a v1 tool
// name at the top of this function (not "query"). The effectiveTool ===
// "query" check alone prevents infinite retry loops since the recursive
// call passes a v1 tool name. Removing the version guard also fixes a
// race condition where concurrent searches both probe with "query" while
// the version is null — the second call would otherwise fail after the
// first sets the version to "v1".
if (useUnifiedQueryTool && this.isQueryToolNotFoundError(err)) {
this.markQmdV1Fallback();
const v1Tool = this.resolveQmdMcpTool(params.searchCommand ?? "query");
return this.runQmdSearchViaMcporter({
mcporter: params.mcporter,
tool: v1Tool,
searchCommand: params.searchCommand,
explicitToolOverride: false,
query: params.query,
limit: params.limit,
minScore: params.minScore,
collection: params.collection,
timeoutMs: params.timeoutMs,
});
}
throw err;
}
const parsedUnknown: unknown = JSON.parse(result.stdout);
const parsedRecord = asRecord(parsedUnknown);
const structuredContent = parsedRecord ? asRecord(parsedRecord.structuredContent) : null;
const structured: unknown = structuredContent ?? parsedUnknown;
const structuredRecord = asRecord(structured);
const results: unknown[] =
structuredRecord && Array.isArray(structuredRecord.results)
? (structuredRecord.results as unknown[])
: Array.isArray(structured)
? structured
: [];
const out: QmdQueryResult[] = [];
for (const item of results) {
const itemRecord = asRecord(item);
if (!itemRecord) {
continue;
}
const docidRaw = itemRecord.docid;
const docid = typeof docidRaw === "string" ? docidRaw.replace(/^#/, "").trim() : "";
if (!docid) {
continue;
}
const scoreRaw = itemRecord.score;
const score = typeof scoreRaw === "number" ? scoreRaw : Number(scoreRaw);
const snippet = typeof itemRecord.snippet === "string" ? itemRecord.snippet : "";
out.push({
docid,
score: Number.isFinite(score) ? score : 0,
snippet,
collection: typeof itemRecord.collection === "string" ? itemRecord.collection : undefined,
file: typeof itemRecord.file === "string" ? itemRecord.file : undefined,
body: typeof itemRecord.body === "string" ? itemRecord.body : undefined,
startLine: this.normalizeSnippetLine(itemRecord.start_line ?? itemRecord.startLine),
endLine: this.normalizeSnippetLine(itemRecord.end_line ?? itemRecord.endLine),
});
}
return out;
}
private async readPartialText(
absPath: string,
from?: number,
lines?: number,
): Promise<
{ missing: true } | { missing: false; selectedLines: string[]; moreSourceLinesRemain: boolean }
> {
const start = Math.max(1, from ?? 1);
const count = Math.max(1, lines ?? Number.POSITIVE_INFINITY);
let handle;
try {
handle = await fs.open(absPath);
} catch (err) {
if (isFileMissingError(err)) {
return { missing: true };
}
throw err;
}
const stream = handle.createReadStream({ encoding: "utf-8" });
const rl = readline.createInterface({
input: stream,
crlfDelay: Infinity,
});
const selected: string[] = [];
let index = 0;
let moreSourceLinesRemain = false;
try {
for await (const line of rl) {
index += 1;
if (index < start) {
continue;
}
if (selected.length >= count) {
moreSourceLinesRemain = true;
break;
}
selected.push(line);
}
} finally {
rl.close();
await handle.close();
}
return {
missing: false,
selectedLines: selected.slice(0, count),
moreSourceLinesRemain,
};
}
private async readFullText(
absPath: string,
): Promise<{ missing: true } | { missing: false; text: string }> {
try {
const text = await fs.readFile(absPath, "utf-8");
return { missing: false, text };
} catch (err) {
if (isFileMissingError(err)) {
return { missing: true };
}
throw err;
}
}
private ensureDb(): SqliteDatabase {
if (this.db) {
return this.db;
}
const { DatabaseSync } = requireNodeSqlite();
this.db = new DatabaseSync(this.indexPath, { readOnly: true });
// busy_timeout is per-connection; set it on every open so concurrent
// processes retry instead of failing immediately with SQLITE_BUSY.
// Use a lower value than the write path (5 s) because this read-only
// connection runs synchronous queries on the main thread via DatabaseSync.
// In WAL mode readers rarely block, so 1 s is a safe upper bound.
this.db.exec("PRAGMA busy_timeout = 1000");
return this.db;
}
private async exportSessions(): Promise<void> {
if (!this.sessionExporter) {
return;
}
const exportDir = this.sessionExporter.dir;
await fs.mkdir(exportDir, { recursive: true });
const files = await listSessionFilesForAgent(this.agentId);
const keep = new Set<string>();
const tracked = new Set<string>();
const cutoff = this.sessionExporter.retentionMs
? Date.now() - this.sessionExporter.retentionMs
: null;
for (const sessionFile of files) {
const entry = await buildSessionEntry(sessionFile);
if (!entry) {
continue;
}
if (cutoff && entry.mtimeMs < cutoff) {
continue;
}
const targetName = `${path.basename(sessionFile, ".jsonl")}.md`;
const target = path.join(exportDir, targetName);
tracked.add(sessionFile);
const state = this.exportedSessionState.get(sessionFile);
if (!state || state.hash !== entry.hash || state.mtimeMs !== entry.mtimeMs) {
await writeFileWithinRoot({
rootDir: exportDir,
relativePath: targetName,
data: this.renderSessionMarkdown(entry),
encoding: "utf-8",
});
}
this.exportedSessionState.set(sessionFile, {
hash: entry.hash,
mtimeMs: entry.mtimeMs,
target,
});
keep.add(target);
}
const exported = await fs.readdir(exportDir).catch(() => []);
for (const name of exported) {
if (!name.endsWith(".md")) {
continue;
}
const full = path.join(exportDir, name);
if (!keep.has(full)) {
await fs.rm(full, { force: true });
}
}
for (const [sessionFile, state] of this.exportedSessionState) {
if (!tracked.has(sessionFile) || !state.target.startsWith(exportDir + path.sep)) {
this.exportedSessionState.delete(sessionFile);
}
}
}
private renderSessionMarkdown(entry: SessionFileEntry): string {
const header = `# Session ${path.basename(entry.absPath, path.extname(entry.absPath))}`;
const body = entry.content?.trim().length ? entry.content.trim() : "(empty)";
return `${header}\n\n${body}\n`;
}
private pickSessionCollectionName(): string {
const existing = new Set(this.qmd.collections.map((collection) => collection.name));
const base = `sessions-${this.sanitizeCollectionNameSegment(this.agentId)}`;
if (!existing.has(base)) {
return base;
}
let counter = 2;
let candidate = `${base}-${counter}`;
while (existing.has(candidate)) {
counter += 1;
candidate = `${base}-${counter}`;
}
return candidate;
}
private sanitizeCollectionNameSegment(input: string): string {
const lower = normalizeLowercaseStringOrEmpty(input).replace(/[^a-z0-9-]+/g, "-");
const trimmed = lower.replace(/^-+|-+$/g, "");
return trimmed || "agent";
}
private async resolveDocLocation(
docid?: string,
hints?: { preferredCollection?: string; preferredFile?: string },
): Promise<{ rel: string; abs: string; source: MemorySource } | null> {
const normalizedHints = this.normalizeDocHints(hints);
if (!docid) {
return this.resolveDocLocationFromHints(normalizedHints);
}
const normalized = docid.startsWith("#") ? docid.slice(1) : docid;
if (!normalized) {
return null;
}
const cacheKey = `${normalizedHints.preferredCollection ?? "*"}:${normalized}`;
const cached = this.docPathCache.get(cacheKey);
if (cached) {
return cached;
}
const db = this.ensureDb();
let rows: Array<{ collection: string; path: string }> = [];
try {
rows = db
.prepare("SELECT collection, path FROM documents WHERE hash = ? AND active = 1")
.all(normalized) as Array<{ collection: string; path: string }>;
if (rows.length === 0) {
rows = db
.prepare("SELECT collection, path FROM documents WHERE hash LIKE ? AND active = 1")
.all(`${normalized}%`) as Array<{ collection: string; path: string }>;
}
} catch (err) {
if (this.isSqliteBusyError(err)) {
log.debug(`qmd index is busy while resolving doc path: ${String(err)}`);
throw this.createQmdBusyError(err);
}
throw err;
}
if (rows.length === 0) {
return null;
}
const location = this.pickDocLocation(rows, normalizedHints);
if (!location) {
return null;
}
this.docPathCache.set(cacheKey, location);
return location;
}
private resolveDocLocationFromHints(hints: {
preferredCollection?: string;
preferredFile?: string;
}): { rel: string; abs: string; source: MemorySource } | null {
if (!hints.preferredCollection || !hints.preferredFile) {
return null;
}
const indexedLocation = this.resolveIndexedDocLocationFromHint(
hints.preferredCollection,
hints.preferredFile,
);
if (indexedLocation) {
return indexedLocation;
}
const collectionRelativePath = this.toCollectionRelativePath(
hints.preferredCollection,
hints.preferredFile,
);
if (!collectionRelativePath) {
return null;
}
return this.toDocLocation(hints.preferredCollection, collectionRelativePath);
}
private resolveIndexedDocLocationFromHint(
collection: string,
preferredFile: string,
): { rel: string; abs: string; source: MemorySource } | null {
const trimmedCollection = collection.trim();
const trimmedFile = preferredFile.trim();
if (!trimmedCollection || !trimmedFile) {
return null;
}
const exactPath = path.normalize(trimmedFile).replace(/\\/g, "/");
let rows: Array<{ path: string }> = [];
try {
const db = this.ensureDb();
const exactRows = db
.prepare("SELECT path FROM documents WHERE collection = ? AND path = ? AND active = 1")
.all(trimmedCollection, exactPath) as Array<{ path: string }>;
if (exactRows.length > 0) {
return this.toDocLocation(trimmedCollection, exactRows[0].path);
}
rows = db
.prepare("SELECT path FROM documents WHERE collection = ? AND active = 1")
.all(trimmedCollection) as Array<{ path: string }>;
} catch (err) {
if (this.isSqliteBusyError(err)) {
log.debug(`qmd index is busy while resolving hinted path: ${String(err)}`);
throw this.createQmdBusyError(err);
}
// Hint-based lookup is best effort. Fall back to the raw hinted path when
// the index is unavailable or still warming.
log.debug(`qmd index hint lookup skipped: ${String(err)}`);
return null;
}
const matches = rows.filter((row) => this.matchesPreferredFileHint(row.path, trimmedFile));
if (matches.length !== 1) {
return null;
}
return this.toDocLocation(trimmedCollection, matches[0].path);
}
private normalizeDocHints(hints?: { preferredCollection?: string; preferredFile?: string }): {
preferredCollection?: string;
preferredFile?: string;
} {
const preferredCollection = hints?.preferredCollection?.trim();
const preferredFile = hints?.preferredFile?.trim();
if (!preferredFile) {
return preferredCollection ? { preferredCollection } : {};
}
const parsedQmdFile = this.parseQmdFileUri(preferredFile);
return {
preferredCollection: parsedQmdFile?.collection ?? preferredCollection,
preferredFile: parsedQmdFile?.collectionRelativePath ?? preferredFile,
};
}
private parseQmdFileUri(fileRef: string): {
collection?: string;
collectionRelativePath?: string;
} | null {
if (!normalizeLowercaseStringOrEmpty(fileRef).startsWith("qmd://")) {
return null;
}
try {
const parsed = new URL(fileRef);
const collection = decodeURIComponent(parsed.hostname).trim();
const pathname = decodeURIComponent(parsed.pathname).replace(/^\/+/, "").trim();
if (!collection && !pathname) {
return null;
}
return {
collection: collection || undefined,
collectionRelativePath: pathname || undefined,
};
} catch {
return null;
}
}
private toCollectionRelativePath(collection: string, filePath: string): string | null {
const root = this.collectionRoots.get(collection);
if (!root) {
return null;
}
const trimmedFilePath = filePath.trim();
if (!trimmedFilePath) {
return null;
}
const normalizedInput = path.normalize(trimmedFilePath);
const absolutePath = path.isAbsolute(normalizedInput)
? normalizedInput
: path.resolve(root.path, normalizedInput);
if (!this.isWithinRoot(root.path, absolutePath)) {
return null;
}
const relative = path.relative(root.path, absolutePath);
if (!relative || relative === ".") {
return null;
}
return relative.replace(/\\/g, "/");
}
private pickDocLocation(
rows: Array<{ collection: string; path: string }>,
hints?: { preferredCollection?: string; preferredFile?: string },
): { rel: string; abs: string; source: MemorySource } | null {
if (hints?.preferredCollection) {
for (const row of rows) {
if (row.collection !== hints.preferredCollection) {
continue;
}
const location = this.toDocLocation(row.collection, row.path);
if (location) {
return location;
}
}
}
if (hints?.preferredFile) {
for (const row of rows) {
if (!this.matchesPreferredFileHint(row.path, hints.preferredFile)) {
continue;
}
const location = this.toDocLocation(row.collection, row.path);
if (location) {
return location;
}
}
}
for (const row of rows) {
const location = this.toDocLocation(row.collection, row.path);
if (location) {
return location;
}
}
return null;
}
private matchesPreferredFileHint(rowPath: string, preferredFile: string): boolean {
const preferred = path.normalize(preferredFile).replace(/\\/g, "/");
const normalizedRowPath = path.normalize(rowPath).replace(/\\/g, "/");
if (normalizedRowPath === preferred || normalizedRowPath.endsWith(`/${preferred}`)) {
return true;
}
const normalizedPreferredLookup = this.normalizeQmdLookupPath(preferredFile);
if (!normalizedPreferredLookup) {
return false;
}
const normalizedRowLookup = this.normalizeQmdLookupPath(rowPath);
return (
normalizedRowLookup === normalizedPreferredLookup ||
normalizedRowLookup.endsWith(`/${normalizedPreferredLookup}`)
);
}
private normalizeQmdLookupPath(filePath: string): string {
return filePath
.replace(/\\/g, "/")
.split("/")
.filter((segment) => segment.length > 0 && segment !== ".")
.map((segment) => this.normalizeQmdLookupSegment(segment))
.filter(Boolean)
.join("/");
}
private normalizeQmdLookupSegment(segment: string): string {
const trimmed = segment.trim();
if (!trimmed) {
return "";
}
if (trimmed === "." || trimmed === "..") {
return trimmed;
}
const parsed = path.posix.parse(trimmed);
const normalizePart = (value: string): string =>
localeLowercasePreservingWhitespace(value.normalize("NFKD"))
.replace(/[^\p{Letter}\p{Number}]+/gu, "-")
.replace(/-{2,}/g, "-")
.replace(/^-+|-+$/g, "");
const normalizedName = normalizePart(parsed.name);
const normalizedExt = localeLowercasePreservingWhitespace(parsed.ext.normalize("NFKD")).replace(
/[^\p{Letter}\p{Number}.]+/gu,
"",
);
const fallbackName = normalizeLowercaseStringOrEmpty(parsed.name.normalize("NFKD")).replace(
/\s+/g,
"-",
);
return `${normalizedName || fallbackName || "file"}${normalizedExt}`;
}
private extractSnippetLines(snippet: string): { startLine: number; endLine: number } {
const headerLines = this.parseSnippetHeaderLines(snippet);
if (headerLines) {
return headerLines;
}
const lines = snippet.split("\n").length;
return { startLine: 1, endLine: lines };
}
private resolveSnippetLines(
entry: QmdQueryResult,
snippet: string,
): { startLine: number; endLine: number } {
const explicitStart = this.normalizeSnippetLine(entry.startLine);
const explicitEnd = this.normalizeSnippetLine(entry.endLine);
const headerLines = this.parseSnippetHeaderLines(snippet);
if (explicitStart !== undefined && explicitEnd !== undefined) {
return explicitStart <= explicitEnd
? { startLine: explicitStart, endLine: explicitEnd }
: { startLine: explicitEnd, endLine: explicitStart };
}
if (explicitStart !== undefined) {
if (headerLines) {
const width = headerLines.endLine - headerLines.startLine;
return {
startLine: explicitStart,
endLine: explicitStart + Math.max(0, width),
};
}
return { startLine: explicitStart, endLine: explicitStart };
}
if (explicitEnd !== undefined) {
if (headerLines) {
const width = headerLines.endLine - headerLines.startLine;
return {
startLine: Math.max(1, explicitEnd - Math.max(0, width)),
endLine: explicitEnd,
};
}
return { startLine: explicitEnd, endLine: explicitEnd };
}
if (headerLines) {
return headerLines;
}
return { startLine: 1, endLine: snippet.split("\n").length };
}
private normalizeSnippetLine(value: unknown): number | undefined {
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : undefined;
}
private parseSnippetHeaderLines(snippet: string): { startLine: number; endLine: number } | null {
const match = SNIPPET_HEADER_RE.exec(snippet);
if (!match) {
return null;
}
const start = Number(match[1]);
const count = Number(match[2]);
if (Number.isFinite(start) && Number.isFinite(count)) {
return { startLine: start, endLine: start + count - 1 };
}
return null;
}
private readCounts(): {
totalDocuments: number;
sourceCounts: Array<{ source: MemorySource; files: number; chunks: number }>;
} {
try {
const db = this.ensureDb();
const rows = db
.prepare(
"SELECT collection, COUNT(*) as c FROM documents WHERE active = 1 GROUP BY collection",
)
.all() as Array<{ collection: string; c: number }>;
const bySource = new Map<MemorySource, { files: number; chunks: number }>();
for (const source of this.sources) {
bySource.set(source, { files: 0, chunks: 0 });
}
let total = 0;
for (const row of rows) {
const root = this.collectionRoots.get(row.collection);
const source = root?.kind ?? "memory";
const entry = bySource.get(source) ?? { files: 0, chunks: 0 };
entry.files += row.c ?? 0;
entry.chunks += row.c ?? 0;
bySource.set(source, entry);
total += row.c ?? 0;
}
return {
totalDocuments: total,
sourceCounts: Array.from(bySource.entries()).map(([source, value]) => ({
source,
files: value.files,
chunks: value.chunks,
})),
};
} catch (err) {
log.warn(`failed to read qmd index stats: ${String(err)}`);
return {
totalDocuments: 0,
sourceCounts: Array.from(this.sources).map((source) => ({ source, files: 0, chunks: 0 })),
};
}
}
private logScopeDenied(sessionKey?: string): void {
const channel = deriveQmdScopeChannel(sessionKey) ?? "unknown";
const chatType = deriveQmdScopeChatType(sessionKey) ?? "unknown";
const key = sessionKey?.trim() || "<none>";
log.warn(
`qmd search denied by scope (channel=${channel}, chatType=${chatType}, session=${key})`,
);
}
private isScopeAllowed(sessionKey?: string): boolean {
return isQmdScopeAllowed(this.qmd.scope, sessionKey);
}
private toDocLocation(
collection: string,
collectionRelativePath: string,
): { rel: string; abs: string; source: MemorySource } | null {
const root = this.collectionRoots.get(collection);
if (!root) {
return null;
}
const normalizedRelative = collectionRelativePath.replace(/\\/g, "/");
const absPath = path.normalize(path.resolve(root.path, collectionRelativePath));
const relativeToWorkspace = path.relative(this.workspaceDir, absPath);
const relPath = this.buildSearchPath(
collection,
normalizedRelative,
relativeToWorkspace,
absPath,
);
return { rel: relPath, abs: absPath, source: root.kind };
}
private buildSearchPath(
collection: string,
collectionRelativePath: string,
relativeToWorkspace: string,
absPath: string,
): string {
const sanitized = collectionRelativePath.replace(/^\/+/, "");
const insideWorkspace = this.isInsideWorkspace(relativeToWorkspace);
if (insideWorkspace) {
const normalized = relativeToWorkspace.replace(/\\/g, "/");
if (!normalized) {
return path.basename(absPath);
}
// `qmd/<collection>/...` is a reserved virtual path namespace consumed by
// readFile(). If a real workspace file happens to live under `qmd/...`,
// return the explicit collection-scoped virtual path so search->read
// remains roundtrip-safe.
if (normalized === "qmd" || normalized.startsWith("qmd/")) {
return `qmd/${collection}/${sanitized}`;
}
return normalized;
}
return `qmd/${collection}/${sanitized}`;
}
private isInsideWorkspace(relativePath: string): boolean {
if (!relativePath) {
return true;
}
if (relativePath.startsWith("..")) {
return false;
}
if (relativePath.startsWith(`..${path.sep}`)) {
return false;
}
return !path.isAbsolute(relativePath);
}
private resolveReadPath(relPath: string): string {
if (relPath.startsWith("qmd/")) {
const [, collection, ...rest] = relPath.split("/");
if (!collection || rest.length === 0) {
throw new Error("invalid qmd path");
}
const root = this.collectionRoots.get(collection);
if (!root) {
throw new Error(`unknown qmd collection: ${collection}`);
}
const joined = rest.join("/");
const resolved = path.resolve(root.path, joined);
if (!this.isWithinRoot(root.path, resolved)) {
throw new Error("qmd path escapes collection");
}
return resolved;
}
const absPath = path.resolve(this.workspaceDir, relPath);
if (!this.isWithinWorkspace(absPath)) {
throw new Error("path escapes workspace");
}
const workspaceRel = path.relative(this.workspaceDir, absPath).replace(/\\/g, "/");
if (!isDefaultMemoryPath(workspaceRel) && !this.isIndexedWorkspaceReadPath(absPath)) {
throw new Error("path required");
}
return absPath;
}
private isIndexedWorkspaceReadPath(absPath: string): boolean {
const normalizedAbsPath = path.normalize(absPath);
for (const [collection, root] of this.collectionRoots.entries()) {
if (!this.isWithinRoot(root.path, normalizedAbsPath)) {
continue;
}
const collectionRelativePath = path
.relative(root.path, normalizedAbsPath)
.replace(/\\/g, "/");
if (!collectionRelativePath || collectionRelativePath.startsWith("..")) {
continue;
}
try {
const exactRow = this.ensureDb()
.prepare("SELECT path FROM documents WHERE collection = ? AND active = 1 AND path = ?")
.get(collection, collectionRelativePath) as { path: string } | undefined;
if (
exactRow &&
path.normalize(path.resolve(root.path, exactRow.path)) === normalizedAbsPath
) {
return true;
}
const rows = this.ensureDb()
.prepare("SELECT path FROM documents WHERE collection = ? AND active = 1")
.all(collection) as Array<{ path: string }>;
const match = rows.find((row) =>
this.matchesPreferredFileHint(row.path, collectionRelativePath),
);
if (match && path.normalize(path.resolve(root.path, match.path)) === normalizedAbsPath) {
return true;
}
} catch (err) {
if (this.isSqliteBusyError(err)) {
log.debug(`qmd index is busy while checking read path: ${String(err)}`);
throw this.createQmdBusyError(err);
}
log.debug(`qmd indexed read-path lookup skipped: ${String(err)}`);
}
}
return false;
}
private isWithinWorkspace(absPath: string): boolean {
const normalizedWorkspace = this.workspaceDir.endsWith(path.sep)
? this.workspaceDir
: `${this.workspaceDir}${path.sep}`;
if (absPath === this.workspaceDir) {
return true;
}
const candidate = absPath.endsWith(path.sep) ? absPath : `${absPath}${path.sep}`;
return candidate.startsWith(normalizedWorkspace);
}
private isWithinRoot(root: string, candidate: string): boolean {
const normalizedRoot = root.endsWith(path.sep) ? root : `${root}${path.sep}`;
if (candidate === root) {
return true;
}
const next = candidate.endsWith(path.sep) ? candidate : `${candidate}${path.sep}`;
return next.startsWith(normalizedRoot);
}
private clampResultsByInjectedChars(results: MemorySearchResult[]): MemorySearchResult[] {
const budget = this.qmd.limits.maxInjectedChars;
if (!budget || budget <= 0) {
return results;
}
let remaining = budget;
const clamped: MemorySearchResult[] = [];
for (const entry of results) {
if (remaining <= 0) {
break;
}
const snippet = entry.snippet ?? "";
if (snippet.length <= remaining) {
clamped.push(entry);
remaining -= snippet.length;
} else {
const trimmed = snippet.slice(0, Math.max(0, remaining));
clamped.push({ ...entry, snippet: trimmed });
break;
}
}
return clamped;
}
private diversifyResultsBySource(
results: MemorySearchResult[],
limit: number,
): MemorySearchResult[] {
const target = Math.max(0, limit);
if (target <= 0) {
return [];
}
if (results.length <= 1) {
return results.slice(0, target);
}
const bySource = new Map<MemorySource, MemorySearchResult[]>();
for (const entry of results) {
const list = bySource.get(entry.source) ?? [];
list.push(entry);
bySource.set(entry.source, list);
}
const hasSessions = bySource.has("sessions");
const hasMemory = bySource.has("memory");
if (!hasSessions || !hasMemory) {
return results.slice(0, target);
}
const sourceOrder = Array.from(bySource.entries())
.toSorted((a, b) => (b[1][0]?.score ?? 0) - (a[1][0]?.score ?? 0))
.map(([source]) => source);
const diversified: MemorySearchResult[] = [];
while (diversified.length < target) {
let emitted = false;
for (const source of sourceOrder) {
const next = bySource.get(source)?.shift();
if (!next) {
continue;
}
diversified.push(next);
emitted = true;
if (diversified.length >= target) {
break;
}
}
if (!emitted) {
break;
}
}
return diversified;
}
private shouldSkipUpdate(force?: boolean): boolean {
if (force) {
return false;
}
const debounceMs = this.qmd.update.debounceMs;
if (debounceMs <= 0) {
return false;
}
if (!this.lastUpdateAt) {
return false;
}
return Date.now() - this.lastUpdateAt < debounceMs;
}
private isSqliteBusyError(err: unknown): boolean {
const message = formatErrorMessage(err);
const normalized = normalizeLowercaseStringOrEmpty(message);
return normalized.includes("sqlite_busy") || normalized.includes("database is locked");
}
private isUnsupportedQmdOptionError(err: unknown): boolean {
const message = formatErrorMessage(err);
const normalized = normalizeLowercaseStringOrEmpty(message);
return (
normalized.includes("unknown flag") ||
normalized.includes("unknown option") ||
normalized.includes("unrecognized option") ||
normalized.includes("flag provided but not defined") ||
normalized.includes("unexpected argument")
);
}
private createQmdBusyError(err: unknown): Error {
const message = formatErrorMessage(err);
return new Error(`qmd index busy while reading results: ${message}`);
}
private async waitForPendingUpdateBeforeSearch(): Promise<void> {
const pending = this.pendingUpdate;
if (!pending) {
return;
}
await Promise.race([
pending.catch(() => undefined),
new Promise<void>((resolve) => setTimeout(resolve, SEARCH_PENDING_UPDATE_WAIT_MS)),
]);
}
private async runQueryAcrossCollections(
query: string,
limit: number,
collectionNames: string[],
command: "query" | "search" | "vsearch",
): Promise<QmdQueryResult[]> {
log.debug(
`qmd ${command} multi-collection workaround active (${collectionNames.length} collections)`,
);
const bestByResultKey = new Map<string, QmdQueryResult>();
for (const collectionName of collectionNames) {
const args = this.buildSearchArgs(command, query, limit);
args.push("-c", collectionName);
const result = await this.runQmd(args, { timeoutMs: this.qmd.limits.timeoutMs });
const parsed = parseQmdQueryJson(result.stdout, result.stderr);
for (const entry of parsed) {
const normalizedHints = this.normalizeDocHints({
preferredCollection: entry.collection ?? collectionName,
preferredFile: entry.file,
});
const normalizedDocId =
typeof entry.docid === "string" && entry.docid.trim().length > 0
? entry.docid
: undefined;
const withCollection = {
...entry,
docid: normalizedDocId,
collection: normalizedHints.preferredCollection ?? entry.collection ?? collectionName,
file: normalizedHints.preferredFile ?? entry.file,
} satisfies QmdQueryResult;
const resultKey = this.buildQmdResultKey(withCollection);
if (!resultKey) {
continue;
}
const prev = bestByResultKey.get(resultKey);
const prevScore = typeof prev?.score === "number" ? prev.score : Number.NEGATIVE_INFINITY;
const nextScore =
typeof withCollection.score === "number"
? withCollection.score
: Number.NEGATIVE_INFINITY;
if (!prev || nextScore > prevScore) {
bestByResultKey.set(resultKey, withCollection);
}
}
}
return [...bestByResultKey.values()].toSorted((a, b) => (b.score ?? 0) - (a.score ?? 0));
}
private buildQmdResultKey(entry: QmdQueryResult): string | null {
if (typeof entry.docid === "string" && entry.docid.trim().length > 0) {
return `docid:${entry.docid}`;
}
const hints = this.normalizeDocHints({
preferredCollection: entry.collection,
preferredFile: entry.file,
});
if (!hints.preferredCollection || !hints.preferredFile) {
return null;
}
const collectionRelativePath = this.toCollectionRelativePath(
hints.preferredCollection,
hints.preferredFile,
);
if (!collectionRelativePath) {
return null;
}
return `file:${hints.preferredCollection}:${collectionRelativePath}`;
}
private async runMcporterAcrossCollections(
params: QmdMcporterAcrossCollectionsParams,
): Promise<QmdQueryResult[]> {
const bestByDocId = new Map<string, QmdQueryResult>();
for (const collectionName of params.collectionNames) {
const parsed = params.explicitToolOverride
? await this.runQmdSearchViaMcporter({
mcporter: this.qmd.mcporter,
tool: params.tool,
searchCommand: params.searchCommand,
explicitToolOverride: true,
query: params.query,
limit: params.limit,
minScore: params.minScore,
collection: collectionName,
timeoutMs: this.qmd.limits.timeoutMs,
})
: await this.runQmdSearchViaMcporter({
mcporter: this.qmd.mcporter,
tool: params.tool,
searchCommand: params.searchCommand,
explicitToolOverride: false,
query: params.query,
limit: params.limit,
minScore: params.minScore,
collection: collectionName,
timeoutMs: this.qmd.limits.timeoutMs,
});
for (const entry of parsed) {
if (typeof entry.docid !== "string" || !entry.docid.trim()) {
continue;
}
const prev = bestByDocId.get(entry.docid);
const prevScore = typeof prev?.score === "number" ? prev.score : Number.NEGATIVE_INFINITY;
const nextScore = typeof entry.score === "number" ? entry.score : Number.NEGATIVE_INFINITY;
if (!prev || nextScore > prevScore) {
bestByDocId.set(entry.docid, entry);
}
}
}
return [...bestByDocId.values()].toSorted((a, b) => (b.score ?? 0) - (a.score ?? 0));
}
private listManagedCollectionNames(sources?: MemorySource[]): string[] {
if (!sources?.length) {
return this.managedCollectionNames;
}
const allowed = new Set(sources);
return this.managedCollectionNames.filter((name) => {
const source = this.collectionRoots.get(name)?.kind;
return source ? allowed.has(source) : false;
});
}
private computeManagedCollectionNames(): string[] {
const seen = new Set<string>();
const names: string[] = [];
for (const collection of this.qmd.collections) {
const name = collection.name?.trim();
if (!name || seen.has(name)) {
continue;
}
seen.add(name);
names.push(name);
}
return names;
}
private buildCollectionFilterArgs(collectionNames: string[]): string[] {
if (collectionNames.length === 0) {
return [];
}
const names = collectionNames.filter(Boolean);
return names.flatMap((name) => ["-c", name]);
}
private buildSearchArgs(
command: "query" | "search" | "vsearch",
query: string,
limit: number,
): string[] {
const normalizedQuery = command === "search" ? normalizeHanBm25Query(query) : query;
if (command === "query") {
return ["query", normalizedQuery, "--json", "-n", String(limit)];
}
return [command, normalizedQuery, "--json", "-n", String(limit)];
}
}
function resolveQmdManagerRuntimeConfig(
cfg: OpenClawConfig,
agentId: string,
): QmdManagerRuntimeConfig {
return {
workspaceDir: resolveAgentWorkspaceDir(cfg, agentId),
syncSettings: resolveMemorySearchSyncConfig(cfg, agentId),
contextLimits: resolveAgentContextLimits(cfg, agentId),
};
}
¤ Diese beiden folgenden Angebotsgruppen bietet das Unternehmen0.52Angebot
(Wie Sie bei der Firma Beratungs- und Dienstleistungen beauftragen können 2026-04-27)
¤
*Eine klare Vorstellung vom Zielzustand
|
|