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:
Olow304 2026-01-27 18:21:20 -05:00
parent 3fe4b2595a
commit 5d499c552b
8 changed files with 3956 additions and 4 deletions

View 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"
}
}
}
}

View 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;
}

View 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

File diff suppressed because it is too large Load Diff

View 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": "*"
}
}

View File

@ -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;

View File

@ -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
View 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;
}