/** * Persistent command history store for terminal autocomplete. * Stores commands per host with frequency tracking and timestamp ordering. * Uses localStorageAdapter as the persistence layer (works in renderer process). */ import { localStorageAdapter } from "../../../infrastructure/persistence/localStorageAdapter"; const STORAGE_KEY = "netcatty:commandHistory"; const MAX_ENTRIES = 10000; const MAX_ENTRIES_PER_HOST = 5000; export interface HistoryEntry { command: string; hostId: string; /** OS type for cross-host matching */ os: "linux" | "windows" | "macos"; /** Number of times this exact command was executed */ frequency: number; /** Timestamp of last execution */ lastUsedAt: number; /** Timestamp of first execution */ createdAt: number; } interface HistoryStore { entries: HistoryEntry[]; version: number; } let cachedStore: HistoryStore | null = null; function loadStore(): HistoryStore { if (cachedStore) return cachedStore; try { const parsed = localStorageAdapter.read(STORAGE_KEY); if (parsed) { cachedStore = parsed; return parsed; } } catch { // Corrupted data, reset } cachedStore = { entries: [], version: 1 }; return cachedStore; } let saveTimer: ReturnType | null = null; function persistStoreNow(store: HistoryStore): boolean { const ok = localStorageAdapter.write(STORAGE_KEY, store); if (ok) return true; // Storage full — evict lowest scored entries (not just oldest by insertion) const now = Date.now(); store.entries.sort((a, b) => scoreEntryAt(b, now) - scoreEntryAt(a, now)); store.entries = store.entries.slice(0, Math.floor(MAX_ENTRIES / 2)); return localStorageAdapter.write(STORAGE_KEY, store); } function saveStore(store: HistoryStore): void { cachedStore = store; // Debounce saves to avoid excessive writes if (saveTimer) clearTimeout(saveTimer); saveTimer = setTimeout(() => { persistStoreNow(store); saveTimer = null; }, 500); } /** * Flush any pending debounced history write immediately. * Used after bulk imports (e.g. local histfile seeding) so a seed-complete * flag is not persisted before the imported commands land in storage. * Returns false when the write could not be persisted. */ export function flushCommandHistoryStore(): boolean { if (!cachedStore) return true; if (saveTimer) { clearTimeout(saveTimer); saveTimer = null; } return persistStoreNow(cachedStore); } /** * Record a command execution. Updates frequency if the command already exists * for this host, otherwise creates a new entry. */ export function recordCommand( command: string, hostId: string, os: "linux" | "windows" | "macos" = "linux", ): void { const trimmed = command.trim(); if (!trimmed || trimmed.length > 2000) return; const store = loadStore(); const now = Date.now(); // Find existing entry for same command + host const existingIdx = store.entries.findIndex( (e) => e.command === trimmed && e.hostId === hostId, ); if (existingIdx >= 0) { store.entries[existingIdx].frequency++; store.entries[existingIdx].lastUsedAt = now; } else { store.entries.push({ command: trimmed, hostId, os, frequency: 1, lastUsedAt: now, createdAt: now, }); } // Enforce per-host limit (evict by score, not insertion order) const hostEntries = store.entries.filter((e) => e.hostId === hostId); if (hostEntries.length > MAX_ENTRIES_PER_HOST) { hostEntries.sort((a, b) => scoreEntryAt(a, now) - scoreEntryAt(b, now)); const toRemove = new Set( hostEntries.slice(0, hostEntries.length - MAX_ENTRIES_PER_HOST).map((e) => e.command), ); store.entries = store.entries.filter( (e) => e.hostId !== hostId || !toRemove.has(e.command), ); } // Enforce global limit if (store.entries.length > MAX_ENTRIES) { store.entries.sort((a, b) => scoreEntryAt(b, now) - scoreEntryAt(a, now)); store.entries = store.entries.slice(0, MAX_ENTRIES); } saveStore(store); } /** Remove one command from autocomplete history for a specific host. */ export function removeCommandHistoryEntry(command: string, hostId: string): boolean { const trimmed = command.trim(); if (!trimmed) return false; const store = loadStore(); const nextEntries = store.entries.filter( (entry) => entry.command !== trimmed || entry.hostId !== hostId, ); if (nextEntries.length === store.entries.length) return false; store.entries = nextEntries; if (saveTimer) { clearTimeout(saveTimer); saveTimer = null; } return persistStoreNow(store); } /** * Score an entry for ranking at a specific timestamp. * Caches Date.now() at query boundaries to avoid repeated syscalls during sort. */ function scoreEntryAt(entry: HistoryEntry, now: number): number { const ageMs = now - entry.lastUsedAt; const ageHours = ageMs / (1000 * 60 * 60); // Exponential decay: halve relevance every 24 hours const recencyScore = Math.pow(0.5, ageHours / 24); return entry.frequency * recencyScore; } export interface HistoryQueryOptions { /** Filter by host ID (strict isolation — only this host's history) */ hostId?: string; /** Maximum number of results */ limit?: number; } export interface RecentHistoryQueryOptions extends HistoryQueryOptions { /** Base command name, e.g. `cd` or `ls` */ commandName: string; /** Exact command text to exclude from results */ excludeCommand?: string; /** Optional path prefix to require on the current argument */ argumentPrefix?: string; } /** * Query history entries matching a prefix. * Returns entries sorted by relevance (frequency * recency). */ export function queryHistory( prefix: string, options: HistoryQueryOptions = {}, ): HistoryEntry[] { const { hostId, limit = 20 } = options; if (limit <= 0) return []; const store = loadStore(); const lowerPrefix = prefix.toLowerCase(); const now = Date.now(); // Cache once per query const filtered = store.entries.filter((entry) => { // Must match prefix if (!entry.command.toLowerCase().startsWith(lowerPrefix)) return false; // Must not be identical to prefix if (entry.command === prefix) return false; // Host filtering: strict per-host isolation if (hostId) { return entry.hostId === hostId; } return true; }); // Sort by score (frequency * recency) filtered.sort((a, b) => scoreEntryAt(b, now) - scoreEntryAt(a, now)); // Deduplicate by command text (keep highest scored) const seen = new Set(); const results: HistoryEntry[] = []; for (const entry of filtered) { if (seen.has(entry.command)) continue; seen.add(entry.command); results.push(entry); if (results.length >= limit) break; } return results; } /** * Fuzzy query: matches commands containing all characters of the query * in order (not necessarily contiguous). Used as a fallback when prefix * matching yields few results. */ export function fuzzyQueryHistory( query: string, options: HistoryQueryOptions = {}, ): HistoryEntry[] { const { hostId, limit = 10 } = options; if (limit <= 0) return []; const store = loadStore(); const lowerQuery = query.toLowerCase(); const now = Date.now(); // Cache once per query const scored: { entry: HistoryEntry; matchScore: number }[] = []; for (const entry of store.entries) { // Host filtering if (hostId) { if (entry.hostId !== hostId) continue; } const matchScore = fuzzyScore(lowerQuery, entry.command.toLowerCase()); if (matchScore > 0 && entry.command !== query) { scored.push({ entry, matchScore }); } } scored.sort((a, b) => b.matchScore * scoreEntryAt(b.entry, now) - a.matchScore * scoreEntryAt(a.entry, now), ); const seen = new Set(); const results: HistoryEntry[] = []; for (const { entry } of scored) { if (seen.has(entry.command)) continue; seen.add(entry.command); results.push(entry); if (results.length >= limit) break; } return results; } /** * Query the most recently used history entries for the same command name. * Useful when the user is currently completing a path argument and wants * a few recent command-line examples (e.g. recent `cd ...` commands). */ export function queryRecentHistoryByCommand( options: RecentHistoryQueryOptions, ): HistoryEntry[] { const { commandName, excludeCommand, argumentPrefix, hostId, limit = 3, } = options; if (!commandName || limit <= 0) return []; const store = loadStore(); const trimmedCommandName = commandName.trim().toLowerCase(); const commandPrefix = `${trimmedCommandName} `; const normalizedArgumentPrefix = normalizeArgumentToken(argumentPrefix ?? ""); const filtered = store.entries.filter((entry) => { const lowerCommand = entry.command.toLowerCase(); if (lowerCommand !== trimmedCommandName && !lowerCommand.startsWith(commandPrefix)) { return false; } if (excludeCommand && entry.command === excludeCommand) return false; if (normalizedArgumentPrefix) { const currentToken = normalizeArgumentToken(getCurrentCommandToken(entry.command)); if (!currentToken.startsWith(normalizedArgumentPrefix)) { return false; } } if (hostId) { return entry.hostId === hostId; } return true; }); filtered.sort((a, b) => b.lastUsedAt - a.lastUsedAt); const seen = new Set(); const results: HistoryEntry[] = []; for (const entry of filtered) { if (seen.has(entry.command)) continue; seen.add(entry.command); results.push(entry); if (results.length >= limit) break; } return results; } function getCurrentCommandToken(command: string): string { const tokens = tokenizeShellLike(command); return tokens.length > 0 ? (tokens[tokens.length - 1] || "") : ""; } function normalizeArgumentToken(token: string): string { return token .trim() .replace(/^['"]/, "") .replace(/['"]$/, "") .replace(/\\ /g, " ") .toLowerCase(); } function tokenizeShellLike(input: string): string[] { const tokens: string[] = []; let current = ""; let inSingleQuote = false; let inDoubleQuote = false; let escaped = false; for (let i = 0; i < input.length; i++) { const ch = input[i]; if (escaped) { current += ch; escaped = false; continue; } if (ch === "\\") { escaped = true; current += ch; continue; } if (ch === "'" && !inDoubleQuote) { inSingleQuote = !inSingleQuote; current += ch; continue; } if (ch === '"' && !inSingleQuote) { inDoubleQuote = !inDoubleQuote; current += ch; continue; } if (ch === " " && !inSingleQuote && !inDoubleQuote) { if (current.length > 0) { tokens.push(current); current = ""; } continue; } current += ch; } tokens.push(current); return tokens; } /** * Compute a fuzzy match score. Returns 0 for no match. * Higher score = better match quality. * Rewards: first-char match, consecutive matches, word-boundary matches. */ function fuzzyScore(query: string, target: string): number { if (query.length === 0) return 0; if (query.length > target.length) return 0; let score = 0; let queryIdx = 0; let prevMatchIdx = -2; for (let i = 0; i < target.length && queryIdx < query.length; i++) { if (target[i] === query[queryIdx]) { queryIdx++; // First character bonus if (i === 0) score += 10; // Consecutive match bonus if (i === prevMatchIdx + 1) score += 5; // Word boundary bonus if (i === 0 || target[i - 1] === " " || target[i - 1] === "/" || target[i - 1] === "-" || target[i - 1] === "_") { score += 3; } score += 1; prevMatchIdx = i; } } // All query characters must be matched return queryIdx === query.length ? score : 0; } /** * Clear all history for a specific host, or all history if no hostId given. */ export function clearHistory(hostId?: string): void { const store = loadStore(); if (hostId) { store.entries = store.entries.filter((e) => e.hostId !== hostId); } else { store.entries = []; } saveStore(store); }