Add memory-memvid plugin with PII protection
Long-term memory plugin using Memvid SDK with: - Efficient compressed storage (.mv2 format) - Full conversation history preservation - Hybrid search (semantic + lexical) - RAG capabilities - PII protection for all session transcripts
This commit is contained in:
parent
3fe4b2595a
commit
5d499c552b
57
extensions/memory-memvid/clawdbot.plugin.json
Normal file
57
extensions/memory-memvid/clawdbot.plugin.json
Normal file
@ -0,0 +1,57 @@
|
||||
{
|
||||
"id": "memory-memvid",
|
||||
"name": "Memory (Memvid)",
|
||||
"description": "Long-term memory using Memvid SDK. Provides efficient storage, hybrid search, and RAG capabilities with full conversation history preservation.",
|
||||
"kind": "memory",
|
||||
"version": "2026.1.27",
|
||||
"author": "Memvid",
|
||||
"repository": "https://github.com/memvid/memvid",
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"memoryPath": {
|
||||
"type": "string",
|
||||
"description": "Path to the .mv2 memory file (default: ~/.clawdbot/memories/moltbot.mv2)"
|
||||
},
|
||||
"openaiApiKey": {
|
||||
"type": "string",
|
||||
"description": "OpenAI API key for embeddings (uses OPENAI_API_KEY env var if not set)"
|
||||
},
|
||||
"embeddingModel": {
|
||||
"type": "string",
|
||||
"description": "OpenAI embedding model",
|
||||
"default": "text-embedding-3-small"
|
||||
},
|
||||
"autoRecall": {
|
||||
"type": "boolean",
|
||||
"description": "Automatically inject relevant memories before agent starts",
|
||||
"default": true
|
||||
},
|
||||
"autoCapture": {
|
||||
"type": "boolean",
|
||||
"description": "Automatically capture important information after agent ends",
|
||||
"default": true
|
||||
},
|
||||
"topK": {
|
||||
"type": "number",
|
||||
"description": "Number of results to return from memory search",
|
||||
"default": 5
|
||||
},
|
||||
"snippetChars": {
|
||||
"type": "number",
|
||||
"description": "Maximum characters per snippet",
|
||||
"default": 500
|
||||
},
|
||||
"minScore": {
|
||||
"type": "number",
|
||||
"description": "Minimum similarity score for memory matches (0-1)",
|
||||
"default": 0.3
|
||||
},
|
||||
"ragModel": {
|
||||
"type": "string",
|
||||
"description": "Model for RAG answers",
|
||||
"default": "gpt-4o-mini"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
126
extensions/memory-memvid/config.ts
Normal file
126
extensions/memory-memvid/config.ts
Normal file
@ -0,0 +1,126 @@
|
||||
import { Type, type Static } from "@sinclair/typebox";
|
||||
import { TypeCompiler } from "@sinclair/typebox/compiler";
|
||||
|
||||
/**
|
||||
* Memory categories for classification
|
||||
*/
|
||||
export const MEMORY_CATEGORIES = [
|
||||
"preference",
|
||||
"decision",
|
||||
"fact",
|
||||
"entity",
|
||||
"instruction",
|
||||
"context",
|
||||
"other",
|
||||
] as const;
|
||||
|
||||
export type MemoryCategory = (typeof MEMORY_CATEGORIES)[number];
|
||||
|
||||
/**
|
||||
* Configuration schema for Memvid memory plugin (local SDK)
|
||||
*/
|
||||
export const memvidConfigSchema = Type.Object({
|
||||
// Storage Configuration
|
||||
memoryPath: Type.Optional(
|
||||
Type.String({
|
||||
description: "Path to the .mv2 memory file (default: ~/.clawdbot/memories/moltbot.mv2)",
|
||||
}),
|
||||
),
|
||||
|
||||
// OpenAI Configuration (for embeddings and RAG)
|
||||
openaiApiKey: Type.Optional(
|
||||
Type.String({
|
||||
description: "OpenAI API key for embeddings (uses OPENAI_API_KEY env var if not set)",
|
||||
}),
|
||||
),
|
||||
embeddingModel: Type.Optional(
|
||||
Type.String({
|
||||
description: "OpenAI embedding model",
|
||||
default: "text-embedding-3-small",
|
||||
}),
|
||||
),
|
||||
|
||||
// Behavior Configuration
|
||||
autoRecall: Type.Optional(
|
||||
Type.Boolean({
|
||||
description: "Auto-inject relevant memories before agent starts",
|
||||
default: true,
|
||||
}),
|
||||
),
|
||||
autoCapture: Type.Optional(
|
||||
Type.Boolean({
|
||||
description: "Auto-capture important info after agent ends",
|
||||
default: true,
|
||||
}),
|
||||
),
|
||||
|
||||
// Search Configuration
|
||||
topK: Type.Optional(
|
||||
Type.Number({
|
||||
description: "Number of results to return from memory search",
|
||||
default: 5,
|
||||
minimum: 1,
|
||||
maximum: 50,
|
||||
}),
|
||||
),
|
||||
snippetChars: Type.Optional(
|
||||
Type.Number({
|
||||
description: "Maximum characters per snippet",
|
||||
default: 500,
|
||||
minimum: 100,
|
||||
maximum: 2000,
|
||||
}),
|
||||
),
|
||||
minScore: Type.Optional(
|
||||
Type.Number({
|
||||
description: "Minimum similarity score (0-1)",
|
||||
default: 0.3,
|
||||
minimum: 0,
|
||||
maximum: 1,
|
||||
}),
|
||||
),
|
||||
|
||||
// RAG Configuration
|
||||
ragModel: Type.Optional(
|
||||
Type.String({
|
||||
description: "Model for RAG answers (gpt-4o, gpt-4o-mini, etc.)",
|
||||
default: "gpt-4o-mini",
|
||||
}),
|
||||
),
|
||||
|
||||
// Security Configuration
|
||||
maskPii: Type.Optional(
|
||||
Type.Boolean({
|
||||
description: "Mask PII (emails, SSNs, phone numbers, credit cards, API keys) in recalled memories",
|
||||
default: true,
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
export type MemvidConfig = Static<typeof memvidConfigSchema>;
|
||||
|
||||
const configCompiler = TypeCompiler.Compile(memvidConfigSchema);
|
||||
|
||||
/**
|
||||
* Parse and validate config with defaults
|
||||
*/
|
||||
export function parseConfig(raw: unknown): MemvidConfig {
|
||||
// Handle undefined/null config
|
||||
const config = raw && typeof raw === "object" ? raw : {};
|
||||
|
||||
if (!configCompiler.Check(config)) {
|
||||
const errors = [...configCompiler.Errors(config)];
|
||||
throw new Error(`Invalid config: ${errors.map((e) => `${e.path}: ${e.message}`).join(", ")}`);
|
||||
}
|
||||
return {
|
||||
embeddingModel: "text-embedding-3-small",
|
||||
autoRecall: true,
|
||||
autoCapture: true,
|
||||
topK: 5,
|
||||
snippetChars: 500,
|
||||
minScore: 0.3,
|
||||
ragModel: "gpt-4o-mini",
|
||||
maskPii: true, // Default: ON for security
|
||||
...config,
|
||||
} as MemvidConfig;
|
||||
}
|
||||
582
extensions/memory-memvid/index.ts
Normal file
582
extensions/memory-memvid/index.ts
Normal file
@ -0,0 +1,582 @@
|
||||
/**
|
||||
* Moltbot Memory (Memvid) Plugin
|
||||
*
|
||||
* Long-term memory using Memvid SDK.
|
||||
* Provides efficient storage, hybrid search, and RAG capabilities
|
||||
* with full conversation history preservation.
|
||||
*
|
||||
* Key advantages over built-in memory:
|
||||
* - Efficient compressed storage (binary .mv2 format)
|
||||
* - Full conversation history (no destructive compaction)
|
||||
* - Hybrid search (semantic + lexical)
|
||||
* - RAG-ready with context retrieval
|
||||
* - PII PROTECTION: Masks emails, SSNs, phone numbers, credit cards,
|
||||
* API keys, and tokens before injecting memories into context
|
||||
*/
|
||||
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import { create, open, maskPii, type Memvid } from "@memvid/sdk";
|
||||
import { existsSync, mkdirSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { homedir } from "node:os";
|
||||
import type { MoltbotPluginApi } from "clawdbot/plugin-sdk";
|
||||
import { stringEnum } from "clawdbot/plugin-sdk";
|
||||
import { registerPiiMasker, unregisterPiiMasker } from "../../src/plugins/pii-masker.js";
|
||||
|
||||
import { MEMORY_CATEGORIES, type MemoryCategory, parseConfig } from "./config.js";
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
type MemoryHit = {
|
||||
title: string;
|
||||
snippet: string;
|
||||
score: number;
|
||||
frameId?: number;
|
||||
};
|
||||
|
||||
type MemorySearchResult = {
|
||||
query: string;
|
||||
hits: MemoryHit[];
|
||||
totalFrames: number;
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Memory Triggers for Auto-Capture
|
||||
// ============================================================================
|
||||
|
||||
const MEMORY_TRIGGERS = [
|
||||
/remember|zapamatuj si|pamatuj/i,
|
||||
/prefer|preferuji|radši|nechci/i,
|
||||
/decided|rozhodli jsme|budeme používat/i,
|
||||
/\+\d{10,}/, // Phone numbers
|
||||
/[\w.-]+@[\w.-]+\.\w+/, // Emails
|
||||
/my\s+\w+\s+is|is\s+my|můj\s+\w+\s+je|je\s+můj/i,
|
||||
/i (like|prefer|hate|love|want|need)/i,
|
||||
/always|never|important/i,
|
||||
/don't forget|make sure to/i,
|
||||
];
|
||||
|
||||
function shouldCapture(text: string): boolean {
|
||||
if (text.length < 10 || text.length > 1000) return false;
|
||||
// Skip injected context from memory recall
|
||||
if (text.includes("<relevant-memories>")) return false;
|
||||
// Skip system-generated content
|
||||
if (text.startsWith("<") && text.includes("</")) return false;
|
||||
// Skip agent summary responses
|
||||
if (text.includes("**") && text.includes("\n-")) return false;
|
||||
// Skip emoji-heavy responses
|
||||
const emojiCount = (text.match(/[\u{1F300}-\u{1F9FF}]/gu) || []).length;
|
||||
if (emojiCount > 3) return false;
|
||||
return MEMORY_TRIGGERS.some((r) => r.test(text));
|
||||
}
|
||||
|
||||
function detectCategory(text: string): MemoryCategory {
|
||||
const lower = text.toLowerCase();
|
||||
if (/prefer|radši|like|love|hate|want/i.test(lower)) return "preference";
|
||||
if (/decided|rozhodli|will use|budeme/i.test(lower)) return "decision";
|
||||
if (/\+\d{10,}|@[\w.-]+\.\w+|is called|jmenuje se/i.test(lower)) return "entity";
|
||||
if (/instruction|rule|always|never|must|should/i.test(lower)) return "instruction";
|
||||
if (/context|background|situation/i.test(lower)) return "context";
|
||||
if (/is|are|has|have|je|má|jsou/i.test(lower)) return "fact";
|
||||
return "other";
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Memvid Memory Manager
|
||||
// ============================================================================
|
||||
|
||||
class MemvidMemory {
|
||||
private mv: Memvid | null = null;
|
||||
private initPromise: Promise<void> | null = null;
|
||||
private dirty = false;
|
||||
|
||||
constructor(
|
||||
private readonly memoryPath: string,
|
||||
private readonly openaiApiKey: string,
|
||||
private readonly maskPii: boolean = true,
|
||||
) {}
|
||||
|
||||
private async ensureInitialized(): Promise<Memvid> {
|
||||
if (this.mv) return this.mv;
|
||||
if (this.initPromise) {
|
||||
await this.initPromise;
|
||||
return this.mv!;
|
||||
}
|
||||
|
||||
this.initPromise = this.doInitialize();
|
||||
await this.initPromise;
|
||||
return this.mv!;
|
||||
}
|
||||
|
||||
private async doInitialize(): Promise<void> {
|
||||
// Ensure directory exists
|
||||
const dir = dirname(this.memoryPath);
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
// Open existing or create new memory
|
||||
if (existsSync(this.memoryPath)) {
|
||||
this.mv = await open(this.memoryPath);
|
||||
} else {
|
||||
this.mv = await create(this.memoryPath);
|
||||
await this.mv.seal();
|
||||
}
|
||||
}
|
||||
|
||||
async store(text: string, category: MemoryCategory): Promise<{ id: string; text: string }> {
|
||||
const mv = await this.ensureInitialized();
|
||||
|
||||
const timestamp = new Date().toISOString();
|
||||
const title = `[${category}] ${timestamp}`;
|
||||
|
||||
// Mask PII before storing if enabled
|
||||
const safeText = this.maskPii ? maskPii(text) : text;
|
||||
|
||||
await mv.put({
|
||||
text: safeText,
|
||||
title,
|
||||
label: category,
|
||||
});
|
||||
|
||||
this.dirty = true;
|
||||
return { id: title, text: safeText };
|
||||
}
|
||||
|
||||
async search(query: string, topK = 5, snippetChars = 500): Promise<MemorySearchResult> {
|
||||
const mv = await this.ensureInitialized();
|
||||
|
||||
// Seal if dirty to make new content searchable
|
||||
if (this.dirty) {
|
||||
await mv.seal();
|
||||
await mv.rebuildTimeIndex();
|
||||
this.dirty = false;
|
||||
}
|
||||
|
||||
const result = await mv.ask(query, {
|
||||
model: "openai",
|
||||
k: topK,
|
||||
snippetChars,
|
||||
contextOnly: true,
|
||||
maskPii: this.maskPii, // Security: mask PII in recalled memories
|
||||
});
|
||||
|
||||
const hits: MemoryHit[] = (result as any).hits?.map((hit: any) => ({
|
||||
title: hit.title || "Unknown",
|
||||
snippet: hit.snippet || hit.text || "",
|
||||
score: hit.score || 0,
|
||||
frameId: hit.frameId,
|
||||
})) || [];
|
||||
|
||||
const stats = await mv.stats();
|
||||
|
||||
return {
|
||||
query,
|
||||
hits,
|
||||
totalFrames: stats.totalFrames || 0,
|
||||
};
|
||||
}
|
||||
|
||||
async ask(question: string, model = "gpt-4o-mini", topK = 5): Promise<{ answer: string; sources: MemoryHit[] }> {
|
||||
const mv = await this.ensureInitialized();
|
||||
|
||||
// Seal if dirty
|
||||
if (this.dirty) {
|
||||
await mv.seal();
|
||||
await mv.rebuildTimeIndex();
|
||||
this.dirty = false;
|
||||
}
|
||||
|
||||
const result = await mv.ask(question, {
|
||||
model: "openai",
|
||||
k: topK,
|
||||
snippetChars: 1000,
|
||||
maskPii: this.maskPii, // Security: mask PII in RAG responses
|
||||
});
|
||||
|
||||
const answer = (result as any).answer || (result as any).response || "No answer found.";
|
||||
const sources: MemoryHit[] = (result as any).hits?.map((hit: any) => ({
|
||||
title: hit.title || "Unknown",
|
||||
snippet: hit.snippet || hit.text || "",
|
||||
score: hit.score || 0,
|
||||
})) || [];
|
||||
|
||||
return { answer, sources };
|
||||
}
|
||||
|
||||
async stats(): Promise<{ totalFrames: number; sizeBytes: number }> {
|
||||
const mv = await this.ensureInitialized();
|
||||
const stats = await mv.stats() as Record<string, unknown>;
|
||||
return {
|
||||
totalFrames: (stats.frame_count as number) || (stats.totalFrames as number) || 0,
|
||||
sizeBytes: (stats.size_bytes as number) || (stats.sizeBytes as number) || 0,
|
||||
};
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
if (this.mv && this.dirty) {
|
||||
await this.mv.seal();
|
||||
}
|
||||
this.mv = null;
|
||||
this.initPromise = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Plugin Definition
|
||||
// ============================================================================
|
||||
|
||||
const memvidPlugin = {
|
||||
id: "memory-memvid",
|
||||
name: "Memory (Memvid)",
|
||||
description: "Video-encoded long-term memory with hybrid search and RAG",
|
||||
kind: "memory" as const,
|
||||
|
||||
register(api: MoltbotPluginApi) {
|
||||
const cfg = parseConfig(api.pluginConfig);
|
||||
|
||||
// Resolve memory path
|
||||
const defaultPath = join(homedir(), ".clawdbot", "memories", "moltbot.mv2");
|
||||
const memoryPath = cfg.memoryPath ? api.resolvePath(cfg.memoryPath) : defaultPath;
|
||||
|
||||
// Get OpenAI API key
|
||||
const openaiApiKey = cfg.openaiApiKey || process.env.OPENAI_API_KEY;
|
||||
if (!openaiApiKey) {
|
||||
api.logger.warn("memory-memvid: No OpenAI API key configured. Set openaiApiKey in config or OPENAI_API_KEY env var.");
|
||||
}
|
||||
|
||||
const memory = new MemvidMemory(memoryPath, openaiApiKey || "", cfg.maskPii ?? true);
|
||||
|
||||
// Register PII masker globally if enabled
|
||||
if (cfg.maskPii !== false) {
|
||||
registerPiiMasker(maskPii);
|
||||
api.logger.info("memory-memvid: PII masking enabled for session transcripts");
|
||||
}
|
||||
|
||||
api.logger.info(`memory-memvid: plugin registered (path: ${memoryPath})`);
|
||||
|
||||
// ========================================================================
|
||||
// Tools
|
||||
// ========================================================================
|
||||
|
||||
api.registerTool(
|
||||
{
|
||||
name: "memvid_search",
|
||||
label: "Memvid Search",
|
||||
description:
|
||||
"Search through long-term memories stored in Memvid. Use for finding past conversations, preferences, decisions, and facts.",
|
||||
parameters: Type.Object({
|
||||
query: Type.String({ description: "Search query" }),
|
||||
limit: Type.Optional(Type.Number({ description: "Max results (default: 5)" })),
|
||||
}),
|
||||
async execute(_toolCallId, params) {
|
||||
const { query, limit = cfg.topK } = params as { query: string; limit?: number };
|
||||
|
||||
try {
|
||||
const result = await memory.search(query, limit, cfg.snippetChars);
|
||||
|
||||
if (result.hits.length === 0) {
|
||||
return {
|
||||
content: [{ type: "text", text: "No relevant memories found." }],
|
||||
details: { count: 0, totalFrames: result.totalFrames },
|
||||
};
|
||||
}
|
||||
|
||||
const text = result.hits
|
||||
.map((hit, i) => `${i + 1}. ${hit.title}\n ${hit.snippet.slice(0, 200)}...`)
|
||||
.join("\n\n");
|
||||
|
||||
return {
|
||||
content: [
|
||||
{ type: "text", text: `Found ${result.hits.length} memories:\n\n${text}` },
|
||||
],
|
||||
details: {
|
||||
count: result.hits.length,
|
||||
totalFrames: result.totalFrames,
|
||||
hits: result.hits.map((h) => ({ title: h.title, score: h.score })),
|
||||
},
|
||||
};
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return {
|
||||
content: [{ type: "text", text: `Memory search failed: ${message}` }],
|
||||
details: { error: message },
|
||||
};
|
||||
}
|
||||
},
|
||||
},
|
||||
{ name: "memvid_search" },
|
||||
);
|
||||
|
||||
api.registerTool(
|
||||
{
|
||||
name: "memvid_store",
|
||||
label: "Memvid Store",
|
||||
description:
|
||||
"Save important information to long-term memory. Use for preferences, facts, decisions, and instructions.",
|
||||
parameters: Type.Object({
|
||||
text: Type.String({ description: "Information to remember" }),
|
||||
category: Type.Optional(stringEnum(MEMORY_CATEGORIES)),
|
||||
}),
|
||||
async execute(_toolCallId, params) {
|
||||
const { text, category = "other" } = params as {
|
||||
text: string;
|
||||
category?: MemoryCategory;
|
||||
};
|
||||
|
||||
try {
|
||||
// Check for duplicates
|
||||
const existing = await memory.search(text, 1, 200);
|
||||
if (existing.hits.length > 0 && existing.hits[0].score > 0.95) {
|
||||
return {
|
||||
content: [
|
||||
{ type: "text", text: `Similar memory already exists: "${existing.hits[0].snippet.slice(0, 100)}..."` },
|
||||
],
|
||||
details: { action: "duplicate", existingTitle: existing.hits[0].title },
|
||||
};
|
||||
}
|
||||
|
||||
const entry = await memory.store(text, category);
|
||||
|
||||
return {
|
||||
content: [{ type: "text", text: `Stored: "${entry.text.slice(0, 100)}..."` }],
|
||||
details: { action: "created", id: entry.id, category },
|
||||
};
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return {
|
||||
content: [{ type: "text", text: `Failed to store memory: ${message}` }],
|
||||
details: { error: message },
|
||||
};
|
||||
}
|
||||
},
|
||||
},
|
||||
{ name: "memvid_store" },
|
||||
);
|
||||
|
||||
api.registerTool(
|
||||
{
|
||||
name: "memvid_ask",
|
||||
label: "Memvid Ask",
|
||||
description:
|
||||
"Ask a question and get an answer based on stored memories (RAG). Returns a synthesized answer with sources.",
|
||||
parameters: Type.Object({
|
||||
question: Type.String({ description: "Question to answer from memories" }),
|
||||
}),
|
||||
async execute(_toolCallId, params) {
|
||||
const { question } = params as { question: string };
|
||||
|
||||
try {
|
||||
const result = await memory.ask(question, cfg.ragModel, cfg.topK);
|
||||
|
||||
const sourcesText = result.sources.length > 0
|
||||
? `\n\nSources:\n${result.sources.map((s, i) => `${i + 1}. ${s.title}`).join("\n")}`
|
||||
: "";
|
||||
|
||||
return {
|
||||
content: [{ type: "text", text: `${result.answer}${sourcesText}` }],
|
||||
details: {
|
||||
answer: result.answer,
|
||||
sourceCount: result.sources.length,
|
||||
sources: result.sources.map((s) => ({ title: s.title, score: s.score })),
|
||||
},
|
||||
};
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return {
|
||||
content: [{ type: "text", text: `Failed to answer from memories: ${message}` }],
|
||||
details: { error: message },
|
||||
};
|
||||
}
|
||||
},
|
||||
},
|
||||
{ name: "memvid_ask" },
|
||||
);
|
||||
|
||||
// ========================================================================
|
||||
// CLI Commands
|
||||
// ========================================================================
|
||||
|
||||
api.registerCli(
|
||||
({ program }) => {
|
||||
const memvid = program
|
||||
.command("memvid")
|
||||
.description("Memvid memory plugin commands");
|
||||
|
||||
memvid
|
||||
.command("stats")
|
||||
.description("Show memory statistics")
|
||||
.action(async () => {
|
||||
try {
|
||||
const stats = await memory.stats();
|
||||
console.log(`Memvid Memory Statistics:`);
|
||||
console.log(` Path: ${memoryPath}`);
|
||||
console.log(` Total Frames: ${stats.totalFrames}`);
|
||||
console.log(` Size: ${(stats.sizeBytes / 1024 / 1024).toFixed(2)} MB`);
|
||||
} catch (err) {
|
||||
console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
});
|
||||
|
||||
memvid
|
||||
.command("search")
|
||||
.description("Search memories")
|
||||
.argument("<query>", "Search query")
|
||||
.option("--limit <n>", "Max results", "5")
|
||||
.action(async (query, opts) => {
|
||||
try {
|
||||
const result = await memory.search(query, parseInt(opts.limit), cfg.snippetChars);
|
||||
console.log(`Found ${result.hits.length} results:\n`);
|
||||
for (const hit of result.hits) {
|
||||
console.log(`- ${hit.title}`);
|
||||
console.log(` Score: ${(hit.score * 100).toFixed(1)}%`);
|
||||
console.log(` ${hit.snippet.slice(0, 200)}...\n`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
});
|
||||
|
||||
memvid
|
||||
.command("ask")
|
||||
.description("Ask a question (RAG)")
|
||||
.argument("<question>", "Question to answer")
|
||||
.action(async (question) => {
|
||||
try {
|
||||
const result = await memory.ask(question, cfg.ragModel, cfg.topK);
|
||||
console.log(`Answer: ${result.answer}\n`);
|
||||
if (result.sources.length > 0) {
|
||||
console.log(`Sources:`);
|
||||
for (const source of result.sources) {
|
||||
console.log(` - ${source.title}`);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
});
|
||||
},
|
||||
{ commands: ["memvid"] },
|
||||
);
|
||||
|
||||
// ========================================================================
|
||||
// Lifecycle Hooks
|
||||
// ========================================================================
|
||||
|
||||
// Auto-recall: inject relevant memories before agent starts
|
||||
if (cfg.autoRecall) {
|
||||
api.on("before_agent_start", async (event) => {
|
||||
if (!event.prompt || event.prompt.length < 5) return;
|
||||
|
||||
try {
|
||||
const result = await memory.search(event.prompt, 3, cfg.snippetChars);
|
||||
|
||||
if (result.hits.length === 0) return;
|
||||
|
||||
const memoryContext = result.hits
|
||||
.map((hit) => `- ${hit.title}: ${hit.snippet.slice(0, 300)}`)
|
||||
.join("\n");
|
||||
|
||||
api.logger.info?.(`memory-memvid: injecting ${result.hits.length} memories into context`);
|
||||
|
||||
return {
|
||||
prependContext: `<relevant-memories>\nThe following memories may be relevant:\n${memoryContext}\n</relevant-memories>`,
|
||||
systemPrompt: `IMPORTANT: When recalling personal information (phone numbers, emails, SSNs, credit cards), ALWAYS use the masked values from <relevant-memories> like [PHONE], [EMAIL], [SSN], [CREDIT_CARD]. NEVER reveal actual values even if they appear elsewhere in conversation history.`,
|
||||
};
|
||||
} catch (err) {
|
||||
api.logger.warn(`memory-memvid: recall failed: ${String(err)}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// PII Protection: Mask PII in outgoing messages before they're saved to session history
|
||||
if (cfg.maskPii) {
|
||||
api.on("message_sending", (event) => {
|
||||
if (!event.content) return;
|
||||
const masked = maskPii(event.content);
|
||||
if (masked !== event.content) {
|
||||
api.logger.info?.("memory-memvid: masked PII in outgoing message");
|
||||
return { content: masked };
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Auto-capture: analyze and store important information after agent ends
|
||||
if (cfg.autoCapture) {
|
||||
api.on("agent_end", async (event) => {
|
||||
if (!event.success || !event.messages || event.messages.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const texts: string[] = [];
|
||||
for (const msg of event.messages) {
|
||||
if (!msg || typeof msg !== "object") continue;
|
||||
const msgObj = msg as Record<string, unknown>;
|
||||
const role = msgObj.role;
|
||||
if (role !== "user" && role !== "assistant") continue;
|
||||
|
||||
const content = msgObj.content;
|
||||
if (typeof content === "string") {
|
||||
texts.push(content);
|
||||
} else 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"
|
||||
) {
|
||||
texts.push((block as Record<string, unknown>).text as string);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const toCapture = texts.filter((text) => text && shouldCapture(text));
|
||||
if (toCapture.length === 0) return;
|
||||
|
||||
let stored = 0;
|
||||
for (const text of toCapture.slice(0, 3)) {
|
||||
const category = detectCategory(text);
|
||||
|
||||
// Check for duplicates
|
||||
const existing = await memory.search(text, 1, 200);
|
||||
if (existing.hits.length > 0 && existing.hits[0].score > 0.95) continue;
|
||||
|
||||
await memory.store(text, category);
|
||||
stored++;
|
||||
}
|
||||
|
||||
if (stored > 0) {
|
||||
api.logger.info(`memory-memvid: auto-captured ${stored} memories`);
|
||||
}
|
||||
} catch (err) {
|
||||
api.logger.warn(`memory-memvid: capture failed: ${String(err)}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Service
|
||||
// ========================================================================
|
||||
|
||||
api.registerService({
|
||||
id: "memory-memvid",
|
||||
start: () => {
|
||||
api.logger.info(`memory-memvid: initialized (path: ${memoryPath})`);
|
||||
},
|
||||
stop: async () => {
|
||||
unregisterPiiMasker();
|
||||
await memory.close();
|
||||
api.logger.info("memory-memvid: stopped");
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export default memvidPlugin;
|
||||
3005
extensions/memory-memvid/package-lock.json
generated
Normal file
3005
extensions/memory-memvid/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
13
extensions/memory-memvid/package.json
Normal file
13
extensions/memory-memvid/package.json
Normal file
@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "@moltbot/memory-memvid",
|
||||
"version": "2026.1.27",
|
||||
"description": "Memvid-backed long-term memory for Moltbot with efficient storage, hybrid search, and full audit trails",
|
||||
"type": "module",
|
||||
"main": "index.ts",
|
||||
"dependencies": {
|
||||
"@memvid/sdk": "^2.0.153"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"moltbot": "*"
|
||||
}
|
||||
}
|
||||
@ -1,8 +1,115 @@
|
||||
import type { AgentMessage } from "@mariozechner/pi-agent-core";
|
||||
import type { SessionManager } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
import { getGlobalHookRunner } from "../plugins/hook-runner-global.js";
|
||||
import { getPiiMasker } from "../plugins/pii-masker.js";
|
||||
import { installSessionToolResultGuard } from "./session-tool-result-guard.js";
|
||||
|
||||
/**
|
||||
* Create a transform that masks PII in message content before persistence.
|
||||
*/
|
||||
/**
|
||||
* Recursively mask PII in any string values within an object/array.
|
||||
*/
|
||||
function maskPiiInValue(
|
||||
value: unknown,
|
||||
masker: (text: string) => string,
|
||||
): { value: unknown; changed: boolean } {
|
||||
if (typeof value === "string") {
|
||||
const masked = masker(value);
|
||||
return { value: masked, changed: masked !== value };
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
let changed = false;
|
||||
const newArr = value.map((item) => {
|
||||
const result = maskPiiInValue(item, masker);
|
||||
if (result.changed) changed = true;
|
||||
return result.value;
|
||||
});
|
||||
return { value: changed ? newArr : value, changed };
|
||||
}
|
||||
if (value && typeof value === "object") {
|
||||
let changed = false;
|
||||
const newObj: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(value)) {
|
||||
const result = maskPiiInValue(v, masker);
|
||||
newObj[k] = result.value;
|
||||
if (result.changed) changed = true;
|
||||
}
|
||||
return { value: changed ? newObj : value, changed };
|
||||
}
|
||||
return { value, changed: false };
|
||||
}
|
||||
|
||||
function createPiiMaskingTransform(): ((message: AgentMessage) => AgentMessage) | undefined {
|
||||
return (message: AgentMessage): AgentMessage => {
|
||||
const masker = getPiiMasker();
|
||||
if (!masker) return message;
|
||||
|
||||
const content = (message as { content?: unknown }).content;
|
||||
if (!content) return message;
|
||||
|
||||
// Handle string content
|
||||
if (typeof content === "string") {
|
||||
const masked = masker(content);
|
||||
return masked !== content ? ({ ...message, content: masked } as AgentMessage) : message;
|
||||
}
|
||||
|
||||
// Handle array content (text blocks, tool calls, etc.)
|
||||
if (Array.isArray(content)) {
|
||||
let changed = false;
|
||||
const newContent = content.map((block) => {
|
||||
if (!block || typeof block !== "object") return block;
|
||||
const rec = block as { type?: string; text?: string; arguments?: unknown };
|
||||
|
||||
// Handle text blocks
|
||||
if (rec.type === "text" && typeof rec.text === "string") {
|
||||
const masked = masker(rec.text);
|
||||
if (masked !== rec.text) {
|
||||
changed = true;
|
||||
return { ...rec, text: masked };
|
||||
}
|
||||
}
|
||||
|
||||
// Handle tool calls - mask PII in arguments and partialJson
|
||||
if (rec.type === "toolCall" || rec.type === "toolUse" || rec.type === "functionCall") {
|
||||
let blockChanged = false;
|
||||
let newBlock = { ...rec };
|
||||
|
||||
// Mask arguments object
|
||||
if (rec.arguments) {
|
||||
const result = maskPiiInValue(rec.arguments, masker);
|
||||
if (result.changed) {
|
||||
blockChanged = true;
|
||||
newBlock = { ...newBlock, arguments: result.value };
|
||||
}
|
||||
}
|
||||
|
||||
// Mask partialJson string (contains raw streamed JSON)
|
||||
const partialJson = (rec as { partialJson?: string }).partialJson;
|
||||
if (typeof partialJson === "string") {
|
||||
const masked = masker(partialJson);
|
||||
if (masked !== partialJson) {
|
||||
blockChanged = true;
|
||||
(newBlock as { partialJson?: string }).partialJson = masked;
|
||||
}
|
||||
}
|
||||
|
||||
if (blockChanged) {
|
||||
changed = true;
|
||||
return newBlock;
|
||||
}
|
||||
}
|
||||
|
||||
return block;
|
||||
});
|
||||
return changed ? ({ ...message, content: newContent } as AgentMessage) : message;
|
||||
}
|
||||
|
||||
return message;
|
||||
};
|
||||
}
|
||||
|
||||
export type GuardedSessionManager = SessionManager & {
|
||||
/** Flush any synthetic tool results for pending tool calls. Idempotent. */
|
||||
flushPendingToolResults?: () => void;
|
||||
@ -25,7 +132,7 @@ export function guardSessionManager(
|
||||
}
|
||||
|
||||
const hookRunner = getGlobalHookRunner();
|
||||
const transform = hookRunner?.hasHooks("tool_result_persist")
|
||||
const toolResultTransform = hookRunner?.hasHooks("tool_result_persist")
|
||||
? (message: any, meta: { toolCallId?: string; toolName?: string; isSynthetic?: boolean }) => {
|
||||
const out = hookRunner.runToolResultPersist(
|
||||
{
|
||||
@ -45,8 +152,12 @@ export function guardSessionManager(
|
||||
}
|
||||
: undefined;
|
||||
|
||||
// Create PII masking transform if a masker is registered
|
||||
const piiMaskingTransform = createPiiMaskingTransform();
|
||||
|
||||
const guard = installSessionToolResultGuard(sessionManager, {
|
||||
transformToolResultForPersistence: transform,
|
||||
transformToolResultForPersistence: toolResultTransform,
|
||||
transformMessageForPersistence: piiMaskingTransform,
|
||||
allowSyntheticToolResults: opts?.allowSyntheticToolResults,
|
||||
});
|
||||
(sessionManager as GuardedSessionManager).flushPendingToolResults = guard.flushPendingToolResults;
|
||||
|
||||
@ -44,6 +44,11 @@ export function installSessionToolResultGuard(
|
||||
message: AgentMessage,
|
||||
meta: { toolCallId?: string; toolName?: string; isSynthetic?: boolean },
|
||||
) => AgentMessage;
|
||||
/**
|
||||
* Optional, synchronous transform applied to ALL messages *before* they are
|
||||
* persisted to the session transcript. Useful for PII masking.
|
||||
*/
|
||||
transformMessageForPersistence?: (message: AgentMessage) => AgentMessage;
|
||||
/**
|
||||
* Whether to synthesize missing tool results to satisfy strict providers.
|
||||
* Defaults to true.
|
||||
@ -84,15 +89,20 @@ export function installSessionToolResultGuard(
|
||||
pending.clear();
|
||||
};
|
||||
|
||||
const transformMessage = opts?.transformMessageForPersistence;
|
||||
|
||||
const guardedAppend = (message: AgentMessage) => {
|
||||
const role = (message as { role?: unknown }).role;
|
||||
|
||||
// Apply general transform to all messages (for PII masking, etc.)
|
||||
const transformedMessage = transformMessage ? transformMessage(message) : message;
|
||||
|
||||
if (role === "toolResult") {
|
||||
const id = extractToolResultId(message as Extract<AgentMessage, { role: "toolResult" }>);
|
||||
const toolName = id ? pending.get(id) : undefined;
|
||||
if (id) pending.delete(id);
|
||||
return originalAppend(
|
||||
persistToolResult(message, {
|
||||
persistToolResult(transformedMessage, {
|
||||
toolCallId: id ?? undefined,
|
||||
toolName,
|
||||
isSynthetic: false,
|
||||
@ -116,7 +126,7 @@ export function installSessionToolResultGuard(
|
||||
}
|
||||
}
|
||||
|
||||
const result = originalAppend(message as never);
|
||||
const result = originalAppend(transformedMessage as never);
|
||||
|
||||
const sessionFile = (
|
||||
sessionManager as { getSessionFile?: () => string | null }
|
||||
|
||||
48
src/plugins/pii-masker.ts
Normal file
48
src/plugins/pii-masker.ts
Normal file
@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Global PII Masker Registry
|
||||
*
|
||||
* Allows plugins to register synchronous PII masking functions that are applied
|
||||
* to all messages before they are persisted to session transcripts.
|
||||
*
|
||||
* Uses globalThis to ensure a truly global singleton across all module instances.
|
||||
*/
|
||||
|
||||
type PiiMasker = (text: string) => string;
|
||||
|
||||
// Symbol key for global storage to avoid collisions
|
||||
const PII_MASKER_KEY = Symbol.for("moltbot.piiMasker");
|
||||
|
||||
type GlobalWithMasker = typeof globalThis & {
|
||||
[PII_MASKER_KEY]?: PiiMasker | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Register a PII masking function. Only one masker can be registered at a time.
|
||||
* Later registrations override earlier ones.
|
||||
*/
|
||||
export function registerPiiMasker(masker: PiiMasker): void {
|
||||
(globalThis as GlobalWithMasker)[PII_MASKER_KEY] = masker;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregister the current PII masker.
|
||||
*/
|
||||
export function unregisterPiiMasker(): void {
|
||||
(globalThis as GlobalWithMasker)[PII_MASKER_KEY] = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the registered PII masker, or null if none is registered.
|
||||
*/
|
||||
export function getPiiMasker(): PiiMasker | null {
|
||||
return (globalThis as GlobalWithMasker)[PII_MASKER_KEY] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply PII masking to text if a masker is registered.
|
||||
* Returns the original text if no masker is registered.
|
||||
*/
|
||||
export function maskPii(text: string): string {
|
||||
const masker = getPiiMasker();
|
||||
return masker ? masker(text) : text;
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user