// openclaw.ts — Private Mode plugin for OpenClaw.
// Controls: private mode toggle, memory status indicator, wipe scan/search/execute.
import { parseTimeRange, scanAll, searchAll, generateReport, generateSearchReport, executeWipe, generateWipeReport, type WipeAction } from './core.js';
import { getScannerIds, getAllScanners, registerDiscoveredLocations } from './locations.js';
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { homedir } from 'node:os';
const CONFIG_DIR = join(process.env.HOME || homedir(), '.openclaw');
const PRIVATE_MODE_PATH = join(CONFIG_DIR, 'memory', 'memory-capture-state.json');
// ── Private Mode State ──
function isPrivateMode(): boolean {
try {
if (existsSync(PRIVATE_MODE_PATH)) {
const state = JSON.parse(readFileSync(PRIVATE_MODE_PATH, 'utf-8'));
return state.enabled === false;
}
} catch {}
return false;
}
function getPrivateState(): { enabled: boolean; updatedAt?: string; updatedBy?: string } {
try {
if (existsSync(PRIVATE_MODE_PATH)) {
return JSON.parse(readFileSync(PRIVATE_MODE_PATH, 'utf-8'));
}
} catch {}
return { enabled: true };
}
function setPrivateMode(enabled: boolean, updatedBy: string): void {
const dir = dirname(PRIVATE_MODE_PATH);
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
writeFileSync(PRIVATE_MODE_PATH, JSON.stringify({
enabled,
updatedAt: new Date().toISOString(),
updatedBy,
}, null, 2));
}
function toolResult(text: string, isError = false) {
return {
content: [{ type: 'text' as const, text }],
...(isError ? { isError: true } : {}),
};
}
let lastScanResults: any = null;
let lastScanTimestamp: string | null = null;
export default {
register(api: any) {
// ── Hook: before_agent_start (memory status indicator) ──
api.on('before_agent_start', () => {
const privateMode = isPrivateMode();
const indicator = privateMode
? '( ) memory off — private mode active, no capture'
: '(*) memory on — capturing';
return { prependContext: indicator };
});
// ── Tool: private_mode_toggle ──
api.registerTool(
{
name: 'private_mode_toggle',
label: 'Private Mode',
description: 'Toggle private mode on or off. Private mode pauses all memory capture (conversation embeddings, crystal memories, CC hook). Use "private" to stop capture, "normal" to resume.',
parameters: {
type: 'object',
properties: {
mode: { type: 'string', enum: ['private', 'normal'], description: '"private" = pause all capture, "normal" = resume capture' },
},
required: ['mode'],
},
async execute(_id: string, params: any) {
const goPrivate = params.mode === 'private';
setPrivateMode(!goPrivate, 'agent-tool');
if (goPrivate) {
return toolResult('( ) Private mode ON. All memory capture paused. Nothing from this conversation will be stored.');
} else {
return toolResult('(*) Private mode OFF. Memory capture resumed.');
}
},
},
{ optional: true }
);
// ── Tool: wipe_scan ──
api.registerTool(
{
name: 'wipe_scan',
label: 'Wipe Scan',
description: 'Scan all storage locations for data in a time range. Auto-discovers storage locations on the system. Returns items found grouped by location.',
parameters: {
type: 'object',
properties: {
since: { type: 'string', description: 'Time range: "30m", "2h", "1d", or ISO timestamp' },
locations: { type: 'array', items: { type: 'string' }, description: 'Optional: scan specific locations only.' },
},
required: ['since'],
},
async execute(_id: string, params: any) {
try {
const since = parseTimeRange(params.since);
const results = scanAll(since, params.locations);
const report = generateReport(results, since);
lastScanResults = results;
lastScanTimestamp = new Date().toISOString();
const json = JSON.stringify(results, null, 2);
return toolResult(`${report}\n\n---\n\nRaw JSON
\n\n\`\`\`json\n${json}\n\`\`\`\n `);
} catch (err: any) {
return toolResult(`wipe_scan error: ${err.message}`, true);
}
},
},
{ optional: true }
);
// ── Tool: wipe_search ──
api.registerTool(
{
name: 'wipe_search',
label: 'Wipe Search',
description: 'Search all storage locations for specific keywords or phrases. Returns all matches with surrounding context.',
parameters: {
type: 'object',
properties: {
keywords: { type: 'array', items: { type: 'string' }, description: 'Keywords or phrases to search for.' },
locations: { type: 'array', items: { type: 'string' }, description: 'Optional: search specific locations only.' },
},
required: ['keywords'],
},
async execute(_id: string, params: any) {
try {
const keywords = params.keywords as string[];
if (keywords.length === 0) return toolResult('No keywords provided.', true);
const results = searchAll(keywords, params.locations);
const report = generateSearchReport(results, keywords);
return toolResult(report);
} catch (err: any) {
return toolResult(`wipe_search error: ${err.message}`, true);
}
},
},
{ optional: true }
);
// ── Tool: wipe_execute ──
api.registerTool(
{
name: 'wipe_execute',
label: 'Wipe Execute',
description: 'Execute approved wipe actions. Each action specifies a location, action (delete/move/skip), and item IDs from a previous scan or search.',
parameters: {
type: 'object',
properties: {
actions: {
type: 'array',
items: {
type: 'object',
properties: {
location: { type: 'string' },
action: { type: 'string', enum: ['delete', 'move', 'skip'] },
itemIds: { type: 'array', items: { type: 'string' } },
},
required: ['location', 'action', 'itemIds'],
},
},
},
required: ['actions'],
},
async execute(_id: string, params: any) {
try {
const actions = params.actions as WipeAction[];
const report = await executeWipe(actions);
const summary = generateWipeReport(report);
return toolResult(summary);
} catch (err: any) {
return toolResult(`wipe_execute error: ${err.message}`, true);
}
},
},
{ optional: true }
);
// ── CLI: private-mode ──
api.registerCli(
({ program }: any) => {
const pmCmd = program
.command('private-mode')
.description('Toggle memory capture on/off');
pmCmd.command('on')
.description('Pause all memory capture')
.action(() => { setPrivateMode(false, 'cli'); console.log('( ) Private mode ON — all memory capture paused'); });
pmCmd.command('off')
.description('Resume memory capture')
.action(() => { setPrivateMode(true, 'cli'); console.log('(*) Private mode OFF — memory capture resumed'); });
pmCmd.command('status')
.description('Check memory capture status')
.action(() => {
const state = getPrivateState();
if (state.enabled === false) {
console.log('( ) memory off — private mode active');
if (state.updatedAt) console.log(` Since: ${state.updatedAt}`);
if (state.updatedBy) console.log(` By: ${state.updatedBy}`);
} else {
console.log('(*) memory on — capturing');
}
});
},
{ commands: ['private-mode'] }
);
// ── CLI: wipe ──
api.registerCli(
({ program }: any) => {
const wipeCmd = program
.command('wipe')
.description('Scan and clean up agent history');
wipeCmd.command('scan')
.description('Scan for data in a time range')
.option('--last ', 'Time range: 30m, 2h, 1d')
.option('--since ', 'ISO timestamp')
.option('--json', 'Output raw JSON')
.option('--locations ', 'Comma-separated location IDs')
.action((opts: any) => {
const sinceStr = opts.last || opts.since;
if (!sinceStr) { console.error('Error: --last or --since required'); process.exit(1); }
try {
const since = parseTimeRange(sinceStr);
const locationFilter = opts.locations ? opts.locations.split(',').map((s: string) => s.trim()) : undefined;
const results = scanAll(since, locationFilter);
lastScanResults = results; lastScanTimestamp = new Date().toISOString();
console.log(opts.json ? JSON.stringify(results, null, 2) : generateReport(results, since));
} catch (err: any) { console.error(`Error: ${err.message}`); process.exit(1); }
});
wipeCmd.command('search')
.description('Search for keywords across all storage')
.requiredOption('--keywords ', 'Comma-separated keywords')
.option('--locations ', 'Comma-separated location IDs')
.option('--json', 'Output raw JSON')
.action((opts: any) => {
const keywords = opts.keywords.split(',').map((k: string) => k.trim()).filter(Boolean);
if (keywords.length === 0) { console.error('No keywords.'); process.exit(1); }
const locationFilter = opts.locations ? opts.locations.split(',').map((s: string) => s.trim()) : undefined;
const results = searchAll(keywords, locationFilter);
console.log(opts.json ? JSON.stringify(results, null, 2) : generateSearchReport(results, keywords));
});
wipeCmd.command('execute')
.description('Execute a saved wipe plan')
.requiredOption('--plan ', 'Path to wipe plan JSON')
.action(async (opts: any) => {
try {
const plan = JSON.parse(readFileSync(opts.plan, 'utf-8'));
const report = await executeWipe(plan.actions || plan);
console.log(generateWipeReport(report));
} catch (err: any) { console.error(`Error: ${err.message}`); process.exit(1); }
});
wipeCmd.command('locations')
.description('List all storage locations')
.option('--discover', 'Run auto-discovery')
.action((opts: any) => {
if (opts.discover) {
const found = registerDiscoveredLocations();
if (found.length > 0) { console.log(`Discovered ${found.length} additional location(s):\n${found.map(id => ` + ${id}`).join('\n')}\n`); }
}
const scanners = getAllScanners(true);
console.log('Storage locations:');
for (const s of scanners) {
const marker = s.exists() ? '(*)' : '( )';
const tag = s.id.startsWith('discovered:') ? ' [discovered]' : '';
console.log(` ${marker} ${s.id} — ${s.name}${tag}`);
}
});
},
{ commands: ['wipe'] }
);
// ── HTTP Endpoints ──
//
// OpenClaw v2026.4.2+ plugin HTTP route API:
// { path, handler, auth: "gateway" | "plugin", match?: "exact" | "prefix" }
// No per-method registration. Handlers see all methods for their path and
// dispatch on `req.method` themselves. `auth: "gateway"` means the gateway
// performs the operator-scope auth check before calling the handler.
const sendJson = (res: any, status: number, body: unknown) => {
res.writeHead(status, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(body));
};
const readJsonBody = (req: any): Promise => new Promise((resolve, reject) => {
let body = '';
req.on('data', (c: any) => { body += c; });
req.on('end', () => {
try { resolve(body ? JSON.parse(body) : {}); } catch { reject(new Error('invalid JSON')); }
});
req.on('error', reject);
});
const methodNotAllowed = (res: any, allowed: string) => {
res.writeHead(405, { 'Content-Type': 'application/json', 'Allow': allowed });
res.end(JSON.stringify({ error: `method not allowed; use ${allowed}` }));
};
try {
// /private-mode (GET: read state, POST: toggle)
api.registerHttpRoute({
path: '/private-mode',
auth: 'gateway',
handler: async (req: any, res: any) => {
if (req.method === 'GET') {
sendJson(res, 200, getPrivateState());
return;
}
if (req.method === 'POST') {
try {
const { enabled } = await readJsonBody(req);
if (typeof enabled !== 'boolean') { sendJson(res, 400, { error: 'enabled must be a boolean' }); return; }
setPrivateMode(enabled, 'http');
sendJson(res, 200, getPrivateState());
} catch (err: any) { sendJson(res, 400, { error: err.message || 'invalid JSON' }); }
return;
}
methodNotAllowed(res, 'GET, POST');
},
});
// GET /memory-status
api.registerHttpRoute({
path: '/memory-status',
auth: 'gateway',
handler: (req: any, res: any) => {
if (req.method !== 'GET') { methodNotAllowed(res, 'GET'); return; }
const privateMode = isPrivateMode();
sendJson(res, 200, {
memoryCapture: !privateMode,
indicator: privateMode ? '( ) memory off' : '(*) memory on',
privateMode: getPrivateState(),
});
},
});
// POST /wipe/scan
api.registerHttpRoute({
path: '/wipe/scan',
auth: 'gateway',
handler: async (req: any, res: any) => {
if (req.method !== 'POST') { methodNotAllowed(res, 'POST'); return; }
try {
const { since, locations } = await readJsonBody(req);
if (!since) { sendJson(res, 400, { error: 'since required' }); return; }
const results = scanAll(parseTimeRange(since), locations);
lastScanResults = results; lastScanTimestamp = new Date().toISOString();
sendJson(res, 200, { since: parseTimeRange(since).toISOString(), results });
} catch (err: any) { sendJson(res, 400, { error: err.message }); }
},
});
// POST /wipe/search
api.registerHttpRoute({
path: '/wipe/search',
auth: 'gateway',
handler: async (req: any, res: any) => {
if (req.method !== 'POST') { methodNotAllowed(res, 'POST'); return; }
try {
const { keywords, locations } = await readJsonBody(req);
if (!keywords || !Array.isArray(keywords)) { sendJson(res, 400, { error: 'keywords array required' }); return; }
const results = searchAll(keywords, locations);
sendJson(res, 200, { keywords, results });
} catch (err: any) { sendJson(res, 400, { error: err.message }); }
},
});
// POST /wipe/execute
api.registerHttpRoute({
path: '/wipe/execute',
auth: 'gateway',
handler: async (req: any, res: any) => {
if (req.method !== 'POST') { methodNotAllowed(res, 'POST'); return; }
try {
const { actions } = await readJsonBody(req);
if (!actions) { sendJson(res, 400, { error: 'actions required' }); return; }
const report = await executeWipe(actions);
sendJson(res, 200, report);
} catch (err: any) { sendJson(res, 400, { error: err.message }); }
},
});
// GET /wipe/status
api.registerHttpRoute({
path: '/wipe/status',
auth: 'gateway',
handler: (req: any, res: any) => {
if (req.method !== 'GET') { methodNotAllowed(res, 'GET'); return; }
sendJson(res, 200, { lastScan: lastScanTimestamp, results: lastScanResults });
},
});
} catch {
api.logger.warn('private-mode: HTTP routes not registered (API not available)');
}
api.logger.info('private-mode plugin registered (toggle, status indicator, wipe)');
},
};