openclaw/extensions/memory-ruvector/client.ts
File 4670817426 feat(memory): add ruvector vector database plugin
Add new memory-ruvector extension providing high-performance vector
storage and semantic search capabilities using the ruvector database.

Features:
- Dual-mode operation (remote server or local database)
- Automatic message indexing via hooks
- Semantic search tool for agents
- Multiple embedding providers (OpenAI, Voyage AI, local)
- SONA self-learning for improved search accuracy
- GNN and Cypher graph queries for relationship traversal
- Graceful in-memory fallback
- CLI commands for management

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 08:14:01 +01:00

1003 lines
29 KiB
TypeScript

/**
* RuvectorClient - Wrapper for the ruvector npm package.
*
* Provides a typed interface for vector storage operations including
* connect, disconnect, insert, search, and delete.
*/
import { randomUUID } from "node:crypto";
import { CodeGraph, RuvectorLayer, SonaEngine, VectorDb } from "ruvector";
import type { PluginLogger } from "clawdbot/plugin-sdk";
import {
RuvectorError,
type CypherResult,
type DistanceMetric,
type GNNConfig,
type GraphEdge,
type GraphNode,
type LearnedPattern,
type RuvectorClientConfig,
type RuvectorStats,
type SONAConfig,
type SONAStats,
type VectorEntry,
type VectorInsertInput,
type VectorSearchParams,
type VectorSearchResult,
} from "./types.js";
// =============================================================================
// Ruvector Native Types (from ruvector package)
// =============================================================================
type RuvectorDbInstance = InstanceType<typeof VectorDb>;
type RuvectorInsertEntry = {
id?: string;
vector: Float32Array | number[];
metadata?: Record<string, unknown>;
};
type RuvectorSearchQuery = {
vector: Float32Array | number[];
k: number;
filter?: Record<string, unknown>;
efSearch?: number;
};
type RuvectorSearchResult = {
id: string;
score: number;
vector?: Float32Array;
metadata?: Record<string, unknown>;
};
type RuvectorGetResult = {
id?: string;
vector: Float32Array;
metadata?: Record<string, unknown>;
} | null;
// =============================================================================
// RuvectorClient
// =============================================================================
/**
* Client wrapper for the ruvector vector database.
*
* Usage:
* ```typescript
* const client = new RuvectorClient({
* dimension: 1536,
* storagePath: "./memory.db",
* metric: "cosine",
* }, logger);
*
* await client.connect();
* const id = await client.insert({ vector: [...], metadata: { text: "..." } });
* const results = await client.search({ vector: [...], limit: 5 });
* await client.disconnect();
* ```
*/
export class RuvectorClient {
private db: RuvectorDbInstance | null = null;
private config: RuvectorClientConfig;
private logger: PluginLogger;
private initPromise: Promise<void> | null = null;
// SONA (Self-Organizing Neural Architecture) state
private sonaEngine: InstanceType<typeof SonaEngine> | null = null;
private sonaConfig: SONAConfig | null = null;
private activeTrajectory: string | null = null;
private sonaStatsInternal = {
trajectoriesRecorded: 0,
microLoraUpdates: 0,
totalLearningTimeMs: 0,
learningOperations: 0,
};
// Graph Neural Network state
private graph: InstanceType<typeof CodeGraph> | null = null;
private gnnLayer: InstanceType<typeof RuvectorLayer> | null = null;
private gnnConfig: GNNConfig | null = null;
constructor(config: RuvectorClientConfig, logger: PluginLogger) {
this.config = config;
this.logger = logger;
}
// ===========================================================================
// Connection Management
// ===========================================================================
/**
* Connect to the vector database.
* Initializes the ruvector instance with the configured options.
*
* @throws {RuvectorError} If already connected or initialization fails
*/
async connect(): Promise<void> {
if (this.db) {
throw new RuvectorError("ALREADY_CONNECTED", "Client is already connected");
}
if (this.initPromise) {
return this.initPromise;
}
this.initPromise = this.doConnect();
return this.initPromise;
}
private async doConnect(): Promise<void> {
const { dimension, storagePath, metric = "cosine", hnsw } = this.config;
this.logger.info(
`ruvector-client: connecting (dimension: ${dimension}, metric: ${metric}${storagePath ? `, path: ${storagePath}` : ", in-memory"})`,
);
try {
// Map our metric names to ruvector's expected format
const distanceMetric = mapMetricToRuvector(metric);
// Create ruvector database instance
this.db = new VectorDb({
dimensions: dimension,
storagePath,
distanceMetric,
hnswConfig: hnsw
? {
m: hnsw.m,
efConstruction: hnsw.efConstruction,
efSearch: hnsw.efSearch,
}
: undefined,
});
this.logger.info("ruvector-client: connected successfully");
} catch (err) {
this.initPromise = null;
throw new RuvectorError(
"INITIALIZATION_FAILED",
`Failed to initialize ruvector: ${formatError(err)}`,
err,
);
}
}
/**
* Disconnect from the vector database.
* Cleans up resources and closes any open connections.
*/
async disconnect(): Promise<void> {
if (!this.db && !this.sonaEngine && !this.graph) {
return;
}
this.logger.info("ruvector-client: disconnecting");
// Clean up SONA engine first (may have active trajectories)
if (this.sonaEngine) {
try {
await this.disableSONA();
} catch (err) {
this.logger.warn(`ruvector-client: error during SONA cleanup: ${formatError(err)}`);
}
}
// Clean up GNN layer
if (this.gnnLayer) {
this.gnnLayer = null;
this.gnnConfig = null;
}
// Clean up graph
if (this.graph) {
try {
this.graph = null;
} catch (err) {
this.logger.warn(`ruvector-client: error during graph cleanup: ${formatError(err)}`);
}
}
try {
// Ruvector doesn't have an explicit close method, but we null the reference
// to allow garbage collection. If persisted, data is already on disk.
this.db = null;
this.initPromise = null;
this.logger.info("ruvector-client: disconnected");
} catch (err) {
this.logger.warn(`ruvector-client: error during disconnect: ${formatError(err)}`);
this.db = null;
this.initPromise = null;
}
}
/**
* Check if the client is connected.
*/
isConnected(): boolean {
return this.db !== null;
}
// ===========================================================================
// Vector Operations
// ===========================================================================
/**
* Insert a vector entry into the database.
*
* @param input - The vector entry to insert
* @returns The ID of the inserted entry
* @throws {RuvectorError} If not connected or insert fails
*/
async insert(input: VectorInsertInput): Promise<string> {
const db = this.ensureConnected();
const id = input.id ?? randomUUID();
const vector = normalizeVector(input.vector);
// Validate dimension
if (vector.length !== this.config.dimension) {
throw new RuvectorError(
"INVALID_DIMENSION",
`Vector dimension mismatch: expected ${this.config.dimension}, got ${vector.length}`,
);
}
try {
const entry: RuvectorInsertEntry = {
id,
vector,
metadata: input.metadata as Record<string, unknown>,
};
await db.insert(entry);
this.logger.debug?.(`ruvector-client: inserted vector ${id}`);
return id;
} catch (err) {
throw new RuvectorError("INSERT_FAILED", `Failed to insert vector: ${formatError(err)}`, err);
}
}
/**
* Insert multiple vector entries in batch.
*
* @param inputs - Array of vector entries to insert
* @returns Array of IDs for the inserted entries
* @throws {RuvectorError} If not connected or insert fails
*/
async insertBatch(inputs: VectorInsertInput[]): Promise<string[]> {
const db = this.ensureConnected();
const entries: RuvectorInsertEntry[] = inputs.map((input) => {
const id = input.id ?? randomUUID();
const vector = normalizeVector(input.vector);
if (vector.length !== this.config.dimension) {
throw new RuvectorError(
"INVALID_DIMENSION",
`Vector dimension mismatch: expected ${this.config.dimension}, got ${vector.length}`,
);
}
return {
id,
vector,
metadata: input.metadata as Record<string, unknown>,
};
});
try {
const ids = await db.insertBatch(entries);
this.logger.debug?.(`ruvector-client: batch inserted ${ids.length} vectors`);
return ids;
} catch (err) {
throw new RuvectorError(
"INSERT_FAILED",
`Failed to batch insert vectors: ${formatError(err)}`,
err,
);
}
}
/**
* Search for similar vectors.
*
* @param params - Search parameters
* @returns Array of search results with similarity scores
* @throws {RuvectorError} If not connected or search fails
*/
async search(params: VectorSearchParams): Promise<VectorSearchResult[]> {
const db = this.ensureConnected();
const { vector, limit = 10, minScore = 0, filter } = params;
const queryVector = normalizeVector(vector);
// Validate dimension
if (queryVector.length !== this.config.dimension) {
throw new RuvectorError(
"INVALID_DIMENSION",
`Query vector dimension mismatch: expected ${this.config.dimension}, got ${queryVector.length}`,
);
}
try {
const query: RuvectorSearchQuery = {
vector: queryVector,
k: limit,
filter: filter as Record<string, unknown>,
efSearch: this.config.hnsw?.efSearch,
};
const results: RuvectorSearchResult[] = await db.search(query);
// Map results and filter by minimum score
const mapped: VectorSearchResult[] = results
.map((result) => ({
entry: {
id: result.id,
vector: result.vector ? Array.from(result.vector) : [],
metadata: parseMetadata(result.metadata),
},
score: result.score,
}))
.filter((r) => r.score >= minScore);
this.logger.debug?.(
`ruvector-client: search returned ${mapped.length} results (requested ${limit})`,
);
return mapped;
} catch (err) {
throw new RuvectorError("SEARCH_FAILED", `Failed to search vectors: ${formatError(err)}`, err);
}
}
/**
* Get a vector entry by ID.
*
* @param id - The ID of the entry to retrieve
* @returns The vector entry, or null if not found
* @throws {RuvectorError} If not connected
*/
async get(id: string): Promise<VectorEntry | null> {
const db = this.ensureConnected();
try {
const result: RuvectorGetResult = await db.get(id);
if (!result) {
return null;
}
return {
id: result.id ?? id,
vector: Array.from(result.vector),
metadata: parseMetadata(result.metadata),
};
} catch (err) {
// Log the error for debugging, but treat as "not found" to maintain API contract
// Common case: entry doesn't exist, which some backends report as an error
this.logger.debug?.(`ruvector-client: get(${id}) failed: ${formatError(err)}`);
return null;
}
}
/**
* Delete a vector entry by ID.
*
* @param id - The ID of the entry to delete
* @returns true if deleted, false if not found
* @throws {RuvectorError} If not connected or delete fails
*/
async delete(id: string): Promise<boolean> {
const db = this.ensureConnected();
// Validate ID is non-empty (allow any format since insert accepts custom IDs)
if (!id || typeof id !== "string") {
throw new RuvectorError("INVALID_ID", `Invalid ID: ${id}`);
}
try {
const deleted = await db.delete(id);
this.logger.debug?.(`ruvector-client: delete(${id}) = ${deleted}`);
return deleted;
} catch (err) {
throw new RuvectorError("DELETE_FAILED", `Failed to delete vector: ${formatError(err)}`, err);
}
}
/**
* Get the number of vectors in the database.
*
* @returns The count of stored vectors
* @throws {RuvectorError} If not connected
*/
async count(): Promise<number> {
const db = this.ensureConnected();
try {
return await db.len();
} catch (err) {
this.logger.warn(`ruvector-client: count failed: ${formatError(err)}`);
return 0;
}
}
/**
* Check if the database is empty.
*
* @returns true if empty
* @throws {RuvectorError} If not connected
*/
async isEmpty(): Promise<boolean> {
const db = this.ensureConnected();
try {
return await db.isEmpty();
} catch (err) {
// Fallback to count check
const count = await this.count();
return count === 0;
}
}
/**
* Get database statistics.
*
* @returns Database stats including count, dimension, and metric
*/
async stats(): Promise<RuvectorStats> {
const count = this.isConnected() ? await this.count() : 0;
return {
count,
dimension: this.config.dimension,
metric: this.config.metric ?? "cosine",
connected: this.isConnected(),
};
}
// ===========================================================================
// Graph Operations
// ===========================================================================
/**
* Initialize the graph database for relationship tracking.
*
* @param storagePath - Optional path to persist the graph (in-memory if omitted)
* @throws {RuvectorError} If initialization fails
*/
async initializeGraph(storagePath?: string): Promise<void> {
if (this.graph) {
this.logger.debug?.("ruvector-client: graph already initialized");
return;
}
this.logger.info(
`ruvector-client: initializing graph${storagePath ? ` (path: ${storagePath})` : " (in-memory)"}`,
);
try {
this.graph = new CodeGraph({
storagePath,
inMemory: !storagePath,
});
this.logger.info("ruvector-client: graph initialized successfully");
} catch (err) {
throw new RuvectorError(
"INITIALIZATION_FAILED",
`Failed to initialize graph: ${formatError(err)}`,
err,
);
}
}
/**
* Add an edge (relationship) between two nodes in the graph.
*
* @param edge - The edge to add
* @returns The edge ID
* @throws {RuvectorError} If graph is not initialized or operation fails
*/
async addEdge(edge: GraphEdge): Promise<string> {
const graph = this.ensureGraphInitialized();
const edgeId = edge.id ?? randomUUID();
try {
// Ensure source and target nodes exist
await graph.createNode(edge.sourceId, ["Node"], {});
await graph.createNode(edge.targetId, ["Node"], {});
// Create the edge with properties
await graph.createEdge(edge.sourceId, edge.targetId, edge.relationship, {
id: edgeId,
weight: edge.weight ?? 1.0,
...edge.properties,
});
this.logger.debug?.(
`ruvector-client: added edge ${edgeId} (${edge.sourceId} -[${edge.relationship}]-> ${edge.targetId})`,
);
return edgeId;
} catch (err) {
throw new RuvectorError("INSERT_FAILED", `Failed to add edge: ${formatError(err)}`, err);
}
}
/**
* Remove an edge between two nodes.
*
* @param sourceId - Source node ID
* @param targetId - Target node ID
* @returns true if edge was removed, false if not found
* @throws {RuvectorError} If graph is not initialized or operation fails
*/
async removeEdge(sourceId: string, targetId: string): Promise<boolean> {
const graph = this.ensureGraphInitialized();
try {
// Use Cypher to delete the edge
const result = await graph.cypher(
"MATCH (a)-[r]->(b) WHERE a.id = $sourceId AND b.id = $targetId DELETE r RETURN count(r) as deleted",
{ sourceId, targetId },
);
const deleted = result.rows.length > 0 && (result.rows[0][0] as number) > 0;
this.logger.debug?.(`ruvector-client: removeEdge(${sourceId}, ${targetId}) = ${deleted}`);
return deleted;
} catch (err) {
throw new RuvectorError("DELETE_FAILED", `Failed to remove edge: ${formatError(err)}`, err);
}
}
/**
* Execute a Cypher query on the graph.
*
* @param query - Cypher query string
* @param params - Optional query parameters
* @returns Query result with columns and rows
* @throws {RuvectorError} If graph is not initialized or query fails
*/
async cypherQuery(query: string, params?: Record<string, unknown>): Promise<CypherResult> {
const graph = this.ensureGraphInitialized();
try {
const result = await graph.cypher(query, params);
this.logger.debug?.(`ruvector-client: cypher query returned ${result.rows.length} rows`);
return {
columns: result.columns,
rows: result.rows,
};
} catch (err) {
throw new RuvectorError("SEARCH_FAILED", `Cypher query failed: ${formatError(err)}`, err);
}
}
/**
* Get neighboring nodes for a given node ID.
*
* @param id - The node ID to find neighbors for
* @param depth - Maximum traversal depth (default: 1)
* @returns Array of neighboring nodes
* @throws {RuvectorError} If graph is not initialized or operation fails
*/
async getNeighbors(id: string, depth?: number): Promise<GraphNode[]> {
const graph = this.ensureGraphInitialized();
try {
const neighbors = await graph.neighbors(id, depth ?? 1);
// Map the raw neighbors to GraphNode format
const nodes: GraphNode[] = neighbors.map(
(n: { id: string; labels?: string[]; properties?: Record<string, unknown> }) => ({
id: n.id,
labels: n.labels ?? ["Node"],
properties: n.properties ?? {},
}),
);
this.logger.debug?.(
`ruvector-client: getNeighbors(${id}, ${depth ?? 1}) returned ${nodes.length} nodes`,
);
return nodes;
} catch (err) {
throw new RuvectorError("SEARCH_FAILED", `Failed to get neighbors: ${formatError(err)}`, err);
}
}
/**
* Enable and configure the GNN (Graph Neural Network) layer.
*
* @param config - GNN configuration
* @throws {RuvectorError} If initialization fails
*/
async enableGNN(config: GNNConfig): Promise<void> {
if (!config.enabled) {
this.gnnLayer = null;
this.gnnConfig = null;
this.logger.info("ruvector-client: GNN disabled");
return;
}
this.logger.info(
`ruvector-client: enabling GNN (inputDim: ${config.inputDim}, hiddenDim: ${config.hiddenDim}, heads: ${config.heads})`,
);
try {
this.gnnLayer = new RuvectorLayer(
config.inputDim,
config.hiddenDim,
config.heads,
config.dropout,
);
this.gnnConfig = config;
this.logger.info("ruvector-client: GNN enabled successfully");
} catch (err) {
throw new RuvectorError(
"INITIALIZATION_FAILED",
`Failed to enable GNN: ${formatError(err)}`,
err,
);
}
}
/**
* Check if the graph is initialized.
*/
isGraphInitialized(): boolean {
return this.graph !== null;
}
/**
* Check if GNN is enabled.
*/
isGNNEnabled(): boolean {
return this.gnnLayer !== null && this.gnnConfig?.enabled === true;
}
// ===========================================================================
// SONA (Self-Organizing Neural Architecture) Methods
// ===========================================================================
/**
* Enable SONA self-learning capabilities.
* Initializes the SonaEngine with the provided configuration.
*
* @param config - SONA configuration options
*/
async enableSONA(config: SONAConfig): Promise<void> {
if (this.sonaEngine) {
this.logger.warn("ruvector-client: SONA already enabled, reconfiguring");
await this.disableSONA();
}
this.logger.info(
`ruvector-client: enabling SONA (hiddenDim: ${config.hiddenDim}, enabled: ${config.enabled})`,
);
try {
// Create SONA engine with configuration
const sonaConfig = {
hiddenDim: config.hiddenDim,
learningRate: config.learningRate ?? 0.01,
qualityThreshold: config.qualityThreshold ?? 0.5,
};
this.sonaEngine = SonaEngine.withConfig(sonaConfig);
this.sonaConfig = config;
if (config.enabled) {
this.sonaEngine.setEnabled(true);
}
this.logger.info("ruvector-client: SONA enabled successfully");
} catch (err) {
this.sonaEngine = null;
this.sonaConfig = null;
throw new RuvectorError(
"INITIALIZATION_FAILED",
`Failed to initialize SONA: ${formatError(err)}`,
err,
);
}
}
/**
* Disable SONA self-learning capabilities.
* Cleans up the SONA engine and any active trajectories.
*/
async disableSONA(): Promise<void> {
if (!this.sonaEngine) {
return;
}
this.logger.info("ruvector-client: disabling SONA");
try {
// End any active trajectory
if (this.activeTrajectory) {
try {
this.sonaEngine.endTrajectory(this.activeTrajectory, 0);
} catch {
// Ignore errors when ending trajectory during shutdown
}
this.activeTrajectory = null;
}
this.sonaEngine.setEnabled(false);
this.sonaEngine = null;
this.sonaConfig = null;
this.logger.info("ruvector-client: SONA disabled");
} catch (err) {
this.logger.warn(`ruvector-client: error during SONA disable: ${formatError(err)}`);
this.sonaEngine = null;
this.sonaConfig = null;
}
}
/**
* Record feedback from a search operation for SONA learning.
* This creates a learning trajectory from the search query to the selected result.
*
* @param queryVector - The original query vector used for search
* @param selectedResultId - ID of the result the user selected/found relevant
* @param relevanceScore - How relevant the result was (0-1, higher is better)
*/
async recordSearchFeedback(
queryVector: number[],
selectedResultId: string,
relevanceScore: number,
): Promise<void> {
if (!this.sonaEngine || !this.sonaEngine.isEnabled()) {
this.logger.debug?.("ruvector-client: SONA not enabled, skipping feedback recording");
return;
}
const startTime = Date.now();
try {
// Get the selected result to use its vector as activation
const selectedEntry = await this.get(selectedResultId);
if (!selectedEntry) {
this.logger.warn(`ruvector-client: selected result ${selectedResultId} not found`);
return;
}
// Begin a new learning trajectory
const trajectoryId = this.sonaEngine.beginTrajectory(queryVector);
this.activeTrajectory = trajectoryId;
// Add the search result as a learning step
// Use the result vector as activations and query as attention weights
const activations = selectedEntry.vector;
const resultVector = selectedEntry.vector;
// Create attention weights by computing element-wise products
// Both vectors should have the same dimension, but use safe access for robustness
const attentionWeights: number[] = [];
for (let i = 0; i < queryVector.length; i++) {
const qv = queryVector[i] ?? 0;
const rv = resultVector[i] ?? 0;
attentionWeights.push(Math.abs(qv * rv));
}
this.sonaEngine.addStep(
trajectoryId,
activations,
attentionWeights,
relevanceScore,
);
// End trajectory with the relevance score as quality
this.sonaEngine.endTrajectory(trajectoryId, relevanceScore);
this.activeTrajectory = null;
// Apply micro-LoRA adaptation if relevance is high enough
const threshold = this.sonaConfig?.qualityThreshold ?? 0.5;
if (relevanceScore >= threshold) {
this.sonaEngine.applyMicroLora(queryVector);
this.sonaStatsInternal.microLoraUpdates++;
}
this.sonaStatsInternal.trajectoriesRecorded++;
const elapsed = Date.now() - startTime;
this.sonaStatsInternal.totalLearningTimeMs += elapsed;
this.sonaStatsInternal.learningOperations++;
this.logger.debug?.(
`ruvector-client: recorded search feedback (relevance: ${relevanceScore}, time: ${elapsed}ms)`,
);
} catch (err) {
this.activeTrajectory = null;
this.logger.warn(`ruvector-client: failed to record search feedback: ${formatError(err)}`);
}
}
/**
* Find similar learned patterns from SONA's pattern memory.
*
* @param vector - Query vector to find similar patterns for
* @param k - Maximum number of patterns to return (default: 5)
* @returns Array of learned patterns similar to the query
*/
findSimilarPatterns(vector: number[], k = 5): LearnedPattern[] {
if (!this.sonaEngine || !this.sonaEngine.isEnabled()) {
return [];
}
try {
const patterns = this.sonaEngine.findPatterns(vector, k);
// Map the raw patterns to our LearnedPattern type
return patterns.map((pattern: { id?: string; centroid?: number[]; clusterSize?: number; avgQuality?: number }, index: number) => ({
id: pattern.id ?? `pattern-${index}`,
centroid: pattern.centroid ?? [],
clusterSize: pattern.clusterSize ?? 0,
avgQuality: pattern.avgQuality ?? 0,
}));
} catch (err) {
this.logger.warn(`ruvector-client: failed to find similar patterns: ${formatError(err)}`);
return [];
}
}
/**
* Get statistics from the SONA engine.
*
* @returns SONA statistics including trajectories, patterns, and timing
*/
async getSONAStats(): Promise<SONAStats> {
if (!this.sonaEngine) {
return {
trajectoriesRecorded: 0,
patternsLearned: 0,
microLoraUpdates: 0,
avgLearningTimeMs: 0,
enabled: false,
};
}
try {
const engineStats = this.sonaEngine.getStats();
const avgLearningTimeMs =
this.sonaStatsInternal.learningOperations > 0
? this.sonaStatsInternal.totalLearningTimeMs / this.sonaStatsInternal.learningOperations
: 0;
return {
trajectoriesRecorded: this.sonaStatsInternal.trajectoriesRecorded,
patternsLearned: engineStats.patternsLearned ?? 0,
microLoraUpdates: this.sonaStatsInternal.microLoraUpdates,
avgLearningTimeMs: Math.round(avgLearningTimeMs * 100) / 100,
enabled: this.sonaEngine.isEnabled(),
};
} catch (err) {
this.logger.warn(`ruvector-client: failed to get SONA stats: ${formatError(err)}`);
// Capture sonaEngine reference to avoid race condition
const engine = this.sonaEngine;
return {
trajectoriesRecorded: this.sonaStatsInternal.trajectoriesRecorded,
patternsLearned: 0,
microLoraUpdates: this.sonaStatsInternal.microLoraUpdates,
avgLearningTimeMs: 0,
enabled: engine?.isEnabled() ?? false,
};
}
}
/**
* Force an immediate learning cycle in SONA.
* Useful for ensuring patterns are learned before shutdown.
*/
async forceSONALearn(): Promise<void> {
if (!this.sonaEngine || !this.sonaEngine.isEnabled()) {
return;
}
try {
this.sonaEngine.forceLearn();
this.logger.debug?.("ruvector-client: forced SONA learning cycle");
} catch (err) {
this.logger.warn(`ruvector-client: failed to force SONA learn: ${formatError(err)}`);
}
}
// ===========================================================================
// Private Helpers
// ===========================================================================
/**
* Ensure the client is connected, throwing if not.
*/
private ensureConnected(): RuvectorDbInstance {
if (!this.db) {
throw new RuvectorError("NOT_CONNECTED", "Client is not connected - call connect() first");
}
return this.db;
}
/**
* Ensure the graph is initialized, throwing if not.
*/
private ensureGraphInitialized(): InstanceType<typeof CodeGraph> {
if (!this.graph) {
throw new RuvectorError(
"NOT_CONNECTED",
"Graph is not initialized - call initializeGraph() first",
);
}
return this.graph;
}
}
// =============================================================================
// Utility Functions
// =============================================================================
/**
* Convert a Float32Array or number array to a plain number array.
*/
function normalizeVector(vector: number[] | Float32Array): number[] {
if (vector instanceof Float32Array) {
return Array.from(vector);
}
return vector;
}
/**
* Map our metric names to ruvector's expected format.
* Uses exhaustive switch for type safety.
*/
function mapMetricToRuvector(metric: DistanceMetric): string {
switch (metric) {
case "cosine":
return "cosine";
case "euclidean":
return "euclidean";
case "dot":
return "dot";
default: {
// Exhaustive check - this will error at compile time if a new metric is added
const _exhaustive: never = metric;
return "cosine";
}
}
}
/**
* Parse metadata from ruvector's Record<string, unknown> to our VectorMetadata type.
* Ensures the required `text` field exists, defaulting to empty string if missing.
*/
function parseMetadata(metadata: Record<string, unknown> | undefined): VectorEntry["metadata"] {
const raw = metadata ?? {};
// Build a properly typed result object
const result: VectorEntry["metadata"] = {
text: typeof raw.text === "string" ? raw.text : "",
};
// Copy over other properties safely
for (const [key, value] of Object.entries(raw)) {
if (key !== "text") {
result[key] = value;
}
}
return result;
}
/**
* Format an error for logging.
*/
function formatError(err: unknown): string {
if (err instanceof Error) {
return err.message;
}
return String(err);
}