A brain tracker that won't blow smoke up your ass. Gemma 4, Android, fully local.
Built for the Gemma 4 Challenge β submission category: Build with Gemma 4.
Canonical product spec lives under docs/; see AGENTS.md for AI agent rules.
- About
- Status
- Features
- Tech Stack
- Architecture
- Project Structure
- Getting Started
- Configuration
- Security & Privacy
- How to Contribute
- What's Next
- Known Limitations
- License
- Acknowledgements
- Author
Vestige observes behavioral traces and surfaces patterns without therapy framing, mood scoring, or wellness vocabulary. It runs Gemma 4 E4B locally via LiteRT-LM β your voice never leaves the device, the audio bytes are discarded after inference, and entries can be exported as readable markdown at any time.
The positioning is deliberate: cognition tracker, not journal app. Patterns are sourced β every claim cites the entries it counted. Full product spec: docs/concept-locked.md.
The full loop is implemented and runs on-device: voice / typed capture β Gemma 4 E4B β 3-lens extraction β convergence resolver β ObjectBox, with deterministic pattern detection. EmbeddingGemma embeds each entry by its tone word and powers Vocab Drift: the VOCAB_FREQUENCY cluster mints on the demo corpus (the Drained Vocab Frequency pattern, verified on-device). The earlier ranked content-retrieval path was cut when the embedding axis moved to the tone word (see Known Limitations). Entry Detail surfaces the model's actual work: the three-lens read, the picked archetype, the tone word, and a collapsible raw per-lens model-output view. Capture, history, pattern list + detail, settings, model-status, and onboarding model-download are all built against the canonical spec under ./docs/. Pattern lifecycle is Skip / Drop / Restart β closure is model-detected only (v1.5, see backlog.md Β§pattern-auto-close). The active phase is on-device prompt tuning against a seeded demo corpus; risk through phases 1β3 was managed via five stop-and-test points (STT-AβE), and on-device tuning since (STT-FβH) lifted the model off its audit default into differentiated archetypes. Screen-flow diagrams: docs/diagrams/user-flows.md.
| Feature | What it does |
|---|---|
| Voice capture | AudioCapture β Gemma 4 E4B native audio modality. No third-party STT. Audio bytes discarded after inference. |
| Multi-lens extraction | Each entry runs 3 lens passes (Literal / Inferential / Skeptical), each covering all 5 surfaces in one call; a convergence resolver votes every field consensus / candidate / ambiguous / consensus-with-conflict β tags, archetype, stated commitment, recurrence, and tone word. See ADR-002. |
| Model transparency | Entry Detail exposes the model's actual work β the picked archetype, the per-lens read, the resolved field grid, and a collapsible raw per-lens model-output block. Nothing is hidden behind a score. |
| Tone & vocab drift | The Inferential lens names a one-word tone per entry; entries are embedded by that tone word, and recurring/related tones surface as an EmbeddingGemma cluster (Vocab Drift). Minting on the demo corpus β the Drained Vocab Frequency cluster, verified on-device. |
| Three personas | Witness / Hardass / Editor β tone-only variants. They do not fork extraction logic. |
| Pattern detection | Six primitives counted over the last 90 days; sourced (counts, dates, snippets), no feelings or motivation interpretation. See ADR-003. |
| Storage | ObjectBox is the internal source of truth. Export renders readable markdown from rows on demand. |
| Pattern lifecycle | Skip (returns in 7 days) / Drop (noise, archived) / Restart, with Undo. Closure is model-detected only β v1.5. |
| Export | System-picker (SAF) zip of per-entry markdown. No storage permission; failures surface, never silent. |
| Local-only | Zero outbound network calls during normal operation; model download is the only network event. Verified with tcpdump. |
- Kotlin
2.3.21+ Jetpack Compose (BOM2026.05.00), AGP9.2.1 - Gradle KTS + version catalog (
gradle/libs.versions.toml) - Gemma 4 E4B via LiteRT-LM (
com.google.ai.edge.litertlm:litertlm-android:0.11.0), on-device only - ObjectBox
5.4.2(entries, tags, patterns, vectors) + generated markdown export - Android
minSdk 31/targetSdk 35/compileSdk 36, JVM toolchain 25 (Java source/target compat 17)
Four-module split with manual constructor injection through a single AppContainer (ADR-001 Β§Q1βQ2). Foreground call returns transcription + persona-flavored follow-up fast; the 3-lens convergence pass runs in the background and writes consensus / candidate / ambiguous fields when it lands.
flowchart TB
User([User]) -- voice --> Audio
User -- "type Β· persists directly, skips FG (ADR-018)" --> BG
subgraph onDevice["on-device only β no network at runtime"]
Audio["AudioCapture<br/>mono 16 kHz float32 Β· 30 s cap"]
FG["ForegroundInference<br/>fast transcription + inline persona follow-up"]
BG["BackgroundExtractionWorker<br/>3 sequential lens passes Γ 5 surfaces"]
Gemma[("Gemma 4 E4B<br/>via LiteRT-LM")]
Resolver["Convergence Resolver<br/>consensus Β· candidate Β· ambiguous Β· w/ conflict"]
ObjectBox[("ObjectBox (source of truth)<br/>entries Β· tags Β· patterns Β· vectors")]
Export[("Export renderer<br/>markdown + JSON snapshot")]
Patterns["Pattern Detection<br/>6 primitives Β· 90-day window Β· every 3 entries"]
Audio --> FG
FG -- "prompt + audio" --> Gemma
Gemma -- "transcription + follow-up" --> FG
FG --> ObjectBox
FG -. "hands off" .-> BG
BG -- "3 lens prompts" --> Gemma
Gemma -- "lens output" --> BG
BG --> Resolver
Resolver --> ObjectBox
ObjectBox --> Patterns
Patterns --> ObjectBox
ObjectBox --> Export
end
Module boundaries: :app (UI), :core-inference (LiteRT-LM + lens composition), :core-storage (ObjectBox + markdown), :core-model (domain types). Ownership detail: docs/architecture-brief.md.
.
βββ app/ # :app β Compose UI, navigation, AppContainer (manual DI)
βββ core-model/ # :core-model β domain types, manifests, no Android deps
βββ core-inference/ # :core-inference β LiteRT-LM engine + 3-lens composition
βββ core-storage/ # :core-storage β ObjectBox rows + export markdown renderer
βββ docs/ # canonical product/architecture/UX spec
β βββ README.md # reading order + file inventory
β βββ PRD.md # P0/P1/P2 requirements + phase schedule
β βββ concept-locked.md # full product spec
β βββ adrs/ # ADR-001..018, no 009 (stack, lenses, patterns, lifecycle, runtime, design, β¦)
β βββ architecture-brief.md
β βββ design-guidelines.md
β βββ ux-copy.md # locked microcopy authority
β βββ spec-pattern-action-buttons.md
β βββ sample-data-scenarios.md
β βββ backlog.md # deferred features w/ unblock conditions
β βββ stories/ # phase-1..7 build queue
βββ poc/ # Compose-port reference (screenshots)
βββ gradle/ # version catalog + dependency verification
βββ scripts/ # doctor, lint, secret scan helpers
βββ AGENTS.md # AI implementor guardrails (authoritative)
βββ CLAUDE.md # Claude Code β AGENTS.md pointer
βββ lefthook.yml # pre-commit / commit-msg / pre-push hooks
βββ Makefile # local CI surface
βββ LICENSE
βββ README.md
Four-module split per ADR-001: :app (UI) depends on :core-inference, :core-storage, and :core-model; the core modules do not depend on :app.
| Tool | Required for | Install |
|---|---|---|
| JDK 25 LTS (Temurin) | Gradle runtime + Kotlin toolchain | brew install --cask temurin |
Android SDK + adb |
build + install on device | Android Studio, or brew install --cask android-commandlinetools |
| System Gradle (optional) | regenerating the wrapper jar (make bootstrap-wrapper); not needed for routine builds, since gradle/wrapper/gradle-wrapper.jar is committed |
brew install gradle |
lefthook |
git hooks | brew install lefthook |
gitleaks |
secret scan | brew install gitleaks |
actionlint |
workflow lint | brew install actionlint |
ktlint |
format + lint Kotlin | brew install ktlint |
detekt |
static analysis Kotlin | brew install detekt |
gh |
repo ops | brew install gh |
ANDROID_HOME must be set and $ANDROID_HOME/platform-tools must be on PATH so adb resolves. Gradle dependency verification is pinned in gradle/verification-metadata.xml; refresh it only when changing dependencies.
SonarCloud analysis runs through the Gradle sonar task in CI rather than a standalone scanner config, because Android builds deserve one source of truth at a time.
make setup # bootstrap gradle wrapper, install lefthook hooks
make doctor # verify local toolchain and environment variables
make build # assemble debug APK
make test # unit tests for changed modules (75% coverage gate runs in `make verify`)
make lint # ktlint + detekt + Android lint
make verify # lint + test + build + staged secret scan
make ci # full local check (lint + test + build)
make cleanmake setup is the hook/bootstrap target. make install is now device-only and requires adb; it does not install lefthook anymore.
Reference device: Galaxy S24 Ultra. External devices are best-effort; submission promise is Android 14+, 8 GB RAM, 6 GB free storage.
make install # assemble + adb install debug APK without wiping app data
make reinstall # reinstall APK, push models, seed debug fixtures, tail logcatJust want to try Vestige? Install the release APK from the GitHub release and onboard normally β you get a clean first-run (
Nothing on file.) and capture your own entries. The seeded demo corpus below is a dev-only reproduction aid and never ships in the release APK.
The demo seed is a deterministic ~36-entry corpus loaded by DebugPatternSeeder through an ADB-triggered DebugSeedReceiver that exists only in debug builds (app/src/debug/β¦, registered in the debug manifest overlay). It writes rows straight to ObjectBox and marks onboarding complete, so the seeded build opens past first-run with history already populated.
make reinstall ENV=dev # clean debug install + push models + seed + launch + tail logcat
make reinstall ENV=dev EXTRACT=1 # same, plus run background extraction so cards carry real lens receipts
make seed-entries # re-seed an already-installed debug build (no reinstall)
make seed-entries EXTRACT=1 # re-seed + run extraction- What
EXTRACT=1actually does β and why it's slow. Seeding writes each entry as text straight to ObjectBox and bypasses the foreground model call entirely β there is no audio, no transcription, and no model foreground response.EXTRACT=1then runs the live background extraction once per seeded entry (the same sequential 3-lens convergence a real capture runs). Expect a full GPU load of ~30 s per entry while it churns. Across the ~36-entry corpus that's β18 minutes at 30 s/entry β in practice budget ~25β30 minutes: the measured full 3-lens pass is ~44 s/entry (STT-F), and entries that mint a pattern add an observation + title pass on top. WatchDebugSeedReceiver/Vestigein logcat forseed complete. If you don't want to sit through that, install the clean release APK instead (no seed, no extraction). - Without
EXTRACT=1entries seed instantly but stayPENDINGβ no lens receipts, no patterns, no vocab clusters. Fine for a History/layout check, useless for the extraction and pattern beats. - Idempotent β each seed wipes the entry / tag / pattern / cooldown tables and reloads, so re-running never duplicates.
- Seed timestamps are local wall-clock spanning
2026-04-25β2026-05-22(entry prose names clock times like "2am", so the loader seeds in the device zone, not UTC).
What you'll see on-screen after seeding:
- History β the full timeline populated, newest first.
- Patterns β a Tuesday-afternoon meeting-crash recurrence (template
Crashed) forms once the third supporting entry lands, with a sourced callout. A Thursday-evening cluster is a deliberate negative control β same time slot, unrelated end-of-day logistics. Temporal detection is deterministic, so it does mint aTEMPORAL_RELATIVE"Thursday evening" pattern from the shared weekday + time block alone (β₯ 3 distinct dates); the point of the control is that it surfaces as a benign time-block observation, not a cognitive recurrence β the demo shows the model can tell "I always log at 5pm" from "I crash every Tuesday." - Vocab Drift (requires
EXTRACT=1) β intended to group entries that share no keywords ("drained", "wiped out", "running on empty", "depleted", "burnt out", "brain fog") into one exhaustion cluster, with a separate positives cluster ("locked-in", "clear", "good", "sharp") β the embedding proof. On the demo seed this mints aVOCAB_FREQUENCYpattern (Drained Vocab Frequency), verified on-device 2026-05-24 β the embedding proof is live. - Type "I hate demos" as a live entry and it joins the seeded demo-dread cluster.
Required local artifact filenames match core-model/src/main/resources/model/manifest.properties:
~/Downloads/gemma-4-E4B-it.litertlm
~/Downloads/embeddinggemma-300M_seq512_mixed-precision.tflite
~/Downloads/sentencepiece.model
For a production first-run check with no pushed model and no fixtures:
make reinstall ENV=prodOne-time phone setup
- Settings β About phone β tap Build number 7 times to unlock developer options.
- Settings β Developer options β enable USB debugging. Optional: enable Wireless debugging if you'd rather not cable up.
- (Optional) Stay awake while charging β speeds iteration.
Connect
USB:
adb devices
# expect: <serial> device
# if "unauthorized", accept the prompt on the phone (check "Always allow")Wireless (Android 11+):
# On phone: Developer options β Wireless debugging β Pair device with pairing code
adb pair <ip:port> # use the pair port + 6-digit code shown on phone
adb connect <ip:port> # then use the connect port shown on phone
adb devices # verifyInstall + launch
./gradlew :app:installDebug
adb shell monkey -p dev.anchildress1.vestige -c android.intent.category.LAUNCHER 1installDebug builds and installs in one step. The monkey invocation just opens the launcher activity without you having to tap the icon.
Manual APK install:
./gradlew :app:assembleDebug
adb install -r app/build/outputs/apk/debug/app-debug.apkTail logs
adb logcat -s "VestigeApplication:*" "AndroidRuntime:E" "*:F"Reinstall clean
adb uninstall dev.anchildress1.vestige
./gradlew :app:installDebugTroubleshooting
| Symptom | Fix |
|---|---|
adb: command not found |
export PATH="$ANDROID_HOME/platform-tools:$PATH" in your shell profile |
INSTALL_FAILED_NO_MATCHING_ABIS |
APK didn't include arm64-v8a. Verify with unzip -l app/build/outputs/apk/debug/app-debug.apk | grep arm64-v8a |
INSTALL_FAILED_USER_RESTRICTED |
Disable Verify apps over USB in Developer options |
| App crashes on launch | adb logcat AndroidRuntime:E *:S for stack trace |
| Themed monochrome icon on Android 13+ | Expected β placeholder icon; final design lands Phase 6 |
v1 has effectively zero configuration. The model artifact downloads on first launch over Wi-Fi (3.66 GB) into Context.filesDir/models/. A cheap presence + size probe resolves the artifact state without hashing the multi-GB file on the UI thread; a full-size artifact is then SHA-256-verified off-thread before readiness flips to Ready, so a checksum-corrupt full-size file falls back to Loading rather than a false Ready (AppContainer.probeModelReadiness). The engine itself loads lazily on the first inference, not proactively, because proactive pre-warm regressed into a startup GPU-init crash (ADR-012). Persona default is set during onboarding and changeable from settings. Pattern analysis runs periodically β every 3 completed entries (ADR-014) β with a per-pattern callout cooldown of 3 (ADR-016), hardcoded for v1. No env vars, no .env file, no remote-config layer β adding any of those is a P0 violation per ADR-001 Β§Q7.
Privacy is the differentiator, not a side feature.
- Zero outbound network calls during normal operation. The model download (one-time, Wi-Fi, Hugging Face) is the sole network event. Verified with
tcpdump; the proof clip is part of the demo video. - Audio bytes discarded after inference. Transcription persists as text (the
entry_textsubstrate); raw audio never lands on disk as product data. - No analytics, telemetry, crash beacons, remote config, CDN fonts. Crash logs are local; user can export from settings.
- Network enforcement is code, not vibes. A
NetworkGateabstraction owns the only HTTP path; default state isSEALED,OPENonly during model download. DirectOkHttpClient/URL.openConnectionconstruction outsideNetworkGateis forbidden and grep-checked in CI. See ADR-001 Β§Q7. - No proactive crisis triage. If the user explicitly asks for self-harm help, a static local message points to local emergency services. No diagnosis, no network call.
Contributors: do not introduce dependencies that pull in Firebase, Crashlytics, Segment, Mixpanel, or any analytics SaaS. Do not add a fonts CDN. Do not call out to a cloud LLM as a fallback. Any of these invalidates the entire submission.
PRs are not accepted through the submission deadline (2026-05-24). Issues are welcome β use the GitHub issue tracker. Post-submission, see AGENTS.md and backlog.md for the contribution surface.
Branches and commits follow AGENTS.md and the repo conventions: atomic, GPG-signed, a Generated-by: footer on AI-authored commits (e.g. Generated-by: claude-opus-4-7), Conventional Commits, never on main.
v1 ships 2026-05-24. Deferred features live in backlog.md β v1.5 / v2 / STT-conditional, with explicit unblock-conditions per entry. No "coming soon" handwaving.
What v1 actually does, stated straight.
- Embeddings power Vocab Drift only β no semantic search. EmbeddingGemma 300M embeds each entry by its tone word (the felt quality), and the
VOCAB_FREQUENCYcluster mints on the demo corpus βDrained Vocab Frequencyis a live active pattern, verified on-device 2026-05-24. The earlier ranked content-retrieval path (RetrievalRepoβ keyword + tag + recency + cosine) was cut when the embedding axis moved to the tone word: a content query can't score against a feeling vector, and it was never wired to a live surface. Semantic search across entries is not a v1 feature; embeddings exist to surface tone clustering, nothing more. - Voice captures cap at 30 s.
AudioCaptureemits one final chunk at 30 s; the >30 s multi-chunk path is deferred (backlog.mdβmulti-chunk-foreground). An audio cue at ~28 s warns before the cap fires. - First inference is cold (~15 s). The engine loads lazily on the first capture β proactive pre-warm was reverted after it regressed into a startup GPU-init crash (ADR-012). Subsequent calls run ~7β11 s on E4B GPU; a full background 3-lens extraction is ~44 s/entry.
Polyform Shield 1.0.0 + Supplemental Terms. Source-available, not open-source: read it, run it, modify it for personal or internal use. Don't sell it, don't ship a paid product on top of it, don't use it to compete with Vestige itself. The full grant and exceptions are in LICENSE β that is the legally-binding version; this paragraph is just the plain-English flavor.
- Google's Gemma team for the E4B model and the native audio modality that made this entire concept tractable on a phone.
- The LiteRT-LM team (
google-ai-edge/LiteRT-LM) for the Android SDK that lets Kotlin code run a multimodal LLM without writing JNI by hand. - ObjectBox for an embedded DB that does not require an SQL ceremony.
- Hugging Face /
litert-communityfor hosting the pre-convertedgemma-4-E4B-it-litert-lmartifact. - The Polyform Project for licenses that admit not every project is MIT-shaped.
- DEV (dev.to) for hosting the Gemma 4 Challenge β the venue this whole build was aimed at, and a community that rewards shipping over hand-waving.
- Major League Hacking (MLH) for backing the challenge and the broader hackathon community that makes deadlines like this one fun instead of just terrifying.
Ashley Childress (@anchildress1). Vestige is an Android side-build aimed at the Gemma 4 Challenge "Build with Gemma 4" prize. The brand voice and product opinions are entirely intentional.