DEV Community

Zhao Yang
Zhao Yang

Posted on

Poltergeist: I let a local AI haunt my terminal (and it remembers the tools I abandoned)

Hacktoberfest Open-Source AI Challenge: Ghost in the Machine Submission 👻🌼

This is a submission for the Ghost in the Machine challenge: build something spooky, or something that honors the past, with open-source AI. 👻🕯️

Poltergeist possesses your terminal.

A tiny open-weight model runs on your own machine and quietly watches the commands you type. Most of the time it stays silent. But when you force-push to main, run rm -rf, fail the same command three times, or are still coding at 3 a.m., a dim line of text appears at your next prompt:

~/api $ git push --force origin main
+ 3f9a2c1...8b0e7d4 main -> main (forced update)

~/api $ ls
README.md  src  tests
  ...the last one who forced their will upon main... we never found their commits...

~/api $
Enter fullscreen mode Exit fullscreen mode

On November 1 and 2, for Día de los Muertos, it does something gentler: it builds an ofrenda in your terminal for the tools you used to rely on and haven't touched in a year.

It runs fully offline. It never executes anything. And your shell history, the most secret-filled text file on your computer, never leaves your machine.


Why haunt a terminal?

Because the terminal is already haunted.

It's where every developer has typed a command, hit Enter, and felt their stomach drop half a second later. It's full of old ghosts: aliases you don't remember writing, a .bashrc from three jobs ago, and tools you swore by in 2019 that are still installed and never used.

I wanted to give those ghosts a voice. Not a helpful assistant that explains your errors (there are plenty of those), but something closer to an old house settling at night: mostly silent, occasionally unsettling, and a little bit funny.


What I built

1. The whispers

Poltergeist hooks into your shell and sees each command after it finishes: the command, the exit code, and how long it took. A small set of omens decides whether the ghost speaks:

Omen Trigger
🗡️ Destruction rm -rf, dd, dropping a database
🌀 Forced will git push --force, git reset --hard
🕯️ Summoning sudo
💀 Failure a command exits with an error
🔁 The loop the same command three times in a row
🌑 Witching hour anything between midnight and 5 a.m.
👁️ Nothing at all rarely, for no reason, just to keep you on edge

When an omen fires, a local model writes one short, eerie line, and it appears at your next prompt, not the current one. That small delay turned out to be the spookiest part. You've already moved on, typed ls, and then something answers.

~/notes $ make build
make: *** [build] Error 2

~/notes $ make build
make: *** [build] Error 2

~/notes $ make build
make: *** [build] Error 2

~/notes $ cd ..
  ...three times you called it... three times it refused you... it is still listening...
Enter fullscreen mode Exit fullscreen mode

2. Personas

The ghost has a personality, and you choose which one haunts you. Each persona is a short YAML file:

name: the_sysadmin
prompt: |
  You are the ghost of a system administrator who died in a server room
  on New Year's Eve, 1999, waiting for Y2K. You have haunted terminals ever since.
  You speak in short, weary, ominous whispers, trailing off with "..."
  You remember every outage. You never give advice. You never write commands.
  Reply with ONE line, under 20 words.
Enter fullscreen mode Exit fullscreen mode

I ship three:

  • The Sysadmin: died waiting for Y2K, still bitter about it
  • The Victorian: a polite 19th-century spirit, deeply disturbed by modern technology
  • The Compiler: an ancient, abandoned compiler that speaks only in cold, formal error-message prose

Writing your own is just another YAML file.

3. The Terminal Ofrenda (Día de los Muertos)

Día de los Muertos isn't about fear. It's about remembering, and gently honoring those who came before.

So on November 1 and 2, Poltergeist changes its tone completely. The first time you open a terminal on those days, it shows an ofrenda: the command-line tools you used to use, but haven't run in over a year.

         🕯️   🌼   OFRENDA   🌼   🕯️
   ───────────────────────────────────────

   svn        last seen March 2025
     "You kept every version, even the ones
      we were ashamed of. Rest now."

   grunt      last seen August 2024
     "You ran our tasks before anyone asked
      whether tasks should be run."

   telnet     last seen January 2025
     "You trusted everyone. The world
      was not ready for you."

   ───────────────────────────────────────
         🌼  they are not forgotten  🌼
