UNPKG

@claude-flow/embeddings

Version:

V3 Embedding Service - OpenAI, Transformers.js, Agentic-Flow (ONNX), Mock providers with hyperbolic embeddings, normalization, and chunking

227 lines (214 loc) • 8.94 kB
/** * ADR-121 Phase 5 (lightweight) — ruvector MCP sidecar availability probe. * * The `ruvector` CLI ships its own MCP server with a rich tool surface * (`hooks_*`, vector memory, trajectory tracking, etc.). The full * Phase 5 plan is to wire a sidecar MCP server alongside `@claude-flow/cli` * so embedding-heavy MCP tools can delegate to the optimized Rust * backend. That's a multi-package integration (touches the CLI's MCP * registry, the doctor command, the cost-tracker) and lands in a * follow-up. * * This module ships the *foundation* for that work: a structured * availability probe that detects whether `ruvector` is installed, * what version, and which MCP tools it exposes. The probe is * shellout-based (spawns `ruvector --version` + `ruvector mcp tools`), * times out cleanly, and degrades to a `null` report when the CLI * isn't on the PATH. * * Consumers: * - `ruflo doctor` integration (follow-up PR) — reports the sidecar * availability + version in the system health table * - New `embeddings_check_ruvector_sidecar` MCP tool — exposes the * report to LLM agents that want to know whether the optimized * backend is reachable before issuing embedding-heavy calls * - CI guards — assert the probe handles both installed/not-installed * paths cleanly */ import { spawn, type ChildProcessByStdio } from 'node:child_process'; import type { Readable } from 'node:stream'; /** * Single MCP tool entry as reported by `ruvector mcp tools`. * Mirrors the columns of the CLI's text output. */ export interface RuvectorMcpTool { /** Tool group (e.g. 'hooks-core', 'hooks-trajectory'). */ readonly group: string; /** Tool name (e.g. 'hooks_route'). */ readonly name: string; /** Parameter signature as a free-form string. */ readonly params: string; /** Human-readable description. */ readonly description: string; } export interface RuvectorAvailability { /** True iff `ruvector` is on the PATH and `--version` succeeded. */ readonly available: boolean; /** CLI version (e.g. "0.2.13") when available. */ readonly version?: string; /** Resolved binary path when available. */ readonly binary?: string; /** Tools surfaced by `ruvector mcp tools` when available. */ readonly tools?: readonly RuvectorMcpTool[]; /** Total tool count (cheap top-level number for dashboards). */ readonly toolCount?: number; /** Tool-group breakdown — { 'hooks-core': 10, 'hooks-trajectory': 3, ... }. */ readonly groups?: Readonly<Record<string, number>>; /** Human-readable reason when `available === false`. */ readonly reason?: string; /** Wall-clock ms the probe took. */ readonly probeMs: number; } export interface RuvectorProbeOptions { /** Per-shellout timeout (default 5_000 ms). */ readonly timeoutMs?: number; /** Override the binary name (default 'ruvector'). For tests. */ readonly binary?: string; } /** * Run a child process to completion or timeout. Returns null on * any failure (spawn error, timeout, non-zero exit). The caller * surfaces the failure as a structured `reason` string instead of * propagating the raw error — the probe is meant to be friendly, * not throw. */ async function runShort(bin: string, args: string[], timeoutMs: number): Promise<string | null> { return new Promise((resolve) => { // Use the literal `['ignore', 'pipe', 'pipe']` stdio shape so // TypeScript narrows to ChildProcessByStdio<null, Readable, Readable> // (no stdin handle, both stdout + stderr pipeable). let proc: ChildProcessByStdio<null, Readable, Readable>; try { proc = spawn(bin, args, { stdio: ['ignore', 'pipe', 'pipe'] }) as ChildProcessByStdio<null, Readable, Readable>; } catch { resolve(null); return; } let stdout = ''; let stderr = ''; let done = false; const finish = (val: string | null) => { if (!done) { done = true; resolve(val); } }; proc.stdout.on('data', (b) => { stdout += b.toString(); }); proc.stderr.on('data', (b) => { stderr += b.toString(); }); const timer = setTimeout(() => { try { proc.kill('SIGTERM'); } catch { /* ignore */ } finish(null); }, timeoutMs); proc.on('error', () => { clearTimeout(timer); finish(null); }); proc.on('close', (code) => { clearTimeout(timer); // For `ruvector --version` and `ruvector mcp tools`, both succeed // with exit 0 and print to stdout. A non-zero exit means the CLI // surface changed — treat as "not available" for probe purposes. finish(code === 0 ? stdout : (stderr ? null : stdout || null)); }); }); } /** * Parse the columnar text output of `ruvector mcp tools` into a * structured tool list. The CLI's output shape (as of ruvector * 0.2.x): * * RuVector MCP Tools * * <group> (<count>): * <name> <params> <description> * ... * * Parser is intentionally tolerant of column-alignment variation * (the CLI right-pads to a column width that depends on the * longest tool name) — we split on runs of 2+ spaces, not exact * column positions. */ function parseMcpTools(stdout: string): RuvectorMcpTool[] { const tools: RuvectorMcpTool[] = []; let currentGroup: string | null = null; for (const rawLine of stdout.split('\n')) { const line = rawLine.replace(/\[[0-9;]*m/g, ''); // strip ANSI color const trimmed = line.trim(); if (!trimmed) continue; // Group header looks like " hooks-core (10):" const groupMatch = /^\s+([a-z0-9_-]+)\s*\((\d+)\):\s*$/i.exec(line); if (groupMatch) { currentGroup = groupMatch[1]!; continue; } // Tool row: indented further, has at least 3 columns. if (currentGroup && /^\s{4,}\S/.test(line)) { // Split on runs of 2+ spaces to get [name, params, ...description]. const parts = trimmed.split(/\s{2,}/); if (parts.length < 2) continue; const name = parts[0]!.trim(); const params = (parts[1] ?? '').trim(); const description = parts.slice(2).join(' ').trim() || params; // Re-derive params if description ate them (description was // missing → parts had 2 entries, second was actually the desc). const finalParams = parts.length >= 3 ? params : '(none)'; const finalDesc = parts.length >= 3 ? description : params; // Skip header-shaped rows that slip past the regex (e.g. an // extra divider line in a future CLI release). if (!name || name.startsWith('-') || name === 'RuVector') continue; tools.push({ group: currentGroup, name, params: finalParams, description: finalDesc }); } } return tools; } /** * Probe ruvector CLI availability + MCP tool surface. Never throws; * returns a `RuvectorAvailability` report with `available: false` and * a `reason` string when the CLI isn't reachable. */ export async function probeRuvectorSidecar( options: RuvectorProbeOptions = {}, ): Promise<RuvectorAvailability> { const t0 = Date.now(); const bin = options.binary ?? 'ruvector'; const timeoutMs = options.timeoutMs ?? 5_000; // Step 1 — version probe. If this fails the CLI isn't there. const versionOut = await runShort(bin, ['--version'], timeoutMs); if (!versionOut) { return { available: false, reason: `'${bin} --version' did not return cleanly within ${timeoutMs}ms; CLI likely not installed`, probeMs: Date.now() - t0, }; } const version = versionOut.trim().split('\n')[0]?.trim(); // Step 2 — locate the binary via `which` (best-effort, non-fatal). let binary: string | undefined; const whichOut = await runShort('which', [bin], 2_000); if (whichOut) binary = whichOut.trim().split('\n')[0]?.trim(); // Step 3 — tool list. `ruvector mcp tools` is the documented surface. const toolsOut = await runShort(bin, ['mcp', 'tools'], timeoutMs); if (!toolsOut) { return { available: true, version, binary, reason: `'${bin} mcp tools' did not return cleanly within ${timeoutMs}ms; tool surface unknown`, probeMs: Date.now() - t0, }; } const tools = parseMcpTools(toolsOut); const groups: Record<string, number> = {}; for (const t of tools) groups[t.group] = (groups[t.group] ?? 0) + 1; return { available: true, version, binary, tools, toolCount: tools.length, groups, probeMs: Date.now() - t0, }; } /** * Compact summary for dashboards / `ruflo doctor` output. Returns * a single line suitable for a table row. */ export function formatRuvectorAvailability(r: RuvectorAvailability): string { if (!r.available) { return `ruvector: not available (${r.reason ?? 'unknown reason'}) [${r.probeMs}ms]`; } const groupSummary = r.groups ? Object.entries(r.groups).map(([g, n]) => `${g}=${n}`).join(', ') : 'tools unknown'; return `ruvector: ${r.version ?? '?'} (${r.toolCount ?? 0} MCP tools — ${groupSummary}) [${r.probeMs}ms]`; }