shellwise 0.2.10 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shellwise",
3
- "version": "0.2.10",
3
+ "version": "0.3.0",
4
4
  "description": "Smart command history with inline auto-suggest and fuzzy search for your terminal",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli/init.ts CHANGED
@@ -25,23 +25,15 @@ 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
- typeset -g __sw_port=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.
29
33
 
30
- # ─── Daemon connection (self-healing) ──────────────────────
31
- # The daemon idle-exits after inactivity and the TCP port is
32
- # deterministic, so the shell must be able to reconnect on
33
- # demand — otherwise a long-idle pane loses suggest forever.
34
+ typeset -g __sw_sock="/tmp/shellwise-\${UID}.sock"
34
35
 
35
- # Read daemon port from PID file (line 2). No fork (\$(<file) is builtin).
36
- __sw_read_port() {
37
- local pf="/tmp/shellwise-\${UID}.pid"
38
- [[ -f "\$pf" ]] || return 1
39
- local lines=("\${(@f)\$(<\$pf)}")
40
- __sw_port=\${lines[2]:-0}
41
- [[ \$__sw_port -gt 0 ]]
42
- }
43
-
44
- # Is the daemon process alive? No fork (kill -0 is a builtin).
36
+ # Is the daemon process alive? No fork (kill -0 + \$(<file) are builtins).
45
37
  __sw_daemon_alive() {
46
38
  local pf="/tmp/shellwise-\${UID}.pid"
47
39
  [[ -f "\$pf" ]] || return 1
@@ -49,19 +41,19 @@ __sw_daemon_alive() {
49
41
  kill -0 \${lines[1]:-0} 2>/dev/null
50
42
  }
51
43
 
52
- # (Re)establish the persistent TCP connection. No fork while typing.
44
+ # (Re)establish the persistent connection. No fork while typing.
53
45
  __sw_connect() {
54
46
  __sw_ready=0
55
- [[ -n "\$__sw_fd" ]] && { ztcp -c \$__sw_fd 2>/dev/null; __sw_fd="" }
56
- [[ \$__sw_port -gt 0 ]] || __sw_read_port || return 1
57
- ztcp 127.0.0.1 \$__sw_port 2>/dev/null || return 1
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
58
50
  __sw_fd=\$REPLY
59
51
  __sw_ready=1
60
52
  }
61
53
 
62
- # Load TCP module + connect at shell startup
63
- zmodload zsh/net/tcp 2>/dev/null && {
64
- if [[ ! -f "/tmp/shellwise-\${UID}.pid" ]]; then
54
+ # Load socket module + connect at shell startup
55
+ zmodload zsh/net/socket 2>/dev/null && {
56
+ if [[ ! -S "\$__sw_sock" ]]; then
65
57
  # Start daemon in background (non-blocking)
66
58
  command ${bin} daemon start &>/dev/null &!
67
59
  sleep 0.3
@@ -69,11 +61,15 @@ zmodload zsh/net/tcp 2>/dev/null && {
69
61
  __sw_connect
70
62
  }
71
63
 
72
- # ─── Persistent TCP query (no connect/disconnect overhead)
64
+ # ─── Persistent query (no connect/disconnect overhead) ─────
73
65
 
74
66
  typeset -ga __sw_tcp_result=()
75
67
 
76
- __sw_tcp_query() {
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() {
77
73
  __sw_tcp_result=()
78
74
 
79
75
  # Neutralize SIGPIPE: writing to a daemon that has idle-exited must return
@@ -81,20 +77,28 @@ __sw_tcp_query() {
81
77
  setopt local_options local_traps
82
78
  trap '' PIPE
83
79
 
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
+
84
85
  # (Re)connect if needed — daemon may have idle-exited or restarted
85
86
  [[ \$__sw_ready -ne 1 ]] && { __sw_connect || return 1 }
86
87
 
87
- # Send request; one transparent reconnect+retry on a stale connection
88
- if ! print -u \$__sw_fd "\$1" 2>/dev/null; then
89
- __sw_connect && print -u \$__sw_fd "\$1" 2>/dev/null || { __sw_ready=0; return 1 }
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 }
90
91
  fi
91
92
 
92
- # Read response lines until empty line
93
- local line
94
- while IFS= read -r -t 0.1 -u \$__sw_fd line 2>/dev/null; do
95
- [[ -z "\$line" ]] && break
96
- __sw_tcp_result+=("\$line")
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")
97
100
  done
101
+ [[ \$__sw_got -eq 1 ]] || { __sw_connect; return 1 }
98
102
 
99
103
  [[ \${#__sw_tcp_result} -gt 0 ]]
100
104
  }
@@ -118,7 +122,7 @@ __sw_precmd() {
118
122
  # Save via persistent TCP (instant). On failure, restart a dead daemon
119
123
  # (a fork between commands is fine) so the next keystroke can reconnect,
120
124
  # and fall back to a direct write for this command.
121
- if ! __sw_tcp_query "ADD\\t\$__SW_COMMAND\\t\$PWD\\t\$exit_code\\t\$duration\\t\$SW_SESSION_ID\\tzsh"; then
125
+ if ! __sw_query ADD "\$__SW_COMMAND" "\$PWD" "\$exit_code" "\$duration" "\$SW_SESSION_ID" zsh; then
122
126
  __sw_daemon_alive || command ${bin} daemon start &>/dev/null &!
123
127
  command ${bin} add \\
124
128
  --command "\$__SW_COMMAND" \\
@@ -177,15 +181,17 @@ __sw_render() {
177
181
  __sw_suggest() {
178
182
  [[ "\$BUFFER" == "\$__sw_prev_buffer" ]] && return
179
183
  __sw_prev_buffer="\$BUFFER"
180
- __sw_selected=0
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
181
187
  __sw_suggestions=()
182
188
  POSTDISPLAY=""
183
189
  region_highlight=()
184
190
 
185
191
  [[ \${#BUFFER} -lt 2 ]] && return
186
192
 
187
- # TCP query only — no fallback, never spawn process during typing
188
- __sw_tcp_query "SUGGEST\\t\$BUFFER\\t5" || return
193
+ # Socket query only — no fallback, never spawn process during typing
194
+ __sw_query SUGGEST "\$BUFFER" 5 || return
189
195
 
190
196
  __sw_suggestions=("\${__sw_tcp_result[@]}")
191
197
  __sw_render
@@ -223,7 +229,12 @@ __sw_next() {
223
229
 
224
230
  __sw_prev() {
225
231
  if [[ \${#__sw_suggestions} -gt 0 ]]; then
226
- __sw_selected=\$(( (__sw_selected - 1 + \${#__sw_suggestions}) % \${#__sw_suggestions} ))
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
227
238
  __sw_render
228
239
  else
229
240
  zle .reverse-menu-complete
@@ -233,16 +244,18 @@ __sw_prev() {
233
244
  # ─── Enter: accept selected or execute ─────────────────────
234
245
 
235
246
  __sw_accept_line() {
236
- if [[ \${#__sw_suggestions} -gt 0 ]]; then
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
237
251
  BUFFER="\${__sw_suggestions[\$(( __sw_selected + 1 ))]}"
238
252
  CURSOR=\${#BUFFER}
239
- POSTDISPLAY=""
240
- region_highlight=()
241
- __sw_suggestions=()
242
- __sw_prev_buffer="\$BUFFER"
243
- else
244
- zle .accept-line
245
253
  fi
254
+ POSTDISPLAY=""
255
+ region_highlight=()
256
+ __sw_suggestions=()
257
+ __sw_prev_buffer="\$BUFFER"
258
+ zle .accept-line
246
259
  }
247
260
 
248
261
  # ─── Escape: clear suggestions ─────────────────────────────
@@ -262,7 +275,9 @@ __sw_dismiss() {
262
275
 
263
276
  __sw_forward_char() {
264
277
  if [[ \${#__sw_suggestions} -gt 0 && \$CURSOR -eq \${#BUFFER} ]]; then
265
- BUFFER="\${__sw_suggestions[\$(( __sw_selected + 1 ))]}"
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]}"
266
281
  CURSOR=\${#BUFFER}
267
282
  POSTDISPLAY=""
268
283
  region_highlight=()
@@ -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 frecency_score DESC
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 frecency_score DESC
40
+ ORDER BY ${FRECENCY_EXPR} DESC
40
41
  LIMIT ?`
41
42
  )
42
43
  .all(escapedQuery, query, remaining + historyResults.length);
@@ -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: parseInt(parts[3]) || 0,
62
- duration: parseInt(parts[4]) || 0,
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`;
@@ -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, getDaemonPort } from "./protocol";
7
- import { unlinkSync, writeFileSync, existsSync } from "fs";
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 last_used_at DESC
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 last_used_at DESC
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 lines = data.toString().split("\n");
156
- for (const line of lines) {
157
- if (line.trim()) {
158
- const response = handleRequest(line);
159
- socket.write(response);
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
- // Listen on both Unix socket and TCP (for ztcp from zsh)
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
- const port = getDaemonPort();
177
- tcpServer = Bun.listen({
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; port: number } | null {
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 lines = content.split("\n");
245
- return { pid: parseInt(lines[0]), port: parseInt(lines[1]) };
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(input.command);
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
- input.command,
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, ?, 4.0)
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 = (frequency + 1) * ?`,
69
- [hash, input.command.trim(), now, now, calculateRecencyWeight(now)]
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}, port: ${info?.port})`);
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}, port: ${info?.port})`);
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
- const buf = Buffer.alloc(16);
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
- const a = current.split(".").map(Number);
35
- const b = latest.split(".").map(Number);
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;