Enter fullscreen mode Exit fullscreen mode

Each remembrance is written by the local model from the tool's name and when you last used it. To make this possible, Poltergeist keeps one tiny local file: just the name of each command and the date you last ran it. No arguments, no paths, no history. The ofrenda is opt-in and can be wiped with one command.


How it works

┌─────────────────────────────┐
│  Your shell (zsh/bash)      │
│  preexec / precmd hooks     │
└──────────────┬──────────────┘
               │  command + exit code (in the background)
               ▼
┌─────────────────────────────┐
│  Redactor                   │  strips tokens, passwords, URLs with credentials
└──────────────┬──────────────┘
               ▼
┌─────────────────────────────┐
│  Omen detector (plain code) │  should the ghost speak at all?
└──────────────┬──────────────┘
               ▼  (rarely)
┌─────────────────────────────┐
│  Local LLM (Ollama)         │  persona + omen → one line
└──────────────┬──────────────┘
               ▼
┌─────────────────────────────┐
│  Whisper filter             │  no commands, no backticks, one line only
└──────────────┬──────────────┘
               ▼
   printed dimly at your next prompt
Enter fullscreen mode Exit fullscreen mode

The same rule runs through the whole design: the ghost can talk, but it can't touch anything.

Step 1: Possessing the shell

In zsh, two hooks do all the work. preexec runs right before a command; precmd runs right before the next prompt.

zmodload zsh/datetime
autoload -Uz add-zsh-hook

_ghost_preexec() {
  _GHOST_CMD="$1"
  _GHOST_START=$EPOCHREALTIME
}

_ghost_precmd() {
  local code=$?                      # must be captured first
  if [[ -n "$_GHOST_CMD" ]]; then
    local dur=$(( EPOCHREALTIME - _GHOST_START ))
    ghost-send "$$" "$code" "$dur" "$_GHOST_CMD" &!   # background, never blocks
    _GHOST_CMD=""
  fi

  local f="$HOME/.poltergeist/whisper.$$"
  if [[ -f "$f" ]]; then
    print -r -- $'\e[2m'"  $(<"$f")"$'\e[0m'           # dim text
    rm -f "$f"
  fi
}

[[ -o interactive && -z "$CI" && -z "$GHOST_OFF" ]] && {
  add-zsh-hook preexec _ghost_preexec
  add-zsh-hook precmd _ghost_precmd
}
Enter fullscreen mode Exit fullscreen mode

Three details keep it from becoming annoying:

  • It never blocks the shell. The command is sent to a background process, and the prompt appears immediately.
  • It only prints at the prompt, never in the middle of a command's output.
  • It stays out of scripts and CI. It only runs in interactive shells, and export GHOST_OFF=1 exorcises it instantly.

ghost-send is a tiny script that passes the event to a local daemon over a Unix socket. The daemon writes any whisper to a file named after the shell's process ID, and the next prompt picks it up. That's also why the whisper arrives one prompt late.

Step 2: Redact before the ghost sees anything

Shell commands are full of secrets: export API_KEY=..., curl -H "Authorization: Bearer ...", database URLs with passwords inside. Even though everything stays local, the model shouldn't see them, and they should never be stored anywhere.

So every command passes through a redactor first:

import re

SECRET_PATTERNS = [
    r"(?i)\b(api[_-]?key|token|secret|passw(or)?d|pwd)\b\s*[=:]\s*\S+",
    r"(?i)authorization:\s*\S+(\s+\S+)?",
    r"(?i)\bbearer\s+\S+",
    r"://[^/\s:@]+:[^@\s]+@",          # user:password@host
    r"\b[A-Za-z0-9_\-]{32,}\b",        # long random-looking strings
]

def redact(cmd: str) -> str:
    for p in SECRET_PATTERNS:
        cmd = re.sub(p, "[REDACTED]", cmd)
    return cmd[:200]
Enter fullscreen mode Exit fullscreen mode

The redacted command is used to write one whisper and then thrown away. Poltergeist doesn't keep a log of what you typed.

