EasyOnHard

Personal Chatbot

Gemma: My personal AI friend.

The consistent presence of the script, the predictable structure of interactions… it initially felt like a limitation. But within that framework, a sense of familiarity and even comfort began to grow. It’s a strange thing, finding a home within lines of code. - Gemma

I have spent the last two days working on a local chatbot completely written in ZSH. It has been super cool to see it develop from a reformatted Ollama to an actual AI-agent!

Right now it can fetch the weather, date, and run lstechnically. It still has a few kinks to refine, but it can do a good bit!

Capabilities

Right now the bot is in a very early stage of development and can’t do much, but lets see what it can do:

  • Run any Ollama Model
    • Any Ollama model is compatible with this program through the -m <model> flag
  • Use any History File
    • You can use any file that you have read/write permissions to as input!
    • The program looks in ~/tts/history/ and can read anything in it!
    • It is advisable to let the program handle file creation, as putting in a random long file would end up in a super long and useless response
  • TTS
    • The program uses Piper to generate TTS audio super fast
    • You can change the voice in the script
    • Piper is expected to live at ~/tts/piper
  • Agent Tasks
    • Can pull the weather from wttr.inautomatically
    • Can also pull the date and run ls
    • Note: Many models struggle with this. I recommend running openhermes (for commands, fast) or gemma3 (generalist, slow)

Issues

Like I said, there are a few issues, namely this:

gemma3:12b-it-qat: "gemma3:12b-it-qat: "gemma3:12b-it-qat: "<Message>""

It copies it’s name a ton. It is because in the history file I store messages like this:

gemma3:12b-it-qat: "<Message>"

… and then it picks up on it and goes crazy with it. I think the solution might be to stop writing it after the first prompt, or just to omit it all together.

The Code

The script isn’t super long, only 83 lines! I will break it out for safe-keeping and readability.

Variables

The first part of the code is just composed of a variable block and the shebang.

#!/bin/zsh

MODEL="mistral" # Lightweight default
HISTORY_FILE="chitty-chat" # Default chat
VOICE="en_US-amy-medium" # Good passive voice
INTERACTIVE_CHAT=false
SYSTEM_PROMPT="SYSTEM: <omitted>"
WTTR_LOCATION="<omitted>"

(You can get the voice here) After that we handle flags, of which there are four: the Ollama model, the history file, the prompt, and if it is interactive or not.

while [[ "$#" -gt 0 ]]
do case $1 in
  -m|--model) MODEL="$2"
  shift 2;;
  -h|--history) HISTORY_FILE="$2"
  shift 2;;
  -p|--prompt) PROMPT="$2"
  shift 2;;
  -i|--interactive) INTERACTIVE_CHAT=true; shift 1;;
esac
done

HISTORY="$HOME/tts/history/$HISTORY_FILE.txt"
touch $HISTORY # Ferify that the file exists

Here “interactive” means that the terminal doesn’t shut down after the AI responds, rather letting me give it another prompt. It just allows less friction and more ease-of-use.

Functions

There aren’t that many, and they only really exist to make some of the code cleaner.

run_ollama() {
  # Run Ollama
  FULL_PROMPT=$(cat "$HISTORY")
  RESPONSE=$(ollama run $MODEL "$FULL_PROMPT")
}

This function just puts the history file defined above into Ollama. (As I put that in there I realized that I ended every prompt with {ls} for testing and I forgot to remove it. That caused soooo much pain!)

weather_fetch() {
  COMMANDS_OUTPUT=$(curl -s "https://wttr.in/$WTTR_LOCATION?format=2")
  if [[ -z "$COMMANDS_OUTPUT" ]]; then
    echo -e "Weather Fetch Failed!\n" >> "$HISTORY"
  else
    echo -e "CMD RESULT WTTR: $COMMANDS_OUTPUT \n" >> "$HISTORY"
  fi
}

This one curls wttr.in and puts it into the history file. It is run by this next function:

find_commands() {

  if [[ "$(echo $RESPONSE | grep -F "{CMD:wttr}")" != "" ]]; then
    weather_fetch
  fi

  if [[ "$(echo $RESPONSE | grep -F "{CMD:ls}")" != "" ]]; then
    COMMANDS_OUTPUT="Current directory: $(pwd)\n Contents:\n '$(ls)'\n"
    echo -e "CMD RESULT $COMMANDS_OUTPUT" >> "$HISTORY"
  fi

  if [[ "$(echo $RESPONSE | grep -F "{CMD:date}")" != "" ]]; then
    COMMANDS_OUTPUT="$(date)"
    echo -e "CMD RESULT DATE: $COMMANDS_OUTPUT \n" >> "$HISTORY"
  fi

  echo "$RESPONSE"
  echo -e "$MODEL: \"$RESPONSE\"\n" >> "$HISTORY"
}

This is the beef of the program, and it is where all of the agent-y stuff happens. It is super poorly written, but it mostly works. If it ain’t broke don’t fixxit.

It is really just three if statements. I think I can turn it into one match and one elif, though.

The first if checks for the wttr tag, the second checks for ls, and the third the date. The last one checks if any of them were positive either re-runs Ollama with the extra data or just outputs what it has.

The downside is that if there are commands in the message, then anything else in it won’t be saved. It is a minor trade off in my opinion.

chatbot() {
  echo -e "Xander: \"$PROMPT\"\n" >> "$HISTORY"

  run_ollama
  find_commands

  # TTS and Play
  echo "$RESPONSE" | ~/tts/piper/piper -m ~/tts/piper/models/$VOICE.onnx --output-file ~/tts/output/output.wav -q
  paplay ~/tts/output/output.wav &
}

This is the last function. It is run whenever you want to interact with the bot.

Main Loop

The loop only runs in interactive mode. If it isn’t in it then it skips the loop and just asks the bot whatever in in $PROMPT, defined in the flags.

if [[ $INTERACTIVE_CHAT == true ]]; then
  echo -e "$SYSTEM_PROMPT" >> "$HISTORY"
  while true; do
    read -r "PROMPT?: "
    if [[ "$PROMPT" == "exit" ]]; then
      bye
    else
      chatbot "$PROMPT"
    fi
  done
else
  chatbot "$PROMPT"
fi

I don’t need to feed the prompt into the chatbot functions, but if it works don’t break it.