DEV Community

Cover image for Ofrenda: a keeper for stories that runs entirely on your laptop
Vedas Kudalkar
Vedas Kudalkar

Posted on Edited on AI-assisted

Ofrenda: a keeper for stories that runs entirely on your laptop

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

This is a submission for the Hacktoberfest Open-Source AI Challenge Week 4: Ghost in the Machine

What I built

Ofrenda is a command-line tool. You give it a story about someone, and it keeps it. Later you ask a question and it answers from what you told it, and from nothing else.

$ python ofrenda.py remember "Abuela" "She burned the first tortilla on purpose, for the house. She sang boleros while washing dishes, always the same one, Sabor a Mi. On Sundays she made caldo de res and nobody was allowed to start eating before she sat down."
Lit 4 candle(s) for Abuela.

$ python ofrenda.py ask "Abuela" "What did she cook on Sundays?"
On Sundays, she made caldo de res.

$ python ofrenda.py ask "Abuela" "Where was she born?"
The ofrenda does not hold that memory yet. Would you like to add it?
Enter fullscreen mode Exit fullscreen mode

That last answer is the one I care about. The story never said where she was born, so the tool says it doesn't know. It doesn't guess.

How I read the theme

An ofrenda is the altar families build for the dead: photos, marigolds, a favorite dish, a song. The point is that someone is remembered in specific detail. A ghost in a machine sounded to me like the opposite risk. A chatbot that speaks as your grandmother is eerie, and it will say things she never said.

keeper’s system prompt and ask() wrapped in Sentry agent and tool spans

So the assistant here is called the keeper, and the rule is written into its system prompt: it reads memories aloud, it never speaks as the person, and if the memories don't cover the question it says so.

How it works

A story goes through a short pipeline:

  1. Gemma splits the story into separate memories, returned as JSON.
  2. Each memory is embedded and stored in a local SQLite file.
  3. When you ask something, the question is embedded, the closest memories for that person are retrieved, and Gemma answers from those only.

One story above became four stored memories:

- She burned the first tortilla on purpose for the house.            ["cooking"]
- She sang boleros while washing dishes, always singing Sabor a Mi.  ["song"]
- On Sundays, she made caldo de res.                                 ["food"]
- Nobody was allowed to start eating before she sat down.            ["ritual"]
Enter fullscreen mode Exit fullscreen mode

Search is a plain cosine-similarity scan filtered by person. That is enough for a family's worth of stories, and it means one person's memories can't leak into another's answers.

The memory search function: filtered by person, ranked by cosine similarity

The three pieces I used

Gemma, through Ollama. Both the splitting and the answering run on a local Gemma model. For my demo run I used gemma4:e2b, and the default in the repo is gemma3:4b. Any tag Ollama can serve works. Embeddings come from nomic-embed-text.

llm.chat(), one local Gemma call traced with model name and token counts

Temporal. Saving a story is two steps that depend on a local model, and local models are slow to load and sometimes fail. I wrapped the steps in a Temporal workflow with a retry policy. On my first real run, Ollama returned an HTTP 500 while the model was still loading. Temporal retried the step, and the command finished with all four memories saved. Without the workflow I would have lost the story and seen a stack trace. The activity also falls back to storing the original text as one memory if Gemma returns invalid JSON, so a bad parse can't drop a story.

Gemma extraction with the never-lose-the-story fallback, and the retrying workflow

Sentry Agent Tracing. Every ask is traced with Sentry's AI span names: an agent span for the keeper, a tool span for the memory lookup, and chat and embedding spans with model name and token counts. Tracing only turns on if you set a SENTRY_DSN. One decision here mattered to me: prompts and answers are not attached to traces unless you set OFRENDA_TRACE_CONTENT=1. Family stories shouldn't end up in a monitoring dashboard by accident.

Why open models matter for this

Stories about someone who died are about as private as data gets. With this setup the model, the embeddings and the database all live on your machine. It works with the internet off. The memories are one .db file you own, so there's no service that can change its pricing or shut down and take them with it. I couldn't build this on a hosted API and feel good about it, and that is the real reason I used open models, not cost.

What it doesn't do

It's text only, with no audio recording or transcription. The database isn't encrypted. A small local model can still misread a story, so it's worth checking that what was stored matches what you meant. Search is a linear scan, which is fine for a family and wrong for a million memories.

Demo

Live demo (recorded session): axololtos/ofrenda_demo

Ofrenda runs on a local model through Ollama, so it can't be hosted as a live app without sending people's stories to a server, which is the thing I built it to avoid. That page is a preview: a replay of a real run from my laptop, with the same commands and the same output. To use the real thing, run it locally with the steps below.

Try it

Code and setup steps are here: github.com/axololtos/ofrenda

You need Python, Ollama and the Temporal dev server. The README has the exact commands.

Prize categories: Best Use of Gemma, Best Use of Temporal, Best Use of Sentry Agent Tracing.

Top comments (0)