shellwise 0.2.9 → 0.3.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/package.json +1 -1
- package/src/cli/init.ts +89 -42
- package/src/cli/suggest.ts +3 -2
- package/src/daemon/protocol.ts +8 -9
- package/src/daemon/server.ts +33 -29
- package/src/db/frecency.ts +23 -0
- package/src/db/queries.ts +16 -39
- package/src/index.ts +2 -2
- package/src/tui/input.ts +3 -1
- package/src/utils/update-check.ts +9 -2
package/package.json
CHANGED
package/src/cli/init.ts
CHANGED
|
@@ -25,48 +25,80 @@ typeset -ga __sw_suggestions=()
|
|
|
25
25
|
typeset -g __sw_selected=0
|
|
26
26
|
typeset -g __sw_fd=""
|
|
27
27
|
typeset -g __sw_ready=0
|
|
28
|
+
# ─── Daemon connection (self-healing, Unix socket) ─────────
|
|
29
|
+
# Unix-domain socket: protected by filesystem permissions (0600), unlike a
|
|
30
|
+
# guessable localhost TCP port with no auth. The daemon idle-exits after
|
|
31
|
+
# inactivity, so the shell must reconnect on demand — otherwise a long-idle
|
|
32
|
+
# pane loses suggest forever.
|
|
33
|
+
|
|
34
|
+
typeset -g __sw_sock="/tmp/shellwise-\${UID}.sock"
|
|
35
|
+
|
|
36
|
+
# Is the daemon process alive? No fork (kill -0 + \$(<file) are builtins).
|
|
37
|
+
__sw_daemon_alive() {
|
|
38
|
+
local pf="/tmp/shellwise-\${UID}.pid"
|
|
39
|
+
[[ -f "\$pf" ]] || return 1
|
|
40
|
+
local lines=("\${(@f)\$(<\$pf)}")
|
|
41
|
+
kill -0 \${lines[1]:-0} 2>/dev/null
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
# (Re)establish the persistent connection. No fork while typing.
|
|
45
|
+
__sw_connect() {
|
|
46
|
+
__sw_ready=0
|
|
47
|
+
[[ -n "\$__sw_fd" ]] && { zsocket -c \$__sw_fd 2>/dev/null; __sw_fd="" }
|
|
48
|
+
[[ -S "\$__sw_sock" ]] || return 1
|
|
49
|
+
zsocket "\$__sw_sock" 2>/dev/null || return 1
|
|
50
|
+
__sw_fd=\$REPLY
|
|
51
|
+
__sw_ready=1
|
|
52
|
+
}
|
|
28
53
|
|
|
29
|
-
# Load
|
|
30
|
-
zmodload zsh/net/
|
|
31
|
-
|
|
32
|
-
local __sw_pidfile="/tmp/shellwise-\${UID}.pid"
|
|
33
|
-
if [[ ! -f "\$__sw_pidfile" ]]; then
|
|
54
|
+
# Load socket module + connect at shell startup
|
|
55
|
+
zmodload zsh/net/socket 2>/dev/null && {
|
|
56
|
+
if [[ ! -S "\$__sw_sock" ]]; then
|
|
34
57
|
# Start daemon in background (non-blocking)
|
|
35
58
|
command ${bin} daemon start &>/dev/null &!
|
|
36
59
|
sleep 0.3
|
|
37
60
|
fi
|
|
38
|
-
|
|
39
|
-
local __sw_port=\${"\$(sed -n 2p "\$__sw_pidfile")":-0}
|
|
40
|
-
if [[ \$__sw_port -gt 0 ]]; then
|
|
41
|
-
ztcp 127.0.0.1 \$__sw_port 2>/dev/null && {
|
|
42
|
-
__sw_fd=\$REPLY
|
|
43
|
-
__sw_ready=1
|
|
44
|
-
}
|
|
45
|
-
fi
|
|
46
|
-
fi
|
|
61
|
+
__sw_connect
|
|
47
62
|
}
|
|
48
63
|
|
|
49
|
-
# ─── Persistent
|
|
64
|
+
# ─── Persistent query (no connect/disconnect overhead) ─────
|
|
50
65
|
|
|
51
66
|
typeset -ga __sw_tcp_result=()
|
|
52
67
|
|
|
53
|
-
|
|
68
|
+
# Args: TYPE field1 field2 ... Fields are joined with REAL tabs and sent raw
|
|
69
|
+
# (print -r) so a literal backslash-t/-n the user typed is preserved; any REAL
|
|
70
|
+
# tab/newline (e.g. from a paste) is stripped to a space so it cannot break
|
|
71
|
+
# the protocol framing.
|
|
72
|
+
__sw_query() {
|
|
54
73
|
__sw_tcp_result=()
|
|
55
|
-
[[ \$__sw_ready -ne 1 ]] && return 1
|
|
56
74
|
|
|
57
|
-
#
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
return 1
|
|
62
|
-
}
|
|
75
|
+
# Neutralize SIGPIPE: writing to a daemon that has idle-exited must return
|
|
76
|
+
# a recoverable error, never raise a signal that freezes the line editor.
|
|
77
|
+
setopt local_options local_traps
|
|
78
|
+
trap '' PIPE
|
|
63
79
|
|
|
64
|
-
|
|
65
|
-
local
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
80
|
+
local -a __sw_fields=()
|
|
81
|
+
local __sw_f
|
|
82
|
+
for __sw_f in "\$@"; do __sw_fields+=("\${__sw_f//[\$'\\t\\n']/ }"); done
|
|
83
|
+
local __sw_req="\${(pj:\\t:)__sw_fields}"
|
|
84
|
+
|
|
85
|
+
# (Re)connect if needed — daemon may have idle-exited or restarted
|
|
86
|
+
[[ \$__sw_ready -ne 1 ]] && { __sw_connect || return 1 }
|
|
87
|
+
|
|
88
|
+
# Send raw; one transparent reconnect+retry on a stale connection
|
|
89
|
+
if ! print -ru \$__sw_fd -- "\$__sw_req" 2>/dev/null; then
|
|
90
|
+
__sw_connect && print -ru \$__sw_fd -- "\$__sw_req" 2>/dev/null || { __sw_ready=0; return 1 }
|
|
91
|
+
fi
|
|
92
|
+
|
|
93
|
+
# Read until the blank-line terminator. A timeout means an incomplete
|
|
94
|
+
# response (slow/dead daemon) — reconnect to drain leftover bytes so the
|
|
95
|
+
# next query cannot read a stale response (protocol desync).
|
|
96
|
+
local __sw_line __sw_got=0
|
|
97
|
+
while IFS= read -r -t 0.2 -u \$__sw_fd __sw_line 2>/dev/null; do
|
|
98
|
+
[[ -z "\$__sw_line" ]] && { __sw_got=1; break }
|
|
99
|
+
__sw_tcp_result+=("\$__sw_line")
|
|
69
100
|
done
|
|
101
|
+
[[ \$__sw_got -eq 1 ]] || { __sw_connect; return 1 }
|
|
70
102
|
|
|
71
103
|
[[ \${#__sw_tcp_result} -gt 0 ]]
|
|
72
104
|
}
|
|
@@ -87,8 +119,11 @@ __sw_precmd() {
|
|
|
87
119
|
duration=\${duration%%.*}
|
|
88
120
|
fi
|
|
89
121
|
|
|
90
|
-
# Save via persistent TCP (instant)
|
|
91
|
-
|
|
122
|
+
# Save via persistent TCP (instant). On failure, restart a dead daemon
|
|
123
|
+
# (a fork between commands is fine) so the next keystroke can reconnect,
|
|
124
|
+
# and fall back to a direct write for this command.
|
|
125
|
+
if ! __sw_query ADD "\$__SW_COMMAND" "\$PWD" "\$exit_code" "\$duration" "\$SW_SESSION_ID" zsh; then
|
|
126
|
+
__sw_daemon_alive || command ${bin} daemon start &>/dev/null &!
|
|
92
127
|
command ${bin} add \\
|
|
93
128
|
--command "\$__SW_COMMAND" \\
|
|
94
129
|
--cwd "\$PWD" \\
|
|
@@ -96,6 +131,7 @@ __sw_precmd() {
|
|
|
96
131
|
--duration "\$duration" \\
|
|
97
132
|
--session "\$SW_SESSION_ID" \\
|
|
98
133
|
--shell "zsh" &!
|
|
134
|
+
fi
|
|
99
135
|
|
|
100
136
|
# Show update notice if available
|
|
101
137
|
local __sw_line
|
|
@@ -145,15 +181,17 @@ __sw_render() {
|
|
|
145
181
|
__sw_suggest() {
|
|
146
182
|
[[ "\$BUFFER" == "\$__sw_prev_buffer" ]] && return
|
|
147
183
|
__sw_prev_buffer="\$BUFFER"
|
|
148
|
-
|
|
184
|
+
# -1 = cursor on the typed line, no item selected yet (Enter runs BUFFER).
|
|
185
|
+
# Tab moves into the list (0 = first item).
|
|
186
|
+
__sw_selected=-1
|
|
149
187
|
__sw_suggestions=()
|
|
150
188
|
POSTDISPLAY=""
|
|
151
189
|
region_highlight=()
|
|
152
190
|
|
|
153
191
|
[[ \${#BUFFER} -lt 2 ]] && return
|
|
154
192
|
|
|
155
|
-
#
|
|
156
|
-
|
|
193
|
+
# Socket query only — no fallback, never spawn process during typing
|
|
194
|
+
__sw_query SUGGEST "\$BUFFER" 5 || return
|
|
157
195
|
|
|
158
196
|
__sw_suggestions=("\${__sw_tcp_result[@]}")
|
|
159
197
|
__sw_render
|
|
@@ -191,7 +229,12 @@ __sw_next() {
|
|
|
191
229
|
|
|
192
230
|
__sw_prev() {
|
|
193
231
|
if [[ \${#__sw_suggestions} -gt 0 ]]; then
|
|
194
|
-
|
|
232
|
+
if [[ \$__sw_selected -lt 0 ]]; then
|
|
233
|
+
# From the typed line, Shift+Tab wraps to the last item
|
|
234
|
+
__sw_selected=\$(( \${#__sw_suggestions} - 1 ))
|
|
235
|
+
else
|
|
236
|
+
__sw_selected=\$(( (__sw_selected - 1 + \${#__sw_suggestions}) % \${#__sw_suggestions} ))
|
|
237
|
+
fi
|
|
195
238
|
__sw_render
|
|
196
239
|
else
|
|
197
240
|
zle .reverse-menu-complete
|
|
@@ -201,16 +244,18 @@ __sw_prev() {
|
|
|
201
244
|
# ─── Enter: accept selected or execute ─────────────────────
|
|
202
245
|
|
|
203
246
|
__sw_accept_line() {
|
|
204
|
-
|
|
247
|
+
# Only replace BUFFER when the user has actually moved into the list
|
|
248
|
+
# (Tab/Shift+Tab → __sw_selected >= 0). With nothing selected, Enter runs
|
|
249
|
+
# exactly what was typed.
|
|
250
|
+
if [[ \${#__sw_suggestions} -gt 0 && \$__sw_selected -ge 0 ]]; then
|
|
205
251
|
BUFFER="\${__sw_suggestions[\$(( __sw_selected + 1 ))]}"
|
|
206
252
|
CURSOR=\${#BUFFER}
|
|
207
|
-
POSTDISPLAY=""
|
|
208
|
-
region_highlight=()
|
|
209
|
-
__sw_suggestions=()
|
|
210
|
-
__sw_prev_buffer="\$BUFFER"
|
|
211
|
-
else
|
|
212
|
-
zle .accept-line
|
|
213
253
|
fi
|
|
254
|
+
POSTDISPLAY=""
|
|
255
|
+
region_highlight=()
|
|
256
|
+
__sw_suggestions=()
|
|
257
|
+
__sw_prev_buffer="\$BUFFER"
|
|
258
|
+
zle .accept-line
|
|
214
259
|
}
|
|
215
260
|
|
|
216
261
|
# ─── Escape: clear suggestions ─────────────────────────────
|
|
@@ -230,7 +275,9 @@ __sw_dismiss() {
|
|
|
230
275
|
|
|
231
276
|
__sw_forward_char() {
|
|
232
277
|
if [[ \${#__sw_suggestions} -gt 0 && \$CURSOR -eq \${#BUFFER} ]]; then
|
|
233
|
-
|
|
278
|
+
# Accept inline: nothing selected yet → take the top item (index 1)
|
|
279
|
+
local idx=\$(( __sw_selected < 0 ? 1 : __sw_selected + 1 ))
|
|
280
|
+
BUFFER="\${__sw_suggestions[\$idx]}"
|
|
234
281
|
CURSOR=\${#BUFFER}
|
|
235
282
|
POSTDISPLAY=""
|
|
236
283
|
region_highlight=()
|
package/src/cli/suggest.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { getDb } from "../db/connection";
|
|
2
|
+
import { FRECENCY_EXPR } from "../db/frecency";
|
|
2
3
|
import { getCommonSuggestions } from "../data/common-commands";
|
|
3
4
|
|
|
4
5
|
/**
|
|
@@ -18,7 +19,7 @@ export function runSuggest(query: string, limit: number = 5): void {
|
|
|
18
19
|
.query<{ command: string }, [string, number]>(
|
|
19
20
|
`SELECT command FROM command_stats
|
|
20
21
|
WHERE command LIKE ? || '%' ESCAPE '\\'
|
|
21
|
-
ORDER BY
|
|
22
|
+
ORDER BY ${FRECENCY_EXPR} DESC
|
|
22
23
|
LIMIT ?`
|
|
23
24
|
)
|
|
24
25
|
.all(escapedQuery, limit);
|
|
@@ -36,7 +37,7 @@ export function runSuggest(query: string, limit: number = 5): void {
|
|
|
36
37
|
.query<{ command: string }, [string, string, number]>(
|
|
37
38
|
`SELECT command FROM command_stats
|
|
38
39
|
WHERE command LIKE '%' || ? || '%' ESCAPE '\\' AND command != ?
|
|
39
|
-
ORDER BY
|
|
40
|
+
ORDER BY ${FRECENCY_EXPR} DESC
|
|
40
41
|
LIMIT ?`
|
|
41
42
|
)
|
|
42
43
|
.all(escapedQuery, query, remaining + historyResults.length);
|
package/src/daemon/protocol.ts
CHANGED
|
@@ -53,16 +53,21 @@ export function parseRequest(raw: string): Request | null {
|
|
|
53
53
|
switch (type) {
|
|
54
54
|
case "SUGGEST":
|
|
55
55
|
return { type: "SUGGEST", query: parts[1] || "", limit: parseInt(parts[2]) || 5 };
|
|
56
|
-
case "ADD":
|
|
56
|
+
case "ADD": {
|
|
57
|
+
// Distinguish a real 0 from an unparseable/missing field (a desynced
|
|
58
|
+
// line must not masquerade as a successful exit code 0).
|
|
59
|
+
const exit = parseInt(parts[3]);
|
|
60
|
+
const dur = parseInt(parts[4]);
|
|
57
61
|
return {
|
|
58
62
|
type: "ADD",
|
|
59
63
|
command: parts[1] || "",
|
|
60
64
|
cwd: parts[2] || "",
|
|
61
|
-
exitCode:
|
|
62
|
-
duration:
|
|
65
|
+
exitCode: Number.isNaN(exit) ? -1 : exit,
|
|
66
|
+
duration: Number.isNaN(dur) ? 0 : dur,
|
|
63
67
|
session: parts[5] || "",
|
|
64
68
|
shell: parts[6] || "",
|
|
65
69
|
};
|
|
70
|
+
}
|
|
66
71
|
case "STOP":
|
|
67
72
|
return { type: "STOP" };
|
|
68
73
|
case "PING":
|
|
@@ -77,12 +82,6 @@ export function getSocketPath(): string {
|
|
|
77
82
|
return `/tmp/shellwise-${uid}.sock`;
|
|
78
83
|
}
|
|
79
84
|
|
|
80
|
-
/** TCP port = 19850 + (uid % 100) to avoid collisions */
|
|
81
|
-
export function getDaemonPort(): number {
|
|
82
|
-
const uid = process.getuid?.() ?? 501;
|
|
83
|
-
return 19850 + (uid % 100);
|
|
84
|
-
}
|
|
85
|
-
|
|
86
85
|
export function getPidPath(): string {
|
|
87
86
|
const uid = process.getuid?.() ?? process.pid;
|
|
88
87
|
return `/tmp/shellwise-${uid}.pid`;
|
package/src/daemon/server.ts
CHANGED
|
@@ -3,14 +3,17 @@ import { insertCommand } from "../db/queries";
|
|
|
3
3
|
import { getHostname } from "../utils/platform";
|
|
4
4
|
import { getCommonSuggestions } from "../data/common-commands";
|
|
5
5
|
import { checkForUpdate, getUpdateNotice } from "../utils/update-check";
|
|
6
|
-
import { parseRequest, getSocketPath, getPidPath
|
|
7
|
-
import {
|
|
6
|
+
import { parseRequest, getSocketPath, getPidPath } from "./protocol";
|
|
7
|
+
import { FRECENCY_EXPR } from "../db/frecency";
|
|
8
|
+
import { unlinkSync, writeFileSync, existsSync, chmodSync } from "fs";
|
|
8
9
|
import type { Socket } from "bun";
|
|
10
|
+
|
|
11
|
+
// Reject a connection that floods us without ever sending a newline.
|
|
12
|
+
const MAX_LINE_BYTES = 64 * 1024;
|
|
9
13
|
const IDLE_TIMEOUT = 30 * 60_000; // 30 min
|
|
10
14
|
|
|
11
15
|
let idleTimer: ReturnType<typeof setTimeout> | null = null;
|
|
12
16
|
let server: ReturnType<typeof Bun.listen> | null = null;
|
|
13
|
-
let tcpServer: ReturnType<typeof Bun.listen> | null = null;
|
|
14
17
|
let updateNotified = false;
|
|
15
18
|
|
|
16
19
|
// Pre-warm DB + prepared statements on start
|
|
@@ -22,13 +25,13 @@ function initPreparedStatements() {
|
|
|
22
25
|
suggestPrefix = db.prepare(
|
|
23
26
|
`SELECT command FROM command_stats
|
|
24
27
|
WHERE command LIKE ?1 || '%' ESCAPE '\\'
|
|
25
|
-
ORDER BY
|
|
28
|
+
ORDER BY ${FRECENCY_EXPR} DESC
|
|
26
29
|
LIMIT ?2`
|
|
27
30
|
);
|
|
28
31
|
suggestContains = db.prepare(
|
|
29
32
|
`SELECT command FROM command_stats
|
|
30
33
|
WHERE command LIKE '%' || ?1 || '%' ESCAPE '\\' AND command != ?1
|
|
31
|
-
ORDER BY
|
|
34
|
+
ORDER BY ${FRECENCY_EXPR} DESC
|
|
32
35
|
LIMIT ?2`
|
|
33
36
|
);
|
|
34
37
|
}
|
|
@@ -151,37 +154,42 @@ export function startServer(): void {
|
|
|
151
154
|
initPreparedStatements();
|
|
152
155
|
|
|
153
156
|
const socketHandlers = {
|
|
157
|
+
open(socket: Socket) {
|
|
158
|
+
// Per-connection accumulator: TCP/stream sockets don't preserve message
|
|
159
|
+
// boundaries, so buffer partial reads and only act on complete lines.
|
|
160
|
+
(socket as unknown as { buf: string }).buf = "";
|
|
161
|
+
},
|
|
154
162
|
data(socket: Socket, data: Buffer) {
|
|
155
|
-
const
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
163
|
+
const state = socket as unknown as { buf: string };
|
|
164
|
+
let buf = state.buf + data.toString();
|
|
165
|
+
let nl: number;
|
|
166
|
+
while ((nl = buf.indexOf("\n")) >= 0) {
|
|
167
|
+
const line = buf.slice(0, nl);
|
|
168
|
+
buf = buf.slice(nl + 1);
|
|
169
|
+
if (line.trim()) socket.write(handleRequest(line));
|
|
161
170
|
}
|
|
171
|
+
// Drop a pathologically long line with no terminator (flood guard)
|
|
172
|
+
state.buf = buf.length > MAX_LINE_BYTES ? "" : buf;
|
|
162
173
|
},
|
|
163
|
-
open() {},
|
|
164
174
|
close() {},
|
|
165
175
|
error(_socket: Socket, error: Error) {
|
|
166
176
|
console.error("[shellwise daemon] socket error:", error.message);
|
|
167
177
|
},
|
|
168
178
|
};
|
|
169
179
|
|
|
170
|
-
//
|
|
180
|
+
// Unix-domain socket only — protected by filesystem permissions (0600).
|
|
181
|
+
// No TCP: a guessable localhost port with no auth would let any local
|
|
182
|
+
// process poison history or read it.
|
|
171
183
|
server = Bun.listen({
|
|
172
184
|
unix: socketPath,
|
|
173
185
|
socket: socketHandlers,
|
|
174
186
|
});
|
|
187
|
+
try {
|
|
188
|
+
chmodSync(socketPath, 0o600);
|
|
189
|
+
} catch {}
|
|
175
190
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
hostname: "127.0.0.1",
|
|
179
|
-
port,
|
|
180
|
-
socket: socketHandlers,
|
|
181
|
-
});
|
|
182
|
-
|
|
183
|
-
// Save PID + port
|
|
184
|
-
writeFileSync(pidPath, `${process.pid}\n${port}`);
|
|
191
|
+
// Save PID (owner-only)
|
|
192
|
+
writeFileSync(pidPath, `${process.pid}`, { mode: 0o600 });
|
|
185
193
|
|
|
186
194
|
resetIdleTimer();
|
|
187
195
|
|
|
@@ -200,10 +208,6 @@ export function stopServer(): void {
|
|
|
200
208
|
server.stop(true);
|
|
201
209
|
server = null;
|
|
202
210
|
}
|
|
203
|
-
if (tcpServer) {
|
|
204
|
-
tcpServer.stop(true);
|
|
205
|
-
tcpServer = null;
|
|
206
|
-
}
|
|
207
211
|
closeDb();
|
|
208
212
|
|
|
209
213
|
const socketPath = getSocketPath();
|
|
@@ -236,13 +240,13 @@ export function isDaemonRunning(): boolean {
|
|
|
236
240
|
}
|
|
237
241
|
}
|
|
238
242
|
|
|
239
|
-
export function getDaemonInfo(): { pid: number
|
|
243
|
+
export function getDaemonInfo(): { pid: number } | null {
|
|
240
244
|
const pidPath = getPidPath();
|
|
241
245
|
if (!existsSync(pidPath)) return null;
|
|
242
246
|
try {
|
|
243
247
|
const content = require("fs").readFileSync(pidPath, "utf-8").trim();
|
|
244
|
-
const
|
|
245
|
-
return
|
|
248
|
+
const pid = parseInt(content.split("\n")[0]);
|
|
249
|
+
return Number.isNaN(pid) ? null : { pid };
|
|
246
250
|
} catch {
|
|
247
251
|
return null;
|
|
248
252
|
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Frecency = frequency × recency-weight, computed AT QUERY TIME.
|
|
3
|
+
*
|
|
4
|
+
* Recency must decay as time passes, so the weight is derived from the age
|
|
5
|
+
* of `last_used_at` on every read instead of being frozen into a stored
|
|
6
|
+
* column at write time. This single SQL expression is the source of truth
|
|
7
|
+
* shared by the daemon (inline suggest), the daemon-less fallback, and the
|
|
8
|
+
* Ctrl+R full search, so all three rank results identically.
|
|
9
|
+
*
|
|
10
|
+
* Requires `frequency` and `last_used_at` (epoch ms) columns in scope.
|
|
11
|
+
*/
|
|
12
|
+
export const FRECENCY_EXPR = `
|
|
13
|
+
frequency * (
|
|
14
|
+
CASE
|
|
15
|
+
WHEN (strftime('%s','now') * 1000 - last_used_at) < 3600000 THEN 4.0
|
|
16
|
+
WHEN (strftime('%s','now') * 1000 - last_used_at) < 86400000 THEN 2.0
|
|
17
|
+
WHEN (strftime('%s','now') * 1000 - last_used_at) < 604800000 THEN 1.5
|
|
18
|
+
WHEN (strftime('%s','now') * 1000 - last_used_at) < 2592000000 THEN 1.0
|
|
19
|
+
WHEN (strftime('%s','now') * 1000 - last_used_at) < 7776000000 THEN 0.5
|
|
20
|
+
ELSE 0.25
|
|
21
|
+
END
|
|
22
|
+
)
|
|
23
|
+
`;
|
package/src/db/queries.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { getDb } from "./connection";
|
|
2
|
+
import { FRECENCY_EXPR } from "./frecency";
|
|
2
3
|
import { createHash } from "crypto";
|
|
3
4
|
|
|
4
5
|
export interface CommandRecord {
|
|
@@ -37,8 +38,14 @@ export function hashCommand(command: string): string {
|
|
|
37
38
|
}
|
|
38
39
|
|
|
39
40
|
export function insertCommand(input: InsertCommandInput): void {
|
|
41
|
+
// Defense in depth: strip control chars (incl. NUL/tab/newline) and cap
|
|
42
|
+
// length before persisting, so a garbage/poisoned ADD can't store binary
|
|
43
|
+
// blobs or megabyte lines that later corrupt TUI rendering.
|
|
44
|
+
const command = input.command.replace(/[\x00-\x1f\x7f]/g, " ").trim();
|
|
45
|
+
if (command.length < 2 || command.length > 8192) return;
|
|
46
|
+
|
|
40
47
|
const db = getDb();
|
|
41
|
-
const hash = hashCommand(
|
|
48
|
+
const hash = hashCommand(command);
|
|
42
49
|
const now = Date.now();
|
|
43
50
|
|
|
44
51
|
db.transaction(() => {
|
|
@@ -46,7 +53,7 @@ export function insertCommand(input: InsertCommandInput): void {
|
|
|
46
53
|
`INSERT INTO commands (command, command_hash, cwd, exit_code, duration_ms, hostname, session_id, shell, created_at)
|
|
47
54
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
48
55
|
[
|
|
49
|
-
|
|
56
|
+
command,
|
|
50
57
|
hash,
|
|
51
58
|
input.cwd ?? null,
|
|
52
59
|
input.exit_code ?? null,
|
|
@@ -58,30 +65,21 @@ export function insertCommand(input: InsertCommandInput): void {
|
|
|
58
65
|
]
|
|
59
66
|
);
|
|
60
67
|
|
|
61
|
-
// Upsert command_stats
|
|
68
|
+
// Upsert command_stats. frecency_score is no longer used for ranking
|
|
69
|
+
// (recency is computed at query time via FRECENCY_EXPR); we keep the
|
|
70
|
+
// column populated with the raw frequency for debugging/back-compat.
|
|
62
71
|
db.run(
|
|
63
72
|
`INSERT INTO command_stats (command_hash, command, frequency, last_used_at, frecency_score)
|
|
64
|
-
VALUES (?, ?, 1, ?,
|
|
73
|
+
VALUES (?, ?, 1, ?, 1)
|
|
65
74
|
ON CONFLICT(command_hash) DO UPDATE SET
|
|
66
75
|
frequency = frequency + 1,
|
|
67
76
|
last_used_at = ?,
|
|
68
|
-
frecency_score =
|
|
69
|
-
[hash,
|
|
77
|
+
frecency_score = frequency + 1`,
|
|
78
|
+
[hash, command, now, now]
|
|
70
79
|
);
|
|
71
80
|
})();
|
|
72
81
|
}
|
|
73
82
|
|
|
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
83
|
export interface SearchOptions {
|
|
86
84
|
query?: string;
|
|
87
85
|
cwd?: string;
|
|
@@ -120,7 +118,7 @@ export function searchCommands(opts: SearchOptions): CommandStats[] {
|
|
|
120
118
|
|
|
121
119
|
const rows = db
|
|
122
120
|
.query<CommandStats, (string | number)[]>(
|
|
123
|
-
`SELECT command_hash, command, frequency, last_used_at, frecency_score
|
|
121
|
+
`SELECT command_hash, command, frequency, last_used_at, ${FRECENCY_EXPR} AS frecency_score
|
|
124
122
|
FROM command_stats cs
|
|
125
123
|
${where}
|
|
126
124
|
ORDER BY frecency_score DESC
|
|
@@ -185,24 +183,3 @@ export function getExistingHashes(): Set<string> {
|
|
|
185
183
|
.all();
|
|
186
184
|
return new Set(rows.map((r) => r.command_hash));
|
|
187
185
|
}
|
|
188
|
-
|
|
189
|
-
export function refreshAllFrecency(): void {
|
|
190
|
-
const db = getDb();
|
|
191
|
-
const now = Date.now();
|
|
192
|
-
const stats = db
|
|
193
|
-
.query<{ command_hash: string; frequency: number; last_used_at: number }, []>(
|
|
194
|
-
"SELECT command_hash, frequency, last_used_at FROM command_stats"
|
|
195
|
-
)
|
|
196
|
-
.all();
|
|
197
|
-
|
|
198
|
-
const update = db.prepare(
|
|
199
|
-
"UPDATE command_stats SET frecency_score = ? WHERE command_hash = ?"
|
|
200
|
-
);
|
|
201
|
-
|
|
202
|
-
db.transaction(() => {
|
|
203
|
-
for (const s of stats) {
|
|
204
|
-
const weight = calculateRecencyWeight(s.last_used_at);
|
|
205
|
-
update.run(s.frequency * weight, s.command_hash);
|
|
206
|
-
}
|
|
207
|
-
})();
|
|
208
|
-
}
|
package/src/index.ts
CHANGED
|
@@ -154,7 +154,7 @@ async function main(): Promise<void> {
|
|
|
154
154
|
await new Promise((r) => setTimeout(r, 200));
|
|
155
155
|
if (isDaemonRunning()) {
|
|
156
156
|
const info = getDaemonInfo();
|
|
157
|
-
console.log(`Daemon started (pid: ${info?.pid}
|
|
157
|
+
console.log(`Daemon started (pid: ${info?.pid})`);
|
|
158
158
|
} else {
|
|
159
159
|
console.error("Failed to start daemon.");
|
|
160
160
|
process.exit(1);
|
|
@@ -177,7 +177,7 @@ async function main(): Promise<void> {
|
|
|
177
177
|
case "status": {
|
|
178
178
|
if (isDaemonRunning()) {
|
|
179
179
|
const info = getDaemonInfo();
|
|
180
|
-
console.log(`Daemon running (pid: ${info?.pid}
|
|
180
|
+
console.log(`Daemon running (pid: ${info?.pid})`);
|
|
181
181
|
} else {
|
|
182
182
|
console.log("Daemon not running.");
|
|
183
183
|
}
|
package/src/tui/input.ts
CHANGED
|
@@ -72,7 +72,9 @@ function getTtyReadFd(): number {
|
|
|
72
72
|
}
|
|
73
73
|
|
|
74
74
|
export function readKeypress(): Buffer {
|
|
75
|
-
|
|
75
|
+
// Large enough to hold a pasted chunk in one read; a 16-byte buffer split
|
|
76
|
+
// multi-byte UTF-8 (emoji, accented chars) across reads and corrupted them.
|
|
77
|
+
const buf = Buffer.alloc(4096);
|
|
76
78
|
const bytesRead = readSync(getTtyReadFd(), buf);
|
|
77
79
|
return buf.subarray(0, bytesRead);
|
|
78
80
|
}
|
|
@@ -31,8 +31,15 @@ function writeCache(data: CachedCheck): void {
|
|
|
31
31
|
}
|
|
32
32
|
|
|
33
33
|
function compareVersions(current: string, latest: string): number {
|
|
34
|
-
|
|
35
|
-
|
|
34
|
+
// Strip a pre-release/build suffix (e.g. "1.2.3-beta") and parse each part
|
|
35
|
+
// safely; an unparseable segment must not become NaN and skew the compare.
|
|
36
|
+
const parse = (v: string) =>
|
|
37
|
+
v.split("-")[0].split(".").map((p) => {
|
|
38
|
+
const n = parseInt(p, 10);
|
|
39
|
+
return Number.isNaN(n) ? 0 : n;
|
|
40
|
+
});
|
|
41
|
+
const a = parse(current);
|
|
42
|
+
const b = parse(latest);
|
|
36
43
|
for (let i = 0; i < 3; i++) {
|
|
37
44
|
if ((a[i] ?? 0) < (b[i] ?? 0)) return -1;
|
|
38
45
|
if ((a[i] ?? 0) > (b[i] ?? 0)) return 1;
|