@claude-flow/embeddings
Version:
V3 Embedding Service - OpenAI, Transformers.js, Agentic-Flow (ONNX), Mock providers with hyperbolic embeddings, normalization, and chunking
186 lines • 7.62 kB
JavaScript
/**
* 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 } from 'node:child_process';
/**
* 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, args, timeoutMs) {
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;
try {
proc = spawn(bin, args, { stdio: ['ignore', 'pipe', 'pipe'] });
}
catch {
resolve(null);
return;
}
let stdout = '';
let stderr = '';
let done = false;
const finish = (val) => { 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) {
const tools = [];
let currentGroup = 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 = {}) {
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;
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 = {};
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) {
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]`;
}
//# sourceMappingURL=ruvector-mcp-probe.js.map