← 中文资源

简体中文版本

构建可搜索的命令历史工具

解析 shell 历史、按频率与新旧排序并对模糊匹配打分,使想要的命令排在第一位。

阅读英文原文

项目目标

你的 shell 历史里已经存着你想用的命令,问题在于回忆。本项目解析历史文件、去重并按匹配质量结合新旧与频率排序,使最可能的命令排在首位。打分是普通数值,因此排序确定且易于断言,而不是黑盒。

核心特性

  • 解析 bash 与 zsh 历史格式并容忍时间戳
  • 去重同时跟踪最近一次使用
  • 按新旧、频率与匹配质量排序
  • 在子串之外支持模糊子序列匹配

架构与分层

解析器把历史文件读为命令记录;去重器折叠重复并跟踪新旧与频率;打分器按匹配类型、频率与新旧加权排名;CLI 打印最优结果。打分是记录与查询之上的纯函数。

实施阶段

六个阶段为:历史解析、去重、模糊匹配、打分器、CLI、测试与加固。匹配权重高于原始频率,避免最常执行的命令总是胜出。

测试与验收

用 vitest 运行。解析测试覆盖带时间戳的 zsh 扩展行与普通 bash 行;去重测试断言重复折叠为一条并保留最新时间与准确计数;打分测试断言精确匹配高于前缀、前缀高于子串、模糊高于无匹配。

延伸方向

按目录统计频率、按主机区分历史与 shell 键位集成都是解析器与打分器外围的增量。打分是纯函数,因此新信号只是一项加权而非重新设计。

本译文由 AI 依据英文原文完整重写,覆盖目标、特性、架构、分阶段实现、测试与扩展方向,供中文读者对照英文原稿阅读。

代码示例

export function score(record, query, now) {
  const cmd = record.command.toLowerCase();
  const q = query.toLowerCase();
  let base = 0;
  if (cmd === q) base = 100;
  else if (cmd.startsWith(q)) base = 60;
  else if (cmd.includes(q)) base = 40;
  else if (isSubsequence(q, cmd)) base = 15;
  else return 0;
  const frequency = Math.min(20, record.count * 2);
  const ageDays = (now - record.lastUsed) / 86_400_000;
  const recency = Math.max(0, 10 - ageDays);
  return base + frequency + recency;
}
mkdir hist-search && cd hist-search
npm init -y
npm install -D vitest@^2.1.0
{
  "name": "hist-search",
  "type": "module",
  "scripts": {
    "hist": "node src/cli.js",
    "test": "vitest run"
  }
}
export function parseHistory(text) {
  const lines = text.split(/\r?\n/);
  const records = [];
  let pendingTime = null;
  for (const line of lines) {
    if (line.startsWith(': ')) {
      const parts = line.slice(2).split(':');
      pendingTime = Number(parts[0]) * 1000;
      if (parts[2]) records.push({ command: parts[2].trim(), at: pendingTime });
    } else if (line.trim()) {
      records.push({ command: line.trim(), at: pendingTime });
      pendingTime = null;
    }
  }
  return records.filter((r) => r.command && !r.command.startsWith('#'));
}
export function dedupe(records) {
  const map = new Map();
  records.forEach((record, index) => {
    const key = record.command;
    const existing = map.get(key);
    if (existing) {
      existing.count += 1;
      if ((record.at ?? 0) >= (existing.lastUsed ?? 0)) existing.lastUsed = record.at ?? existing.lastUsed;
      existing.lastIndex = index;
    } else {
      map.set(key, { command: key, count: 1, lastUsed: record.at ?? null, lastIndex: index });
    }
  });
  return [...map.values()];
}
export function isSubsequence(query, text) {
  let i = 0;
  for (const ch of text) {
    if (ch === query[i]) i++;
    if (i === query.length) return true;
  }
  return i === query.length;
}

export function matchKind(query, text) {
  if (text === query) return 'exact';
  if (text.startsWith(query)) return 'prefix';
  if (text.includes(query)) return 'substring';
  if (isSubsequence(query, text)) return 'fuzzy';
  return 'none';
}
import { matchKind } from './fuzzy.js';

const BASE = { exact: 100, prefix: 60, substring: 40, fuzzy: 15, none: 0 };

export function score(record, query, now = Date.now()) {
  const kind = matchKind(query.toLowerCase(), record.command.toLowerCase());
  if (kind === 'none') return null;
  const frequency = Math.min(20, record.count * 2);
  const ageDays = record.lastUsed ? (now - record.lastUsed) / 86_400_000 : 30;
  const recency = Math.max(0, 10 - ageDays);
  return { command: record.command, kind, score: BASE[kind] + frequency + recency };
}

export function rank(records, query, now = Date.now()) {
  return records.map((r) => score(r, query, now)).filter(Boolean)
    .sort((a, b) => b.score - a.score || a.command.localeCompare(b.command));
}
import { readFile } from 'node:fs/promises';
import { parseHistory } from './parse.js';
import { dedupe } from './dedupe.js';
import { rank } from './score.js';

const args = parse(process.argv.slice(2));
const path = process.env.HISTFILE ?? args.file;
if (!path) { console.error('usage: hist <query> [--file history] [--top 1]'); process.exit(1); }
const records = dedupe(parseHistory(await readFile(path, 'utf8')));
const results = rank(records, args.query ?? '', Date.now());
const top = Number(args.top ?? 10);
if (args.best) {
  if (!results.length) { console.error('no match'); process.exit(1); }
  console.log(results[0].command);
} else {
  for (const r of results.slice(0, top)) console.log(String(Math.round(r.score)).padStart(3) + '  ' + r.command);
}
import { rank } from '../src/score.js';

const now = Date.parse('2026-10-22T00:00:00Z');
const records = [
  { command: 'git checkout main', count: 1, lastUsed: now },
  { command: 'git commit -m x', count: 1, lastUsed: now },
  { command: 'grep -r checkout .', count: 3, lastUsed: now },
];

test('prefix outranks substring and fuzzy', () => {
  const ranked = rank(records, 'git ch', now);
  expect(ranked[0].command).toBe('git checkout main');
});

test('exact match wins outright', () => {
  const ranked = rank(records, 'git commit -m x', now);
  expect(ranked[0].kind).toBe('exact');
});