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.
@@ -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
+ }
@@ -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
+ }