A Daily-Use Gemini REPL with Search Grounding and Persistent Cache
In this chapter we build an interactive command-line tool that combines Google’s Gemini API with optional search grounding and a persistent SQLite cache. The result is a practical daily-driver REPL: you can ask Gemini questions, ground answers in live web search results, and selectively cache useful responses so they become context for future queries. This project ties together two libraries developed earlier in the book — the gemini client library and the cache-engine SQLite wrapper — into a polished readline-enabled command-line application.
How It Works
The daily-use REPL implements a simple but effective workflow:
- Ask a question — Type a natural language query and Gemini responds using its training data, optional grounding web search, plus any relevant cached context.
- Ask with search — Prefix your query with
!to enable Google Search grounding, useful for current events or factual lookups. - Cache useful answers — Type
>to save the last answer to a persistent SQLite database. When you ask a new question, the tool extracts keywords from your query and retrieves only cached entries that share keyword overlap — so only relevant context is included. - Manage the cache — Type
!alone to clear cache entries older than one week.
This cache-as-context pattern is a lightweight alternative to retrieval-augmented generation (RAG). Instead of embedding documents into a vector store, you manually curate a set of useful facts. At query time, bag-of-words matching retrieves only the cached entries relevant to your current question, keeping context focused and avoiding noise.
Prerequisites
You need SBCL with Quicklisp installed, GNU readline (brew install readline on macOS), and a GOOGLE_API_KEY environment variable set for the Gemini API.
Project Structure
The project consists of three files: an ASDF system definition, a bootstrap script, and the main application code. It depends on two local libraries (gemini and cache-engine) and two Quicklisp libraries (cl-readline and cl-json).
daily-use.asd
1 ;;;; daily-use.asd
2
3 (asdf:defsystem #:daily-use
4 :description "Interactive REPL for Gemini AI with search grounding and persistent cache"
5 :author "Mark Watson"
6 :license "Apache 2"
7 :depends-on (#:gemini #:cache-engine #:cl-readline #:cl-json)
8 :components ((:file "daily-use")))
run.lisp
The bootstrap script registers the local system directories, loads all dependencies via Quicklisp, verifies the API key, and launches the REPL:
1 ;;;; run.lisp — Bootstrap and launch the daily-use REPL
2 ;;;;
3 ;;;; Usage: sbcl --load run.lisp
4
5 (require :asdf)
6
7 ;; Register local systems
8 (push (truename "./") asdf:*central-registry*)
9 (push (truename "../gemini/") asdf:*central-registry*)
10 (push (truename "../cache_engine/") asdf:*central-registry*)
11
12 ;; Load dependencies via Quicklisp
13 (handler-case
14 (ql:quickload '(:daily-use) :silent t)
15 (error (c)
16 (format t "~%Error loading daily-use: ~A~%" c)
17 (format t "~%Make sure you have Quicklisp installed and the following libraries available:~%")
18 (format t " - cl-json~%")
19 (format t " - cl-readline (requires GNU readline on the system)~%")
20 (format t " - sqlite~%")
21 (format t "~%On macOS, ensure readline is installed: brew install readline~%")
22 (uiop:quit 1)))
23
24 ;; Verify GOOGLE_API_KEY is set
25 (unless (uiop:getenv "GOOGLE_API_KEY")
26 (format t "~%Error: GOOGLE_API_KEY environment variable is not set.~%")
27 (format t "Export it before running: export GOOGLE_API_KEY=your-key-here~%")
28 (uiop:quit 1))
29
30 ;; Launch the REPL
31 (daily-use:main)
32 (uiop:quit 0)
Note the use of handler-case to provide a helpful error message if dependencies are missing, and the explicit API key check before launching — small touches that make a command-line tool pleasant to use.
The Main Application
Package and Configuration
The application defines a single package and two configuration variables:
1 ;;;; daily-use.lisp — Interactive Gemini REPL with search grounding and cache
2 ;;;;
3 ;;;; Commands:
4 ;;;; <text> Ask Gemini a question (plain, no search)
5 ;;;; !<text> Ask Gemini with Google Search grounding
6 ;;;; > Add last answer to the persistent cache
7 ;;;; ! Clear cache entries older than one week
8 ;;;; h / H / help Show help
9 ;;;; q / quit / exit Exit the REPL
10 ;;;; Ctrl-D Exit the REPL
11
12 (defpackage #:daily-use
13 (:use #:cl)
14 (:export #:main))
15
16 (in-package #:daily-use)
17
18 ;;; ---- Configuration ----
19
20 (defvar *model* "gemini-3.1-flash-lite")
21 (defvar *cache-db-path*
22 (merge-pathnames ".daily-use-cache.db" (user-homedir-pathname)))
The model is set to gemini-3.1-flash-lite for fast, inexpensive responses suitable for interactive use. The cache database lives in the user’s home directory so it persists across sessions and working directories.
State and Cache Context
Two dynamic variables track the runtime state:
1 ;;; ---- State ----
2
3 (defvar *cache* nil
4 "The cache-engine instance for persisting useful answers.")
5 (defvar *last-answer* nil
6 "The last answer returned by Gemini, available for caching with '>'.")
Before looking at the cache builder, we need a way to extract meaningful keywords from the user’s query. The extract-keywords function splits text into words, strips punctuation and stop words, and returns a list of content-bearing terms:
1 ;;; ---- Keyword extraction ----
2
3 (defvar *stop-words*
4 '("a" "an" "the" "is" "are" "was" "were" "be" "been" "being"
5 "have" "has" "had" "do" "does" "did" "will" "would" "shall" "should"
6 "may" "might" "must" "can" "could" "am" "it" "its"
7 "in" "on" "at" "to" "for" "of" "with" "by" "from" "as"
8 "and" "or" "but" "not" "no" "nor" "so" "yet"
9 "this" "that" "these" "those" "what" "which" "who" "whom"
10 "i" "me" "my" "we" "our" "you" "your" "he" "she" "they" "them"
11 "how" "when" "where" "why" "if" "then" "than" "about")
12 "Common English stop words to filter from search queries.")
13
14 (defun extract-keywords (text)
15 "Extracts meaningful keywords from TEXT by splitting on whitespace,
16 downcasing, removing punctuation, and filtering stop words and short words."
17 (let* ((downcased (string-downcase text))
18 (words (uiop:split-string downcased
19 :separator '(#\Space #\Tab #\Newline)))
20 (cleaned (mapcar (lambda (w)
21 (string-trim '(#\? #\! #\. #\, #\; #\: #\" #\' #\( #\))
22 w))
23 words)))
24 (remove-if (lambda (w)
25 (or (<= (length w) 2)
26 (member w *stop-words* :test #'string=)))
27 cleaned)))
For example, the query "what sci-fi movies are playing today in Flagstaff AZ?" produces the keyword list ("sci-fi" "movies" "playing" "today" "flagstaff"). Words shorter than three characters, punctuation, and common stop words are all filtered out.
The build-context-from-cache function uses these keywords to retrieve only relevant cached entries:
1 ;;; ---- Cache context builder ----
2
3 (defun build-context-from-cache (query)
4 "Retrieves cached items relevant to QUERY and builds a context string.
5 Uses bag-of-words matching: extracts keywords from the query and finds
6 cached entries containing any of those keywords."
7 (let* ((keywords (extract-keywords query))
8 (items (when keywords
9 (cache-engine:lookup *cache* keywords
10 :limit 10 :match-any t))))
11 (if items
12 (format nil "Use the following context from previous conversations when answering:~%~%~{- ~A~%~}~%---~%~%"
13 items)
14 "")))
The :match-any t argument tells the cache engine to use OR matching — a cached entry is included if it contains any of the query keywords, not all of them. This bag-of-words approach ensures that if you cached a movie-related answer last week and now ask about movies again, that context surfaces. But if you ask about something unrelated — say, a recipe — the movie answer stays out of the prompt.
When relevant cached items are found, the function produces a context preamble like:
1 Use the following context from previous conversations when answering:
2
3 - Project Hail Mary is playing at Harkins Flagstaff 16.
4
5 ---
The ~{- ~A~%~} format directive iterates over the matched items, printing each as a bulleted line. This context is prepended to the prompt so Gemini can reference previously cached facts without the user repeating them.
Query Dispatch
The ask-gemini function handles both plain and search-grounded queries:
1 ;;; ---- Query dispatch ----
2
3 (defun ask-gemini (prompt &key search-p)
4 "Sends PROMPT to Gemini, optionally with Google Search grounding.
5 Prepends relevant cached context to the prompt."
6 (let* ((context (build-context-from-cache prompt))
7 (full-prompt (concatenate 'string context prompt)))
8 (handler-case
9 (if search-p
10 (gemini:generate-with-search full-prompt *model*)
11 (gemini:generate full-prompt *model*))
12 (error (c)
13 (format nil "[Error calling Gemini API: ~A]" c)))))
The handler-case wrapping is important for a daily-use tool — network errors, rate limits, and API issues should produce a readable message rather than dropping the user into the debugger.
The REPL Loop
The heart of the application is repl-loop, which uses cl-readline for line editing and history:
1 ;;; ---- REPL ----
2
3 (defun repl-loop ()
4 "Main REPL loop with cl-readline for line editing and history."
5 (format t "~% Gemini Daily-Use REPL (type 'h' for help)~%~%")
6 (loop
7 (let ((input (rl:readline :prompt "gemini> " :add-history t)))
8 ;; Handle EOF (Ctrl-D)
9 (when (null input)
10 (format t "~%Goodbye.~%")
11 (return))
12
13 (let ((trimmed (string-trim '(#\Space #\Tab) input)))
14 (cond
15 ;; Empty line — skip
16 ((string= trimmed "")
17 nil)
18
19 ;; Quit
20 ((member trimmed '("q" "quit" "exit") :test #'string-equal)
21 (format t "Goodbye.~%")
22 (return))
23
24 ;; Help
25 ((member trimmed '("h" "help") :test #'string-equal)
26 (print-help))
27
28 ;; ">" — cache last answer
29 ((string= trimmed ">")
30 (if *last-answer*
31 (progn
32 (cache-engine:add_cache *cache* *last-answer*)
33 (format t " [Cached. ~D items total]~%"
34 (cache-engine:count-items *cache*)))
35 (format t " [No answer to cache yet]~%")))
36
37 ;; "!" alone — clear old cache
38 ((string= trimmed "!")
39 (let ((before (cache-engine:count-items *cache*)))
40 (cache-engine:clear-cache-older-one-week *cache*)
41 (let ((after (cache-engine:count-items *cache*)))
42 (format t " [Cleared ~D old entries. ~D items remain]~%"
43 (- before after) after))))
44
45 ;; "!<query>" — search-grounded question
46 ((char= (char trimmed 0) #\!)
47 (let ((query (string-trim '(#\Space #\Tab) (subseq trimmed 1))))
48 (if (string= query "")
49 ;; Edge case: "! " with only whitespace after
50 (let ((before (cache-engine:count-items *cache*)))
51 (cache-engine:clear-cache-older-one-week *cache*)
52 (let ((after (cache-engine:count-items *cache*)))
53 (format t " [Cleared ~D old entries. ~D items remain]~%"
54 (- before after) after)))
55 (progn
56 (format t " [Searching...]~%")
57 (finish-output)
58 (display-answer (ask-gemini query :search-p t))))))
59
60 ;; Plain question
61 (t
62 (format t " [Thinking...]~%")
63 (finish-output)
64 (display-answer trimmed)))))))
The cond dispatch is worth studying. The ! character serves double duty: alone it clears old cache entries, but followed by text it triggers a search-grounded query. The (char= (char trimmed 0) #\!) test catches the !<query> case, and a secondary check for an empty query after stripping the ! handles the edge case of ! followed by whitespace.
The (finish-output) calls before API queries ensure the status messages ([Thinking...], [Searching...]) appear immediately rather than being buffered until after the API response arrives.
Entry Point
The main function initializes the cache engine and wraps the REPL in unwind-protect to guarantee cleanup:
1 ;;; ---- Entry point ----
2
3 (defun main ()
4 "Initialize cache and start the REPL."
5 (setf *cache* (make-instance 'cache-engine:cache-engine
6 :db-path (namestring *cache-db-path*)))
7 (setf *last-answer* nil)
8 (unwind-protect
9 (repl-loop)
10 (cache-engine:close-cache *cache*)
11 (format t " [Cache closed]~%")))
The unwind-protect ensures the SQLite connection is closed even if the user exits with Ctrl-C or an unhandled error occurs — essential for a tool that writes to a persistent database.
Running the Tool
Start the REPL with:
1 cd src/daily_use
2 export GOOGLE_API_KEY=your-key-here
3 make run
Example Session
The following session demonstrates the search-then-cache workflow. First we ask a question with Google Search grounding (prefix !), then cache the answer, then ask the same question without search — Gemini can now answer from the cached context:
1 $ make run
2 sbcl --load run.lisp
3 This is SBCL 2.5.10, an implementation of ANSI Common Lisp.
4
5 Gemini Daily-Use REPL (type 'h' for help)
6
7 gemini> h
8
9 Gemini Daily-Use REPL
10 ─────────────────────────────────────────
11 <text> Ask Gemini a question
12 !<text> Ask with Google Search grounding
13 > Add last answer to cache
14 ! Clear cache entries older than 1 week
15 h / help Show this help
16 q / quit Exit
17 Ctrl-D Exit
18 ─────────────────────────────────────────
19 Model: gemini-3.1-flash-lite
20 Cache: /Users/markwatson/.daily-use-cache.db (0 items)
21
22 gemini> !what sci-fi movies are playing today in Flagstaff AZ?
23 [Searching...]
24
25 For today, Monday, May 11, 2026, the following science fiction movie is playing
26 in Flagstaff, AZ:
27
28 * **Project Hail Mary** (PG-13) is showing at the **Harkins Flagstaff 16**.
29
30 Please check the Harkins Theatres website or your preferred ticketing platform
31 to confirm specific showtimes, as they can change throughout the day.
32
33 gemini> >
34 [Cached. 1 items total]
35 gemini> what sci-fi movies are playing today in Flagstaff AZ?
36 [Thinking...]
37
38 For today, Monday, May 11, 2026, the science fiction movie **Project Hail Mary**
39 (PG-13) is playing at the **Harkins Flagstaff 16**.
40
41 Please check the Harkins Theatres website or your preferred ticketing platform
42 to confirm specific showtimes, as they can change throughout the day.
Notice that the second query (without the ! prefix) produces the same accurate, current answer — even though it did not use Google Search. The keywords "sci-fi", "movies", "flagstaff" matched the cached answer, so it was automatically included as context for Gemini.
REPL Command Reference
| Input | Action |
|---|---|
<text> |
Ask Gemini a question |
!<text> |
Ask with Google Search grounding |
> |
Add last answer to persistent cache |
! |
Clear cache entries older than 1 week |
h / help
|
Show help |
q / quit
|
Exit |
Ctrl-D |
Exit |
Key Takeaways
- Cache as context with relevance filtering — Selectively caching LLM responses and using bag-of-words keyword matching to retrieve only relevant entries keeps prompts focused. This is a lightweight alternative to vector-based RAG.
- Search grounding — The
!prefix leverages Google Search through the Gemini API, making the tool useful for current events and factual queries that exceed the model’s training cutoff. - GNU readline — The
cl-readlinelibrary provides full line editing, history, andCtrl-Rsearch out of the box, making the REPL feel like a native shell tool. unwind-protect— Wrapping the REPL ensures the SQLite database connection is closed cleanly, even on unexpected exits.- Composing libraries — This tool demonstrates how small, focused Common Lisp libraries (gemini, cache-engine, cl-readline) compose cleanly into a practical application through ASDF and Quicklisp.