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 type { SessionManager } from "@mariozechner/pi-coding-agent";
|
||||||
|
|
||||||
import { getGlobalHookRunner } from "../plugins/hook-runner-global.js";
|
import { getGlobalHookRunner } from "../plugins/hook-runner-global.js";
|
||||||
|
import { getPiiMasker } from "../plugins/pii-masker.js";
|
||||||
import { installSessionToolResultGuard } from "./session-tool-result-guard.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 & {
|
export type GuardedSessionManager = SessionManager & {
|
||||||
/** Flush any synthetic tool results for pending tool calls. Idempotent. */
|
/** Flush any synthetic tool results for pending tool calls. Idempotent. */
|
||||||
flushPendingToolResults?: () => void;
|
flushPendingToolResults?: () => void;
|
||||||
@ -25,7 +132,7 @@ export function guardSessionManager(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const hookRunner = getGlobalHookRunner();
|
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 }) => {
|
? (message: any, meta: { toolCallId?: string; toolName?: string; isSynthetic?: boolean }) => {
|
||||||
const out = hookRunner.runToolResultPersist(
|
const out = hookRunner.runToolResultPersist(
|
||||||
{
|
{
|
||||||
@ -45,8 +152,12 @@ export function guardSessionManager(
|
|||||||
}
|
}
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
|
// Create PII masking transform if a masker is registered
|
||||||
|
const piiMaskingTransform = createPiiMaskingTransform();
|
||||||
|
|
||||||
const guard = installSessionToolResultGuard(sessionManager, {
|
const guard = installSessionToolResultGuard(sessionManager, {
|
||||||
transformToolResultForPersistence: transform,
|
transformToolResultForPersistence: toolResultTransform,
|
||||||
|
transformMessageForPersistence: piiMaskingTransform,
|
||||||
allowSyntheticToolResults: opts?.allowSyntheticToolResults,
|
allowSyntheticToolResults: opts?.allowSyntheticToolResults,
|
||||||
});
|
});
|
||||||
(sessionManager as GuardedSessionManager).flushPendingToolResults = guard.flushPendingToolResults;
|
(sessionManager as GuardedSessionManager).flushPendingToolResults = guard.flushPendingToolResults;
|
||||||
|
|||||||
@ -44,6 +44,11 @@ export function installSessionToolResultGuard(
|
|||||||
message: AgentMessage,
|
message: AgentMessage,
|
||||||
meta: { toolCallId?: string; toolName?: string; isSynthetic?: boolean },
|
meta: { toolCallId?: string; toolName?: string; isSynthetic?: boolean },
|
||||||
) => AgentMessage;
|
) => 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.
|
* Whether to synthesize missing tool results to satisfy strict providers.
|
||||||
* Defaults to true.
|
* Defaults to true.
|
||||||
@ -84,15 +89,20 @@ export function installSessionToolResultGuard(
|
|||||||
pending.clear();
|
pending.clear();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const transformMessage = opts?.transformMessageForPersistence;
|
||||||
|
|
||||||
const guardedAppend = (message: AgentMessage) => {
|
const guardedAppend = (message: AgentMessage) => {
|
||||||
const role = (message as { role?: unknown }).role;
|
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") {
|
if (role === "toolResult") {
|
||||||
const id = extractToolResultId(message as Extract<AgentMessage, { role: "toolResult" }>);
|
const id = extractToolResultId(message as Extract<AgentMessage, { role: "toolResult" }>);
|
||||||
const toolName = id ? pending.get(id) : undefined;
|
const toolName = id ? pending.get(id) : undefined;
|
||||||
if (id) pending.delete(id);
|
if (id) pending.delete(id);
|
||||||
return originalAppend(
|
return originalAppend(
|
||||||
persistToolResult(message, {
|
persistToolResult(transformedMessage, {
|
||||||
toolCallId: id ?? undefined,
|
toolCallId: id ?? undefined,
|
||||||
toolName,
|
toolName,
|
||||||
isSynthetic: false,
|
isSynthetic: false,
|
||||||
@ -116,7 +126,7 @@ export function installSessionToolResultGuard(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = originalAppend(message as never);
|
const result = originalAppend(transformedMessage as never);
|
||||||
|
|
||||||
const sessionFile = (
|
const sessionFile = (
|
||||||
sessionManager as { getSessionFile?: () => string | null }
|
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