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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shellwise",
3
- "version": "0.2.9",
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,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 TCP module + connect at shell startup
30
- zmodload zsh/net/tcp 2>/dev/null && {
31
- # Read daemon port from PID file
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
- if [[ -f "\$__sw_pidfile" ]]; then
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 TCP query (no connect/disconnect overhead)
64
+ # ─── Persistent query (no connect/disconnect overhead) ─────
50
65
 
51
66
  typeset -ga __sw_tcp_result=()
52
67
 
53
- __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() {
54
73
  __sw_tcp_result=()
55
- [[ \$__sw_ready -ne 1 ]] && return 1
56
74
 
57
- # Send request
58
- print -u \$__sw_fd "\$1" 2>/dev/null || {
59
- # Connection broken — mark unavailable
60
- __sw_ready=0
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
- # Read response lines until empty line
65
- local line
66
- while IFS= read -r -t 0.1 -u \$__sw_fd line 2>/dev/null; do
67
- [[ -z "\$line" ]] && break
68
- __sw_tcp_result+=("\$line")
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) or fallback to background process
91
- __sw_tcp_query "ADD\\t\$__SW_COMMAND\\t\$PWD\\t\$exit_code\\t\$duration\\t\$SW_SESSION_ID\\tzsh" || \\
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
- __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
149
187
  __sw_suggestions=()
150
188
  POSTDISPLAY=""
151
189
  region_highlight=()
152
190
 
153
191
  [[ \${#BUFFER} -lt 2 ]] && return
154
192
 
155
- # TCP query only — no fallback, never spawn process during typing
156
- __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
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
- __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
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
- 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
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
- 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]}"
234
281
  CURSOR=\${#BUFFER}
235
282
  POSTDISPLAY=""
236
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;