@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
text/typescript
/**
* 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]`;
}