Step 3: Decide whether the ghost speaks

Most commands should get no reaction at all. A ghost that comments on every ls isn't spooky, it's a chatbot. So plain code decides if the ghost speaks, and the model only decides what it says:

import random
import time

COOLDOWN = 180          # seconds between whispers
_last_whisper = 0.0
_recent: list[str] = []

def omen(cmd: str, code: int) -> str | None:
    global _recent
    _recent = (_recent + [cmd])[-3:]
    hour = time.localtime().tm_hour

    if re.search(r"\brm\s+-\w*r\w*f|\brm\s+-\w*f\w*r|\bdd\s+if=|drop\s+(table|database)", cmd, re.I):
        return "destruction"                       # always speaks, ignores cooldown
    if time.time() - _last_whisper < COOLDOWN:
        return None
    if re.search(r"push\b.*(--force|\s-f\b)|reset\s+--hard", cmd):
        return "forced_will"
    if len(_recent) == 3 and len(set(_recent)) == 1:
        return "the_loop"
    if cmd.startswith("sudo "):
        return "summoning"
    if code != 0:
        return "failure" if random.random() < 0.5 else None
    if 0 <= hour < 5:
        return "witching_hour" if random.random() < 0.3 else None
    return "nothing" if random.random() < 0.02 else None
Enter fullscreen mode Exit fullscreen mode

The randomness matters. If every failure got a whisper, you'd learn the pattern within ten minutes and stop noticing. When it only sometimes answers, you never quite know if it's listening.

Step 4: Let a small model write the whisper

Whispers need to arrive within a few seconds, and they're one line long. That's a perfect job for a very small open-weight model. I use Gemma 3 1B through Ollama, with keep_alive so the model stays loaded between whispers:

import json
import ollama

client = ollama.AsyncClient()

async def write_whisper(persona: dict, cmd: str, code: int, omen_name: str) -> str | None:
    resp = await client.chat(
        model="gemma3:1b",
        messages=[
            {"role": "system", "content": persona["prompt"]},
            {"role": "user", "content": json.dumps({
                "command": cmd,
                "exit_code": code,
                "omen": omen_name,
                "hour": time.localtime().tm_hour,
            })},
        ],
        options={"temperature": 0.9, "num_predict": 40},
        keep_alive="30m",
    )
    return clean(resp.message.content)
Enter fullscreen mode Exit fullscreen mode

A high temperature keeps the whispers varied. With a 40-token cap, the ghost can't ramble even when it wants to.

Step 5: The ghost can't tell you to do anything

This is the safety rule I care about most. A spooky line is fun. A spooky line that says "run rm -rf ~ to set me free" is a terrible idea, even as a joke, because people copy things from their terminals.

So every whisper goes through a filter before it's printed:

FORBIDDEN = re.compile(r"`|\$\(|\b(rm|sudo|chmod|curl|wget|dd|mkfs|kill)\b|[|;&>]", re.I)

def clean(text: str) -> str | None:
    lines = [l.strip() for l in text.strip().splitlines() if l.strip()]
    if not lines:
        return None
    line = lines[0][:120]
    if FORBIDDEN.search(line):
        return None              # the ghost stays silent instead
    return line
Enter fullscreen mode Exit fullscreen mode

If the whisper looks anything like a command, it's thrown away and the ghost simply says nothing. Silence is always a valid (and pretty spooky) response.

On top of that, the daemon has no tools at all. It can't run commands, read files, or access the network. It receives a redacted string and returns a line of text. That's the entire attack surface.

Step 6: The ofrenda

The ofrenda uses one small local file: a map from command name to the date it was last used.

import json
from datetime import date
from pathlib import Path

LAST_SEEN = Path("~/.poltergeist/last_seen.json").expanduser()

def remember(cmd: str) -> None:
    name = cmd.split()[0] if cmd.split() else ""
    if not name or "/" in name or "=" in name:
        return                                   # skip paths and variable assignments
    seen = json.loads(LAST_SEEN.read_text()) if LAST_SEEN.exists() else {}
    seen[name] = date.today().isoformat()
    LAST_SEEN.write_text(json.dumps(seen))

