Show HN: Deja predicts your next shell command without an LLM Giammarco-Ferranti released Deja, an open-source zsh plugin that predicts shell commands using fuzzy matching, directory awareness, and sequence prediction, without relying on an LLM. The tool runs as a local daemon with sub-millisecond response times and stores all data in a local SQLite database, offering a faster, privacy-preserving alternative to zsh-autosuggestions. Deja is a smarter replacement for zsh-autosuggestions https://github.com/zsh-users/zsh-autosuggestions . Instead of only surfacing commands that start with what you've typed, Deja uses fuzzy matching , directory awareness , and command sequence prediction to suggest what you actually want to run — as inline ghost text, after every keystroke, with zero latency. No account. No sync server. No TUI. Just ghost text that knows where you are. - Fuzzy matching — suggests commands even when you skip letters or mix up order - Directory awareness — commands you run in ~/projects/foo rank higher when you're in ~/projects/foo - Sequence prediction — knows that you usually run make test after make build - Frecency scoring — blends frequency + recency with a 1-week exponential decay - Ghost text inline — uses zsh's POSTDISPLAY widget, not a separate pane - Daemon architecture — one lightweight background process serves all terminal windows; <1ms response per keystroke - Local-only — all data stays in a local SQLite database; nothing leaves your machine - Respects your history settings — a leading space HIST IGNORE SPACE or a HISTORY IGNORE match keeps a command out of deja too, not just out of ~/.zsh history - Alternatives picker — press Tab to cycle through ranked alternatives without leaving the line brew install Giammarco-Ferranti/deja/deja && deja import && grep -qF 'deja/init.zsh' ~/.zshrc 2 /dev/null || echo 'if -r "$HOME/.local/share/deja/init.zsh" ; then source "$HOME/.local/share/deja/init.zsh"; else eval "$ deja init zsh "; fi' ~/.zshrc && exec zsh curl -fsSL https://raw.githubusercontent.com/Giammarco-Ferranti/deja/main/install.sh | sh Both commands install deja, import your existing zsh history, add the integration to ~/.zshrc idempotent , and reload your shell. To audit the curl installer before running it, view it on GitHub https://github.com/Giammarco-Ferranti/deja/blob/main/install.sh . If you manage zsh with Oh My Zsh https://ohmyz.sh , enable deja the idiomatic way, the same flow as zsh-autosuggestions . The binary still comes from Homebrew or the curl script; the plugin just sources deja's integration for you. 1. Install the deja binary. Skip or remove the activation lines it offers to add to ~/.zshrc, since the plugin loads the integration for you: brew install Giammarco-Ferranti/deja/deja or the curl installer above 2. Clone the plugin into Oh My Zsh's custom plugins dir: git clone https://github.com/Giammarco-Ferranti/deja \ ${ZSH CUSTOM:-~/.oh-my-zsh/custom}/plugins/deja 3. Add deja to plugins= ... in ~/.zshrc: plugins= ... deja 4. Import your history once and reload: deja import && exec zsh If you use zinit https://github.com/zdharma-continuum/zinit , add this to your .zshrc : zinit ice wait"0" lucid depth=1 pick"deja.plugin.zsh" zinit light Giammarco-Ferranti/deja zinit handles the Oh My Zsh plugin integration, so the activation lines in ~/.zshrc are not needed. Make sure the deja binary is installed separately via Homebrew or the curl installer. Prefer not to clone the whole repo? Fetch just the plugin file instead: mkdir -p ${ZSH CUSTOM:-~/.oh-my-zsh/custom}/plugins/deja curl -fsSL https://raw.githubusercontent.com/Giammarco-Ferranti/deja/main/deja.plugin.zsh \ -o ${ZSH CUSTOM:-~/.oh-my-zsh/custom}/plugins/deja/deja.plugin.zsh Pick one activation, not both. The plugin loads deja's integration for you, so if the installer already appended activation lines to ~/.zshrc , remove them. Keeping both double-sources the integration. Deja replaces zsh-autosuggestions , so don't list both in plugins= . If deja detects zsh-autosuggestions is loaded it stands down see Troubleshooting troubleshooting . The install commands above already do this for you. If you skipped them and have the binary on $PATH some other way, run these once to import your zsh history and activate the integration: python deja import eval "$ deja init zsh " By default deja import reads your zsh history from $HISTFILE when it's exported , falling back to ~/.zsh history . If your history lives elsewhere or $HISTFILE is set in ~/.zshrc without export , so child processes can't see it point deja at the file explicitly: deja import --file /path/to/history To make it permanent, add this to your ~/.zshrc : ~/.zshrc if -r "$HOME/.local/share/deja/init.zsh" ; then source "$HOME/.local/share/deja/init.zsh" else eval "$ deja init zsh " fi deja init zsh does not print the integration script — it writes it to ~/.local/share/deja/init.zsh and prints a source line for that file. So eval "$ deja init zsh " spends a full binary launch, ~25–36 ms on every shell you open, regenerating a file that is almost always byte-identical. Sourcing the file directly skips that; the eval above is only the first-run bootstrap, for before the file exists. Deja keeps the cached script current itself. Each shell compares the installed binary's stat identity against the one baked into the script — 0.083 ms, no subprocess — and if they differ, regenerates it in the background. The shell that noticed carries on with the old script; the next one picks up the new. So an upgrade never costs you a slow shell startup, and never leaves you on a stale integration either. Deja auto-spawns its daemon on first use and keeps it running across sessions. | Key | Action | Rebind with | |---|---|---| | → right arrow | Accept full suggestion | DEJA ACCEPT KEY extra key | | Ctrl+→ | Accept next word only | DEJA WORD ACCEPT KEY extra key | | Shift+→ | Cycle fuzzy preset forward tight → smart → loose | DEJA CYCLE FUZZY KEY | | Shift+← | Cycle fuzzy preset backward loose → smart → tight | DEJA CYCLE FUZZY BACK KEY | | Shift+↑ | Toggle ghost text on an empty prompt persisted, global | DEJA TOGGLE EMPTY KEY | | Tab | Open inline alternatives picker | DEJA CYCLE KEY | | Ctrl+X | Suppress current suggestion session-wide | DEJA TOGGLE KEY | | unbound | Dismiss the current ghost this line only | DEJA DISMISS KEY | Accept the ghost suggestion with → , not Enter . Enter executes whatever's literally in your buffer; → is what commits the ghost into the buffer first. Every binding above can be remapped by exporting the matching env var before the lines that load deja in your ~/.zshrc . Values are zle key sequences e.g. ^I is Tab, ^X is Ctrl+X, ^ 1;2C is Shift+→ ; run bindkey -L or pipe a keypress through cat -v to discover a key's sequence. Set any var to empty to leave that key unbound. defaults export DEJA CYCLE KEY='^I' Tab → cycle alternatives export DEJA TOGGLE KEY='^X' Ctrl+X → suppress for the session export DEJA CYCLE FUZZY KEY='^ 1;2C' Shift+→ → next fuzzy preset export DEJA CYCLE FUZZY BACK KEY='^ 1;2D' Shift+← → previous fuzzy preset export DEJA TOGGLE EMPTY KEY='^ 1;2A' Shift+↑ → flip empty-prompt suggestions these are unbound by default → and Ctrl+→ already accept via wrapped widgets : export DEJA ACCEPT KEY= accept full suggestion on a dedicated key export DEJA WORD ACCEPT KEY= accept the next word on a dedicated key export DEJA DISMISS KEY= clear the ghost for this line unlike Ctrl+X, the session keeps suggesting DEJA DISMISS KEY deja-clear differs from DEJA TOGGLE KEY deja-toggle : dismiss only wipes the ghost on the current line, while toggle suppresses suggestions for the whole session until you toggle back or start a new shell. Examples: Use Tab to accept the suggestion and free Tab from cycling : export DEJA ACCEPT KEY='^I' DEJA CYCLE KEY= Move alternatives-cycling off Tab so fzf/native completion keeps it: export DEJA CYCLE KEY='^N' Esc as dismiss: binding DEJA DISMISS KEY='^ ' Esc directly is discouraged — Esc is the prefix byte for arrow keys, function keys, and vi-mode, so a bare ^ binding can break those. Prefer a non-prefix key e.g. ^G , or set it knowing the tradeoff. Deja paints its suggestion through zsh's region highlight , so the ghost text accepts any style string zsh understands. The default is fg=8 ANSI bright black , which renders as dim grey on most themes. export DEJA HIGHLIGHT STYLE='fg=cyan' The grammar is the same one zsh-autosuggestions uses for ZSH AUTOSUGGEST HIGHLIGHT STYLE , so an existing style carries over unchanged: | Value | Effect | |---|---| | fg=8 | dim grey, the default | | fg=cyan | named colour | | fg=244 | index into the 256 colour palette | | fg=cyan,bg=white | foreground plus background | | fg=cyan,bold,underline | attributes; standout and blink also work | Export it before the integration loads. The variable is read once, at the moment the init script is sourced, and it only applies when already unset. Setting it below the source line in ~/.zshrc has no effect, so put it above: ~/.zshrc export DEJA HIGHLIGHT STYLE='fg=cyan,bold' must come first if -r "$HOME/.local/share/deja/init.zsh" ; then source "$HOME/.local/share/deja/init.zsh" else eval "$ deja init zsh " fi If the default is too faint on your colour scheme, fg=244 or fg=8,bold usually lifts it clear of the background without competing with the text you are typing. Deja's matcher accepts any in-order subsequence of the characters you've typed. By default it stops the typed letters from sprawling too far apart in a candidate — gco will match git checkout , but won't match git remote add origin . Pick a preset to tune that strictness: | Preset | Behavior | Example: typing gco | |---|---|---| | loose | typed letters can be far apart up to 8 chars between | gco → git checkout -- README | | smart | typed letters stay close together up to 4 chars between — default | gco → git checkout main | | tight | typed letters must be near-adjacent up to 1 char between | gco → gco , g.co , gc.o | Change the preset on the fly takes effect immediately, persists across restarts : deja fuzzy show current preset + examples deja fuzzy tight set the preset deja fuzzy cycle advance to the next preset tight → smart → loose → tight deja fuzzy back step to the previous preset loose → smart → tight → loose Or cycle without leaving the line — press Shift+→ forward or Shift+← backward at any prompt and the next preset is applied immediately. The ghost suggestion repaints under the new mode in the same frame, and a picker-style confirmation appears below the prompt showing where you are in the ladder: deja: fuzzy tight smart loose Rebind via DEJA CYCLE FUZZY KEY / DEJA CYCLE FUZZY BACK KEY set either to empty to disable that direction; tmux users may need set -g xterm-keys on for the default Shift+arrow sequences to pass through . Or override per shell session via environment variable: export DEJA FUZZY=smart before the daemon starts; takes precedence over the saved preset By default, on a fresh prompt — before you've typed anything — Deja predicts the command you're most likely to run next, based on command-sequence, frecency, and directory signals, and shows it as ghost text. If you'd rather see nothing until you start typing, turn empty-prompt suggestions off: deja empty show whether empty-prompt suggestions are on deja empty off never suggest on an empty prompt aliases: deja empty hide deja empty on restore the default aliases: deja empty show deja empty toggle flip the setting, printing just the new state on|off Or flip it without leaving the line — press Shift+↑ at any prompt. The ghost appears or disappears in the same frame, and a picker-style confirmation shows the new state below the prompt: deja: empty on off Rebind via DEJA TOGGLE EMPTY KEY set it to empty to leave Shift+↑ unbound; tmux users may need set -g xterm-keys on for the default Shift+arrow sequences to pass through . Note that — like fuzzy cycling — the keypress changes the persisted, global setting, not just the current session. Changes take effect immediately if the daemon is running and persist across restarts saved to ~/.local/share/deja/config . Override per shell session with an environment variable: export DEJA EMPTY=off before the daemon starts; takes precedence over the saved setting This is a global, persisted setting. It's different from Ctrl+X , which suppresses all suggestions for the current shell session only see Key Bindings key-bindings . Deja records the commands you run, so it honours the same rules zsh uses to decide what not to remember. If zsh won't keep a command, deja won't either. Keep one command out of history with a leading space. With setopt hist ignore space set it in ~/.zshrc ; some frameworks enable it by default , any line starting with a space or tab is discarded by zsh. Deja skips it too: setopt hist ignore space export AWS SECRET ACCESS KEY=wJal… leading space: neither zsh nor deja records this The check runs inside the shell hook, before deja is invoked, so the command is never handed to another process and never appears in ps . Keep a pattern out of history with HISTORY IGNORE . Deja applies your pattern the way zsh does, as a glob against the whole line: HISTORY IGNORE=' AWS SECRET | --password ' Ignored commands break the prediction chain. A skipped command is also dropped as the "previous command" used for sequence prediction, so it can't resurface indirectly through the sequences table. One deliberate difference from zsh. Deja drops space-prefixed commands even if you haven't set hist ignore space , because the daemon can't see your shell's setopt state and errs toward forgetting. Those commands stay in your zsh history as usual; they're simply not learned by deja. Suggestions are always offered trimmed, so nothing useful is lost. Commands recorded before you upgraded are still in the database. To start over from your current history: pkill -f 'deja daemon' rm ~/.local/share/deja/deja.db deja import deja import applies the same rules, so space-prefixed lines in your history file are skipped. Deja stores commands in plaintext in a local SQLite database and never sends them anywhere. It does not redact secrets embedded in otherwise ordinary commands such as curl -H "Authorization: ..." , so use the leading space for those. Every subcommand supports --help e.g. deja query --help for flag-level details. The most common issues: Suggestions aren't appearing. 1. Check the daemon is reachable: deja ping should print pong . 2. Confirm the integration is loaded in your shell: ~/.zshrc must source ~/.local/share/deja/init.zsh see Setup setup and the shell must have been re-sourced exec zsh . 3. Ctrl+X toggles per-session suppression — start a new shell to clear it. Ghost text is too faint to read. The suggestion is painted fg=8 dim grey by default, which some terminal themes render almost invisibly. Choose a brighter style with DEJA HIGHLIGHT STYLE , exported above the source line in ~/.zshrc . See Ghost text appearance ghost-text-appearance . Using another inline-suggestion plugin. Deja renders its own ghost text and replaces zsh-autosuggestions — don't run both. If Deja detects zsh-autosuggestions is loaded it prints a one-line notice and stands down rather than wrapping the same ZLE widgets, which can wedge the line editor . To use Deja, remove zsh-autosuggestions from plugins= in ~/.zshrc and restart your shell. The daemon seems stuck. deja daemon --restart Or stop it and let a fresh terminal auto-respawn it via the init script: pkill -f 'deja daemon' Suggestions still work but feel slow after upgrading deja. Daemons outlive shell sessions, so the one still running is from the previous version. New shells detect this and fall back to a slower path that keeps working; deja daemon --restart replaces it. Upgrading from a version older than the one that introduced --restart needs a one-time pkill -f 'deja daemon' instead, since those daemons left no pidfile to find them by. Stale socket after a crash. rm ~/.local/share/deja/sock Then open a new shell. Reset the database start over from current ~/.zsh history . pkill -f 'deja daemon' rm ~/.local/share/deja/deja.db deja import Where data lives. | Path | Purpose | |---|---| | ~/.local/share/deja/deja.db | SQLite database history, stats, sequences | | ~/.local/share/deja/sock | Unix socket the daemon listens on | | ~/.local/share/deja/init.zsh | Generated zsh integration script | Everything is per-user: each account gets its own database, socket, and daemon under its own $HOME . The directory is 0700 and the database files are 0600 , so other local accounts can't read your history. Deja re-applies those modes every time it runs, which repairs installs created by older versions with no action from you. Deja is built around four signals that are combined into a single composite score: score = 1.0 × fuzzy + 0.4 × frecency + 0.3 × directory affinity + 0.5 × sequence score | Signal | What it measures | |---|---| | Fuzzy | Subsequence match quality with bonuses for consecutive characters, word boundaries, and prefix hits | | Frecency | Log-scaled frequency combined with exponential recency decay 1-week half-life | | Directory affinity | How often you've run this command from the current directory | | Sequence score | Probability that this command follows the one you just ran | Scoring only runs over commands that are plausible continuations of what you typed. If any command in your history starts with the text on the line, only those are considered — typing cd suggests a cd … you have actually run, never claude --resume just because c…d…␣ happens to be hiding in it. If nothing starts with the line but its first word is a command you've run, the suggestion stays inside that command, so git ceckout still finds git checkout main . Fuzzy expansion applies when the text is not a command you've run: gco → git checkout is untouched. The flip side is that an anchored line with nothing to match shows no ghost text at all, rather than an unrelated command. ┌─────────────────┐ Unix socket ┌──────────────────────┐ │ zsh widget │ ──────────────────────▶ │ deja daemon │ │ per keystroke │ ◀────────────────────── │ single process, │ └─────────────────┘ suggestion <1ms │ all terminals │ └──────────┬───────────┘ │ SQLite WAL commands · stats · seqs The daemon loads all state into memory at startup map string CommandStat , directory affinities, sequence pairs and uses a sync.RWMutex so reads never block each other. Writes command recording take microseconds. If the daemon is unavailable, deja query falls back to a direct SQLite read automatically. That path ranks in two passes — once on fuzzy, frecency and sequence signal, then again over the top 50 with directory affinities fetched just for them — so a keystroke costs a few queries rather than one per command in your history. git clone https://github.com/Giammarco-Ferranti/deja.git cd deja make build produces ./bin/deja go test ./... run all tests go vet ./... lint Releases are automated via release-please https://github.com/googleapis/release-please and driven by conventional commits https://www.conventionalcommits.org/ on main : - feat: ... → minor bump - fix: ... → patch bump - feat : ... or a BREAKING CHANGE: footer → major bump - chore: , docs: , test: , refactor: → no version bump After qualifying commits land on main , the release-please workflow opens and keeps updating a Release PR that bumps .release-please-manifest.json and updates CHANGELOG.md . Merging that PR is the release action — it creates the vX.Y.Z git tag, which triggers release.yml to run the test suite and only on green publish binaries via GoReleaser and update the Homebrew tap https://github.com/Giammarco-Ferranti/homebrew-deja . Maintainers should not run git tag manually. Contributions are welcome — see CONTRIBUTING.md /Giammarco-Ferranti/deja/blob/main/CONTRIBUTING.md for setup, workflow, and commit conventions. For anything larger than a small fix, please open an issue first so we can align on direction. The scorer internal/scorer/ is the most iteration-heavy part of the codebase — the four signal weights are the best place to experiment if you want to improve suggestion quality. Please report vulnerabilities privately via GitHub's "Report a vulnerability" button on the repo's Security tab, not as public issues. Your command history is stored in plaintext in a local SQLite database. Deja keeps ~/.local/share/deja/ at 0700 and the database files at 0600 so other accounts on the same machine cannot read it see Where data lives troubleshooting . It is not encrypted at rest, so anyone who can already act as you, or as root, can read it. For how deja handles sensitive commands, and how to keep one out of the database, see Privacy privacy . 1. Remove the activation lines from ~/.zshrc — whichever form you used: if -r "$HOME/.local/share/deja/init.zsh" ; then source "$HOME/.local/share/deja/init.zsh" else eval "$ deja init zsh " fi 2. Stop the running daemon: pkill -f 'deja daemon' 3. Delete local data history DB, socket, generated init script : rm -rf ~/.local/share/deja/ 4. Remove the binary, depending on how you installed it: - Homebrew: brew uninstall deja and optionally brew untap Giammarco-Ferranti/deja - curl installer: rm "$ which deja " default location is ~/.local/bin/deja MIT — see LICENSE /Giammarco-Ferranti/deja/blob/main/LICENSE .