shellwise 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +141 -0
- package/bin/sw.js +16 -0
- package/package.json +46 -0
- package/scripts/setup.sh +92 -0
- package/scripts/uninstall.sh +36 -0
- package/src/cli/add.ts +40 -0
- package/src/cli/import.ts +76 -0
- package/src/cli/init.ts +325 -0
- package/src/cli/prune.ts +6 -0
- package/src/cli/search.ts +291 -0
- package/src/cli/stats.ts +42 -0
- package/src/cli/suggest.ts +60 -0
- package/src/daemon/client.ts +41 -0
- package/src/daemon/protocol.ts +89 -0
- package/src/daemon/server.ts +222 -0
- package/src/data/common-commands.ts +276 -0
- package/src/db/connection.ts +29 -0
- package/src/db/queries.ts +181 -0
- package/src/db/schema.ts +58 -0
- package/src/index.ts +207 -0
- package/src/search/fuzzy.ts +77 -0
- package/src/search/index.ts +59 -0
- package/src/search/scorer.ts +71 -0
- package/src/tui/components/result-list.ts +106 -0
- package/src/tui/components/search-box.ts +20 -0
- package/src/tui/components/status-bar.ts +10 -0
- package/src/tui/input.ts +71 -0
- package/src/tui/renderer.ts +45 -0
- package/src/tui/theme.ts +34 -0
- package/src/utils/paths.ts +27 -0
- package/src/utils/platform.ts +13 -0
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { getDb } from "./connection";
|
|
2
|
+
import { createHash } from "crypto";
|
|
3
|
+
|
|
4
|
+
export interface CommandRecord {
|
|
5
|
+
id: number;
|
|
6
|
+
command: string;
|
|
7
|
+
command_hash: string;
|
|
8
|
+
cwd: string | null;
|
|
9
|
+
exit_code: number | null;
|
|
10
|
+
duration_ms: number | null;
|
|
11
|
+
hostname: string | null;
|
|
12
|
+
session_id: string | null;
|
|
13
|
+
shell: string | null;
|
|
14
|
+
created_at: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface CommandStats {
|
|
18
|
+
command_hash: string;
|
|
19
|
+
command: string;
|
|
20
|
+
frequency: number;
|
|
21
|
+
last_used_at: number;
|
|
22
|
+
frecency_score: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface InsertCommandInput {
|
|
26
|
+
command: string;
|
|
27
|
+
cwd?: string;
|
|
28
|
+
exit_code?: number;
|
|
29
|
+
duration_ms?: number;
|
|
30
|
+
hostname?: string;
|
|
31
|
+
session_id?: string;
|
|
32
|
+
shell?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function hashCommand(command: string): string {
|
|
36
|
+
return createHash("sha256").update(command.trim()).digest("hex").slice(0, 16);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function insertCommand(input: InsertCommandInput): void {
|
|
40
|
+
const db = getDb();
|
|
41
|
+
const hash = hashCommand(input.command);
|
|
42
|
+
const now = Date.now();
|
|
43
|
+
|
|
44
|
+
db.transaction(() => {
|
|
45
|
+
db.run(
|
|
46
|
+
`INSERT INTO commands (command, command_hash, cwd, exit_code, duration_ms, hostname, session_id, shell, created_at)
|
|
47
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
48
|
+
[
|
|
49
|
+
input.command,
|
|
50
|
+
hash,
|
|
51
|
+
input.cwd ?? null,
|
|
52
|
+
input.exit_code ?? null,
|
|
53
|
+
input.duration_ms ?? null,
|
|
54
|
+
input.hostname ?? null,
|
|
55
|
+
input.session_id ?? null,
|
|
56
|
+
input.shell ?? null,
|
|
57
|
+
now,
|
|
58
|
+
]
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
// Upsert command_stats
|
|
62
|
+
db.run(
|
|
63
|
+
`INSERT INTO command_stats (command_hash, command, frequency, last_used_at, frecency_score)
|
|
64
|
+
VALUES (?, ?, 1, ?, 4.0)
|
|
65
|
+
ON CONFLICT(command_hash) DO UPDATE SET
|
|
66
|
+
frequency = frequency + 1,
|
|
67
|
+
last_used_at = ?,
|
|
68
|
+
frecency_score = (frequency + 1) * ?`,
|
|
69
|
+
[hash, input.command.trim(), now, now, calculateRecencyWeight(now)]
|
|
70
|
+
);
|
|
71
|
+
})();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function calculateRecencyWeight(lastUsedAt: number): number {
|
|
75
|
+
const age = Date.now() - lastUsedAt;
|
|
76
|
+
const hour = 3600_000;
|
|
77
|
+
if (age < hour) return 4.0;
|
|
78
|
+
if (age < 24 * hour) return 2.0;
|
|
79
|
+
if (age < 7 * 24 * hour) return 1.5;
|
|
80
|
+
if (age < 30 * 24 * hour) return 1.0;
|
|
81
|
+
if (age < 90 * 24 * hour) return 0.5;
|
|
82
|
+
return 0.25;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface SearchOptions {
|
|
86
|
+
query?: string;
|
|
87
|
+
cwd?: string;
|
|
88
|
+
limit?: number;
|
|
89
|
+
exitCode?: number;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function searchCommands(opts: SearchOptions): CommandStats[] {
|
|
93
|
+
const db = getDb();
|
|
94
|
+
const conditions: string[] = [];
|
|
95
|
+
const params: (string | number)[] = [];
|
|
96
|
+
|
|
97
|
+
if (opts.query) {
|
|
98
|
+
conditions.push("cs.command LIKE ?");
|
|
99
|
+
params.push(`%${opts.query}%`);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (opts.cwd) {
|
|
103
|
+
conditions.push(
|
|
104
|
+
"cs.command_hash IN (SELECT DISTINCT command_hash FROM commands WHERE cwd = ?)"
|
|
105
|
+
);
|
|
106
|
+
params.push(opts.cwd);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (opts.exitCode !== undefined) {
|
|
110
|
+
conditions.push(
|
|
111
|
+
"cs.command_hash IN (SELECT DISTINCT command_hash FROM commands WHERE exit_code = ?)"
|
|
112
|
+
);
|
|
113
|
+
params.push(opts.exitCode);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
|
117
|
+
const limit = opts.limit ?? 200;
|
|
118
|
+
|
|
119
|
+
const rows = db
|
|
120
|
+
.query<CommandStats, (string | number)[]>(
|
|
121
|
+
`SELECT command_hash, command, frequency, last_used_at, frecency_score
|
|
122
|
+
FROM command_stats cs
|
|
123
|
+
${where}
|
|
124
|
+
ORDER BY frecency_score DESC
|
|
125
|
+
LIMIT ?`
|
|
126
|
+
)
|
|
127
|
+
.all(...params, limit);
|
|
128
|
+
|
|
129
|
+
return rows;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function getUniqueCommandCount(): number {
|
|
133
|
+
const db = getDb();
|
|
134
|
+
const row = db.query<{ count: number }, []>(
|
|
135
|
+
"SELECT COUNT(*) as count FROM command_stats"
|
|
136
|
+
).get();
|
|
137
|
+
return row?.count ?? 0;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function getTotalCommandCount(): number {
|
|
141
|
+
const db = getDb();
|
|
142
|
+
const row = db.query<{ count: number }, []>(
|
|
143
|
+
"SELECT COUNT(*) as count FROM commands"
|
|
144
|
+
).get();
|
|
145
|
+
return row?.count ?? 0;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function pruneOlderThan(days: number): number {
|
|
149
|
+
const db = getDb();
|
|
150
|
+
const cutoff = Date.now() - days * 24 * 3600_000;
|
|
151
|
+
|
|
152
|
+
const result = db.run("DELETE FROM commands WHERE created_at < ?", [cutoff]);
|
|
153
|
+
|
|
154
|
+
// Cleanup orphaned stats
|
|
155
|
+
db.run(
|
|
156
|
+
`DELETE FROM command_stats WHERE command_hash NOT IN (SELECT DISTINCT command_hash FROM commands)`
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
return result.changes;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function refreshAllFrecency(): void {
|
|
163
|
+
const db = getDb();
|
|
164
|
+
const now = Date.now();
|
|
165
|
+
const stats = db
|
|
166
|
+
.query<{ command_hash: string; frequency: number; last_used_at: number }, []>(
|
|
167
|
+
"SELECT command_hash, frequency, last_used_at FROM command_stats"
|
|
168
|
+
)
|
|
169
|
+
.all();
|
|
170
|
+
|
|
171
|
+
const update = db.prepare(
|
|
172
|
+
"UPDATE command_stats SET frecency_score = ? WHERE command_hash = ?"
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
db.transaction(() => {
|
|
176
|
+
for (const s of stats) {
|
|
177
|
+
const weight = calculateRecencyWeight(s.last_used_at);
|
|
178
|
+
update.run(s.frequency * weight, s.command_hash);
|
|
179
|
+
}
|
|
180
|
+
})();
|
|
181
|
+
}
|
package/src/db/schema.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { Database } from "bun:sqlite";
|
|
2
|
+
|
|
3
|
+
const MIGRATIONS = [
|
|
4
|
+
{
|
|
5
|
+
version: 1,
|
|
6
|
+
up(db: Database) {
|
|
7
|
+
db.exec(`
|
|
8
|
+
CREATE TABLE IF NOT EXISTS commands (
|
|
9
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
10
|
+
command TEXT NOT NULL,
|
|
11
|
+
command_hash TEXT NOT NULL,
|
|
12
|
+
cwd TEXT,
|
|
13
|
+
exit_code INTEGER,
|
|
14
|
+
duration_ms INTEGER,
|
|
15
|
+
hostname TEXT,
|
|
16
|
+
session_id TEXT,
|
|
17
|
+
shell TEXT,
|
|
18
|
+
created_at INTEGER NOT NULL
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
CREATE TABLE IF NOT EXISTS command_stats (
|
|
22
|
+
command_hash TEXT PRIMARY KEY,
|
|
23
|
+
command TEXT NOT NULL,
|
|
24
|
+
frequency INTEGER DEFAULT 1,
|
|
25
|
+
last_used_at INTEGER NOT NULL,
|
|
26
|
+
frecency_score REAL DEFAULT 0
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
CREATE INDEX IF NOT EXISTS idx_commands_hash ON commands(command_hash);
|
|
30
|
+
CREATE INDEX IF NOT EXISTS idx_commands_created ON commands(created_at DESC);
|
|
31
|
+
CREATE INDEX IF NOT EXISTS idx_commands_cwd ON commands(cwd);
|
|
32
|
+
CREATE INDEX IF NOT EXISTS idx_stats_frecency ON command_stats(frecency_score DESC);
|
|
33
|
+
`);
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
export function runMigrations(db: Database): void {
|
|
39
|
+
db.exec(`
|
|
40
|
+
CREATE TABLE IF NOT EXISTS schema_version (
|
|
41
|
+
version INTEGER PRIMARY KEY
|
|
42
|
+
);
|
|
43
|
+
`);
|
|
44
|
+
|
|
45
|
+
const row = db.query<{ version: number }, []>(
|
|
46
|
+
"SELECT MAX(version) as version FROM schema_version"
|
|
47
|
+
).get();
|
|
48
|
+
const currentVersion = row?.version ?? 0;
|
|
49
|
+
|
|
50
|
+
for (const migration of MIGRATIONS) {
|
|
51
|
+
if (migration.version > currentVersion) {
|
|
52
|
+
db.transaction(() => {
|
|
53
|
+
migration.up(db);
|
|
54
|
+
db.run("INSERT INTO schema_version (version) VALUES (?)", [migration.version]);
|
|
55
|
+
})();
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
|
|
3
|
+
import { runAdd } from "./cli/add";
|
|
4
|
+
import { runSearch } from "./cli/search";
|
|
5
|
+
import { runSuggest } from "./cli/suggest";
|
|
6
|
+
import { runInit } from "./cli/init";
|
|
7
|
+
import { runImport } from "./cli/import";
|
|
8
|
+
import { runStats } from "./cli/stats";
|
|
9
|
+
import { runPrune } from "./cli/prune";
|
|
10
|
+
import { closeDb } from "./db/connection";
|
|
11
|
+
import { startServer, isDaemonRunning, getDaemonInfo } from "./daemon/server";
|
|
12
|
+
import { daemonRequest } from "./daemon/client";
|
|
13
|
+
import { getSocketPath, getPidPath, getDaemonPort } from "./daemon/protocol";
|
|
14
|
+
|
|
15
|
+
const args = process.argv.slice(2);
|
|
16
|
+
const command = args[0];
|
|
17
|
+
|
|
18
|
+
function parseFlags(args: string[]): Record<string, string> {
|
|
19
|
+
const flags: Record<string, string> = {};
|
|
20
|
+
for (let i = 0; i < args.length; i++) {
|
|
21
|
+
if (args[i].startsWith("--") && i + 1 < args.length) {
|
|
22
|
+
const key = args[i].slice(2);
|
|
23
|
+
flags[key] = args[i + 1];
|
|
24
|
+
i++;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return flags;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function printHelp(): void {
|
|
31
|
+
console.log(`shellwise - Smart command history with fuzzy search
|
|
32
|
+
|
|
33
|
+
Usage: sw <command> [options]
|
|
34
|
+
|
|
35
|
+
Commands:
|
|
36
|
+
search [--query <text>] Interactive fuzzy search (Ctrl+R)
|
|
37
|
+
suggest --query <text> Get top suggestion (used by shell hook)
|
|
38
|
+
add --command <cmd> Save a command to history
|
|
39
|
+
init <zsh|bash> Output shell integration script
|
|
40
|
+
import [zsh|bash] Import existing shell history
|
|
41
|
+
stats Show usage statistics
|
|
42
|
+
prune --days <n> Remove entries older than n days
|
|
43
|
+
daemon start|stop|status Manage background daemon (faster suggest)
|
|
44
|
+
|
|
45
|
+
Setup:
|
|
46
|
+
Add to ~/.zshrc: eval "$(sw init zsh)"
|
|
47
|
+
Add to ~/.bashrc: eval "$(sw init bash)"
|
|
48
|
+
|
|
49
|
+
Features:
|
|
50
|
+
- Auto-save: commands are recorded automatically
|
|
51
|
+
- Auto-suggest: inline dropdown as you type (Tab/S-Tab to navigate)
|
|
52
|
+
- Ctrl+R: full interactive fuzzy search
|
|
53
|
+
- Daemon mode: ~1-3ms suggest via Unix socket`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function main(): Promise<void> {
|
|
57
|
+
try {
|
|
58
|
+
switch (command) {
|
|
59
|
+
case "search": {
|
|
60
|
+
const flags = parseFlags(args.slice(1));
|
|
61
|
+
await runSearch(flags.query || "");
|
|
62
|
+
break;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
case "suggest": {
|
|
66
|
+
const flags = parseFlags(args.slice(1));
|
|
67
|
+
if (!flags.query) break;
|
|
68
|
+
const limit = flags.limit ? parseInt(flags.limit) : 5;
|
|
69
|
+
|
|
70
|
+
// Try daemon first (fast path ~1-3ms)
|
|
71
|
+
const result = await daemonRequest(`SUGGEST\t${flags.query}\t${limit}\n`);
|
|
72
|
+
if (result) {
|
|
73
|
+
process.stdout.write(result.join("\n"));
|
|
74
|
+
} else {
|
|
75
|
+
// Fallback: direct DB query (~20ms)
|
|
76
|
+
runSuggest(flags.query, limit);
|
|
77
|
+
}
|
|
78
|
+
break;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
case "add": {
|
|
82
|
+
const flags = parseFlags(args.slice(1));
|
|
83
|
+
if (!flags.command) {
|
|
84
|
+
console.error("Usage: sw add --command <cmd>");
|
|
85
|
+
process.exit(1);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Try daemon first
|
|
89
|
+
const addMsg = `ADD\t${flags.command}\t${flags.cwd || ""}\t${flags["exit-code"] || "0"}\t${flags.duration || "0"}\t${flags.session || ""}\t${flags.shell || ""}\n`;
|
|
90
|
+
const addResult = await daemonRequest(addMsg);
|
|
91
|
+
if (!addResult) {
|
|
92
|
+
// Fallback: direct
|
|
93
|
+
runAdd({
|
|
94
|
+
command: flags.command,
|
|
95
|
+
cwd: flags.cwd,
|
|
96
|
+
exitCode: flags["exit-code"] ? parseInt(flags["exit-code"]) : undefined,
|
|
97
|
+
duration: flags.duration ? parseInt(flags.duration) : undefined,
|
|
98
|
+
session: flags.session,
|
|
99
|
+
shell: flags.shell,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
break;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
case "init": {
|
|
106
|
+
const shell = args[1];
|
|
107
|
+
if (!shell) {
|
|
108
|
+
console.error("Usage: sw init <zsh|bash>");
|
|
109
|
+
process.exit(1);
|
|
110
|
+
}
|
|
111
|
+
runInit(shell, "sw");
|
|
112
|
+
break;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
case "import": {
|
|
116
|
+
runImport(args[1]);
|
|
117
|
+
break;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
case "stats": {
|
|
121
|
+
runStats();
|
|
122
|
+
break;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
case "prune": {
|
|
126
|
+
const flags = parseFlags(args.slice(1));
|
|
127
|
+
const days = parseInt(flags.days || "90");
|
|
128
|
+
runPrune(days);
|
|
129
|
+
break;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
case "daemon": {
|
|
133
|
+
const sub = args[1];
|
|
134
|
+
switch (sub) {
|
|
135
|
+
case "start": {
|
|
136
|
+
if (isDaemonRunning()) {
|
|
137
|
+
console.log("Daemon already running.");
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
// Fork to background
|
|
141
|
+
const proc = Bun.spawn(["sw", "daemon", "_run"], {
|
|
142
|
+
stdio: ["ignore", "ignore", "ignore"],
|
|
143
|
+
// @ts-ignore - Bun supports detached
|
|
144
|
+
detached: true,
|
|
145
|
+
});
|
|
146
|
+
proc.unref();
|
|
147
|
+
// Wait a bit and verify
|
|
148
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
149
|
+
if (isDaemonRunning()) {
|
|
150
|
+
const info = getDaemonInfo();
|
|
151
|
+
console.log(`Daemon started (pid: ${info?.pid}, port: ${info?.port})`);
|
|
152
|
+
} else {
|
|
153
|
+
console.error("Failed to start daemon.");
|
|
154
|
+
process.exit(1);
|
|
155
|
+
}
|
|
156
|
+
break;
|
|
157
|
+
}
|
|
158
|
+
case "_run":
|
|
159
|
+
// Internal: actual daemon process
|
|
160
|
+
startServer();
|
|
161
|
+
break;
|
|
162
|
+
case "stop": {
|
|
163
|
+
if (!isDaemonRunning()) {
|
|
164
|
+
console.log("Daemon not running.");
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
await daemonRequest("STOP\n");
|
|
168
|
+
console.log("Daemon stopped.");
|
|
169
|
+
break;
|
|
170
|
+
}
|
|
171
|
+
case "status": {
|
|
172
|
+
if (isDaemonRunning()) {
|
|
173
|
+
const info = getDaemonInfo();
|
|
174
|
+
console.log(`Daemon running (pid: ${info?.pid}, port: ${info?.port})`);
|
|
175
|
+
} else {
|
|
176
|
+
console.log("Daemon not running.");
|
|
177
|
+
}
|
|
178
|
+
break;
|
|
179
|
+
}
|
|
180
|
+
default:
|
|
181
|
+
console.error("Usage: sw daemon start|stop|status");
|
|
182
|
+
process.exit(1);
|
|
183
|
+
}
|
|
184
|
+
break;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
case "help":
|
|
188
|
+
case "--help":
|
|
189
|
+
case "-h":
|
|
190
|
+
case undefined:
|
|
191
|
+
printHelp();
|
|
192
|
+
break;
|
|
193
|
+
|
|
194
|
+
default:
|
|
195
|
+
console.error(`Unknown command: ${command}`);
|
|
196
|
+
printHelp();
|
|
197
|
+
process.exit(1);
|
|
198
|
+
}
|
|
199
|
+
} finally {
|
|
200
|
+
if (command !== "daemon") closeDb();
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
main().catch((err) => {
|
|
205
|
+
console.error(err);
|
|
206
|
+
process.exit(1);
|
|
207
|
+
});
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
export interface FuzzyMatch {
|
|
2
|
+
text: string;
|
|
3
|
+
score: number;
|
|
4
|
+
positions: number[];
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Fuzzy match algorithm — returns match score and character positions.
|
|
9
|
+
* Prioritizes: sequential matches > word boundary > any match
|
|
10
|
+
*/
|
|
11
|
+
export function fuzzyMatch(query: string, target: string): FuzzyMatch | null {
|
|
12
|
+
if (!query) return { text: target, score: 1, positions: [] };
|
|
13
|
+
|
|
14
|
+
const queryLower = query.toLowerCase();
|
|
15
|
+
const targetLower = target.toLowerCase();
|
|
16
|
+
|
|
17
|
+
// Quick check: all query chars exist in target?
|
|
18
|
+
let checkIdx = 0;
|
|
19
|
+
for (let i = 0; i < queryLower.length; i++) {
|
|
20
|
+
const found = targetLower.indexOf(queryLower[i], checkIdx);
|
|
21
|
+
if (found === -1) return null;
|
|
22
|
+
checkIdx = found + 1;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Score matching with position tracking
|
|
26
|
+
const positions: number[] = [];
|
|
27
|
+
let score = 0;
|
|
28
|
+
let queryIdx = 0;
|
|
29
|
+
let prevMatchIdx = -2;
|
|
30
|
+
|
|
31
|
+
for (let i = 0; i < targetLower.length && queryIdx < queryLower.length; i++) {
|
|
32
|
+
if (targetLower[i] === queryLower[queryIdx]) {
|
|
33
|
+
positions.push(i);
|
|
34
|
+
|
|
35
|
+
// Consecutive match bonus
|
|
36
|
+
if (i === prevMatchIdx + 1) {
|
|
37
|
+
score += 3;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Word boundary bonus (start, after space/slash/dash/dot)
|
|
41
|
+
if (i === 0 || " /\\-_.".includes(target[i - 1])) {
|
|
42
|
+
score += 2;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Exact case match bonus
|
|
46
|
+
if (target[i] === query[queryIdx]) {
|
|
47
|
+
score += 0.5;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
score += 1; // base score per match
|
|
51
|
+
prevMatchIdx = i;
|
|
52
|
+
queryIdx++;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (queryIdx < queryLower.length) return null;
|
|
57
|
+
|
|
58
|
+
// Normalize score (0-1 range)
|
|
59
|
+
const maxPossible = queryLower.length * 6.5; // max per char: 3+2+0.5+1
|
|
60
|
+
const normalized = Math.min(score / maxPossible, 1);
|
|
61
|
+
|
|
62
|
+
// Bonus for shorter targets (prefer exact-ish matches)
|
|
63
|
+
const lengthBonus = query.length / target.length;
|
|
64
|
+
const finalScore = normalized * 0.8 + lengthBonus * 0.2;
|
|
65
|
+
|
|
66
|
+
return { text: target, score: finalScore, positions };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function fuzzyFilter(query: string, items: string[]): FuzzyMatch[] {
|
|
70
|
+
const results: FuzzyMatch[] = [];
|
|
71
|
+
for (const item of items) {
|
|
72
|
+
const match = fuzzyMatch(query, item);
|
|
73
|
+
if (match) results.push(match);
|
|
74
|
+
}
|
|
75
|
+
results.sort((a, b) => b.score - a.score);
|
|
76
|
+
return results;
|
|
77
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { fuzzyMatch } from "./fuzzy";
|
|
2
|
+
import { rankResults, type ScoredResult } from "./scorer";
|
|
3
|
+
import { searchCommands, type CommandStats } from "../db/queries";
|
|
4
|
+
import { getDb } from "../db/connection";
|
|
5
|
+
|
|
6
|
+
export type { ScoredResult } from "./scorer";
|
|
7
|
+
|
|
8
|
+
export interface SearchInput {
|
|
9
|
+
query: string;
|
|
10
|
+
cwd?: string;
|
|
11
|
+
limit?: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function search(input: SearchInput): ScoredResult[] {
|
|
15
|
+
const limit = input.limit ?? 50;
|
|
16
|
+
|
|
17
|
+
// Phase 1: Pre-filter from DB (SQL LIKE)
|
|
18
|
+
const dbResults = searchCommands({
|
|
19
|
+
query: input.query || undefined,
|
|
20
|
+
limit: 200,
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
if (dbResults.length === 0) return [];
|
|
24
|
+
|
|
25
|
+
// Phase 2: Fuzzy match & score
|
|
26
|
+
if (!input.query) {
|
|
27
|
+
// No query = return by frecency
|
|
28
|
+
return dbResults.slice(0, limit).map((stat) => ({
|
|
29
|
+
command: stat.command,
|
|
30
|
+
commandHash: stat.command_hash,
|
|
31
|
+
frequency: stat.frequency,
|
|
32
|
+
lastUsedAt: stat.last_used_at,
|
|
33
|
+
fuzzyScore: 1,
|
|
34
|
+
frecencyScore: stat.frecency_score,
|
|
35
|
+
finalScore: stat.frecency_score,
|
|
36
|
+
matchPositions: [],
|
|
37
|
+
}));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const matches = [];
|
|
41
|
+
for (const stat of dbResults) {
|
|
42
|
+
const match = fuzzyMatch(input.query, stat.command);
|
|
43
|
+
if (match) matches.push(match);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Get CWD-related commands for bonus scoring
|
|
47
|
+
let cwdCommands: Set<string> | undefined;
|
|
48
|
+
if (input.cwd) {
|
|
49
|
+
const db = getDb();
|
|
50
|
+
const rows = db
|
|
51
|
+
.query<{ command_hash: string }, [string]>(
|
|
52
|
+
"SELECT DISTINCT command_hash FROM commands WHERE cwd = ?"
|
|
53
|
+
)
|
|
54
|
+
.all(input.cwd);
|
|
55
|
+
cwdCommands = new Set(rows.map((r) => r.command_hash));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return rankResults(matches, dbResults, input.cwd, cwdCommands).slice(0, limit);
|
|
59
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { FuzzyMatch } from "./fuzzy";
|
|
2
|
+
import type { CommandStats } from "../db/queries";
|
|
3
|
+
|
|
4
|
+
export interface ScoredResult {
|
|
5
|
+
command: string;
|
|
6
|
+
commandHash: string;
|
|
7
|
+
frequency: number;
|
|
8
|
+
lastUsedAt: number;
|
|
9
|
+
fuzzyScore: number;
|
|
10
|
+
frecencyScore: number;
|
|
11
|
+
finalScore: number;
|
|
12
|
+
matchPositions: number[];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface ScoreWeights {
|
|
16
|
+
fuzzy: number;
|
|
17
|
+
frecency: number;
|
|
18
|
+
cwdBonus: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const DEFAULT_WEIGHTS: ScoreWeights = {
|
|
22
|
+
fuzzy: 0.6,
|
|
23
|
+
frecency: 0.3,
|
|
24
|
+
cwdBonus: 0.1,
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export function rankResults(
|
|
28
|
+
matches: FuzzyMatch[],
|
|
29
|
+
stats: CommandStats[],
|
|
30
|
+
currentCwd?: string,
|
|
31
|
+
cwdCommands?: Set<string>,
|
|
32
|
+
weights: ScoreWeights = DEFAULT_WEIGHTS
|
|
33
|
+
): ScoredResult[] {
|
|
34
|
+
const statsMap = new Map<string, CommandStats>();
|
|
35
|
+
for (const s of stats) {
|
|
36
|
+
statsMap.set(s.command, s);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const results: ScoredResult[] = [];
|
|
40
|
+
|
|
41
|
+
for (const match of matches) {
|
|
42
|
+
const stat = statsMap.get(match.text);
|
|
43
|
+
if (!stat) continue;
|
|
44
|
+
|
|
45
|
+
// Normalize frecency to 0-1 range
|
|
46
|
+
const maxFrecency = stats.reduce((max, s) => Math.max(max, s.frecency_score), 1);
|
|
47
|
+
const normalizedFrecency = stat.frecency_score / maxFrecency;
|
|
48
|
+
|
|
49
|
+
// CWD bonus
|
|
50
|
+
const cwd = currentCwd && cwdCommands?.has(stat.command_hash) ? 1 : 0;
|
|
51
|
+
|
|
52
|
+
const finalScore =
|
|
53
|
+
match.score * weights.fuzzy +
|
|
54
|
+
normalizedFrecency * weights.frecency +
|
|
55
|
+
cwd * weights.cwdBonus;
|
|
56
|
+
|
|
57
|
+
results.push({
|
|
58
|
+
command: match.text,
|
|
59
|
+
commandHash: stat.command_hash,
|
|
60
|
+
frequency: stat.frequency,
|
|
61
|
+
lastUsedAt: stat.last_used_at,
|
|
62
|
+
fuzzyScore: match.score,
|
|
63
|
+
frecencyScore: stat.frecency_score,
|
|
64
|
+
finalScore,
|
|
65
|
+
matchPositions: match.positions,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
results.sort((a, b) => b.finalScore - a.finalScore);
|
|
70
|
+
return results;
|
|
71
|
+
}
|