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

493 lines
15 KiB
TypeScript

/**
* Automatic Message Indexing Hooks for ruvector Memory Plugin
*
* Provides hook handlers for:
* - message_received: Index incoming user messages
* - message_sent: Index outgoing bot messages
* - agent_end: Index agent responses with full context
*
* Features debouncing and batching to avoid overwhelming the database.
*/
import type {
ClawdbotPluginApi,
PluginHookAgentContext,
PluginHookAgentEndEvent,
PluginHookMessageContext,
PluginHookMessageReceivedEvent,
PluginHookMessageSentEvent,
} from "clawdbot/plugin-sdk";
import type { RuvectorDB, MessageDocument } from "./db.js";
import type { EmbeddingProvider } from "./embeddings.js";
// ============================================================================
// Types
// ============================================================================
export type IndexableMessage = {
content: string;
direction: "inbound" | "outbound";
channel: string;
user?: string;
conversationId?: string;
sessionKey?: string;
agentId?: string;
timestamp: number;
metadata?: Record<string, unknown>;
};
type BatchEntry = {
message: IndexableMessage;
resolve: () => void;
reject: (err: Error) => void;
};
// ============================================================================
// Message Batcher
// ============================================================================
/**
* Batches messages for efficient bulk indexing.
* Flushes when batch size is reached or after debounce delay.
*/
export class MessageBatcher {
private batch: BatchEntry[] = [];
private flushTimer: ReturnType<typeof setTimeout> | null = null;
private isProcessing = false;
private destroyed = false;
constructor(
private readonly db: RuvectorDB,
private readonly embeddings: EmbeddingProvider,
private readonly options: {
batchSize: number;
debounceMs: number;
logger: ClawdbotPluginApi["logger"];
},
) {}
/**
* Queue a message for indexing. Returns a promise that resolves when indexed.
*/
async queue(message: IndexableMessage): Promise<void> {
if (this.destroyed) {
throw new Error("Batcher has been destroyed");
}
return new Promise<void>((resolve, reject) => {
this.batch.push({ message, resolve, reject });
// Flush immediately if batch is full
if (this.batch.length >= this.options.batchSize) {
this.flush();
return;
}
// Otherwise, schedule flush after debounce delay
this.scheduleFlush();
});
}
private scheduleFlush(): void {
if (this.flushTimer) return;
this.flushTimer = setTimeout(() => {
this.flushTimer = null;
this.flush();
}, this.options.debounceMs);
}
private async flush(): Promise<void> {
if (this.batch.length === 0 || this.isProcessing) return;
// Clear timer if exists
if (this.flushTimer) {
clearTimeout(this.flushTimer);
this.flushTimer = null;
}
// Take current batch and reset
const toProcess = this.batch.splice(0, this.options.batchSize);
if (toProcess.length === 0) return;
this.isProcessing = true;
try {
// Generate embeddings for all messages in a single batch API call
const embeddings = await this.embeddings.embedBatch(
toProcess.map((entry) => entry.message.content),
);
// Build documents for bulk insert
const documents: MessageDocument[] = toProcess.map((entry, i) => ({
content: entry.message.content,
vector: embeddings[i],
direction: entry.message.direction,
channel: entry.message.channel,
user: entry.message.user,
conversationId: entry.message.conversationId,
sessionKey: entry.message.sessionKey,
agentId: entry.message.agentId,
timestamp: entry.message.timestamp,
metadata: entry.message.metadata,
}));
// Bulk insert
await this.db.insertBatch(documents);
// Resolve all promises
for (const entry of toProcess) {
entry.resolve();
}
this.options.logger.info?.(
`memory-ruvector: indexed ${toProcess.length} messages`,
);
} catch (err) {
// Reject all promises on error
const error = err instanceof Error ? err : new Error(String(err));
for (const entry of toProcess) {
entry.reject(error);
}
this.options.logger.warn(
`memory-ruvector: batch indexing failed: ${error.message}`,
);
} finally {
this.isProcessing = false;
// Process remaining batch if any
if (this.batch.length > 0) {
this.scheduleFlush();
}
}
}
/**
* Force flush any pending messages. Call on shutdown.
* Waits for any in-progress flush to complete with a timeout.
*/
async forceFlush(): Promise<void> {
// Clear any pending timer
if (this.flushTimer) {
clearTimeout(this.flushTimer);
this.flushTimer = null;
}
// Wait for in-progress flush to complete (with timeout to avoid hanging)
const maxWaitMs = 30_000;
const startTime = Date.now();
while (this.isProcessing && Date.now() - startTime < maxWaitMs) {
await new Promise((resolve) => setTimeout(resolve, 100));
}
// Flush remaining batches with retry limit
const maxRetries = 3;
let retries = 0;
while (this.batch.length > 0 && retries < maxRetries) {
const prevLength = this.batch.length;
await this.flush();
// If batch length didn't decrease, something is stuck
if (this.batch.length >= prevLength) {
retries++;
await new Promise((resolve) => setTimeout(resolve, 100));
} else {
retries = 0;
}
}
if (this.batch.length > 0) {
this.options.logger.warn(
`memory-ruvector: forceFlush completed with ${this.batch.length} messages still pending`,
);
// Reject remaining entries so callers aren't left hanging
for (const entry of this.batch) {
entry.reject(new Error("Batcher shutdown with pending messages"));
}
this.batch = [];
}
}
/**
* Cleanup resources. Call when the plugin is unloaded.
*/
destroy(): void {
this.destroyed = true;
if (this.flushTimer) {
clearTimeout(this.flushTimer);
this.flushTimer = null;
}
// Reject any pending entries
for (const entry of this.batch) {
entry.reject(new Error("Batcher destroyed"));
}
this.batch = [];
}
}
// ============================================================================
// Content Filters
// ============================================================================
const MIN_CONTENT_LENGTH = 5;
const MAX_CONTENT_LENGTH = 8000;
/**
* Determine if content should be indexed.
* Filters out very short messages, system markers, and injected context.
*/
function shouldIndex(content: string): boolean {
if (!content || typeof content !== "string") return false;
const trimmed = content.trim();
// Skip too short or too long
if (trimmed.length < MIN_CONTENT_LENGTH || trimmed.length > MAX_CONTENT_LENGTH) {
return false;
}
// Skip system-generated/injected content markers
if (trimmed.includes("<relevant-memories>")) return false;
if (trimmed.includes("<system>")) return false;
// Skip XML/HTML-like documents that start with a tag and have matching close tags
// But allow messages that merely contain some HTML tags
if (trimmed.startsWith("<") && /^<[a-zA-Z][^>]*>[\s\S]*<\/[a-zA-Z]+>\s*$/.test(trimmed)) return false;
// Skip control commands
if (trimmed.startsWith("/")) return false;
// Skip likely empty or whitespace-only
if (/^\s*$/.test(trimmed)) return false;
return true;
}
/**
* Clean content for embedding (remove excessive whitespace, etc.)
*/
function cleanContent(content: string): string {
return content
.trim()
.replace(/\r\n/g, "\n")
.replace(/\n{3,}/g, "\n\n")
.replace(/[ \t]+/g, " ");
}
// ============================================================================
// Hook Registration
// ============================================================================
export type HooksConfig = {
enabled: boolean;
indexInbound: boolean;
indexOutbound: boolean;
indexAgentResponses: boolean;
batchSize: number;
debounceMs: number;
};
export const defaultHooksConfig: HooksConfig = {
enabled: true,
indexInbound: true,
indexOutbound: true,
indexAgentResponses: true,
batchSize: 10,
debounceMs: 500,
};
/**
* Register message indexing hooks with the plugin API.
*/
export function registerHooks(
api: ClawdbotPluginApi,
db: RuvectorDB,
embeddings: EmbeddingProvider,
config: HooksConfig,
): { batcher: MessageBatcher | null } {
if (!config.enabled) {
api.logger.info?.("memory-ruvector: hooks disabled by config");
return { batcher: null };
}
const batcher = new MessageBatcher(db, embeddings, {
batchSize: config.batchSize,
debounceMs: config.debounceMs,
logger: api.logger,
});
// -------------------------------------------------------------------------
// message_received hook - Index incoming user messages
// -------------------------------------------------------------------------
if (config.indexInbound) {
api.on(
"message_received",
async (
event: PluginHookMessageReceivedEvent,
ctx: PluginHookMessageContext,
) => {
try {
if (!shouldIndex(event.content)) return;
const message: IndexableMessage = {
content: cleanContent(event.content),
direction: "inbound",
channel: ctx.channelId,
user: event.from,
conversationId: ctx.conversationId,
timestamp: event.timestamp ?? Date.now(),
metadata: event.metadata,
};
// Queue for batched indexing (fire and forget, don't block message handling)
batcher.queue(message).catch((err) => {
api.logger.warn(
`memory-ruvector: failed to index received message: ${String(err)}`,
);
});
} catch (err) {
api.logger.warn(
`memory-ruvector: message_received hook error: ${String(err)}`,
);
}
},
{ priority: 100 }, // Low priority, run after core handlers
);
api.logger.info?.("memory-ruvector: registered message_received hook");
}
// -------------------------------------------------------------------------
// message_sent hook - Index outgoing bot messages
// -------------------------------------------------------------------------
if (config.indexOutbound) {
api.on(
"message_sent",
async (
event: PluginHookMessageSentEvent,
ctx: PluginHookMessageContext,
) => {
try {
// Only index successful sends
if (!event.success) return;
if (!shouldIndex(event.content)) return;
const message: IndexableMessage = {
content: cleanContent(event.content),
direction: "outbound",
channel: ctx.channelId,
user: event.to,
conversationId: ctx.conversationId,
timestamp: Date.now(),
};
// Queue for batched indexing
batcher.queue(message).catch((err) => {
api.logger.warn(
`memory-ruvector: failed to index sent message: ${String(err)}`,
);
});
} catch (err) {
api.logger.warn(
`memory-ruvector: message_sent hook error: ${String(err)}`,
);
}
},
{ priority: 100 },
);
api.logger.info?.("memory-ruvector: registered message_sent hook");
}
// -------------------------------------------------------------------------
// agent_end hook - Index agent responses with full context
// -------------------------------------------------------------------------
if (config.indexAgentResponses) {
api.on(
"agent_end",
async (
event: PluginHookAgentEndEvent,
ctx: PluginHookAgentContext,
) => {
try {
// Only index successful agent runs
if (!event.success) return;
if (!event.messages || event.messages.length === 0) return;
// Extract text content from messages
const textsToIndex: Array<{
content: string;
direction: "inbound" | "outbound";
}> = [];
for (const msg of event.messages) {
if (!msg || typeof msg !== "object") continue;
const msgObj = msg as Record<string, unknown>;
const role = msgObj.role;
// Only process user (inbound) and assistant (outbound) messages
if (role !== "user" && role !== "assistant") continue;
const direction: "inbound" | "outbound" =
role === "user" ? "inbound" : "outbound";
const content = msgObj.content;
// Handle string content
if (typeof content === "string") {
if (shouldIndex(content)) {
textsToIndex.push({ content: cleanContent(content), direction });
}
continue;
}
// Handle array content (content blocks)
if (Array.isArray(content)) {
for (const block of content) {
if (
block &&
typeof block === "object" &&
"type" in block &&
(block as Record<string, unknown>).type === "text" &&
"text" in block &&
typeof (block as Record<string, unknown>).text === "string"
) {
const text = (block as Record<string, unknown>).text as string;
if (shouldIndex(text)) {
textsToIndex.push({ content: cleanContent(text), direction });
}
}
}
}
}
// Limit to most recent messages to avoid overwhelming on long sessions
const toIndex = textsToIndex.slice(-10);
// Queue all for batched indexing
const promises = toIndex.map((item) => {
const message: IndexableMessage = {
content: item.content,
direction: item.direction,
channel: ctx.messageProvider ?? "unknown",
sessionKey: ctx.sessionKey,
agentId: ctx.agentId,
timestamp: Date.now(),
};
return batcher.queue(message);
});
// Wait for all to be queued (not necessarily indexed)
await Promise.allSettled(promises);
} catch (err) {
api.logger.warn(
`memory-ruvector: agent_end hook error: ${String(err)}`,
);
}
},
{ priority: 100 },
);
api.logger.info?.("memory-ruvector: registered agent_end hook");
}
return { batcher };
}