openclaw/extensions/memory-ruvector/index.ts
File a801c7e721 feat(memory-ruvector): add ruvLLM adaptive learning features
Implements ruvLLM integration with multi-temporal learning:

P0 - Foundation:
- Extended config schema for ruvllm options
- TrajectoryRecorder for search pattern recording
- ContextInjector for agent prompt enrichment
- SONA engine integration with trajectory support

P1 - Learning Core:
- PatternStore with K-means++ clustering
- Search re-ranking using learned patterns
- GraphExpander for automatic edge discovery
- ruvector_recall tool (pattern-aware recall)

P2 - Adaptive Loops:
- BackgroundLoop (30s interval pattern clustering)
- InstantLoop (real-time feedback processing)
- RelationshipInferrer (entity extraction)
- ruvector_learn tool (manual knowledge injection)

P3 - Advanced Features:
- EWCConsolidator (catastrophic forgetting prevention)
- ConsolidationLoop (deep pattern analysis)
- GraphAttention (multi-head context aggregation)
- Pattern export/import CLI commands

Tests: 275 passing (229 + 46 new)

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

1194 lines
41 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,
createRuvectorRecallTool,
} 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";
import { PatternStore } from "./sona/patterns.js";
import { ContextInjector, registerContextInjectionHook } from "./context-injection.js";
import { TrajectoryRecorder } from "./sona/trajectory.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 ruvector_recall tool (pattern-aware memory recall)
api.registerTool(
createRuvectorRecallTool({
api,
service,
embedQuery,
}),
{ name: "ruvector_recall", optional: true },
);
// Register the service for lifecycle management
api.registerService({
id: "memory-ruvector",
async start(_ctx) {
await service.start();
// Initialize pattern store for learning
const client = service.getClient();
client.initializePatternStore();
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;
// =========================================================================
// ruvLLM Integration (Context Injection + Trajectory Recording)
// =========================================================================
let contextInjector: ContextInjector | null = null;
let trajectoryRecorder: TrajectoryRecorder | null = null;
if (config.ruvllm?.enabled) {
api.logger.info("memory-ruvector: ruvLLM features enabled");
// Initialize context injector if enabled
if (config.ruvllm.contextInjection.enabled) {
contextInjector = new ContextInjector(config.ruvllm.contextInjection, {
db,
embeddings,
logger: api.logger,
});
registerContextInjectionHook(api, contextInjector);
api.logger.info(
`memory-ruvector: context injection enabled (maxTokens: ${config.ruvllm.contextInjection.maxTokens}, threshold: ${config.ruvllm.contextInjection.relevanceThreshold})`,
);
}
// Initialize trajectory recorder if enabled
if (config.ruvllm.trajectoryRecording.enabled) {
trajectoryRecorder = new TrajectoryRecorder(
config.ruvllm.trajectoryRecording,
api.logger,
);
api.logger.info(
`memory-ruvector: trajectory recording enabled (max: ${config.ruvllm.trajectoryRecording.maxTrajectories})`,
);
}
}
// =========================================================================
// 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 },
});
// Record trajectory for ruvLLM learning
let trajectoryId = "";
if (trajectoryRecorder?.isEnabled()) {
trajectoryId = trajectoryRecorder.record({
query,
queryVector: vector,
resultIds: results.map((r) => r.document.id),
resultScores: results.map((r) => r.score),
sessionId: sessionKey,
});
}
if (results.length === 0) {
return {
content: [{ type: "text", text: "No relevant messages found." }],
details: { count: 0, trajectoryId: trajectoryId || undefined },
};
}
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,
trajectoryId: trajectoryId || undefined,
},
};
} 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 },
);
// =========================================================================
// Pattern Store for ruvLLM Learning
// =========================================================================
const patternStore = new PatternStore({
maxClusters: 10,
minSamplesPerCluster: 3,
qualityThreshold: config.sona?.qualityThreshold ?? 0.5,
});
// Pattern-aware recall tool (local mode)
api.registerTool(
{
name: "ruvector_recall",
label: "Pattern-Aware Memory Recall",
description:
"Recall memories using learned patterns and optional graph expansion. " +
"Combines semantic vector search with pattern matching from past interactions " +
"and knowledge graph traversal for comprehensive memory retrieval.",
parameters: {
type: "object",
properties: {
query: { type: "string", description: "Search query text" },
limit: { type: "number", description: "Max results (default: 10)" },
usePatterns: {
type: "boolean",
description: "Use learned patterns to re-rank results (default: true)",
},
expandGraph: {
type: "boolean",
description: "Include graph-connected memories (default: false)",
},
graphDepth: {
type: "number",
description: "Depth for graph traversal (1-3, default: 1)",
},
patternBoost: {
type: "number",
description: "Boost factor for pattern matches (0-1, default: 0.2)",
},
},
required: ["query"],
},
async execute(_toolCallId, params) {
const {
query,
limit = 10,
usePatterns = true,
expandGraph = false,
graphDepth = 1,
patternBoost = 0.2,
} = params as {
query: string;
limit?: number;
usePatterns?: boolean;
expandGraph?: boolean;
graphDepth?: number;
patternBoost?: number;
};
try {
const queryVector = await embeddings.embed(query);
let results = await db.search(queryVector, {
limit,
minScore: 0.1,
});
// Apply pattern re-ranking if enabled
if (usePatterns && patternStore.getClusterCount() > 0) {
results = rerankWithPatterns(results, queryVector, patternStore, patternBoost);
}
// Graph expansion
let graphResults: Array<{
id: string;
content: string;
score: number;
source: "graph";
}> = [];
if (expandGraph) {
const hasGraphSupport =
"findRelated" in db &&
typeof (db as Record<string, unknown>).findRelated === "function";
if (hasGraphSupport) {
const graphDb = db as typeof db & {
findRelated: (id: string, rel?: string, depth?: number) => Promise<Array<{ document: { id: string; content: string }; score: number }>>;
};
// Get graph-connected results from top search hits
for (const result of results.slice(0, 5)) {
try {
const related = await graphDb.findRelated(
result.document.id ?? "",
undefined,
Math.max(1, Math.min(graphDepth, 3)),
);
for (const rel of related) {
// Skip if already in results
if (results.some((r) => r.document.id === rel.document.id)) continue;
if (graphResults.some((r) => r.id === rel.document.id)) continue;
graphResults.push({
id: rel.document.id ?? "",
content: rel.document.content,
score: rel.score * 0.8, // Decay for graph distance
source: "graph",
});
}
} catch {
// Skip graph expansion errors
}
}
graphResults.sort((a, b) => b.score - a.score);
graphResults = graphResults.slice(0, Math.max(3, Math.floor(limit / 3)));
}
}
if (results.length === 0 && graphResults.length === 0) {
return {
content: [{ type: "text", text: "No relevant memories found." }],
details: { count: 0, graphCount: 0 },
};
}
// Format output
const vectorText = 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");
let graphText = "";
if (graphResults.length > 0) {
graphText =
"\n\nGraph-connected:\n" +
graphResults
.map(
(r, i) =>
` ${i + 1}. ${r.content.slice(0, 150)}${
r.content.length > 150 ? "..." : ""
} (${(r.score * 100).toFixed(0)}%)`,
)
.join("\n");
}
// Pattern info
let patternInfo = "";
if (usePatterns) {
const clusterCount = patternStore.getClusterCount();
const sampleCount = patternStore.getSampleCount();
if (clusterCount > 0 || sampleCount > 0) {
patternInfo = ` [patterns: ${clusterCount} clusters from ${sampleCount} samples]`;
}
}
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,
source: "vector" as const,
}));
return {
content: [
{
type: "text",
text: `Found ${results.length} memories${patternInfo}:\n\n${vectorText}${graphText}`,
},
],
details: {
count: results.length,
graphCount: graphResults.length,
messages: sanitizedResults,
graphResults,
usePatterns,
expandGraph,
},
};
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
api.logger.warn(`ruvector_recall: recall failed: ${message}`);
return {
content: [{ type: "text", text: `Recall failed: ${message}` }],
details: { error: message },
};
}
},
},
{ name: "ruvector_recall", 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));
}
});
// Pattern export command (P3 Advanced Features)
rv.command("export-patterns")
.description("Export learned patterns to a JSON file")
.argument("<path>", "File path to export patterns to")
.option("--compact", "Output compact JSON without indentation", false)
.action(async (exportPath: string, opts: { compact?: boolean }) => {
// Validate path
if (!exportPath || typeof exportPath !== "string" || exportPath.trim() === "") {
console.error("Error: path must be a non-empty string");
process.exitCode = 1;
return;
}
const clusterCount = patternStore.getClusterCount();
const sampleCount = patternStore.getSampleCount();
if (clusterCount === 0 && sampleCount === 0) {
console.log("No patterns to export. Learn some patterns first via feedback.");
return;
}
const exportData = patternStore.export();
const output = {
version: "1.0.0",
exportedAt: Date.now(),
dimension: config.dimension,
metric: config.metric,
clusters: exportData.clusters,
samples: exportData.samples,
metadata: {
clusterCount,
sampleCount,
},
};
try {
const { writeFile } = await import("node:fs/promises");
const jsonOutput = opts.compact
? JSON.stringify(output)
: JSON.stringify(output, null, 2);
await writeFile(exportPath, jsonOutput, "utf-8");
console.log(`Exported ${clusterCount} clusters and ${sampleCount} samples to ${exportPath}`);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
console.error(`Failed to export patterns: ${message}`);
process.exitCode = 1;
}
});
// Pattern import command (P3 Advanced Features)
rv.command("import-patterns")
.description("Import learned patterns from a JSON file")
.argument("<path>", "File path to import patterns from")
.option("--merge", "Merge with existing patterns instead of replacing", false)
.action(async (importPath: string, opts: { merge?: boolean }) => {
// Validate path
if (!importPath || typeof importPath !== "string" || importPath.trim() === "") {
console.error("Error: path must be a non-empty string");
process.exitCode = 1;
return;
}
try {
const { readFile } = await import("node:fs/promises");
let content: string;
try {
content = await readFile(importPath, "utf-8");
} catch (readErr) {
const readMessage = readErr instanceof Error ? readErr.message : String(readErr);
console.error(`Failed to read file: ${readMessage}`);
process.exitCode = 1;
return;
}
let data: unknown;
try {
data = JSON.parse(content);
} catch (parseErr) {
console.error(`Invalid JSON: ${parseErr instanceof Error ? parseErr.message : String(parseErr)}`);
process.exitCode = 1;
return;
}
// Type validation
if (
typeof data !== "object" ||
data === null ||
!("version" in data) ||
!("clusters" in data) ||
!("samples" in data)
) {
console.error("Invalid pattern export format: missing required fields (version, clusters, samples)");
process.exitCode = 1;
return;
}
const typedData = data as {
version: string;
exportedAt?: number;
dimension?: number;
clusters: Array<{
id: string;
centroid: number[];
members: string[];
avgQuality: number;
lastUpdated: number;
}>;
samples: Array<{
id: string;
queryVector: number[];
resultVector: number[];
relevanceScore: number;
timestamp: number;
}>;
};
// Validate arrays
if (!Array.isArray(typedData.clusters) || !Array.isArray(typedData.samples)) {
console.error("Invalid pattern export format: clusters and samples must be arrays");
process.exitCode = 1;
return;
}
// Warn about dimension mismatch
if (typedData.dimension && typedData.dimension !== config.dimension) {
console.warn(
`Warning: dimension mismatch (file: ${typedData.dimension}, config: ${config.dimension}). ` +
"Patterns may not work correctly.",
);
}
const beforeClusters = patternStore.getClusterCount();
const beforeSamples = patternStore.getSampleCount();
if (opts.merge) {
// Merge mode: add samples and re-cluster
for (const sample of typedData.samples) {
patternStore.addSample(sample);
}
patternStore.cluster();
console.log(
`Merged ${typedData.samples.length} samples. ` +
`Before: ${beforeClusters} clusters, ${beforeSamples} samples. ` +
`After: ${patternStore.getClusterCount()} clusters, ${patternStore.getSampleCount()} samples.`,
);
} else {
// Replace mode: full import
patternStore.import({
clusters: typedData.clusters,
samples: typedData.samples,
});
console.log(
`Imported ${typedData.clusters.length} clusters and ${typedData.samples.length} samples from ${importPath}`,
);
}
// Show export timestamp if available
if (typedData.exportedAt) {
console.log(` (exported at ${new Date(typedData.exportedAt).toISOString()})`);
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
console.error(`Failed to import patterns: ${message}`);
process.exitCode = 1;
}
});
// Pattern statistics command
rv.command("pattern-stats")
.description("Show learned pattern statistics")
.action(() => {
const clusterCount = patternStore.getClusterCount();
const sampleCount = patternStore.getSampleCount();
const clusters = patternStore.getClusters();
console.log("Pattern Store Statistics:");
console.log(` Total samples: ${sampleCount}`);
console.log(` Total clusters: ${clusterCount}`);
if (clusterCount > 0) {
console.log("\nCluster Details:");
for (const cluster of clusters) {
const age = Date.now() - cluster.lastUpdated;
const ageStr = age < 3600000
? `${Math.floor(age / 60000)}m ago`
: `${Math.floor(age / 3600000)}h ago`;
console.log(
` ${cluster.id}: ${cluster.members.length} members, ` +
`quality ${(cluster.avgQuality * 100).toFixed(1)}%, ` +
`updated ${ageStr}`,
);
}
} else {
console.log("\nNo clusters yet. Provide feedback via ruvector_feedback tool to learn patterns.");
}
});
// Trajectory statistics command (ruvLLM)
rv.command("trajectory-stats")
.description("Show ruvLLM trajectory recording statistics")
.action(() => {
if (!trajectoryRecorder) {
console.log("Trajectory recording not enabled.");
console.log("Enable ruvllm.trajectoryRecording in config to use this feature.");
return;
}
const stats = trajectoryRecorder.getStats();
console.log("Trajectory Recording Statistics:");
console.log(` Total trajectories: ${stats.totalTrajectories}`);
console.log(` With feedback: ${stats.trajectoriesWithFeedback}`);
console.log(
` Average feedback: ${stats.trajectoriesWithFeedback > 0 ? (stats.averageFeedbackScore * 100).toFixed(1) + "%" : "N/A"}`,
);
if (stats.oldestTimestamp) {
console.log(` Oldest: ${new Date(stats.oldestTimestamp).toISOString()}`);
}
if (stats.newestTimestamp) {
console.log(` Newest: ${new Date(stats.newestTimestamp).toISOString()}`);
}
});
// Context injection status command (ruvLLM)
rv.command("ruvllm-status")
.description("Show ruvLLM feature status")
.action(() => {
console.log("ruvLLM Feature Status:");
console.log(` ruvLLM enabled: ${config.ruvllm?.enabled ?? false}`);
if (config.ruvllm?.enabled) {
console.log("\nContext Injection:");
console.log(` Enabled: ${contextInjector !== null}`);
if (contextInjector) {
console.log(` Max tokens: ${contextInjector.getMaxTokens()}`);
console.log(` Relevance threshold: ${contextInjector.getRelevanceThreshold()}`);
}
console.log("\nTrajectory Recording:");
console.log(` Enabled: ${trajectoryRecorder !== null}`);
if (trajectoryRecorder) {
const stats = trajectoryRecorder.getStats();
console.log(` Trajectories: ${stats.totalTrajectories}`);
console.log(` With feedback: ${stats.trajectoriesWithFeedback}`);
}
}
});
},
{ 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();
}
// Clean up trajectory recorder (prune before shutdown)
if (trajectoryRecorder) {
trajectoryRecorder.prune();
trajectoryRecorder.clear();
}
await db.close();
api.logger.info("memory-ruvector: service stopped");
},
});
}
// =============================================================================
// Helper Functions
// =============================================================================
import type { SearchResult } from "./db.js";
/**
* Re-rank search results using learned patterns.
*
* @param results - Original search results
* @param queryVector - Query vector used for search
* @param patternStore - Pattern store with learned clusters
* @param boostFactor - How much to boost pattern-matched results
* @returns Re-ranked results
*/
function rerankWithPatterns(
results: SearchResult[],
queryVector: number[],
patternStore: PatternStore,
boostFactor: number,
): SearchResult[] {
if (results.length === 0 || patternStore.getClusterCount() === 0) {
return results;
}
// Find similar patterns to the query
const similarPatterns = patternStore.findSimilar(queryVector, 5);
if (similarPatterns.length === 0) {
return results;
}
// Calculate pattern-based boosts
const boostedResults = results.map((result) => {
let patternBoost = 0;
for (const pattern of similarPatterns) {
// Pattern centroid contains [query, result], extract result portion
const dim = queryVector.length;
const patternResultCentroid = pattern.centroid.slice(dim, dim * 2);
if (patternResultCentroid.length > 0 && result.document.vector.length > 0) {
const similarity = cosineSimilarity(result.document.vector, patternResultCentroid);
patternBoost += similarity * pattern.avgQuality * boostFactor;
}
}
// Normalize boost
patternBoost = Math.min(patternBoost / similarPatterns.length, boostFactor);
return {
...result,
score: Math.min(1.0, result.score + patternBoost),
};
});
// Sort by new score
boostedResults.sort((a, b) => b.score - a.score);
return boostedResults;
}
/**
* Calculate cosine similarity between two vectors.
*/
function cosineSimilarity(a: number[], b: number[]): number {
const len = Math.min(a.length, b.length);
if (len === 0) return 0;
let dotProduct = 0;
let normA = 0;
let normB = 0;
for (let i = 0; i < len; i++) {
const aVal = a[i] ?? 0;
const bVal = b[i] ?? 0;
dotProduct += aVal * bVal;
normA += aVal * aVal;
normB += bVal * bVal;
}
const denominator = Math.sqrt(normA) * Math.sqrt(normB);
if (denominator === 0) return 0;
return dotProduct / denominator;
}