This is a submission for the Hacktoberfest Open-Source AI Challenge Week 4: Ghost in the Machine.
What I Built
You find an old recipe card. You can photograph it, back it up, and share it. None of that tells you who wrote it.
An AI could give you a convincing answer. That is exactly the wrong thing to preserve.
Ghostkeeper is a local-first museum for objects and the accounts people attach to them. Add a picture, an attributed memory, and the details you actually know. A small Gemma model selects passages worth keeping and suggests questions to ask next. You check the sources, approve the exhibit, and keep an HTML copy that opens offline.
The important constraint: the model does not write the exhibit's quotations. The server retrieves the original words.
The walkthrough uses this short, explicitly fictional account:
Aaji kept this recipe card in a blue biscuit tin. I remember a yellow cloth around the jar. Nobody remembers who wrote the card.
The yellow cloth is a supplied memory. The writer is unknown. A useful curator should preserve that distinction, not turn the card into a beautifully written but invented biography.
That is my interpretation of Ghost in the Machine: remembrance without impersonation. No cloned relative, no simulated conversation with someone who died. Just their attributed words beside an object, with the gaps still visible.
Is this just a prompt that says "don't hallucinate"? No. A prompt asks the model to behave. Ghostkeeper also limits what the model can contribute to the evidence: it selects IDs, while ordinary code controls which words become quotations. That does not make the recollection true. It makes the quotation traceable.
Demo
The video follows one object through the actual app: entering its account, running local Gemma, opening a quote's source, reviewing the result, generating a stock ElevenLabs narrator, hearing a short sample, and opening the exported exhibit with the browser offline.
The source material and illustration are fictional. The model request, narration request, and offline export are real. Footage is edited and retimed for clarity, so it is not a latency measurement.
The application itself is not publicly hosted. This walkthrough demonstrates it without exposing a private archive; the repository contains the code and fictional screenshots, not personal source material.
Code
amangithub01
/
ghostkeeper
A local-first personal museum: source-grounded Gemma curation, owner review, offline exhibits, and optional ElevenLabs narration.
Ghostkeeper
Objects survive. Their stories disappear. Save both.
Ghostkeeper is a local-first personal museum for the things people keep: a recipe card, an old radio, a letter, a photograph. Attach an attributed account, let a small local Gemma model select passages worth preserving, review the evidence, and keep an exhibit that opens offline.
The project interprets Hacktoberfest 2026's Ghost in the Machine theme as remembrance, not impersonation. It does not simulate a deceased person or invent missing family history. The model selects evidence; ordinary code retrieves the original words.
This repository contains the application, reproducible setup, tests, and reviewed fictional documentation images. It does not contain API keys, a personal collection, raw recordings, video-editing projects, dependencies, or generated build output.
Read on: Demo · How it works · Run locally · Narration · Architecture · Privacy · Tests · Limitations
Demo video
The repository includes setup instructions, a credential-free environment template, focused unit tests, and Playwright acceptance tests. The code is public for inspection, but the project does not yet have a license.
To run it locally:
git clone https://github.com/amangithub01/ghostkeeper.git
cd ghostkeeper
npm ci
ollama pull gemma3:1b
cp .env.example .env.local
npm run dev
Requires Node.js 22.18 or newer and Ollama. Open http://127.0.0.1:3000. Ollama must be running. ElevenLabs settings are optional; the local curation and export journey does not depend on them.
How I Built It
A local core, with a separate cloud branch
The interface is Next.js, React, TypeScript, and Tailwind. A local Next.js API validates inputs, calls Ollama, checks the result, and writes the collection to a local JSON file.
The main path is:
Object + attributed source
-> numbered evidence passages
-> local Gemma selects passage IDs and suggests questions
-> server resolves IDs to original source text
-> validation
-> owner review and approval
-> offline exhibit
ElevenLabs sits on a separate, optional branch after approval and explicit sharing consent.
The code follows those boundaries:
| Component | Responsibility |
|---|---|
createArtifact() |
Validate the object and attach its supplied source. |
evidencePassages() and curate()
|
Prepare evidence, call local Gemma, and resolve its selections. |
validateExhibit() |
Check quotations against their cited source text. |
store.create() and store.update()
|
Persist local records and reject stale revisions. |
narrationText() and generateNarration()
|
Build an approved script and make the consent-gated speech request. |
exportHtml() |
Create the portable, self-contained exhibit. |
localRequest() |
Reject non-loopback hosts and cross-origin browser requests. |
The first version failed for a useful reason
Initially, I asked the small model to return quotations and source identifiers. A real fictional-radio example failed with an ungrounded quote error.
The validator was doing its job. A quote that is nearly the source is not an exact quotation, and a copied identifier that is nearly correct is not the right source.
I could have loosened the check or quietly substituted a hand-written exhibit. Instead, I changed the job I was giving the model.
Before: select the evidence, copy its text, and reproduce the source identifier.
Now: select a short passage ID. The server does the copying.
evidencePassages() uses Intl.Segmenter to split source text into sentence-sized passages. Longer passages are split into chunks of at most 1,000 UTF-16 code units, without dividing a surrogate pair. Each gets a simple ID such as P1.
For the recipe-card example:
| ID | Original passage |
|---|---|
P1 |
Aaji kept this recipe card in a blue biscuit tin. |
P2 |
I remember a yellow cloth around the jar. |
P3 |
Nobody remembers who wrote the card. |
curate() calls Ollama's /api/chat with the configured gemma3:1b model. Its JSON schema limits selections to the supplied passage IDs. The model is asked for one to four distinct selections and a small set of interview questions. It is not asked to transcribe quotes or reproduce the archive's source UUIDs.
Then the server resolves the selections:
const quotes = selection.passageIds.map((id: unknown) => {
const passage = passages.find((p) => p.id === id);
if (!passage) {
throw new AppError("Unknown evidence selection.", 502);
}
return { sourceId: passage.sourceId, quote: passage.quote };
});
return validateExhibit(
{ quotes, questions: selection.questions },
artifact.sources,
model,
);
Excerpt shortened for readability; the repository contains the full validation and error messages.
Unknown IDs, duplicate selections, and invalid output fail rather than turning into a convincing-looking fallback. validateExhibit() separately checks that every resolved quote is an exact, continuous substring of its cited source.
The model has less clerical work to get wrong. More importantly, it no longer has authority to rewrite the evidence.
The source is one click away
Each quotation keeps its source attribution. View source opens the original account, not another model-generated explanation.
The panel titled "The story isn't finished" contains optional questions. It is not a failed generation, and answering every question is not a prerequisite for export.
There is a deliberate limit here: exact-quote checking establishes a relationship between two strings, not between a memory and historical reality. A mistaken account can be quoted perfectly.
Questions remain model output, too. In this capture, Gemma asked what the blue tin might suggest about the card's history. That could encourage inference beyond the account. A prompt requesting non-leading questions does not guarantee neutral questions; the owner should skip or rewrite ones that do not fit. The app does not promote those questions into evidence.
Approval belongs to a version, not an object forever
An owner must review an exhibit before exporting or narrating it. Adding another source clears the old exhibit and its approval.
That matters more than a reassuring badge. If a second person adds a different recollection, the first person's review should not silently approve the new material.
The local store serializes writes within one process, writes to a temporary file before renaming it, and checks revisions so a stale browser window cannot overwrite a newer update unnoticed. This is a single-process local archive, not a distributed database.
ElevenLabs: a narrator, not a relative
After review, the owner can explicitly permit a narration request. narrationText() builds a script from the title, source attribution, and selected quotations, with a reminder that these are attributed memories rather than independently verified facts.
generateNarration() sends that text to ElevenLabs using a stock narrator and returns an MP3. It does not send the photograph or the full archive.
The new walkthrough includes both a real generation and a short sample of the returned audio. Its explanatory voiceover also uses ElevenLabs' stock Sarah voice. No voice was cloned.
The key stays server-side. Provider errors distinguish missing configuration, authentication, voice access, permissions, quota, and network failures without displaying credentials or raw provider responses. Paid synthesis requests are not automatically retried.
Audio is temporary in the browser unless downloaded. It is not embedded in the HTML exhibit.
The last step works without the app
exportHtml() produces a self-contained HTML file containing the image, selected quotations, questions, attribution, and full original source text. No JavaScript, remote fonts, or running Ghostkeeper server is required to read it.
This is portability, not a full archive backup/import format. It also creates a sharing responsibility: somebody receiving the export receives the full sources, not just the passages visible near the top.
What I actually checked
The existing unit suite currently has 16 passing tests. The evidence-selection tests use controlled model responses to exercise the contract; they are not a benchmark of Gemma's judgement.
| Check | What the evidence supports |
|---|---|
| Exact source resolution | A selected passage resolves to the original text; extra invented model prose is not used as evidence. |
| Invalid selections | Unknown, duplicate, and empty passage selections are rejected. |
| Multiple sources and Unicode | Attribution survives passage splitting, including long text containing surrogate pairs. |
| New evidence | Adding a source clears the previous exhibit and approval. |
| Export and local storage | Approval gates, escaping, concurrent saves, stale revisions, reload, and corruption preservation have focused checks. |
| Actual recipe walkthrough | Local Gemma selected three quotes; all three matched the fictional source exactly. The narration played, and the HTML opened offline. |
The four passing Playwright tests cover the real local curation journey, reload and deletion, approval invalidation, mobile layout, request guards, and narration-setting diagnostics. They do not make paid ElevenLabs requests.
What I have not established: model-selection quality across a representative dataset, historical accuracy, family-user satisfaction, or superiority to another model. A successful demonstration and deterministic contract tests cannot justify those claims.
Why Does Open Innovation Matter?
For Ghostkeeper, the useful property is not simply that a model is small. It is that the curator can run where the source material lives.
After downloading the model, curation does not need a hosted language-model API. That is a meaningful choice for a letter or recollection someone wants to preserve but does not want to upload.
Gemma's open weights are available under Google's terms; this project uses the existing model, not a fine-tune. The model setting and prompts are visible, and the source-validation code can be inspected independently of the model. Local inference still uses the owner's hardware and electricity; "local" is not a claim of zero cost.
The second form of independence is simpler: plain HTML. The approved exhibit can outlive a provider account or the running app.
HTML export does not require an open model; it is a separate portability choice. Together, local inference and a readable export reduce two dependencies: where an account must be processed, and which service must remain available to read it.
My Agent Session
I built and iterated on Ghostkeeper with GitHub Copilot CLI. This section is a process summary, not a published session replay.
The useful work was not a single prompt that produced a finished application. It was the loop between implementation, a real failure, and a narrower fix:
- Build the local collection, source attribution, and offline export without adding a hosted database.
- Connect Gemma and reproduce the ungrounded-quotation failure on a fictional radio account.
- Keep the validator strict, change model output to passage IDs, and add regression cases for unknown selections, multiple sources, and Unicode.
- Diagnose narration configuration separately from provider authentication, voice access, and quota failures.
- Exercise the real app and record a fictional example instead of publishing somebody's family archive.
The implementation and tests are public. No credentials or private collection data are included in the repository or demo.
Prize Categories
These are the partner categories supported by the implemented work:
| Category | Implemented use |
|---|---|
| Best Use of Gemma | Real local gemma3:1b inference through Ollama for passage selection and questions. |
| Best Use of ElevenLabs | Consent-gated stock-voice exhibit narration, plus the demonstration's voiceover. |
| Best Use of GitHub Copilot | Copilot CLI helped implement, debug, and iterate on the application and demonstration. |
Ollama, Next.js, React, and TypeScript support the implementation; they are not extra partner entries. Render and DigitalOcean are not deployed or connected. No additional sponsor integration is claimed.
Limits I Would Rather State Than Hide
This is a single-user local prototype. It accepts typed or pasted text; it does not record interviews, transcribe audio, recognize handwriting, research the web, or independently verify history.
The archive is not encrypted by the app. Protect the machine and its backups. The server binds to loopback and checks request hosts and origins, but that is not public-service authentication. Do not expose it through a public tunnel or deploy it unchanged.
Deleting an object does not remove exported files, downloaded MP3s, backups, or text already sent to a provider.
The next work I would prioritize is evaluating question quality with people, handling contradictory accounts without merging them into a single "truth," and making backup and restore less manual. Those are next steps, not completed features.
The Spirit of It
In the fictional demo, the model selected this sentence:
Nobody remembers who wrote the card.
Ghostkeeper did not turn that absence into an answer. The sentence remained part of the exhibit, next to an invitation to ask another question.
That is the small moment this prototype is built around: finding something worth asking while there may still be someone who can answer.
A photo answers, "What did the object look like?"
An attributed account can answer, "What did it mean to someone?"
Ghostkeeper puts them together, without pretending that an unknown writer has suddenly become known.





Top comments (1)
I like the stance that an AI can write a convincing answer, and that's exactly the wrong thing to preserve. Keeping the model to selecting passages and suggesting questions, with the human approving the exhibit, is a strong design. How did you find the 1B model at picking passages worth keeping? Did it need much prompt tuning?