https://github.com/anchildress1/vestige
Brain tracker that won't blow smoke up your ass. Gemma 4, Android, fully local.
https://github.com/anchildress1/vestige
adhd-friendly android brain-tracker cognition-engine dev-challenge gemma4 google-gemma jetpack-compose kotlin litert-lm llm-inference local-llm objectbox on-device-ai
Last synced: 10 days ago
JSON representation
Brain tracker that won't blow smoke up your ass. Gemma 4, Android, fully local.
- Host: GitHub
- URL: https://github.com/anchildress1/vestige
- Owner: anchildress1
- License: other
- Created: 2026-05-08T17:39:13.000Z (28 days ago)
- Default Branch: main
- Last Pushed: 2026-05-23T02:55:33.000Z (14 days ago)
- Last Synced: 2026-05-23T03:27:51.965Z (14 days ago)
- Topics: adhd-friendly, android, brain-tracker, cognition-engine, dev-challenge, gemma4, google-gemma, jetpack-compose, kotlin, litert-lm, llm-inference, local-llm, objectbox, on-device-ai
- Language: Kotlin
- Homepage:
- Size: 19.4 MB
- Stars: 1
- Watchers: 0
- Forks: 0
- Open Issues: 3
-
Metadata Files:
- Readme: README.md
- License: LICENSE
- Codeowners: .github/CODEOWNERS
- Agents: AGENTS.md
Awesome Lists containing this project
README
Vestige
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.
## Table of Contents
- [About](#about)
- [Status](#status)
- [Features](#features)
- [Tech Stack](#tech-stack)
- [Architecture](#architecture)
- [Project Structure](#project-structure)
- [Getting Started](#getting-started)
- [Configuration](#configuration)
- [Security & Privacy](#security--privacy)
- [How to Contribute](#how-to-contribute)
- [What's Next](#whats-next)
- [Known Limitations](#known-limitations)
- [License](#license)
- [Acknowledgements](#acknowledgements)
- [Author](#author)
---
## About
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`](docs/concept-locked.md).
---
## Status
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](#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/`](docs). Pattern lifecycle is Skip / Drop / Restart — closure is model-detected only (v1.5, see [`backlog.md`](docs/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`](docs/diagrams/user-flows.md).
---
## Features
| 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](docs/adrs/ADR-002-multi-lens-extraction-pattern.md). |
| 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](docs/adrs/ADR-003-pattern-detection-and-persistence.md). |
| 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`. |
---
## Tech Stack
- Kotlin `2.3.21` + Jetpack Compose (BOM `2026.05.00`), AGP `9.2.1`
- Gradle KTS + version catalog ([`gradle/libs.versions.toml`](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)
---
## Architecture
Four-module split with manual constructor injection through a single `AppContainer` ([ADR-001 §Q1–Q2](docs/adrs/ADR-001-stack-and-build-infra.md)). 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.
```mermaid
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
mono 16 kHz float32 · 30 s cap"]
FG["ForegroundInference
fast transcription + inline persona follow-up"]
BG["BackgroundExtractionWorker
3 sequential lens passes × 5 surfaces"]
Gemma[("Gemma 4 E4B
via LiteRT-LM")]
Resolver["Convergence Resolver
consensus · candidate · ambiguous · w/ conflict"]
ObjectBox[("ObjectBox (source of truth)
entries · tags · patterns · vectors")]
Export[("Export renderer
markdown + JSON snapshot")]
Patterns["Pattern Detection
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`](docs/architecture-brief.md).
---
## Project Structure
```
.
├── 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](docs/adrs/ADR-001-stack-and-build-infra.md): `:app` (UI) depends on `:core-inference`, `:core-storage`, and `:core-model`; the core modules do not depend on `:app`.
---
## Getting Started
### Prerequisites
| 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.
### Build
```bash
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 clean
```
`make setup` is the hook/bootstrap target. `make install` is now device-only and requires `adb`; it does not install lefthook anymore.
### Run on a device
Reference device: Galaxy S24 Ultra. External devices are best-effort; submission promise is Android 14+, 8 GB RAM, 6 GB free storage.
```bash
make install # assemble + adb install debug APK without wiping app data
make reinstall # reinstall APK, push models, seed debug fixtures, tail logcat
```
### Demo data on hardware (dev builds only)
> **Just 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.
```bash
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=1` actually 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=1` then 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. Watch `DebugSeedReceiver` / `Vestige` in logcat for `seed complete`. **If you don't want to sit through that, install the clean release APK instead** (no seed, no extraction).
- **Without `EXTRACT=1`** entries seed instantly but stay `PENDING` — 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 a `TEMPORAL_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** a `VOCAB_FREQUENCY` pattern (`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`](core-model/src/main/resources/model/manifest.properties):
```text
~/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:
```bash
make reinstall ENV=prod
```
**One-time phone setup**
1. **Settings → About phone** → tap **Build number** 7 times to unlock developer options.
2. **Settings → Developer options** → enable **USB debugging**. Optional: enable **Wireless debugging** if you'd rather not cable up.
3. (Optional) **Stay awake** while charging — speeds iteration.
**Connect**
USB:
```bash
adb devices
# expect: device
# if "unauthorized", accept the prompt on the phone (check "Always allow")
```
Wireless (Android 11+):
```bash
# On phone: Developer options → Wireless debugging → Pair device with pairing code
adb pair # use the pair port + 6-digit code shown on phone
adb connect # then use the connect port shown on phone
adb devices # verify
```
**Install + launch**
```bash
./gradlew :app:installDebug
adb shell monkey -p dev.anchildress1.vestige -c android.intent.category.LAUNCHER 1
```
`installDebug` builds and installs in one step. The `monkey` invocation just opens the launcher activity without you having to tap the icon.
Manual APK install:
```bash
./gradlew :app:assembleDebug
adb install -r app/build/outputs/apk/debug/app-debug.apk
```
**Tail logs**
```bash
adb logcat -s "VestigeApplication:*" "AndroidRuntime:E" "*:F"
```
**Reinstall clean**
```bash
adb uninstall dev.anchildress1.vestige
./gradlew :app:installDebug
```
**Troubleshooting**
| 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 |
---
## Configuration
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](docs/adrs/ADR-012-gpu-inference-performance-gaps.md)). Persona default is set during onboarding and changeable from settings. Pattern analysis runs periodically — every 3 completed entries ([ADR-014](docs/adrs/ADR-014-foreground-background-split-and-periodic-pattern-analysis.md)) — with a per-pattern callout cooldown of 3 ([ADR-016](docs/adrs/ADR-016-pattern-callout-cooldown-per-pattern.md)), 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](docs/adrs/ADR-001-stack-and-build-infra.md).
---
## Security & Privacy
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_text` substrate); 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 `NetworkGate` abstraction owns the only HTTP path; default state is `SEALED`, `OPEN` only during model download. Direct `OkHttpClient` / `URL.openConnection` construction outside `NetworkGate` is forbidden and grep-checked in CI. See [ADR-001 §Q7](docs/adrs/ADR-001-stack-and-build-infra.md).
- **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.
---
## How to Contribute
PRs are not accepted through the submission deadline (2026-05-24). Issues are welcome — use the GitHub issue tracker. Post-submission, see [`AGENTS.md`](AGENTS.md) and [`backlog.md`](docs/backlog.md) for the contribution surface.
Branches and commits follow [`AGENTS.md`](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`.
---
## What's Next
v1 ships 2026-05-24. Deferred features live in [`backlog.md`](docs/backlog.md) — v1.5 / v2 / STT-conditional, with explicit unblock-conditions per entry. No "coming soon" handwaving.
---
## Known Limitations
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_FREQUENCY` cluster mints on the demo corpus — `Drained Vocab Frequency` is 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.** `AudioCapture` emits one final chunk at 30 s; the >30 s multi-chunk path is deferred ([`backlog.md`](docs/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](docs/adrs/ADR-012-gpu-inference-performance-gaps.md)). Subsequent calls run ~7–11 s on E4B GPU; a full background 3-lens extraction is ~44 s/entry.
---
## License
[Polyform Shield 1.0.0](LICENSE) + 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](LICENSE) — that is the legally-binding version; this paragraph is just the plain-English flavor.
---
## Acknowledgements
- 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`](https://github.com/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-community`** for hosting the pre-converted [`gemma-4-E4B-it-litert-lm`](https://huggingface.co/litert-community/gemma-4-E4B-it-litert-lm) artifact.
- The **Polyform Project** for licenses that admit not every project is MIT-shaped.
- **[DEV](https://dev.to) (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)](https://mlh.io)** for backing the challenge and the broader hackathon community that makes deadlines like this one fun instead of just terrifying.
---
## Author
[Ashley Childress](https://github.com/anchildress1) ([@anchildress1](https://github.com/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.