Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
440 lines
12 KiB
TypeScript
440 lines
12 KiB
TypeScript
/**
|
|
* 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<HistoryStore>(STORAGE_KEY);
|
|
if (parsed) {
|
|
cachedStore = parsed;
|
|
return parsed;
|
|
}
|
|
} catch {
|
|
// Corrupted data, reset
|
|
}
|
|
cachedStore = { entries: [], version: 1 };
|
|
return cachedStore;
|
|
}
|
|
|
|
let saveTimer: ReturnType<typeof setTimeout> | 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<string>();
|
|
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<string>();
|
|
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<string>();
|
|
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);
|
|
}
|