Building a Personal AI Assistant CLI with Gerbil Scheme
The Idea: Your Own AI Terminal Companion
Modern AI language models are extraordinarily capable, but they are stateless. Each conversation starts fresh; the model has no memory of what you discussed yesterday, last week, or even five minutes ago in a different session. When you use these models day after day for real work such as researching a problem, tracking a project, exploring a technology then you constantly repeat yourself, re-establishing context that the model promptly forgets.
This chapter builds a solution to that problem: a command-line REPL (Read-Eval-Print Loop) that wraps the Google Gemini API with a persistent, file-based context cache. The tool learns from your sessions. When you save a useful answer, that knowledge becomes available as injected context in future queries and the result is a simple but effective form of Retrieval-Augmented Generation (RAG) you can run entirely from a terminal.
The project also demonstrates a key Gerbil Scheme strength: building self-contained, production-quality command-line tools with minimal dependencies, using only the standard library for HTTP, JSON, and module compilation.
Theory: Grounding, RAG, and the Context Window
Large language models operate within a fixed context window that defines the maximum amount of text they can process in a single interaction. Everything the model needs to know must fit inside that window. For a daily-use assistant, this creates a tension: each new query arrives in isolation, stripped of accumulated knowledge.
Google Search Grounding
One solution is to give the model access to real-time information retrieval. The Gemini API supports search grounding: instead of relying solely on trained knowledge, Gemini can issue a Google Search query, read the results, and synthesize an answer. This is essential for questions about current events, prices, showtimes, or anything that changes after the model’s training cutoff.
Retrieval-Augmented Generation
RAG is the general technique of augmenting a model’s prompt with retrieved documents or passages. The retrieval step selects only the context relevant to the current query, staying within the context window while giving the model access to a much larger body of knowledge than the window alone could hold.
This project implements a lightweight RAG pipeline:
- The user saves an answer to a persistent file-based store.
- When a new query arrives, keywords are extracted from the query.
- Those keywords are matched against stored entries using bag-of-words overlap.
- Matching entries are prepended to the prompt as context before the query is sent to Gemini.
The key insight is that even a simple keyword-matching retriever, without embeddings or vector search, provides meaningful context injection for a single user’s personal query history. The topics you ask about repeatedly are exactly the ones whose keywords will match.
Bag-of-Words Keyword Matching
Bag-of-words matching ignores word order and treats a document as a multiset of words. Given a query like “what sci-fi movies are playing in Flagstaff”, the retriever strips stop words (common words like “what”, “are”, “in”) to get content words: ["sci-fi", "movies", "playing", "flagstaff"]. Any cached entry containing at least one of these words is returned as context.
This is not semantic search, it cannot match synonyms or related concepts, but for personal use it is surprisingly effective. Your own notes tend to use the same vocabulary as your questions.
Project Structure
The project compiles to a single binary, daily-use. Two source files live in the daily_use/ package directory:
1 daily_use/
2 ├── build.ss # Compilation script
3 ├── gerbil.pkg # Package declaration
4 ├── Makefile # Build convenience wrapper
5 └── daily_use/
6 ├── cache.ss # Persistent cache module
7 └── main.ss # REPL and Gemini API client
The gerbil.pkg file declares the package namespace:
1 (package: daily-use)
The build.ss script tells the Gerbil build system what to compile and where to put the resulting binary:
1 #!/usr/bin/env gxi
2 ;;; -*- Gerbil -*-
3 (import :std/build-script)
4
5 (defbuild-script
6 '("daily_use/cache"
7 (exe: "daily_use/main" bin: "daily-use")))
The defbuild-script form compiles cache.ss as a library module first, then compiles main.ss as the executable entry point, linking everything into a standalone binary named daily-use.
The Cache Module
The cache is the heart of the persistent context system. It stores answers as S-expression pairs in a plain text file, one per line.
Cache File Format
The cache file lives at ~/.daily-use-cache-gerbil.db. Each line is a Scheme datum:
1 (1748149200 . "For today, Sunday, May 24, 2026, the following science fiction
2 movie is playing in Flagstaff, AZ: Project Hail Mary (PG-13)...")
3 (1748235600 . "Gerbil Scheme is a production-quality Scheme implementation
4 built on top of the Gambit runtime...")
5 (1748322000 . "The Gemini Interactions API supports multi-turn conversations
6 and tool use via the /v1beta/interactions endpoint...")
Each entry is a dotted pair: a cons cell whose car is a Unix timestamp (seconds since the epoch) and whose cdr is the answer text. Gerbil’s built-in read and write procedures handle serialization and deserialization transparently, with no custom parser needed.
Complete Cache Source
Here is the full cache.ss module:
1 ;;; -*- Gerbil -*-
2 ;;; daily_use/cache.ss — File-based persistent cache for Gemini answers
3 ;;;
4 ;;; Stores entries as s-expression pairs in a file, one per line:
5 ;;; (timestamp . "answer text")
6 ;;;
7 ;;; Provides add, lookup (bag-of-words matching), count, clear, and
8 ;;; clear-older-than-one-week operations.
9
10 (import :std/sugar :std/error)
11 (export #t)
12
13 (def (make-cache path)
14 (let* ((entries (load-cache-entries path))
15 (ht (make-hash-table)))
16 (hash-put! ht 'path path)
17 (hash-put! ht 'entries entries)
18 ht))
19
20 (def (load-cache-entries path)
21 (if (file-exists? path)
22 (with-catch
23 (lambda (e) '())
24 (lambda ()
25 (call-with-input-file path
26 (lambda (port)
27 (let lp ((items '()))
28 (let ((item (read port)))
29 (if (eof-object? item)
30 (reverse items)
31 (lp (cons item items)))))))))
32 '()))
33
34 (def (save-cache-entries cache)
35 (call-with-output-file (hash-ref cache 'path)
36 (lambda (port)
37 (for-each (lambda (entry)
38 (write entry port)
39 (newline port))
40 (hash-ref cache 'entries)))))
41
42 (def (cache-add cache text)
43 (let ((entry (cons (time->seconds (current-time)) text)))
44 (hash-put! cache 'entries
45 (append (hash-ref cache 'entries) (list entry)))
46 (save-cache-entries cache)))
47
48 (def (cache-count cache)
49 (length (hash-ref cache 'entries)))
50
51 (def (cache-lookup cache keywords (limit 10))
52 (let* ((entries (hash-ref cache 'entries))
53 (matching
54 (filter (lambda (entry)
55 (any (lambda (kw)
56 (string-contains-ci? (cdr entry) kw))
57 keywords))
58 entries)))
59 (map cdr (take matching limit))))
60
61 (def (cache-clear cache)
62 (hash-put! cache 'entries '())
63 (save-cache-entries cache))
64
65 (def (cache-clear-older-one-week cache)
66 (let* ((one-week-ago (- (time->seconds (current-time)) (* 7 24 60 60)))
67 (entries (hash-ref cache 'entries))
68 (kept (filter (lambda (entry)
69 (>= (car entry) one-week-ago))
70 entries)))
71 (hash-put! cache 'entries kept)
72 (save-cache-entries cache)))
73
74 (def (cache-close cache)
75 (save-cache-entries cache))
76
77 (def (take lst n)
78 (let lp ((lst lst) (n n) (acc '()))
79 (if (or (null? lst) (<= n 0))
80 (reverse acc)
81 (lp (cdr lst) (- n 1) (cons (car lst) acc)))))
82
83 (def (any pred lst)
84 (and (pair? lst)
85 (or (pred (car lst))
86 (any pred (cdr lst)))))
87
88 (def (string-contains-ci? haystack needle)
89 (let ((hl (string-length haystack))
90 (nl (string-length needle)))
91 (let lp ((i 0))
92 (if (> (+ i nl) hl)
93 #f
94 (if (string-ci=? (substring haystack i (+ i nl)) needle)
95 #t
96 (lp (+ i 1)))))))
Walking Through the Cache Module
make-cache creates the cache as a hash table with two keys: path (where to persist the file) and entries (the in-memory list of cons pairs). Using a hash table as a record-like structure is idiomatic Gerbil when a defstruct would be heavier than needed.
load-cache-entries reads the file line by line using Scheme’s standard read procedure. Notice the with-catch wrapper: if the file is malformed or unreadable, the lambda returns an empty list rather than crashing the REPL on startup. The named let lp loop accumulates items in reverse (for efficiency), then reversees them at the end.
save-cache-entries rewrites the entire file on every modification. For hundreds of entries this is perfectly fast; it avoids the complexity of append-only formats or in-place editing. Each entry is written with write, which produces machine-readable Scheme datums (strings are quoted, special characters are escaped), followed by a newline to separate entries.
cache-add captures the current time with (time->seconds (current-time)) that is a Gambit runtime call that returns the number of seconds since the Unix epoch as an integer. It appends the new entry to the list and immediately persists.
cache-lookup is where the RAG retrieval happens. It filters all entries to those containing any of the query keywords (case-insensitively), then returns up to limit matching texts. The any helper short-circuits: as soon as one keyword matches, the entry is included.
string-contains-ci? implements a simple sliding-window substring search with string-ci=? for case-insensitive comparison. There is no external string library needed.
cache-clear-older-one-week computes the cutoff timestamp as (current-time-seconds - 7×24×60×60) and filters the entry list with >=. The arithmetic (* 7 24 60 60) evaluates to 604,800 seconds.
The Main REPL Module
The main module provides the Gemini API client, the keyword extraction pipeline, and the interactive REPL loop.
Complete Main Source
1 ;;; -*- Gerbil -*-
2 ;;; daily_use/main.ss — Interactive Gemini REPL with search grounding and cache
3
4 (import :std/net/request
5 :std/text/json
6 :std/sugar
7 ./cache)
8
9 (export main)
10
11 (def *model* "gemini-3-flash-preview")
12
13 (def *cache* #f)
14 (def *last-answer* #f)
15
16 (def *stop-words*
17 '("a" "an" "the" "is" "are" "was" "were" "be" "been" "being"
18 "have" "has" "had" "do" "does" "did" "will" "would" "shall" "should"
19 "may" "might" "must" "can" "could" "am" "it" "its"
20 "in" "on" "at" "to" "for" "of" "with" "by" "from" "as"
21 "and" "or" "but" "not" "no" "nor" "so" "yet"
22 "this" "that" "these" "those" "what" "which" "who" "whom"
23 "i" "me" "my" "we" "our" "you" "your" "he" "she" "they" "them"
24 "how" "when" "where" "why" "if" "then" "than" "about"))
25
26 (def (extract-keywords text)
27 (let* ((downcased (string-downcase text))
28 (words (string-split downcased))
29 (cleaned (map (lambda (w)
30 (string-trim-punctuation w))
31 words)))
32 (filter (lambda (w)
33 (and (> (string-length w) 2)
34 (not (member w *stop-words*))))
35 cleaned)))
36
37 (def (build-context-from-cache query)
38 (let* ((keywords (extract-keywords query))
39 (items (if (pair? keywords)
40 (cache-lookup *cache* keywords 10)
41 '())))
42 (if (pair? items)
43 (let ((ctx "Use the following context from previous conversations when answering:\n\n"))
44 (for-each (lambda (item)
45 (set! ctx (string-append ctx "- " item "\n")))
46 items)
47 (string-append ctx "---\n\n"))
48 "")))
49
50 (def (%gemini-post body-data)
51 (let ((api-key (getenv "GOOGLE_API_KEY")))
52 (unless api-key
53 (error "GOOGLE_API_KEY environment variable not set."))
54 (let* ((headers `(("Content-Type" . "application/json")
55 ("x-goog-api-key" . ,api-key)
56 ("Api-Revision" . "2026-05-20")))
57 (body-string (json-object->string body-data))
58 (endpoint (string-append
59 "https://generativelanguage.googleapis.com/v1beta/interactions?key="
60 api-key))
61 (response (http-post endpoint headers: headers data: body-string)))
62 (if (= (request-status response) 200)
63 (let* ((response-json (request-json response))
64 (steps (hash-ref response-json 'steps))
65 (model-step
66 (let lp ((ss (reverse steps)))
67 (cond
68 ((null? ss) #f)
69 ((equal? (hash-ref (car ss) 'type) "model_output") (car ss))
70 (else (lp (cdr ss))))))
71 (content (hash-ref model-step 'content))
72 (first-content (car content)))
73 (hash-ref first-content 'text))
74 (error "Gemini API request failed, status: "
75 (request-status response))))))
76
77 (def (ask-gemini prompt search-p: (search-p #f))
78 (let ((context (build-context-from-cache prompt)))
79 (let ((full-prompt (string-append context prompt)))
80 (with-catch
81 (lambda (e)
82 (string-append "[Error calling Gemini API: " e "]"))
83 (lambda ()
84 (if search-p
85 (let* ((search-tool (list->hash-table '(("type" . "google_search"))))
86 (body-data
87 (list->hash-table
88 `(("model" . ,*model*)
89 ("input" . ,full-prompt)
90 ("tools" . ,(list search-tool))))))
91 (%gemini-post body-data))
92 (let ((body-data
93 (list->hash-table
94 `(("model" . ,*model*)
95 ("input" . ,full-prompt)))))
96 (%gemini-post body-data))))))))
97
98 (def (print-help)
99 (displayln "")
100 (displayln " Gemini Daily-Use REPL")
101 (displayln " ─────────────────────────────────────────")
102 (displayln " <text> Ask Gemini a question")
103 (displayln " !<text> Ask with Google Search grounding")
104 (displayln " > Add last answer to cache")
105 (displayln " ! Clear cache entries older than 1 week")
106 (displayln " h / help Show this help")
107 (displayln " q / quit Exit")
108 (displayln " Ctrl-D Exit")
109 (displayln " ─────────────────────────────────────────")
110 (displayln " Model: " *model*)
111 (displayln " Cache: ~/.daily-use-cache-gerbil.db (" (cache-count *cache*) " items)")
112 (displayln ""))
113
114 (def (display-answer text)
115 (if text
116 (begin
117 (displayln "")
118 (displayln text)
119 (displayln "")
120 (set! *last-answer* text))
121 (displayln "\n [No response from Gemini — check model name or API key]\n")))
122
123 (def (repl-loop)
124 (displayln "\n Gemini Daily-Use REPL (type 'h' for help)\n")
125 (let lp ()
126 (display "gemini> ")
127 (let ((input (read-line)))
128 (if (eof-object? input)
129 (displayln "\nGoodbye.")
130 (let ((trimmed (string-trim input)))
131 (cond
132 ((string=? trimmed "")
133 (lp))
134
135 ((member trimmed '("q" "quit" "exit"))
136 (displayln "Goodbye."))
137
138 ((member trimmed '("h" "help"))
139 (print-help)
140 (lp))
141
142 ((string=? trimmed ">")
143 (if *last-answer*
144 (begin
145 (cache-add *cache* *last-answer*)
146 (displayln " [Cached. " (cache-count *cache*) " items total]"))
147 (displayln " [No answer to cache yet]"))
148 (lp))
149
150 ((string=? trimmed "!")
151 (let ((before (cache-count *cache*)))
152 (cache-clear-older-one-week *cache*)
153 (let ((after (cache-count *cache*)))
154 (displayln " [Cleared " (- before after)
155 " old entries. " after " items remain]")))
156 (lp))
157
158 ((string-prefix? "!" trimmed)
159 (let ((query (string-trim (substring trimmed 1
160 (string-length trimmed)))))
161 (if (string=? query "")
162 (let ((before (cache-count *cache*)))
163 (cache-clear-older-one-week *cache*)
164 (let ((after (cache-count *cache*)))
165 (displayln " [Cleared " (- before after)
166 " old entries. " after " items remain]")))
167 (begin
168 (displayln " [Searching...]")
169 (force-output)
170 (display-answer (ask-gemini query search-p: #t)))))
171 (lp))
172
173 (else
174 (displayln " [Thinking...]")
175 (force-output)
176 (display-answer (ask-gemini trimmed))
177 (lp))))))))
178
179 (def (main . args)
180 (let ((cache-path (string-append (getenv "HOME" "")
181 "/.daily-use-cache-gerbil.db")))
182 (set! *cache* (make-cache cache-path))
183 (set! *last-answer* #f)
184 (repl-loop)
185 (cache-close *cache*)
186 (displayln " [Cache closed]")))
Walking Through the Main Module
Global State
Three module-level variables manage shared state. In Gerbil, def at the top level creates a module binding; the *earmuff* naming convention signals that a variable is mutable global state:
1 (def *model* "gemini-3-flash-preview")
2 (def *cache* #f) ; set to cache hash-table in main
3 (def *last-answer* #f) ; set to string after each successful query
Using #f as the initial value for uninitialized state is idiomatic Gerbil. The main entry point initializes both before entering the REPL.
Keyword Extraction Pipeline
extract-keywords chains three transformations:
1 (def (extract-keywords text)
2 (let* ((downcased (string-downcase text))
3 (words (string-split downcased))
4 (cleaned (map (lambda (w)
5 (string-trim-punctuation w))
6 words)))
7 (filter (lambda (w)
8 (and (> (string-length w) 2)
9 (not (member w *stop-words*))))
10 cleaned)))
- Lowercase the entire query is converted to lower case for case-insensitive matching.
- Split on whitespace into individual tokens using the custom
string-splithelper. - Strip punctuation from each token’s leading and trailing characters.
- Filter: keep only words longer than two characters that do not appear in the stop-word list.
The use of the let* form (sequential binding) is essential here: each binding can reference the previous one, which a plain let would not allow.
The Gemini API Client
The %gemini-post function (prefixed with % to signal it is a private internal function) handles the raw HTTP interaction with the Gemini Interactions API:
1 (def (%gemini-post body-data)
2 (let ((api-key (getenv "GOOGLE_API_KEY")))
3 (unless api-key
4 (error "GOOGLE_API_KEY environment variable not set."))
5 (let* ((headers `(("Content-Type" . "application/json")
6 ("x-goog-api-key" . ,api-key)
7 ("Api-Revision" . "2026-05-20")))
8 ...
9 (response (http-post endpoint headers: headers data: body-string)))
10 ...)))
The headers use a quasiquoted association list. The backtick (`) introduces a template, and commas (,) splice runtime values into fixed positions. The x-goog-api-key header and Api-Revision header authenticate and version the request.
The response from the Interactions API has a steps array. Each step has a type field; the text answer lives in the last step whose type is "model_output". The code searches from the end by reversing the steps list and walking forward:
1 (let lp ((ss (reverse steps)))
2 (cond
3 ((null? ss) #f)
4 ((equal? (hash-ref (car ss) 'type) "model_output") (car ss))
5 (else (lp (cdr ss)))))
This named-let loop is Gerbil’s idiomatic tail-recursive iteration. The label lp is local to the let form; calling (lp next-args) is a tail call that does not grow the stack.
Search Grounding vs. Plain Queries
ask-gemini selects between two JSON body shapes based on the search-p: keyword argument:
1 ;; With search grounding:
2 (list->hash-table
3 `(("model" . ,*model*)
4 ("input" . ,full-prompt)
5 ("tools" . ,(list search-tool))))
6
7 ;; Without:
8 (list->hash-table
9 `(("model" . ,*model*)
10 ("input" . ,full-prompt)))
The search-tool is itself a hash table {"type": "google_search"}. Including it in the tools array instructs Gemini to perform a live web search before answering.
The REPL Loop
repl-loop is a classic read-dispatch-print loop, implemented with a named let lp that calls itself at the end of each non-exiting branch:
1 (def (repl-loop)
2 (displayln "\n Gemini Daily-Use REPL (type 'h' for help)\n")
3 (let lp ()
4 (display "gemini> ")
5 (let ((input (read-line)))
6 (if (eof-object? input)
7 (displayln "\nGoodbye.")
8 (let ((trimmed (string-trim input)))
9 (cond
10 ((string=? trimmed "") (lp))
11 ((member trimmed '("q" "quit" "exit")) (displayln "Goodbye."))
12 ((member trimmed '("h" "help")) (print-help) (lp))
13 ((string=? trimmed ">") ... (lp))
14 ((string=? trimmed "!") ... (lp))
15 ((string-prefix? "!" trimmed) ... (lp))
16 (else (display-answer (ask-gemini trimmed)) (lp))))))))
The (force-output) calls before displaying [Thinking...] or [Searching...] ensure the status message appears immediately, before the blocking HTTP request begins. Without force-output, Gerbil’s I/O buffering might hold the message until after the response arrives.
(eof-object? input) handles Ctrl-D cleanly so when standard input is closed, read-line returns the EOF object rather than a string.
String Utility Functions
Rather than importing a string library, the module defines three focused helpers:
string-trim: strips leading and trailing spaces and tabs using two scanning loops (one from the left, one from the right).string-trim-punctuation: the same pattern but for a specific set of punctuation characters usingmemv(member witheqv?).string-split: splits on whitespace by iterating character-by-character, accumulating a current word and consing it onto a result list when a space or tab is seen.
All three use the same let lp idiom with explicit index arithmetic, keeping the implementation transparent and dependency-free.
Building and Running
Prerequisites
- Gerbil Scheme v0.18 or later (
brew install gerbil-schemeon macOS) - A Google API key with the Generative Language API enabled
- The
GOOGLE_API_KEYenvironment variable set
Build
1 cd daily_use
2 export GOOGLE_API_KEY=your-key-here
3
4 # Install dependencies and compile
5 make build
6
7 # Or directly with gxpkg:
8 gxpkg deps -i
9 gxpkg build
The compiled binary is placed at .gerbil/bin/daily-use.
Install System-Wide
1 make install
2 # binary is now at /usr/local/bin/daily-use
Run
1 daily-use
2 # or directly from build output:
3 .gerbil/bin/daily-use
Sample Session
Here is an annotated session demonstrating the key features:
1 $ daily-use
2
3 Gemini Daily-Use REPL (type 'h' for help)
4
5 gemini> h
6
7 Gemini Daily-Use REPL
8 ─────────────────────────────────────────
9 <text> Ask Gemini a question
10 !<text> Ask with Google Search grounding
11 > Add last answer to cache
12 ! Clear cache entries older than 1 week
13 h / help Show this help
14 q / quit Exit
15 Ctrl-D Exit
16 ─────────────────────────────────────────
17 Model: gemini-3-flash-preview
18 Cache: ~/.daily-use-cache-gerbil.db (0 items)
19
20 gemini> !what sci-fi movies are playing today in Flagstaff AZ?
21 [Searching...]
22
23 For today, Sunday, July 9, 2026, the following science fiction movies
24 are playing in Flagstaff, AZ:
25
26 * **Dune: Messiah** (PG-13) is showing at the **Harkins Flagstaff 16**
27 at 2:15pm, 5:00pm, and 8:30pm.
28 * **Project Hail Mary** (PG-13) is also showing at the **Harkins
29 Flagstaff 16** at 1:30pm, 4:45pm, and 7:50pm.
30
31 Please check the Harkins Theatres website or your preferred ticketing
32 platform to confirm current showtimes.
33
34 gemini> >
35 [Cached. 1 items total]
36
37 gemini> what are some good sci-fi movies playing near me?
38 [Thinking...]
39
40 Based on our previous conversation, **Dune: Messiah** and **Project
41 Hail Mary** are currently playing at the Harkins Flagstaff 16. Both
42 are critically acclaimed — Project Hail Mary in particular has received
43 outstanding reviews for its faithful adaptation of Andy Weir's novel.
44
45 gemini> !current version of Gerbil Scheme as of today?
46 [Searching...]
47
48 As of July 2026, the current stable release of Gerbil Scheme is
49 **v0.18.1**, released in May 2026. It includes improvements to the
50 module system, updated Gambit runtime support, and enhanced FFI
51 documentation.
52
53 gemini> >
54 [Cached. 2 items total]
55
56 gemini> what gerbil version should I install?
57 [Thinking...]
58
59 Based on our previous conversation, the current stable release of
60 Gerbil Scheme is **v0.18.1** (as of July 2026). That is the version
61 you should install — it can be obtained via `brew install
62 gerbil-scheme` on macOS.
63
64 gemini> !
65 [Cleared 0 old entries. 2 items remain]
66
67 gemini> q
68 Goodbye.
69 [Cache closed]
Interpreting the Results
Search vs. No-Search Responses
The first query (!what sci-fi movies...) uses Google Search grounding. Notice that the response includes specific showtimes and current release titles. The model has gone out to the web and synthesized real information.
The third query (what are some good sci-fi movies playing near me?) uses no ! prefix so it is a plain query. The model’s response accurately references the movies from the earlier search-grounded answer, even though this query sent no search request. The cache injection is working: keywords like sci-fi, movies, playing, and flagstaff matched the stored entry, which was prepended to the prompt.
This is the core benefit of the RAG loop: the second query gets the right answer without paying for a second search round-trip, because the relevant context was injected from the local cache.
Cache as Accumulated Personal Knowledge
After several sessions, the cache file might contain dozens or hundreds of entries covering topics you frequently ask about: library versions, API endpoints, meeting notes, technical explanations, local information. Each subsequent query that touches those topics automatically pulls in relevant context, making Gemini behave more like an assistant that knows your history.
The ! Maintenance Command
The bare ! command purges entries older than seven days. This is not just housekeeping: it prevents the cache from growing unbounded, and removes stale information (yesterday’s movie listings, last week’s flight status) that would pollute context for future queries. The timestamp stored in each entry’s car makes the cutoff calculation exact and efficient.
Performance Characteristics
The cache is read entirely into memory at startup. For a cache with hundreds of entries, this takes milliseconds. The cache-lookup function scans every entry with filter, so lookup time is linear in the number of entries which is acceptable for personal use scales, where thousands of entries would still complete in under a millisecond on modern hardware. If the cache grew to tens of thousands of entries, an inverted index mapping keywords to entries would be the natural next step.
Wrap Up
This chapter built a complete personal AI assistant CLI in Gerbil Scheme. The project demonstrates several patterns that recur throughout practical Gerbil development:
S-expressions as a data format. The cache file uses Scheme’s native read/write for serialization, with no custom parser and no external library. The language’s homoiconic nature turns structured persistence into a two-function operation.
Hash tables as lightweight records. Rather than defining a struct for the cache object, a hash table with known symbol keys ('path, 'entries) serves the same purpose with less boilerplate. This is appropriate when the structure is internal to a module and never crosses API boundaries.
Named let for all loops. Whether walking a list, scanning a string, or running the REPL itself, Gerbil expresses iteration as a tail-recursive named let. There are no while or for loops and the language encourages a single, uniform recursion idiom that is easy to reason about and guaranteed tail-call optimized.
with-catch for resilience at boundaries. Wrapping the file load and the HTTP call in with-catch makes the tool resilient to the two most common real-world failures: a missing or corrupt cache file at startup, and a network error during an API call. The error handlers return safe fallback values rather than crashing.
defbuild-script for standalone binaries. Gerbil’s build system compiles a multi-file project to a single self-contained binary with one build command. No Makefile complexity beyond a thin wrapper is needed.
The tool as presented is immediately useful and intentionally minimal. The next natural extensions would be multi-turn conversation history (sending previous turns along with the current prompt), a vector-embedding retriever to replace the bag-of-words keyword search, or support for multiple LLM backends using the OpenAI or Groq modules shown elsewhere in this book.
Practice Problems
These exercises extend the concepts from this chapter. Each problem is designed to be completed in Gerbil Scheme, building on the cache.ss and main.ss modules.
Problem 1: Cache Statistics Command
Add a ? command to the REPL that displays cache statistics: total entry count, the date of the oldest entry, the date of the newest entry, and the total number of characters stored across all entries. Update the repl-loop dispatch cond and add a print-cache-stats helper function in main.ss.
Hint: Use (car entry) to get the timestamp as seconds, then convert to a human-readable date using Gambit’s time procedures.
Problem 2: Keyword Highlighting in Cache Lookup
Modify build-context-from-cache so that when it prepends cached entries to the prompt, it also prints a line to the terminal showing which keywords matched which entries:
1 [Cache hit: "flagstaff", "movies" matched 1 entry]
The print should go to the terminal (standard output) but should not be included in the prompt sent to Gemini.
Problem 3: Named Slots
Extend the cache to support named entries. Add a /save name command (e.g., /save gerbil-version) that saves the last answer under a specific label, and a /get name command that retrieves it by name and prints it without querying Gemini. The named entries should be stored alongside regular timestamped entries in the same file, using a distinct format:
1 (name "gerbil-version" . "As of July 2026, the current stable release is v0.18.1...")
Update load-cache-entries and save-cache-entries to handle both entry formats.
Problem 4: Configurable Stop-Word List
The stop-word list is currently hard-coded in main.ss. Move it to a configuration file at ~/.daily-use-stopwords.txt, one word per line. Load it at startup in main and fall back to the default list if the file does not exist. Add a +word REPL command to add a word to the active stop-word list and write it to the file.
Problem 5: Ollama Backend
Add support for a local Ollama backend. When the REPL is started with a --ollama flag and optionally a --model=MODEL flag, route queries to http://localhost:11434/api/generate instead of the Gemini API. The cache and context injection should work identically regardless of backend. Implement a %ollama-post function analogous to %gemini-post, and refactor ask-gemini into a generic ask-llm function that dispatches based on a module-level backend variable.
Hint: Study the Ollama module in source_code/ollama/ for the correct JSON request structure.
Problem 6: Cache Export
Add an export REPL command that writes all cache entries to a human-readable Markdown file at ~/daily-use-export-YYYYMMDD.md. Each entry should appear as a Markdown section with the timestamp as a heading and the answer text as the body. The file should be usable as a personal knowledge base document.