项目目标
你的 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');
});