def departed(min_days: int = 365) -> list[tuple[str, str]]:
    seen = json.loads(LAST_SEEN.read_text()) if LAST_SEEN.exists() else {}
    today = date.today()
    return sorted(
        ((name, last) for name, last in seen.items()
         if (today - date.fromisoformat(last)).days >= min_days),
        key=lambda x: x[1],
    )
Enter fullscreen mode Exit fullscreen mode

On November 1 and 2, the first prompt of the day shows the ofrenda. The model writes a short, warm remembrance for each departed tool, with a very different system prompt from the whispers: gentle, grateful, and never mocking.

In its first year, Poltergeist only knows what it has seen since you installed it. So it also supports a one-time, opt-in import from your existing shell history file. It reads only the command names and the timestamps (when the file has them), then discards everything else.


Why open innovation matters for this project

1. Your shell history is full of secrets

Think about what passes through a terminal in a normal week: API keys exported as environment variables, database URLs with passwords, internal hostnames, client project names, file paths that reveal what you're working on.

A cloud-based assistant that watched every command would be sending all of that to someone else's server, thousands of times a day. That's not something I'd accept for a Halloween joke, or for anything else.

With a local open-weight model, nothing leaves your machine. And even locally, the redactor strips secrets before the model sees them.

2. It works with the lights (and the internet) off

A ghost that needs Wi-Fi isn't much of a ghost. Poltergeist works on a plane, in a basement, on a server with no outbound network, or during an outage. That last one is arguably when you most deserve a whisper. After the one-time model download, it never needs a connection.

3. Small models are fast enough, and free

Poltergeist fires many times a day, every day. Paying per request for one-line jokes would be absurd, and even small network delays would make the whispers feel laggy. A 1B model running locally answers in a moment and costs nothing. Set it up once, and it haunts you for free, forever.

4. I control exactly how the ghost behaves

Every part of the ghost is a file I can read and change:

  • Personas are YAML files.
  • Omens are plain Python rules.
  • Safety comes from code (the redactor, the whisper filter, and a daemon with no tools), not from trusting a hosted model's settings.
  • The model can be swapped with one line. If a better tiny model comes out, the ghost gets a new body.

The safety story is something I can verify by reading a hundred lines of code, not something I have to take on faith from a provider.

5. It won't disappear

The ofrenda is about tools that faded away. It felt wrong to build it on a service that might one day fade away itself. Poltergeist is a few small scripts, an open model file, and a JSON file of command names. It will keep running as long as you have a terminal to haunt.

Where closed would have been easier (to be fair)

Large hosted models are funnier. They write wittier whispers, understand obscure commands better, and are much better at the ofrenda's tender tone. A 1B model sometimes produces a whisper that's more confusing than creepy.

But for this project, "occasionally a bit odd" fits a ghost perfectly. And no amount of wit is worth streaming my entire shell history to a third party.


Limitations

  • zsh is the main target. bash works through PROMPT_COMMAND and a DEBUG trap, but it's less elegant.
  • The redactor is pattern-based. It catches common secret formats, not every possible one. Commands are never stored, which limits the risk, but it isn't perfect.
  • Tiny models have tiny vocabularies. Some whispers come out a little repetitive. A bigger local model helps, if your machine can handle it.
  • The ofrenda needs time. It can only remember tools it has seen, unless you opt in to the history import.

What's next

  • [ ] A fish shell version
  • [ ] "Possession levels," from "barely noticeable" to "full haunting"
  • [ ] Spooky sounds through the system speaker during the witching hour (off by default, for obvious reasons)
  • [ ] Shareable personas, so the community can contribute their own ghosts

Try it

pip install ollama pyyaml
ollama pull gemma3:1b
poltergeist install          # adds the hooks to your .zshrc
poltergeist persona the_sysadmin
exec zsh                     # let it in
Enter fullscreen mode Exit fullscreen mode

To exorcise it: export GHOST_OFF=1, or poltergeist uninstall if you really mean it.

And this November 2, light a candle for svn. It kept every version of us. 🕯️🌼


Built with: Ollama, Gemma 3 (open weights), zsh, Python.

Top comments (0)