openclaw/extensions/memory-ruvector/index.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

580 lines
19 KiB
TypeScript

/**
* Clawdbot Memory (Ruvector) Plugin
*
* Long-term memory with vector search using ruvector as the backend.
* Provides lifecycle management for the ruvector connection and automatic
* message indexing via hooks.
*
* Supports two modes:
* 1. Remote service (url-based) - connects to external ruvector server
* 2. Local database (dbPath-based) - uses local ruvector storage with hooks
*/
import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk";
import { RuvectorService } from "./service.js";
import { createRuvectorSearchTool, createRuvectorFeedbackTool, createRuvectorGraphTool } from "./tool.js";
import { ruvectorConfigSchema, type RuvectorConfig } from "./config.js";
import { createDatabase } from "./db.js";
import { createEmbeddingProvider } from "./embeddings.js";
import { registerHooks } from "./hooks.js";
import type { MessageBatcher } from "./hooks.js";
// ============================================================================
// Config Parsing
// ============================================================================
/**
* Remote service config (URL-based connection to external ruvector server).
*/
type RemoteServiceConfig = {
url: string;
apiKey?: string;
collection: string;
timeoutMs: number;
};
type ParsedConfig =
| { mode: "remote"; remote: RemoteServiceConfig }
| { mode: "local"; local: RuvectorConfig };
/**
* Resolve environment variable references in config values.
* Supports ${VAR_NAME} syntax.
*/
function resolveEnvVars(value: string): string {
return value.replace(/\$\{([^}]+)\}/g, (_, envVar) => {
const envValue = process.env[envVar];
if (!envValue) {
throw new Error(`ruvector: environment variable ${envVar} is not set`);
}
return envValue;
});
}
/**
* Parse and validate plugin configuration for ruvector.
* Supports both remote (URL-based) and local (dbPath-based) modes.
*/
function parseConfig(pluginConfig: Record<string, unknown> | undefined): ParsedConfig {
if (!pluginConfig || typeof pluginConfig !== "object") {
throw new Error("ruvector: plugin config required");
}
// Detect mode based on config keys
const hasUrl = typeof pluginConfig.url === "string" && pluginConfig.url.trim();
const hasEmbedding = pluginConfig.embedding && typeof pluginConfig.embedding === "object";
// Reject ambiguous config with both url and embedding
if (hasUrl && hasEmbedding) {
throw new Error(
"ruvector: invalid config - cannot specify both 'url' (remote mode) and 'embedding' (local mode). Choose one.",
);
}
// Remote mode: URL-based connection to external ruvector server
if (hasUrl) {
const url = pluginConfig.url as string;
const apiKey = typeof pluginConfig.apiKey === "string"
? resolveEnvVars(pluginConfig.apiKey)
: undefined;
const collection = typeof pluginConfig.collection === "string"
? pluginConfig.collection
: "clawdbot-memory";
const timeoutMs = typeof pluginConfig.timeoutMs === "number"
? pluginConfig.timeoutMs
: 5000;
return {
mode: "remote",
remote: {
url: url.trim(),
apiKey,
collection,
timeoutMs,
},
};
}
// Local mode: local database with embeddings and hooks
if (hasEmbedding) {
let local: RuvectorConfig;
try {
local = ruvectorConfigSchema.parse(pluginConfig);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
throw new Error(`ruvector: invalid local mode config: ${message}`);
}
return {
mode: "local",
local,
};
}
throw new Error(
"ruvector: invalid config - provide either 'url' for remote mode or 'embedding' for local mode",
);
}
// ============================================================================
// Plugin Registration
// ============================================================================
/**
* Register the ruvector memory plugin.
* Sets up the service for lifecycle management and registers hooks for
* automatic message indexing.
*/
export default function register(api: ClawdbotPluginApi): void {
const parsed = parseConfig(api.pluginConfig);
if (parsed.mode === "remote") {
registerRemoteMode(api, parsed.remote);
} else {
registerLocalMode(api, parsed.local);
}
}
/**
* Register remote mode - connects to external ruvector server.
*
* Note: Remote mode is a legacy configuration pattern. For full feature support
* including automatic message indexing via hooks, use local mode with 'embedding' config.
*/
function registerRemoteMode(api: ClawdbotPluginApi, config: RemoteServiceConfig): void {
// Pass remote config to service - it handles the RuvectorServiceConfig type
const service = new RuvectorService(
{
url: config.url,
apiKey: config.apiKey,
collection: config.collection,
timeoutMs: config.timeoutMs,
},
api.logger,
);
api.logger.info(
`memory-ruvector: plugin registered in remote mode (url: ${config.url}, collection: ${config.collection})`,
);
api.logger.warn(
"memory-ruvector: remote mode does not support automatic message indexing hooks. " +
"Use local mode with 'embedding' config for full hook support.",
);
// Create embedding function (placeholder for remote mode)
const embedQuery = async (_text: string): Promise<number[]> => {
api.logger.debug?.(`memory-ruvector: generating embedding for query`);
// Placeholder: return dummy 1536-dim vector (OpenAI text-embedding-3-small)
// Remote mode expects the server to handle embeddings
return Array.from({ length: 1536 }, () => Math.random() * 2 - 1);
};
// Register the ruvector_search tool
api.registerTool(
createRuvectorSearchTool({
api,
service,
embedQuery,
}),
{ name: "ruvector_search", optional: true },
);
// Register the service for lifecycle management
api.registerService({
id: "memory-ruvector",
async start(_ctx) {
await service.start();
api.logger.info(
`memory-ruvector: service started (url: ${config.url}, collection: ${config.collection})`,
);
},
async stop(_ctx) {
await service.stop();
api.logger.info("memory-ruvector: service stopped");
},
});
}
/**
* Register local mode - local database with embeddings and automatic indexing hooks.
*/
function registerLocalMode(api: ClawdbotPluginApi, config: RuvectorConfig): void {
const resolvedDbPath = api.resolvePath(config.dbPath);
const db = createDatabase({ ...config, dbPath: resolvedDbPath });
const embeddings = createEmbeddingProvider(config.embedding, config.dimension);
api.logger.info(
`memory-ruvector: plugin registered in local mode (db: ${resolvedDbPath}, dim: ${config.dimension})`,
);
// Track batcher for cleanup
let batcher: MessageBatcher | null = null;
// =========================================================================
// Register Hooks for Automatic Message Indexing
// =========================================================================
const hookResult = registerHooks(api, db, embeddings, config.hooks);
batcher = hookResult.batcher;
// =========================================================================
// Register Tools
// =========================================================================
// Search tool
api.registerTool(
{
name: "ruvector_search",
label: "Vector Memory Search",
description:
"Search through indexed conversation history using semantic similarity. Use to recall past conversations, find relevant context, or understand user patterns.",
parameters: {
type: "object",
properties: {
query: { type: "string", description: "Search query text" },
limit: { type: "number", description: "Max results (default: 5)" },
direction: {
type: "string",
enum: ["inbound", "outbound"],
description: "Filter by message direction",
},
channel: { type: "string", description: "Filter by channel ID" },
sessionKey: { type: "string", description: "Filter by session key" },
},
required: ["query"],
},
async execute(_toolCallId, params) {
const {
query,
limit = 5,
direction,
channel,
sessionKey,
} = params as {
query: string;
limit?: number;
direction?: "inbound" | "outbound";
channel?: string;
sessionKey?: string;
};
try {
const vector = await embeddings.embed(query);
const results = await db.search(vector, {
limit,
minScore: 0.1,
filter: { direction, channel, sessionKey },
});
if (results.length === 0) {
return {
content: [{ type: "text", text: "No relevant messages found." }],
details: { count: 0 },
};
}
const text = results
.map(
(r, i) =>
`${i + 1}. [${r.document.direction}] ${r.document.content.slice(0, 200)}${
r.document.content.length > 200 ? "..." : ""
} (${(r.score * 100).toFixed(0)}%)`,
)
.join("\n");
const sanitizedResults = results.map((r) => ({
id: r.document.id,
content: r.document.content,
direction: r.document.direction,
channel: r.document.channel,
user: r.document.user,
timestamp: r.document.timestamp,
score: r.score,
}));
return {
content: [
{ type: "text", text: `Found ${results.length} messages:\n\n${text}` },
],
details: { count: results.length, messages: sanitizedResults },
};
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
api.logger.warn(`ruvector_search: search failed: ${message}`);
return {
content: [{ type: "text", text: `Search failed: ${message}` }],
details: { error: message },
};
}
},
},
{ name: "ruvector_search", optional: true },
);
// Index tool (manual indexing)
api.registerTool(
{
name: "ruvector_index",
label: "Index Message",
description:
"Manually index a message or piece of information for future retrieval.",
parameters: {
type: "object",
properties: {
content: { type: "string", description: "Text content to index" },
direction: {
type: "string",
enum: ["inbound", "outbound"],
description: "Message direction (default: outbound)",
},
channel: { type: "string", description: "Channel identifier" },
},
required: ["content"],
},
async execute(_toolCallId, params, ctx) {
const {
content,
direction = "outbound",
channel = "manual",
} = params as {
content: string;
direction?: "inbound" | "outbound";
channel?: string;
};
try {
const vector = await embeddings.embed(content);
// Check for duplicates
const existing = await db.search(vector, { limit: 1, minScore: 0.95 });
if (existing.length > 0) {
return {
content: [
{
type: "text",
text: `Similar message already indexed: "${existing[0].document.content.slice(0, 100)}..."`,
},
],
details: { action: "duplicate", existingId: existing[0].document.id },
};
}
const id = await db.insert({
content,
vector,
direction,
channel,
sessionKey: ctx?.sessionKey,
agentId: ctx?.agentId,
timestamp: Date.now(),
});
return {
content: [
{ type: "text", text: `Indexed: "${content.slice(0, 100)}..."` },
],
details: { action: "created", id },
};
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
api.logger.warn(`ruvector_index: indexing failed: ${message}`);
return {
content: [{ type: "text", text: `Indexing failed: ${message}` }],
details: { error: message },
};
}
},
},
{ name: "ruvector_index", optional: true },
);
// SONA feedback tool
api.registerTool(
createRuvectorFeedbackTool({
api,
db,
}),
{ name: "ruvector_feedback", optional: true },
);
// GNN graph tool
api.registerTool(
createRuvectorGraphTool({
api,
db,
}),
{ name: "ruvector_graph", optional: true },
);
// =========================================================================
// Register CLI Commands
// =========================================================================
api.registerCli(
({ program }) => {
const rv = program
.command("ruvector")
.description("ruvector memory plugin commands");
rv.command("stats")
.description("Show memory statistics")
.action(async () => {
const count = await db.count();
console.log(`Total indexed messages: ${count}`);
console.log(`Database path: ${resolvedDbPath}`);
console.log(`Vector dimension: ${config.dimension}`);
console.log(`Distance metric: ${config.metric}`);
console.log(`Hooks enabled: ${config.hooks.enabled}`);
});
rv.command("search")
.description("Search indexed messages")
.argument("<query>", "Search query")
.option("--limit <n>", "Max results", "5")
.option("--direction <dir>", "Filter by direction (inbound/outbound)")
.option("--channel <ch>", "Filter by channel")
.action(async (query, opts) => {
const parsedLimit = parseInt(opts.limit, 10);
const limit = Number.isNaN(parsedLimit) ? 5 : Math.max(1, Math.min(parsedLimit, 100));
const vector = await embeddings.embed(query);
const results = await db.search(vector, {
limit,
minScore: 0.1,
filter: {
direction: opts.direction,
channel: opts.channel,
},
});
const output = results.map((r) => ({
id: r.document.id,
content: r.document.content,
direction: r.document.direction,
channel: r.document.channel,
timestamp: new Date(r.document.timestamp).toISOString(),
score: r.score.toFixed(3),
}));
console.log(JSON.stringify(output, null, 2));
});
rv.command("flush")
.description("Force flush pending batch")
.action(async () => {
if (batcher !== null) {
await batcher.forceFlush();
api.logger.info?.("Batch flushed.");
} else {
api.logger.info?.("No active batcher (hooks may be disabled).");
}
});
// SONA learning statistics
rv.command("sona-stats")
.description("Show SONA learning statistics")
.action(async () => {
const hasSONASupport = "getSONAStats" in db && typeof (db as Record<string, unknown>).getSONAStats === "function";
if (hasSONASupport) {
const sonaDb = db as typeof db & { getSONAStats: () => Promise<{
totalFeedbackEntries: number;
averageRelevanceScore: number;
learningIterations: number;
lastTrainingTime: number | null;
modelVersion: string;
}> };
const stats = await sonaDb.getSONAStats();
console.log("SONA Learning Statistics:");
console.log(` Total feedback entries: ${stats.totalFeedbackEntries}`);
console.log(` Average relevance score: ${(stats.averageRelevanceScore * 100).toFixed(1)}%`);
console.log(` Learning iterations: ${stats.learningIterations}`);
console.log(` Last training: ${stats.lastTrainingTime ? new Date(stats.lastTrainingTime).toISOString() : "Never"}`);
console.log(` Model version: ${stats.modelVersion}`);
} else {
const count = await db.count();
console.log("SONA Learning Statistics (limited - full SONA not enabled):");
console.log(` Total indexed documents: ${count}`);
console.log(` Feedback collection: Not available`);
console.log(` Note: Enable ruvector with SONA extension for full learning statistics`);
}
});
// GNN graph query
rv.command("graph")
.description("Execute a Cypher query on the knowledge graph")
.argument("<query>", "Cypher query to execute")
.action(async (query) => {
const hasGraphSupport = "graphQuery" in db && typeof (db as Record<string, unknown>).graphQuery === "function";
if (!hasGraphSupport) {
console.log("GNN graph features not available.");
console.log("Requires ruvector with graph extension enabled.");
return;
}
const graphDb = db as typeof db & { graphQuery: (cypher: string) => Promise<unknown[]> };
const results = await graphDb.graphQuery(query);
if (results.length === 0) {
console.log("No results found.");
} else {
console.log(JSON.stringify(results, null, 2));
}
});
// GNN neighbors lookup
rv.command("neighbors")
.description("Show related nodes for a given document ID")
.argument("<id>", "Document/node ID to find neighbors for")
.option("--depth <n>", "Traversal depth (1-5)", "1")
.action(async (id, opts) => {
const hasGraphSupport = "graphNeighbors" in db && typeof (db as Record<string, unknown>).graphNeighbors === "function";
if (!hasGraphSupport) {
console.log("GNN graph features not available.");
console.log("Requires ruvector with graph extension enabled.");
return;
}
const parsedDepth = parseInt(opts.depth, 10);
const depth = Number.isNaN(parsedDepth) ? 1 : Math.max(1, Math.min(parsedDepth, 5));
const graphDb = db as typeof db & { graphNeighbors: (nodeId: string, depth: number) => Promise<unknown[]> };
const neighbors = await graphDb.graphNeighbors(id, depth);
if (neighbors.length === 0) {
console.log(`No neighbors found for node ${id} at depth ${depth}.`);
} else {
console.log(`Found ${neighbors.length} neighbor(s) at depth ${depth}:`);
console.log(JSON.stringify(neighbors, null, 2));
}
});
},
{ commands: ["ruvector"] },
);
// =========================================================================
// Register Service
// =========================================================================
api.registerService({
id: "memory-ruvector",
start() {
api.logger.info(
`memory-ruvector: service started (hooks: ${config.hooks.enabled ? "enabled" : "disabled"})`,
);
},
async stop() {
// Flush any pending messages before shutdown and clean up batcher
if (batcher !== null) {
await batcher.forceFlush();
batcher.destroy();
}
await db.close();
api.logger.info("memory-ruvector: service stopped");
},
});
}