// 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\n
Raw 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)'); }, };