import { type ExpandedSection, isLockContention, isMissingCollection, MEMSEARCH_SPEC, parseChunkCount, parseCompactSummary, parseExpandedSection, parseIndexedChunks, parseSearchHits, parseSkillsStatus, parseVersion, type SearchHit, type SkillsStatus, } from './contract.ts' import type { ExecFn, ExecResult } from './exec.ts' import type { CollectionRef } from './scope.ts' const VERSION_TIMEOUT_MS = 60_000 const CONFIG_TIMEOUT_MS = 10_000 const STATS_TIMEOUT_MS = 10_000 const SKILLS_STATUS_TIMEOUT_MS = 10_000 const EXPAND_TIMEOUT_MS = 10_000 const INDEX_TIMEOUT_MS = 120_000 const DEFAULT_SEARCH_TIMEOUT_MS = 30_000 const DEFAULT_COMPACT_TIMEOUT_MS = 300_000 export const DEFAULT_TOP_K = 5 const NEGATIVE_PROBE_TTL_MS = 30_000 const BACKOFF_DELAYS_MS = [200, 500, 1000, 2000] const UV_INSTRUCTIONS = 'The memory backend is unavailable: uv is not installed. Install it with `curl -LsSf https://astral.sh/uv/install.sh | sh` (or your package manager), then retry.' function memsearchInstructions(detail: string): string { return `The memory backend is unavailable: memsearch could not be run via uvx (${detail}). Check network access or pre-install it with \`uv tool install "memsearch[onnx]"\`, then retry.` } export type Unavailable = { available: false instructions: string reason: 'memsearch-unavailable' | 'uv-missing' } export type Availability = Unavailable | { available: true; version: string } export class BackendUnavailableError extends Error { readonly availability: Unavailable constructor(availability: Unavailable) { super(availability.instructions) this.name = 'BackendUnavailableError' this.availability = availability } } export class MissingCollectionError extends Error { constructor(command: string, collection: CollectionRef) { const named = collection.kind === 'explicit' ? `collection ${collection.name}` : `the collection memsearch config resolves from default ${collection.name}` super(`memsearch ${command} failed: ${named} was never indexed on this machine`) this.name = 'MissingCollectionError' } } export interface CommandOptions { cwd?: string onQueued?(holder: string): void signal?: AbortSignal } export interface Backend { compact(outputDir: string, collection: CollectionRef, options?: CommandOptions): Promise configGet(key: string, options?: CommandOptions): Promise configSet(key: string, value: string, options?: CommandOptions): Promise expand(chunkHash: string, collection: CollectionRef, options?: CommandOptions): Promise index(path: string, collection: CollectionRef, options?: CommandOptions): Promise probe(options?: CommandOptions): Promise resolveCollection(collection: CollectionRef, dir: string, options?: CommandOptions): Promise search(query: string, collection: CollectionRef, options?: CommandOptions & { topK?: number }): Promise skillsStatus(options?: CommandOptions): Promise stats(collection: CollectionRef, options?: CommandOptions): Promise } export function collectionArgs(collection: CollectionRef): string[] { return [collection.kind === 'explicit' ? '-c' : '--default-collection', collection.name] } export interface BackendDeps { env: NodeJS.ProcessEnv exec: ExecFn now(): Date sleep(ms: number): Promise } export function createBackend(deps: BackendDeps): Backend { const searchTimeoutMs = resolveTimeoutMs(deps.env, 'PI_MEMSEARCH_SEARCH_TIMEOUT_MS', DEFAULT_SEARCH_TIMEOUT_MS) const compactTimeoutMs = resolveTimeoutMs(deps.env, 'PI_MEMSEARCH_COMPACT_TIMEOUT_MS', DEFAULT_COMPACT_TIMEOUT_MS) let tail: Promise = Promise.resolve() const queuedLabels: string[] = [] let probeCache: { expiresAtMs?: number; result: Promise } | undefined const resolvedNames = new Map>() function enqueue(label: string, options: CommandOptions, task: () => Promise): Promise { const holder = queuedLabels[0] if (holder !== undefined) options.onQueued?.(holder) queuedLabels.push(label) const next = tail.then(task, task) const settle = () => { queuedLabels.shift() } tail = next.then(settle, settle) return next } function invoke(label: string, args: string[], timeoutMs: number, options: CommandOptions): Promise { return enqueue(label, options, async () => { options.signal?.throwIfAborted() for (let attempt = 0;; attempt++) { const result = await deps.exec( 'uvx', ['--from', MEMSEARCH_SPEC, 'memsearch', ...args], { timeoutMs, ...(options.cwd === undefined ? {} : { cwd: options.cwd }), ...(options.signal ? { signal: options.signal } : {}), }, ) if (result.exitCode !== 0 && isLockContention(result.stderr) && attempt < BACKOFF_DELAYS_MS.length) { await deps.sleep(BACKOFF_DELAYS_MS[attempt] as number) if (!options.signal?.aborted) continue } return result } }) } async function runProbe(options: CommandOptions): Promise { let result: ExecResult try { result = await invoke('availability probe', ['--version'], VERSION_TIMEOUT_MS, options) } catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return { available: false, instructions: UV_INSTRUCTIONS, reason: 'uv-missing' } if (error instanceof Error && error.name === 'AbortError') throw error return { available: false, instructions: memsearchInstructions(`spawn failed: ${error instanceof Error ? error.message : String(error)}`), reason: 'memsearch-unavailable', } } if (result.exitCode !== 0) { return { available: false, instructions: memsearchInstructions(describeFailure(result, VERSION_TIMEOUT_MS)), reason: 'memsearch-unavailable', } } const version = parseVersion(result.stdout) if (version === undefined) { return { available: false, instructions: memsearchInstructions(`unexpected --version output: "${result.stdout.trim()}"`), reason: 'memsearch-unavailable', } } return { available: true, version } } function probe(options: CommandOptions = {}): Promise { const cached = probeCache if (cached && (cached.expiresAtMs === undefined || deps.now().getTime() < cached.expiresAtMs)) return cached.result let entry: { expiresAtMs?: number; result: Promise } | undefined const result = runProbe(options).then( (availability) => { if (!availability.available && entry) entry.expiresAtMs = deps.now().getTime() + NEGATIVE_PROBE_TTL_MS return availability }, (error: unknown) => { probeCache = undefined throw error }, ) entry = { result } probeCache = entry return result } async function ensureAvailable(options: CommandOptions): Promise { const availability = await probe(options) if (!availability.available) throw new BackendUnavailableError(availability) } async function runCommand( name: string, args: string[], timeoutMs: number, options: CommandOptions, collection?: CollectionRef, ): Promise { await ensureAvailable(options) const result = await invoke(holderLabel(name), args, timeoutMs, options) if (result.exitCode !== 0) { if (collection !== undefined && isMissingCollection(result.stderr)) throw new MissingCollectionError(name, collection) throw commandError(name, result, timeoutMs) } return result } async function search( query: string, collection: CollectionRef, options: CommandOptions & { topK?: number } = {}, ): Promise { const topK = options.topK ?? DEFAULT_TOP_K const args = ['search', '-j', '-k', String(topK), ...collectionArgs(collection), '--', query] const result = await runCommand('search', args, searchTimeoutMs, options, collection) return parseSearchHits(result.stdout) } async function expand( chunkHash: string, collection: CollectionRef, options: CommandOptions = {}, ): Promise { const args = ['expand', '-j', ...collectionArgs(collection), '--', chunkHash] const result = await runCommand('expand', args, EXPAND_TIMEOUT_MS, options, collection) return parseExpandedSection(result.stdout) } async function configGet(key: string, options: CommandOptions = {}): Promise { const result = await runCommand('config get', ['config', 'get', key], CONFIG_TIMEOUT_MS, options) return result.stdout.trim() } function resolveCollection(collection: CollectionRef, dir: string, options: CommandOptions = {}): Promise { if (collection.kind === 'explicit') return Promise.resolve(collection.name) const key = `${dir}\0${collection.name}` const cached = resolvedNames.get(key) if (cached !== undefined) return cached const args = ['config', 'get', 'milvus.collection', ...collectionArgs(collection)] const resolved = runCommand('config get', args, CONFIG_TIMEOUT_MS, { ...options, cwd: dir }).then( (result) => result.stdout.trim(), ) resolvedNames.set(key, resolved) resolved.catch(() => resolvedNames.delete(key)) return resolved } async function configSet(key: string, value: string, options: CommandOptions = {}): Promise { await runCommand('config set', ['config', 'set', key, value], CONFIG_TIMEOUT_MS, options) } async function compact( outputDir: string, collection: CollectionRef, options: CommandOptions = {}, ): Promise { const args = ['compact', '-o', outputDir, ...collectionArgs(collection)] const result = await runCommand('compact', args, compactTimeoutMs, options) return parseCompactSummary(result.stdout) } async function index(path: string, collection: CollectionRef, options: CommandOptions = {}): Promise { const result = await runCommand('index', ['index', path, ...collectionArgs(collection)], INDEX_TIMEOUT_MS, options) return parseIndexedChunks(result.stdout) } async function skillsStatus(options: CommandOptions = {}): Promise { const result = await runCommand('skills status', ['skills', 'status', '-j'], SKILLS_STATUS_TIMEOUT_MS, options) return parseSkillsStatus(result.stdout) } async function stats(collection: CollectionRef, options: CommandOptions = {}): Promise { await ensureAvailable(options) const result = await invoke('stats', ['stats', ...collectionArgs(collection)], STATS_TIMEOUT_MS, options) if (result.exitCode !== 0) { if (isMissingCollection(result.stderr)) return 'missing' throw commandError('stats', result, STATS_TIMEOUT_MS) } return parseChunkCount(result.stdout) } return { compact, configGet, configSet, expand, index, probe, resolveCollection, search, skillsStatus, stats } } function resolveTimeoutMs(env: NodeJS.ProcessEnv, key: string, fallback: number): number { const raw = env[key] if (raw === undefined || raw === '') return fallback const value = Number(raw) if (!Number.isInteger(value) || value <= 0) throw new Error(`${key} must be a positive integer of milliseconds, got "${raw}"`) return value } function holderLabel(name: string): string { return name === 'compact' ? 'memory compaction' : name } function commandError(name: string, result: ExecResult, timeoutMs: number): Error { return new Error(`memsearch ${name} failed: ${describeFailure(result, timeoutMs)}`) } function describeFailure(result: ExecResult, timeoutMs: number): string { if (result.exitCode === null) return `timed out after ${timeoutMs}ms (terminated with ${result.signal})` const detail = lastLine(result.stderr) return detail === '' ? `exit ${result.exitCode}` : `exit ${result.exitCode}: ${detail}` } function lastLine(text: string): string { const lines = text .split('\n') .map((line) => line.trim()) .filter((line) => line !== '') return lines[lines.length - 1] ?? '' }