// locations.ts — Storage location scanners for the wipe function. // Dynamic registry: built-in scanners + auto-discovery + external registration. import Database from 'better-sqlite3'; import * as lancedb from '@lancedb/lancedb'; import { existsSync, readFileSync, readdirSync, statSync, unlinkSync, mkdirSync, renameSync, writeFileSync } from 'node:fs'; import { join, basename, extname } from 'node:path'; import { homedir } from 'node:os'; const HOME = process.env.HOME || homedir(); const OC_DIR = join(HOME, '.openclaw'); function resolveWorkspace(): string { const configPath = join(HOME, '.ldm', 'config.json'); if (existsSync(configPath)) { try { const config = JSON.parse(readFileSync(configPath, 'utf-8')); if (config.workspace) return config.workspace; } catch {} } return join(HOME, 'wipcomputerinc'); } // ── Types ── export interface ScanItem { id: string; timestamp: string; preview: string; size?: number; type: 'text' | 'file' | 'image' | 'video' | 'db-row'; } export interface ScanResult { location: string; itemCount: number; items: ScanItem[]; canAutoDelete: boolean; note?: string; } export interface WipeResult { location: string; deleted: number; failed: number; skipped: number; errors: string[]; } export interface SearchMatch { id: string; location: string; timestamp?: string; context: string; // Text surrounding the match matchedTerm: string; // Which keyword matched type: 'text' | 'file' | 'db-row'; } export interface SearchResult { location: string; matchCount: number; matches: SearchMatch[]; note?: string; } export interface LocationScanner { id: string; name: string; /** Returns true if the underlying storage exists on this system */ exists(): boolean; scan(since: Date): ScanResult; search(keywords: string[]): SearchResult; wipe(itemIds: string[]): WipeResult | Promise; } // ── Dynamic Registry ── const registry: Map = new Map(); /** Register a scanner. Overwrites if same ID already exists. */ export function registerScanner(scanner: LocationScanner): void { registry.set(scanner.id, scanner); } /** Unregister a scanner by ID. */ export function unregisterScanner(id: string): void { registry.delete(id); } /** Get all registered scanners. Only returns those whose storage exists on the system. */ export function getAllScanners(includeAbsent = false): LocationScanner[] { const all = Array.from(registry.values()); return includeAbsent ? all : all.filter(s => s.exists()); } /** Get a scanner by ID. */ export function getScanner(id: string): LocationScanner | undefined { return registry.get(id); } /** Get IDs of all registered scanners (including absent ones). */ export function getScannerIds(): string[] { return Array.from(registry.keys()); } /** Get IDs of scanners that exist on this system. */ export function getActiveScannerIds(): string[] { return getAllScanners().map(s => s.id); } // ── Auto-Discovery ── /** * Scan the system for additional storage locations not covered by built-in scanners. * Looks for: SQLite databases in common dirs, JSON state files, log directories, etc. */ export function autoDiscover(): { id: string; path: string; type: string }[] { const found: { id: string; path: string; type: string }[] = []; const knownPaths = new Set(); // Collect all paths already covered by registered scanners // (We check common known paths to avoid duplicates) const builtinPaths = [ join(OC_DIR, 'memory', 'context-embeddings.sqlite'), join(OC_DIR, 'memory-crystal', 'crystal.db'), join(OC_DIR, 'memory-crystal', 'lance'), join(OC_DIR, 'agents', 'main', 'sessions'), join(OC_DIR, 'workspace', 'memory'), join(HOME, 'Library', 'Messages'), ]; builtinPaths.forEach(p => knownPaths.add(p)); // 1. Check ~/.openclaw/memory/ for unknown SQLite databases const memoryDir = join(OC_DIR, 'memory'); if (existsSync(memoryDir)) { try { for (const file of readdirSync(memoryDir)) { const full = join(memoryDir, file); if ((file.endsWith('.sqlite') || file.endsWith('.db')) && !knownPaths.has(full)) { found.push({ id: `discovered:${file}`, path: full, type: 'sqlite' }); } } } catch {} } // 2. Check ~/.openclaw/extensions/ for plugin data directories const extDir = join(OC_DIR, 'extensions'); if (existsSync(extDir)) { try { for (const plugin of readdirSync(extDir)) { const pluginDir = join(extDir, plugin); const dataDir = join(pluginDir, 'data'); if (existsSync(dataDir) && !knownPaths.has(dataDir)) { found.push({ id: `discovered:ext:${plugin}`, path: dataDir, type: 'directory' }); } } } catch {} } // 3. Check for OpenClaw built-in memory SQLite const builtinMemory = join(OC_DIR, 'memory', 'main.sqlite'); if (existsSync(builtinMemory) && !knownPaths.has(builtinMemory)) { found.push({ id: 'discovered:builtin-memory', path: builtinMemory, type: 'sqlite' }); } // 4. Check for Claude Code conversation history const claudeDir = join(HOME, '.claude'); if (existsSync(claudeDir)) { const projectsDir = join(claudeDir, 'projects'); if (existsSync(projectsDir)) { found.push({ id: 'discovered:claude-code-sessions', path: projectsDir, type: 'directory' }); } } return found; } /** * Register auto-discovered locations as GenericFileScanner or GenericSqliteScanner instances. */ export function registerDiscoveredLocations(): string[] { const discovered = autoDiscover(); const registered: string[] = []; for (const loc of discovered) { if (registry.has(loc.id)) continue; if (loc.type === 'sqlite') { registerScanner(new GenericSqliteScanner(loc.id, `Discovered: ${basename(loc.path)}`, loc.path)); registered.push(loc.id); } else if (loc.type === 'directory') { registerScanner(new GenericDirectoryScanner(loc.id, `Discovered: ${basename(loc.path)}`, loc.path)); registered.push(loc.id); } } return registered; } // ── Helpers ── /** Build a case-insensitive regex that matches any of the keywords */ function keywordRegex(keywords: string[]): RegExp { const escaped = keywords.map(k => k.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')); return new RegExp(`(${escaped.join('|')})`, 'gi'); } /** Extract match context: ~50 chars before and after the match */ function matchContext(text: string, matchIndex: number, matchLen: number): string { const start = Math.max(0, matchIndex - 50); const end = Math.min(text.length, matchIndex + matchLen + 50); let ctx = text.slice(start, end).replace(/\n/g, ' '); if (start > 0) ctx = '...' + ctx; if (end < text.length) ctx = ctx + '...'; return ctx; } /** Search text for keywords, return all matches with context */ function findMatches(text: string, keywords: string[], id: string, location: string, timestamp?: string, type: 'text' | 'file' | 'db-row' = 'text'): SearchMatch[] { const regex = keywordRegex(keywords); const matches: SearchMatch[] = []; let m: RegExpExecArray | null; const seen = new Set(); // dedupe by position while ((m = regex.exec(text)) !== null) { const key = `${m.index}:${m[0]}`; if (seen.has(key)) continue; seen.add(key); matches.push({ id, location, timestamp, context: matchContext(text, m.index, m[0].length), matchedTerm: m[0], type, }); } return matches; } function preview(text: string, maxLen = 100): string { const clean = text.replace(/\n/g, ' ').trim(); return clean.length > maxLen ? clean.slice(0, maxLen) + '...' : clean; } function safeOpenDb(path: string, readonly = false): Database.Database | null { try { if (!existsSync(path)) return null; return new Database(path, { readonly }); } catch { return null; } } // ── Generic Scanners (for auto-discovered locations) ── export class GenericSqliteScanner implements LocationScanner { constructor(public id: string, public name: string, private dbPath: string) {} exists(): boolean { return existsSync(this.dbPath); } search(keywords: string[]): SearchResult { const db = safeOpenDb(this.dbPath, true); if (!db) return { location: this.id, matchCount: 0, matches: [] }; try { const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all() as any[]; const matches: SearchMatch[] = []; for (const table of tables) { const cols = db.prepare(`PRAGMA table_info(${table.name})`).all() as any[]; const textCols = cols.filter((c: any) => ['text', 'chunk_text', 'content', 'body', 'message', 'name', 'title', 'description'].includes(c.name)); if (textCols.length === 0) continue; for (const col of textCols) { const clauses = keywords.map(() => `${col.name} LIKE ?`); const params = keywords.map(k => `%${k}%`); try { const rows = db.prepare(`SELECT rowid, ${col.name} FROM ${table.name} WHERE ${clauses.join(' OR ')} LIMIT 100`).all(...params) as any[]; for (const row of rows) { matches.push(...findMatches(String(row[col.name]), keywords, `${table.name}:${row.rowid}`, this.id, undefined, 'db-row')); } } catch {} } } return { location: this.id, matchCount: matches.length, matches }; } finally { db.close(); } } scan(since: Date): ScanResult { const db = safeOpenDb(this.dbPath, true); if (!db) return { location: this.id, itemCount: 0, items: [], canAutoDelete: false, note: 'Database not found' }; try { // Auto-detect tables and time columns const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all() as any[]; const items: ScanItem[] = []; for (const table of tables) { const cols = db.prepare(`PRAGMA table_info(${table.name})`).all() as any[]; const timeCol = cols.find((c: any) => ['timestamp', 'created_at', 'updated_at', 'date', 'time', 'last_capture_at'].includes(c.name) ); if (!timeCol) continue; try { const rows = db.prepare( `SELECT rowid, * FROM ${table.name} WHERE ${timeCol.name} >= ? LIMIT 100` ).all(since.toISOString()) as any[]; for (const row of rows) { // Build preview from first text-like column const textCol = cols.find((c: any) => ['text', 'chunk_text', 'content', 'body', 'message', 'name', 'title'].includes(c.name)); const previewText = textCol && row[textCol.name] ? preview(String(row[textCol.name])) : `[row ${row.rowid}]`; items.push({ id: `${table.name}:${row.rowid}`, timestamp: row[timeCol.name] || since.toISOString(), preview: `[${table.name}] ${previewText}`, type: 'db-row', }); } } catch { // skip tables we can't query } } return { location: this.id, itemCount: items.length, items, canAutoDelete: false, note: 'Auto-discovered database. Review before deleting.' }; } finally { db.close(); } } wipe(_itemIds: string[]): WipeResult { return { location: this.id, deleted: 0, failed: 0, skipped: 0, errors: ['Auto-discovered location. Manual review required.'] }; } } export class GenericDirectoryScanner implements LocationScanner { constructor(public id: string, public name: string, private dirPath: string) {} exists(): boolean { return existsSync(this.dirPath); } search(keywords: string[]): SearchResult { if (!existsSync(this.dirPath)) return { location: this.id, matchCount: 0, matches: [] }; const regex = keywordRegex(keywords); const matches: SearchMatch[] = []; const searchDir = (dir: string, depth: number) => { if (depth <= 0 || matches.length > 500) return; try { for (const entry of readdirSync(dir)) { if (entry.startsWith('.') || entry === 'node_modules') continue; const full = join(dir, entry); try { const stat = statSync(full); if (stat.isDirectory()) { searchDir(full, depth - 1); } else if (stat.size < 1024 * 1024) { // skip files > 1MB const ext = extname(entry).toLowerCase(); if (['.md', '.txt', '.json', '.jsonl', '.log', '.csv'].includes(ext)) { const content = readFileSync(full, 'utf-8'); regex.lastIndex = 0; if (regex.test(content)) { matches.push(...findMatches(content, keywords, full, this.id, stat.mtime.toISOString(), 'file')); } } } } catch {} } } catch {} }; searchDir(this.dirPath, 3); return { location: this.id, matchCount: matches.length, matches }; } scan(since: Date): ScanResult { if (!existsSync(this.dirPath)) { return { location: this.id, itemCount: 0, items: [], canAutoDelete: false, note: 'Directory not found' }; } const items: ScanItem[] = []; this.walkDir(this.dirPath, since, items, 3); // max depth 3 return { location: this.id, itemCount: items.length, items, canAutoDelete: false, note: 'Auto-discovered directory. Review before deleting.' }; } private walkDir(dir: string, since: Date, items: ScanItem[], maxDepth: number) { if (maxDepth <= 0) return; try { for (const entry of readdirSync(dir)) { if (entry.startsWith('.') || entry === 'node_modules') continue; const full = join(dir, entry); try { const stat = statSync(full); if (stat.isDirectory()) { this.walkDir(full, since, items, maxDepth - 1); } else if (stat.mtime >= since) { items.push({ id: full, timestamp: stat.mtime.toISOString(), preview: `${entry} (${(stat.size / 1024).toFixed(1)}KB)`, size: stat.size, type: 'file', }); } } catch {} } } catch {} } wipe(_itemIds: string[]): WipeResult { return { location: this.id, deleted: 0, failed: 0, skipped: 0, errors: ['Auto-discovered location. Manual review required.'] }; } } // ── Built-in Scanners ── // 1. Context Embeddings (SQLite) export class ContextEmbeddingsScanner implements LocationScanner { id = 'context-embeddings'; name = 'Context Embeddings'; private dbPath = join(OC_DIR, 'memory', 'context-embeddings.sqlite'); exists(): boolean { return existsSync(this.dbPath); } search(keywords: string[]): SearchResult { const db = safeOpenDb(this.dbPath, true); if (!db) return { location: this.id, matchCount: 0, matches: [] }; try { // Build SQL LIKE clauses for each keyword const clauses = keywords.map(() => 'chunk_text LIKE ?'); const params = keywords.map(k => `%${k}%`); const rows = db.prepare( `SELECT rowid, chunk_text, role, timestamp FROM conversation_chunks WHERE ${clauses.join(' OR ')} ORDER BY timestamp DESC LIMIT 200` ).all(...params) as any[]; const matches: SearchMatch[] = []; for (const row of rows) { matches.push(...findMatches(row.chunk_text, keywords, String(row.rowid), this.id, row.timestamp, 'db-row')); } return { location: this.id, matchCount: matches.length, matches }; } finally { db.close(); } } scan(since: Date): ScanResult { const db = safeOpenDb(this.dbPath); if (!db) return { location: this.id, itemCount: 0, items: [], canAutoDelete: true, note: 'Database not found' }; try { const rows = db.prepare( 'SELECT rowid, chunk_text, role, timestamp, agent_id FROM conversation_chunks WHERE timestamp >= ? ORDER BY timestamp ASC' ).all(since.toISOString()) as any[]; const items: ScanItem[] = rows.map(r => ({ id: String(r.rowid), timestamp: r.timestamp, preview: `(${r.role}) ${preview(r.chunk_text)}`, type: 'db-row' as const, })); return { location: this.id, itemCount: items.length, items, canAutoDelete: true }; } finally { db.close(); } } wipe(itemIds: string[]): WipeResult { const db = safeOpenDb(this.dbPath); if (!db) return { location: this.id, deleted: 0, failed: 0, skipped: 0, errors: ['Database not found'] }; try { const placeholders = itemIds.map(() => '?').join(','); const result = db.prepare(`DELETE FROM conversation_chunks WHERE rowid IN (${placeholders})`).run(...itemIds); return { location: this.id, deleted: result.changes, failed: 0, skipped: 0, errors: [] }; } catch (err: any) { return { location: this.id, deleted: 0, failed: itemIds.length, skipped: 0, errors: [err.message] }; } finally { db.close(); } } } // 2. Crystal Memories (SQLite) export class CrystalMemoriesScanner implements LocationScanner { id = 'crystal-memories'; name = 'Crystal Memories'; private dbPath = join(OC_DIR, 'memory-crystal', 'crystal.db'); exists(): boolean { return existsSync(this.dbPath); } search(keywords: string[]): SearchResult { const db = safeOpenDb(this.dbPath, true); if (!db) return { location: this.id, matchCount: 0, matches: [] }; try { const clauses = keywords.map(() => 'text LIKE ?'); const params = keywords.map(k => `%${k}%`); const rows = db.prepare( `SELECT id, text, category, created_at FROM memories WHERE (${clauses.join(' OR ')}) AND status != 'deprecated' ORDER BY created_at DESC LIMIT 200` ).all(...params) as any[]; const matches: SearchMatch[] = []; for (const row of rows) { matches.push(...findMatches(row.text, keywords, String(row.id), this.id, row.created_at, 'db-row')); } return { location: this.id, matchCount: matches.length, matches }; } finally { db.close(); } } scan(since: Date): ScanResult { const db = safeOpenDb(this.dbPath); if (!db) return { location: this.id, itemCount: 0, items: [], canAutoDelete: true, note: 'Database not found' }; try { const rows = db.prepare( "SELECT id, text, category, created_at FROM memories WHERE created_at >= ? AND status != 'deprecated' ORDER BY created_at ASC" ).all(since.toISOString()) as any[]; const items: ScanItem[] = rows.map(r => ({ id: String(r.id), timestamp: r.created_at, preview: `[${r.category}] ${preview(r.text)}`, type: 'db-row' as const, })); return { location: this.id, itemCount: items.length, items, canAutoDelete: true }; } finally { db.close(); } } wipe(itemIds: string[]): WipeResult { const db = safeOpenDb(this.dbPath); if (!db) return { location: this.id, deleted: 0, failed: 0, skipped: 0, errors: ['Database not found'] }; try { const placeholders = itemIds.map(() => '?').join(','); const result = db.prepare( `UPDATE memories SET status = 'deprecated', updated_at = ? WHERE id IN (${placeholders})` ).run(new Date().toISOString(), ...itemIds); return { location: this.id, deleted: result.changes, failed: 0, skipped: 0, errors: [] }; } catch (err: any) { return { location: this.id, deleted: 0, failed: itemIds.length, skipped: 0, errors: [err.message] }; } finally { db.close(); } } } // 3. Crystal Capture State (SQLite) export class CrystalCaptureStateScanner implements LocationScanner { id = 'crystal-capture-state'; name = 'Crystal Capture State'; private dbPath = join(OC_DIR, 'memory-crystal', 'crystal.db'); exists(): boolean { return existsSync(this.dbPath); } search(_keywords: string[]): SearchResult { // Capture state doesn't contain searchable text content return { location: this.id, matchCount: 0, matches: [], note: 'Capture state contains metadata only, not searchable text.' }; } scan(since: Date): ScanResult { const db = safeOpenDb(this.dbPath); if (!db) return { location: this.id, itemCount: 0, items: [], canAutoDelete: true, note: 'Database not found' }; try { const rows = db.prepare( 'SELECT rowid, agent_id, source_id, last_message_count, capture_count, last_capture_at FROM capture_state WHERE last_capture_at >= ?' ).all(since.toISOString()) as any[]; const items: ScanItem[] = rows.map(r => ({ id: String(r.rowid), timestamp: r.last_capture_at, preview: `${r.agent_id}/${r.source_id}: ${r.last_message_count} msgs, ${r.capture_count} captures`, type: 'db-row' as const, })); return { location: this.id, itemCount: items.length, items, canAutoDelete: true, note: 'Wipe resets capture positions' }; } finally { db.close(); } } wipe(itemIds: string[]): WipeResult { const db = safeOpenDb(this.dbPath); if (!db) return { location: this.id, deleted: 0, failed: 0, skipped: 0, errors: ['Database not found'] }; try { const placeholders = itemIds.map(() => '?').join(','); const result = db.prepare( `UPDATE capture_state SET last_message_count = 0, capture_count = 0 WHERE rowid IN (${placeholders})` ).run(...itemIds); return { location: this.id, deleted: result.changes, failed: 0, skipped: 0, errors: [] }; } catch (err: any) { return { location: this.id, deleted: 0, failed: itemIds.length, skipped: 0, errors: [err.message] }; } finally { db.close(); } } } // 4. LanceDB Chunks export class LanceDBScanner implements LocationScanner { id = 'lancedb-chunks'; name = 'LanceDB Vector Chunks'; private dataDir = join(OC_DIR, 'memory-crystal', 'lance'); exists(): boolean { return existsSync(join(this.dataDir, 'chunks.lance')); } search(keywords: string[]): SearchResult { // LanceDB stores text alongside vectors. Do a sync scan of the SQLite metadata instead. // For actual vector search, use crystal_search. return { location: this.id, matchCount: 0, matches: [], note: 'Use crystal_search for semantic search. Text keyword search not available on vectors.' }; } scan(since: Date): ScanResult { // Use the crystal.db sources table to estimate what was ingested in the time range. // Also check txn files for recency. const txnDir = join(this.dataDir, 'chunks.lance', '_transactions'); if (!existsSync(txnDir)) { return { location: this.id, itemCount: 0, items: [], canAutoDelete: true, note: 'LanceDB directory not found' }; } try { const files = readdirSync(txnDir).filter(f => f.endsWith('.txn')); const recent = files.filter(f => { const stat = statSync(join(txnDir, f)); return stat.mtime >= since; }); const items: ScanItem[] = recent.map(f => { const stat = statSync(join(txnDir, f)); return { id: `txn:${f}`, timestamp: stat.mtime.toISOString(), preview: `Vector transaction: ${f}`, size: stat.size, type: 'file' as const }; }); return { location: this.id, itemCount: items.length, items, canAutoDelete: true, note: 'Wipe deletes vectors by timestamp from LanceDB using the SDK.', }; } catch (err: any) { return { location: this.id, itemCount: 0, items: [], canAutoDelete: true, note: `Error: ${err.message}` }; } } async wipe(itemIds: string[]): Promise { // Extract the earliest timestamp from itemIds to build a time-based filter. // itemIds are "txn:" from scan, but we delete by time range from the actual table. if (itemIds.length === 0) { return { location: this.id, deleted: 0, failed: 0, skipped: 0, errors: [] }; } try { const db = await lancedb.connect(this.dataDir); const tableNames = await db.tableNames(); if (!tableNames.includes('chunks')) { return { location: this.id, deleted: 0, failed: 0, skipped: 0, errors: ['chunks table not found'] }; } const table = await db.openTable('chunks'); // Find the earliest timestamp among the txn files to use as the delete threshold. const txnDir = join(this.dataDir, 'chunks.lance', '_transactions'); let earliest = new Date(); for (const itemId of itemIds) { const filename = itemId.startsWith('txn:') ? itemId.slice(4) : itemId; const txnPath = join(txnDir, filename); if (existsSync(txnPath)) { const stat = statSync(txnPath); if (stat.mtime < earliest) earliest = stat.mtime; } } // Count rows before deletion const beforeCount = await table.countRows(); // Delete all chunks created at or after the earliest timestamp await table.delete(`created_at >= '${earliest.toISOString()}'`); const afterCount = await table.countRows(); const deleted = beforeCount - afterCount; return { location: this.id, deleted, failed: 0, skipped: 0, errors: [] }; } catch (err: any) { return { location: this.id, deleted: 0, failed: itemIds.length, skipped: 0, errors: [`LanceDB delete error: ${err.message}`] }; } } } // 5. Session JSONL Files export class SessionFilesScanner implements LocationScanner { id = 'session-files'; name = 'Session JSONL Files'; private sessionsDir = join(OC_DIR, 'agents', 'main', 'sessions'); exists(): boolean { return existsSync(this.sessionsDir); } search(keywords: string[]): SearchResult { if (!this.exists()) return { location: this.id, matchCount: 0, matches: [] }; const regex = keywordRegex(keywords); const files = readdirSync(this.sessionsDir).filter(f => f.endsWith('.jsonl')); const matches: SearchMatch[] = []; for (const file of files) { try { const content = readFileSync(join(this.sessionsDir, file), 'utf-8'); if (!regex.test(content)) continue; // fast skip regex.lastIndex = 0; const lines = content.split('\n'); for (const line of lines) { if (!line.trim()) continue; regex.lastIndex = 0; if (!regex.test(line)) continue; regex.lastIndex = 0; try { const obj = JSON.parse(line); const text = typeof obj.message?.content === 'string' ? obj.message.content : Array.isArray(obj.message?.content) ? obj.message.content.filter((b: any) => b.type === 'text').map((b: any) => b.text).join('\n') : line; matches.push(...findMatches(text, keywords, file, this.id, obj.timestamp, 'text')); } catch { matches.push(...findMatches(line, keywords, file, this.id, undefined, 'text')); } } } catch {} if (matches.length > 500) break; // safety limit } return { location: this.id, matchCount: matches.length, matches }; } scan(since: Date): ScanResult { if (!this.exists()) { return { location: this.id, itemCount: 0, items: [], canAutoDelete: true, note: 'Sessions directory not found' }; } const files = readdirSync(this.sessionsDir).filter(f => f.endsWith('.jsonl')); const items: ScanItem[] = []; for (const file of files) { const filePath = join(this.sessionsDir, file); const stat = statSync(filePath); if (stat.mtime < since) continue; try { const content = readFileSync(filePath, 'utf-8'); const lines = content.split('\n').filter(l => l.trim()); let linesInRange = 0; for (const line of lines) { try { const obj = JSON.parse(line); if (obj.timestamp && new Date(obj.timestamp) >= since) linesInRange++; } catch {} } if (linesInRange > 0) { items.push({ id: file, timestamp: stat.mtime.toISOString(), preview: `${file}: ${linesInRange} messages in range (${lines.length} total)`, size: stat.size, type: 'file' as const, }); } } catch {} } return { location: this.id, itemCount: items.length, items, canAutoDelete: true }; } wipe(itemIds: string[]): WipeResult { let deleted = 0, failed = 0; const errors: string[] = []; for (const fileId of itemIds) { const filePath = join(this.sessionsDir, fileId); try { if (existsSync(filePath)) { unlinkSync(filePath); deleted++; } } catch (err: any) { failed++; errors.push(`${fileId}: ${err.message}`); } } return { location: this.id, deleted, failed, skipped: 0, errors }; } } // 6. Workspace Memory Files export class WorkspaceMemoryScanner implements LocationScanner { id = 'workspace-memory'; name = 'Workspace Memory Files'; private memoryDir = join(OC_DIR, 'workspace', 'memory'); private memoryFile = join(OC_DIR, 'workspace', 'MEMORY.md'); exists(): boolean { return existsSync(this.memoryDir) || existsSync(this.memoryFile); } search(keywords: string[]): SearchResult { const regex = keywordRegex(keywords); const matches: SearchMatch[] = []; const filesToCheck: { id: string; path: string }[] = []; if (existsSync(this.memoryFile)) filesToCheck.push({ id: 'MEMORY.md', path: this.memoryFile }); if (existsSync(this.memoryDir)) { for (const f of readdirSync(this.memoryDir).filter(f => f.endsWith('.md'))) { filesToCheck.push({ id: `memory/${f}`, path: join(this.memoryDir, f) }); } } for (const file of filesToCheck) { try { const content = readFileSync(file.path, 'utf-8'); regex.lastIndex = 0; if (!regex.test(content)) continue; const stat = statSync(file.path); matches.push(...findMatches(content, keywords, file.id, this.id, stat.mtime.toISOString(), 'file')); } catch {} } return { location: this.id, matchCount: matches.length, matches }; } /** Parse a markdown file into sections by ## headers. Returns array of { header, body, startLine }. */ private parseSections(content: string): { header: string; body: string; startLine: number }[] { const lines = content.split('\n'); const sections: { header: string; body: string; startLine: number }[] = []; let currentHeader = ''; let currentBody: string[] = []; let currentStart = 0; for (let i = 0; i < lines.length; i++) { if (lines[i].startsWith('## ')) { // Save previous section if any if (currentHeader || currentBody.length > 0) { sections.push({ header: currentHeader, body: currentBody.join('\n'), startLine: currentStart }); } currentHeader = lines[i]; currentBody = []; currentStart = i; } else { currentBody.push(lines[i]); } } // Save last section if (currentHeader || currentBody.length > 0) { sections.push({ header: currentHeader, body: currentBody.join('\n'), startLine: currentStart }); } return sections; } /** Extract timestamp from a ## header like "## [09:30] Claude Code: summary" or "## [HH:MM] Agent: ..." */ private extractTimeFromHeader(header: string, fileDate: string): Date | null { const match = header.match(/##\s*\[(\d{1,2}):(\d{2})\]/); if (!match) return null; const hours = parseInt(match[1], 10); const minutes = parseInt(match[2], 10); // fileDate is like "2026-02-15" const d = new Date(`${fileDate}T${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:00`); return isNaN(d.getTime()) ? null : d; } scan(since: Date): ScanResult { const items: ScanItem[] = []; if (existsSync(this.memoryFile)) { const stat = statSync(this.memoryFile); if (stat.mtime >= since) { // Scan individual sections for MEMORY.md const content = readFileSync(this.memoryFile, 'utf-8'); const sections = this.parseSections(content); if (sections.length > 1) { // File has sections. Report each section as an item. for (let i = 0; i < sections.length; i++) { const s = sections[i]; if (!s.header) continue; items.push({ id: `MEMORY.md:section:${i}`, timestamp: stat.mtime.toISOString(), preview: preview(s.header, 100), type: 'text' as const, }); } } else { items.push({ id: 'MEMORY.md', timestamp: stat.mtime.toISOString(), preview: `MEMORY.md (modified ${stat.mtime.toISOString()})`, size: stat.size, type: 'file' as const }); } } } if (existsSync(this.memoryDir)) { for (const file of readdirSync(this.memoryDir).filter(f => f.endsWith('.md'))) { const filePath = join(this.memoryDir, file); const stat = statSync(filePath); if (stat.mtime < since) continue; // For daily log files (YYYY-MM-DD.md), scan sections with timestamps const dateMatch = file.match(/^(\d{4}-\d{2}-\d{2})\.md$/); if (dateMatch) { const fileDate = dateMatch[1]; const content = readFileSync(filePath, 'utf-8'); const sections = this.parseSections(content); let hasTimeMatchingSections = false; for (let i = 0; i < sections.length; i++) { const s = sections[i]; if (!s.header) continue; const sectionTime = this.extractTimeFromHeader(s.header, fileDate); if (sectionTime && sectionTime >= since) { hasTimeMatchingSections = true; items.push({ id: `memory/${file}:section:${i}`, timestamp: sectionTime.toISOString(), preview: preview(s.header, 100), type: 'text' as const, }); } } // If no sections matched by time but file was modified, show whole file if (!hasTimeMatchingSections && sections.length <= 1) { items.push({ id: `memory/${file}`, timestamp: stat.mtime.toISOString(), preview: `${file} (${(stat.size / 1024).toFixed(1)}KB)`, size: stat.size, type: 'file' as const }); } } else { items.push({ id: `memory/${file}`, timestamp: stat.mtime.toISOString(), preview: `${file} (${(stat.size / 1024).toFixed(1)}KB)`, size: stat.size, type: 'file' as const }); } } } return { location: this.id, itemCount: items.length, items, canAutoDelete: true, note: 'Supports section-level deletion for daily log files.' }; } wipe(itemIds: string[]): WipeResult { let deleted = 0, failed = 0; const errors: string[] = []; // Group section deletions by file const sectionDeletes = new Map(); // filePath -> section indices const fileDeletes: string[] = []; for (const itemId of itemIds) { const sectionMatch = itemId.match(/^(.+):section:(\d+)$/); if (sectionMatch) { const fileId = sectionMatch[1]; const sectionIdx = parseInt(sectionMatch[2], 10); const existing = sectionDeletes.get(fileId) || []; existing.push(sectionIdx); sectionDeletes.set(fileId, existing); } else { fileDeletes.push(itemId); } } // Handle section-level deletions for (const [fileId, sectionIndices] of sectionDeletes) { const filePath = fileId === 'MEMORY.md' ? this.memoryFile : join(OC_DIR, 'workspace', fileId); try { if (!existsSync(filePath)) continue; const content = readFileSync(filePath, 'utf-8'); const sections = this.parseSections(content); // Remove sections by index (sort descending to preserve indices) const toRemove = new Set(sectionIndices); const remaining = sections.filter((_, i) => !toRemove.has(i)); if (remaining.length === 0) { // All sections removed, delete the file unlinkSync(filePath); deleted += sectionIndices.length; } else { // Rebuild file from remaining sections const rebuilt = remaining.map(s => { if (s.header) return s.header + '\n' + s.body; return s.body; }).join('\n').replace(/\n{3,}/g, '\n\n').trim() + '\n'; writeFileSync(filePath, rebuilt); deleted += sectionIndices.length; } } catch (err: any) { failed += sectionIndices.length; errors.push(`${fileId}: ${err.message}`); } } // Handle whole-file deletions (backwards compat) for (const fileId of fileDeletes) { const filePath = fileId === 'MEMORY.md' ? this.memoryFile : join(OC_DIR, 'workspace', fileId); try { if (existsSync(filePath)) { unlinkSync(filePath); deleted++; } } catch (err: any) { failed++; errors.push(`${fileId}: ${err.message}`); } } return { location: this.id, deleted, failed, skipped: 0, errors }; } } // 7. Dev Update Files export class DevUpdatesScanner implements LocationScanner { id = 'dev-updates'; name = 'Dev Update Files'; private dirs = [ join(resolveWorkspace(), 'team', 'Lēsa', 'documents', '_dev-updates'), join(resolveWorkspace(), 'team', 'cc-mini', 'documents', '_dev-updates'), ]; exists(): boolean { return this.dirs.some(d => existsSync(d)); } search(keywords: string[]): SearchResult { const regex = keywordRegex(keywords); const matches: SearchMatch[] = []; for (const dir of this.dirs) { if (!existsSync(dir)) continue; for (const file of readdirSync(dir).filter(f => f.endsWith('.md'))) { try { const content = readFileSync(join(dir, file), 'utf-8'); regex.lastIndex = 0; if (!regex.test(content)) continue; const who = dir.includes('Lēsa') ? 'Lēsa' : 'Parker'; const stat = statSync(join(dir, file)); matches.push(...findMatches(content, keywords, `${who}:${file}`, this.id, stat.mtime.toISOString(), 'file')); } catch {} } } return { location: this.id, matchCount: matches.length, matches }; } scan(since: Date): ScanResult { const items: ScanItem[] = []; for (const dir of this.dirs) { if (!existsSync(dir)) continue; for (const file of readdirSync(dir).filter(f => f.endsWith('.md') || f.endsWith('.rtf'))) { const filePath = join(dir, file); const stat = statSync(filePath); if (stat.mtime >= since) { const who = dir.includes('Lēsa') ? 'Lēsa' : 'Parker'; items.push({ id: `${who}:${file}`, timestamp: stat.mtime.toISOString(), preview: `[${who}] ${file} (${(stat.size / 1024).toFixed(1)}KB)`, size: stat.size, type: 'file' as const }); } } } return { location: this.id, itemCount: items.length, items, canAutoDelete: true }; } wipe(itemIds: string[]): WipeResult { let deleted = 0, failed = 0; const errors: string[] = []; for (const itemId of itemIds) { const [agent, ...rest] = itemId.split(':'); const file = rest.join(':'); const dir = agent === 'Lēsa' ? this.dirs[0] : this.dirs[1]; try { if (existsSync(join(dir, file))) { unlinkSync(join(dir, file)); deleted++; } } catch (err: any) { failed++; errors.push(`${itemId}: ${err.message}`); } } return { location: this.id, deleted, failed, skipped: 0, errors }; } } // 8. iMessage Attachments export class IMessageAttachmentsScanner implements LocationScanner { id = 'imessage-attachments'; name = 'iMessage Attachments'; private attachmentsDir = join(HOME, 'Library', 'Messages', 'Attachments'); exists(): boolean { return existsSync(this.attachmentsDir); } search(_keywords: string[]): SearchResult { return { location: this.id, matchCount: 0, matches: [], note: 'Attachment files (images/video) are not text-searchable.' }; } scan(since: Date): ScanResult { if (!this.exists()) return { location: this.id, itemCount: 0, items: [], canAutoDelete: true, note: 'Attachments directory not found' }; const items: ScanItem[] = []; this.walkDir(this.attachmentsDir, since, items); return { location: this.id, itemCount: items.length, items, canAutoDelete: true, note: 'Wipe moves files to ~/Desktop/edit/wipe-attachments/ for review' }; } private walkDir(dir: string, since: Date, items: ScanItem[]) { try { for (const entry of readdirSync(dir)) { if (entry === '.DS_Store') continue; const full = join(dir, entry); try { const stat = statSync(full); if (stat.isDirectory()) { this.walkDir(full, since, items); } else if (stat.mtime >= since) { const ext = extname(entry).slice(1).toLowerCase(); const isImage = ['jpg', 'jpeg', 'png', 'gif', 'heic', 'webp', 'tiff'].includes(ext); const isVideo = ['mov', 'mp4', 'avi', 'm4v'].includes(ext); items.push({ id: full, timestamp: stat.mtime.toISOString(), preview: `${entry} (${(stat.size / 1024).toFixed(1)}KB)`, size: stat.size, type: isVideo ? 'video' : isImage ? 'image' : 'file' }); } } catch {} } } catch {} } wipe(itemIds: string[]): WipeResult { const reviewDir = join(HOME, 'Desktop', 'edit', 'wipe-attachments'); if (!existsSync(reviewDir)) mkdirSync(reviewDir, { recursive: true }); let deleted = 0, failed = 0; const errors: string[] = []; for (const filePath of itemIds) { try { if (existsSync(filePath)) { renameSync(filePath, join(reviewDir, basename(filePath))); deleted++; } } catch (err: any) { failed++; errors.push(`${basename(filePath)}: ${err.message}`); } } return { location: this.id, deleted, failed, skipped: 0, errors }; } } // 9. iMessage chat.db export class IMessageChatDbScanner implements LocationScanner { id = 'imessage-chatdb'; name = 'iMessage chat.db'; private dbPath = join(HOME, 'Library', 'Messages', 'chat.db'); exists(): boolean { return existsSync(this.dbPath); } search(keywords: string[]): SearchResult { const db = safeOpenDb(this.dbPath, true); if (!db) return { location: this.id, matchCount: 0, matches: [] }; try { const cocoaEpoch = new Date('2001-01-01T00:00:00Z').getTime(); // Get all messages and search attributedBody const rows = db.prepare('SELECT rowid, date, is_from_me, attributedBody FROM message WHERE attributedBody IS NOT NULL ORDER BY date DESC LIMIT 2000').all() as any[]; const matches: SearchMatch[] = []; const regex = keywordRegex(keywords); for (const r of rows) { try { const buf = r.attributedBody as Buffer; const str = buf.toString('utf-8'); regex.lastIndex = 0; if (!regex.test(str)) continue; // Extract readable text let text = str; const nsIdx = str.indexOf('NSString'); if (nsIdx >= 0) { const after = str.slice(nsIdx + 8); const match = after.match(/[\x20-\x7E\u00A0-\uFFFF]{10,}/); if (match) text = match[0]; } const date = new Date(r.date / 1_000_000 + cocoaEpoch); matches.push(...findMatches(text, keywords, String(r.rowid), this.id, date.toISOString(), 'text')); } catch {} if (matches.length > 500) break; } return { location: this.id, matchCount: matches.length, matches, note: 'Cannot auto-delete. Delete messages manually in Messages app.' }; } finally { db.close(); } } scan(since: Date): ScanResult { const db = safeOpenDb(this.dbPath, true); if (!db) return { location: this.id, itemCount: 0, items: [], canAutoDelete: false, note: 'chat.db not accessible' }; try { const cocoaEpoch = new Date('2001-01-01T00:00:00Z').getTime(); const sinceNanos = (since.getTime() - cocoaEpoch) * 1_000_000; const rows = db.prepare(` SELECT m.rowid, m.date, m.is_from_me, m.attributedBody, h.id as handle_id FROM message m LEFT JOIN handle h ON m.handle_id = h.rowid WHERE m.date >= ? ORDER BY m.date ASC `).all(sinceNanos) as any[]; const items: ScanItem[] = rows.map(r => { let text = '[binary content]'; if (r.attributedBody) { try { const buf = r.attributedBody as Buffer; const str = buf.toString('utf-8'); const nsIdx = str.indexOf('NSString'); if (nsIdx >= 0) { const after = str.slice(nsIdx + 8); const match = after.match(/[\x20-\x7E\u00A0-\uFFFF]{10,}/); if (match) text = match[0]; } } catch {} } const date = new Date(r.date / 1_000_000 + cocoaEpoch); const who = r.is_from_me ? 'me' : (r.handle_id || 'unknown'); return { id: String(r.rowid), timestamp: date.toISOString(), preview: `[${who}] ${preview(text, 80)}`, type: 'text' as const }; }); return { location: this.id, itemCount: items.length, items, canAutoDelete: false, note: 'Cannot safely edit iMessage database. Delete messages manually in Messages app.' }; } finally { db.close(); } } wipe(_itemIds: string[]): WipeResult { return { location: this.id, deleted: 0, failed: 0, skipped: 0, errors: ['iMessage chat.db cannot be safely edited. Delete messages manually in the Messages app.'] }; } } // 10. Messages Preview Cache export class MessagesPreviewCacheScanner implements LocationScanner { id = 'messages-preview-cache'; name = 'Messages Preview Cache'; private cacheDir = join(HOME, 'Library', 'Messages', 'Caches', 'Previews', 'Attachments'); exists(): boolean { return existsSync(this.cacheDir); } search(_keywords: string[]): SearchResult { return { location: this.id, matchCount: 0, matches: [], note: 'Preview cache files (KTX thumbnails) are not text-searchable.' }; } scan(since: Date): ScanResult { if (!this.exists()) return { location: this.id, itemCount: 0, items: [], canAutoDelete: true, note: 'Preview cache not found' }; const items: ScanItem[] = []; this.walkDir(this.cacheDir, since, items); return { location: this.id, itemCount: items.length, items, canAutoDelete: true }; } private walkDir(dir: string, since: Date, items: ScanItem[]) { try { for (const entry of readdirSync(dir)) { if (entry === '.DS_Store') continue; const full = join(dir, entry); try { const stat = statSync(full); if (stat.isDirectory()) { this.walkDir(full, since, items); } else if (stat.mtime >= since && entry.endsWith('.ktx')) { items.push({ id: full, timestamp: stat.mtime.toISOString(), preview: `${entry} (${(stat.size / 1024).toFixed(1)}KB)`, size: stat.size, type: 'image' as const }); } } catch {} } } catch {} } wipe(itemIds: string[]): WipeResult { let deleted = 0, failed = 0; const errors: string[] = []; for (const filePath of itemIds) { try { if (existsSync(filePath)) { unlinkSync(filePath); deleted++; } } catch (err: any) { failed++; errors.push(`${basename(filePath)}: ${err.message}`); } } return { location: this.id, deleted, failed, skipped: 0, errors }; } } // ── Initialize Built-in Scanners ── function registerBuiltins() { registerScanner(new ContextEmbeddingsScanner()); registerScanner(new CrystalMemoriesScanner()); registerScanner(new CrystalCaptureStateScanner()); registerScanner(new LanceDBScanner()); registerScanner(new SessionFilesScanner()); registerScanner(new WorkspaceMemoryScanner()); registerScanner(new DevUpdatesScanner()); registerScanner(new IMessageAttachmentsScanner()); registerScanner(new IMessageChatDbScanner()); registerScanner(new MessagesPreviewCacheScanner()); } // Register on import registerBuiltins();