Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
193 commits
Select commit Hold shift + click to select a range
f83cbac
feat(pro): autolink the pro native library via react-native.config.js
dishit-wednesday Jun 27, 2026
e4443b8
feat(locket): core whisper file-transcription support (chunkable opts…
dishit-wednesday Jun 27, 2026
dd7f01b
feat(locket): add 'whisper' remote-server provider type
dishit-wednesday Jun 27, 2026
5748d1c
feat(core): persistent + in-memory debug logging for export
dishit-wednesday Jun 27, 2026
0b28261
feat(ios): background-audio mode for locket recorder
dishit-wednesday Jun 27, 2026
59e4439
chore(ios): pod install for OffgridPro
dishit-wednesday Jun 27, 2026
eb9aea1
chore: bump pro submodule to feat/locket (locket feature)
dishit-wednesday Jun 27, 2026
e72ac68
feat(transcription): offset/duration chunking, live segments, CoreML …
dishit-wednesday Jun 28, 2026
52cdfdf
feat(nav): Memory tab (Pro recorder/paywall) + move Settings to Home …
dishit-wednesday Jun 28, 2026
db0a6ae
feat(chat): summarize oversized transcript/document attachments
dishit-wednesday Jun 28, 2026
4d7ab20
chore: bump pro submodule (transcription slicing + attach-to-chat)
dishit-wednesday Jun 28, 2026
3cbe0b8
feat(chat): stream transcript summaries live + bigger Summarize chip
dishit-wednesday Jun 29, 2026
f2e7716
chore: bump pro submodule (meeting intelligence + transcription work)
dishit-wednesday Jun 29, 2026
d050a81
fix(whisper): iOS streaming lifetime patch + PATCH-LIVE marker (whisp…
dishit-wednesday Jun 29, 2026
0009c8b
feat(rag): recordings knowledge base - chunk metadata, indexText, see…
dishit-wednesday Jun 29, 2026
ec76b64
perf(chat): keep tool/date prompt prefix cacheable, fixing TTFT
dishit-wednesday Jun 29, 2026
2687940
feat(whisper): CoreML asset guard, force English for .en models, tdrz…
dishit-wednesday Jun 29, 2026
e928e0b
chore: on-device memory-snapshot logging util
dishit-wednesday Jun 29, 2026
f121648
perf(summary): disable model thinking + cap map chunks so summaries s…
dishit-wednesday Jun 29, 2026
edf8325
feat(whisper): log memory footprint around model load
dishit-wednesday Jun 29, 2026
bb143cb
feat(nav): rename Memory tab to Recorder
dishit-wednesday Jun 29, 2026
ff0c194
test(whisper): assert loadModel called with options arg
dishit-wednesday Jun 29, 2026
cc7c0a4
polish(recorder): redesign the Recorder paywall
dishit-wednesday Jun 29, 2026
8f04e4e
chore: bump pro submodule (recorder + meeting intelligence + UI polish)
dishit-wednesday Jun 29, 2026
1cb80c3
test: fix stale assertions + add CoreML/RAG metadata coverage
dishit-wednesday Jul 1, 2026
f31a935
fix(whisper): free VAD PCM buffer to stop long-file OOM
dishit-wednesday Jul 1, 2026
2c2ab81
feat(pro): temporary reset-license testing controls
dishit-wednesday Jul 1, 2026
acab070
chore: bump pro submodule (recorder UI polish + cleanup classifier)
dishit-wednesday Jul 1, 2026
e94d86a
fix(chat): extract ChatInput action button to satisfy max-lines
dishit-wednesday Jul 1, 2026
bfd6c40
chore: point pro submodule at canonical off-grid-ai/mobile-pro
dishit-wednesday Jul 1, 2026
9ec2cfc
refactor(whisper): extract buildTranscribeOpts + split model catalogue
dishit-wednesday Jul 1, 2026
8c93f1f
test(locket): split-planning unit tests + whisper.rn VAD types
dishit-wednesday Jul 6, 2026
e8b7ba6
chore(locket): bump pro submodule (compression + recovery-hydration f…
dishit-wednesday Jul 6, 2026
cb979e3
feat: optional summary prompt overrides (systemPrompt/combinePrompt)
dishit-wednesday Jul 7, 2026
cf2752e
chore(deps): add @notifee/react-native for local scheduled notifications
dishit-wednesday Jul 7, 2026
5c6bc85
chore(locket): bump pro submodule to feat/locket-enhance (v2 enhancem…
dishit-wednesday Jul 7, 2026
aef5ee5
test(locket): review-fix coverage + bump pro submodule
dishit-wednesday Jul 7, 2026
3dd609a
test(locket): kill-safety recovery coverage + bump pro submodule
dishit-wednesday Jul 7, 2026
e2111eb
feat: multi-backend summary dispatch (LiteRT/remote/local)
dishit-wednesday Jul 7, 2026
f3e4ac4
chore: bump pro submodule (multi-backend summaries, meeting reminders…
dishit-wednesday Jul 7, 2026
4526e3d
feat: home.recorder slot + render RecorderHomeCard on Home
dishit-wednesday Jul 7, 2026
a3e2ad5
feat: replace recorder bottom tab with Settings tab
dishit-wednesday Jul 7, 2026
20a3583
chore: bump pro submodule (locket single-screen detail, auto-prune, h…
dishit-wednesday Jul 7, 2026
85c5103
chore(ios): onnxruntime-objc modular headers + bump pro (VAD-gated re…
dishit-wednesday Jul 8, 2026
34c009b
feat: home header cleanup + onnxruntime VAD pod
dishit-wednesday Jul 8, 2026
5b2fa29
chore: bump pro submodule (locket Today/Days/search, batch transcribe…
dishit-wednesday Jul 8, 2026
e412a39
chore: bump pro submodule (VAD gate tightening, VAD comparison tool, …
dishit-wednesday Jul 8, 2026
dc7afd1
chore: bump pro submodule (VAD inference-failure crash guard)
dishit-wednesday Jul 9, 2026
7fa7b22
Merge pull request #502 from off-grid-ai/feat/vad-gated-recorder
dishit-wednesday Jul 9, 2026
60e77cd
Merge pull request #469 from off-grid-ai/feat/locket-pro-enhance
dishit-wednesday Jul 9, 2026
a944066
Merge dev into feat/locket-pro (catch-up reconciliation, 451 commits …
dishit-wednesday Jul 9, 2026
1140016
chore: bump pro submodule (speech-only auto-trim tuning + 0:00 guard …
dishit-wednesday Jul 9, 2026
741e1ab
chore: bump pro submodule (remove diagnostic Check-speech buttons)
dishit-wednesday Jul 9, 2026
cf458ce
chore: bump pro submodule to feat/locket tip (trim + button removal +…
dishit-wednesday Jul 9, 2026
decceca
chore: gitignore dev insights sim harness + scratch screenshots
dishit-wednesday Jul 10, 2026
abe93b1
feat(summarizer/llm): grammar, repeat_penalty, streaming + abort for …
dishit-wednesday Jul 10, 2026
4e7ff47
feat(dev): GBNF grammar test harness across chat, tools, and LiteRT
dishit-wednesday Jul 10, 2026
2ad817c
feat(whisper): iOS CoreML encoder for ANE-accelerated encode
dishit-wednesday Jul 10, 2026
77185b8
chore(assets): update ggml-hexagon HTP libs + add silero VAD model
dishit-wednesday Jul 10, 2026
fb04c84
fix(llm): remove iOS Metal <4GB offload cap
dishit-wednesday Jul 10, 2026
92b9caf
chore: bump pro submodule (locket insights pipeline + hub/detail scre…
dishit-wednesday Jul 10, 2026
2a909a4
feat(summarizer): dynamic context sizing + token streaming + abort
dishit-wednesday Jul 12, 2026
9865b30
feat(litert): expose getContextTokens for backend-aware chunk sizing
dishit-wednesday Jul 12, 2026
0cfecfe
fix(whisper): fall back to another on-disk model when the active one …
dishit-wednesday Jul 12, 2026
1ed573d
chore(pro): bump submodule (DayStrip, day-scoped Insights, card schem…
dishit-wednesday Jul 12, 2026
52d4b86
chore: bump pro submodule to pushed feat/continuous-smart-recorder ti…
dishit-wednesday Jul 12, 2026
1fe0940
test(whisper): update store/service tests for model fallback + CoreML…
dishit-wednesday Jul 12, 2026
62754cd
fix(llm): reset streaming accumulators before ungrammared grammar-retry
dishit-wednesday Jul 12, 2026
4c73b24
chore(pro): bump submodule to review fixes (scheduler teardown + auto…
dishit-wednesday Jul 12, 2026
ce30b39
fix(whisper): drop deleted model from presentModelIds on active-model…
dishit-wednesday Jul 12, 2026
0feedca
chore(pro): bump submodule to off-thread checkpoint scan fix (audio-d…
dishit-wednesday Jul 12, 2026
f7d524f
chore(pro): bump submodule (permissions + player trim + wip) & add ha…
dishit-wednesday Jul 13, 2026
bac57f4
fix(whisper): pass maxContext=0 to stop repetition/hallucination on n…
dishit-wednesday Jul 13, 2026
156b819
Merge remote-tracking branch 'upstream/feat/locket-pro' into feat/vad…
dishit-wednesday Jul 13, 2026
31b9f34
feat(ui): add a global Toast component and mount it in App
dishit-wednesday Jul 13, 2026
15cdbde
feat(insights): route summaries to a remote provider when one is active
dishit-wednesday Jul 13, 2026
ff9a273
feat(whisper): accept an optional vocabulary hint as the initial prompt
dishit-wednesday Jul 13, 2026
244c7dc
fix(litert): drain fbjni finalizer at conversation teardown to avoid …
dishit-wednesday Jul 13, 2026
1a5d48f
chore(pro): bump submodule (insights transcribe, vocabulary, settings…
dishit-wednesday Jul 13, 2026
13b563e
feat(models): memory-aware text-model selector
dishit-wednesday Jul 13, 2026
6a24d79
feat(models): text-only load option to skip the vision mmproj clip
dishit-wednesday Jul 13, 2026
48a958e
chore(pro): bump submodule (memory-aware auto-load + battery-exemptio…
dishit-wednesday Jul 13, 2026
f9ef008
chore(pro): bump submodule to locket unified feed (+ residency helper…
dishit-wednesday Jul 14, 2026
a43a71b
chore: remove accidentally-committed scratch files (android/temp.txt,…
dishit-wednesday Jul 14, 2026
aa94f68
fix(summarizer): abort() also stops the active remote provider
dishit-wednesday Jul 14, 2026
18cd101
fix(litert): strip temporary DIAG-UAF logging from the conversation h…
dishit-wednesday Jul 14, 2026
98681a2
chore(pro): bump submodule to review fixes (brief-card tidy, battery …
dishit-wednesday Jul 14, 2026
e8fc497
fix(whisper): keep non-ASCII transcripts + guard overlapping file tra…
dishit-wednesday Jul 14, 2026
6b285fa
feat(services): export modelResidencyManager for a single-source RAM …
dishit-wednesday Jul 14, 2026
3133d64
chore(docs): remove pro/locket planning + handoff docs from open-core
dishit-wednesday Jul 15, 2026
79f0ee8
Merge pull request #532 from off-grid-ai/feat/vad-gated-recorder
dishit-wednesday Jul 15, 2026
bbd65ea
Merge offgrid/dev into locket-pro (core catch-up)
dishit-wednesday Jul 15, 2026
617f548
chore(locket): bump pro submodule to caught-up pin + mock its native …
dishit-wednesday Jul 15, 2026
d79d489
chore(locket): repoint pro pin to the LATEST recorder branch catch-up
dishit-wednesday Jul 15, 2026
3b76a7c
fix(whisper): restore fallback-recorder cancel in forceReset (dropped…
dishit-wednesday Jul 16, 2026
41765af
fix(whisper): scope forceReset's recorder-cancel to the realtime sess…
dishit-wednesday Jul 16, 2026
1bdc6c3
Merge pull request #559 from off-grid-ai/chore/locket-catchup-dev
dishit-wednesday Jul 16, 2026
f0c05eb
Merge remote-tracking branch 'offgrid/dev' into chore/locket-catchup-…
dishit-wednesday Jul 16, 2026
14b343d
fix(core): re-export stripMarkdownForSpeech for the pro audio submodule
dishit-wednesday Jul 16, 2026
197c210
chore(locket): bump pro pin to dev's current pro (ff0d8742) — audio s…
dishit-wednesday Jul 16, 2026
f842650
chore(core): drop the vestigial stripMarkdownForSpeech export (audit …
dishit-wednesday Jul 16, 2026
8888a46
Merge pull request #562 from off-grid-ai/chore/locket-catchup-dev-2
dishit-wednesday Jul 16, 2026
16e59b8
chore(locket): point pro pin at feat/locket (the → main base) after b…
dishit-wednesday Jul 16, 2026
1d00a31
fix(core): re-export AlertButton + Chunk for pro; bump pro pin to fea…
dishit-wednesday Jul 16, 2026
8453202
Merge pull request #566 from off-grid-ai/chore/locket-catchup-dev-2
dishit-wednesday Jul 16, 2026
ccda30f
chore(recorder): drop the Silero VAD model from public core (now bund…
dishit-wednesday Jul 16, 2026
89843be
Merge pull request #567 from off-grid-ai/chore/locket-catchup-dev-2
dishit-wednesday Jul 16, 2026
fa0df51
fix(whisper): clear file-transcription stop handle in forceReset
dishit-wednesday Jul 16, 2026
9f0d693
Merge pull request #570 from off-grid-ai/feat/rps-core-p0
dishit-wednesday Jul 17, 2026
b300b45
chore(locket): point pro submodule at merged feat/locket (RPS stack 6…
dishit-wednesday Jul 17, 2026
c1afb95
chore(ios): sync Podfile.lock with merged pro (add OffgridPro pod)
dishit-wednesday Jul 17, 2026
e749eff
fix(ios): stamp Podfile.lock with pinned CocoaPods 1.15.2 (match CI)
dishit-wednesday Jul 17, 2026
42013e6
chore(ci): TEMP diagnostic - print git status before uat.sh (find dir…
dishit-wednesday Jul 17, 2026
39b321b
fix(ci): ignore .bundle/config in uat.sh clean-tree check; drop diagn…
dishit-wednesday Jul 17, 2026
bd554df
fix(ci): let CI regenerate ios/Podfile.lock so it matches Manifest.lock
dishit-wednesday Jul 17, 2026
f49f323
test(locket): parameterize cross-screen sync over states + running-st…
dishit-wednesday Jul 17, 2026
d032056
chore(beta): 0.0.104-beta.1 (build 1784286784) [skip ci]
Jul 17, 2026
a1d1a33
test(locket): cross-screen sync + player smoke tests; scope dynamic-i…
dishit-wednesday Jul 17, 2026
2511047
chore(recorder): remove dead MemoryTabScreen (unwired)
dishit-wednesday Jul 17, 2026
d22ec8f
test(locket): rendered guards for queue-aware CTAs + green card
dishit-wednesday Jul 17, 2026
d4147b0
chore(pro): bump pointer to onboarding + queue-aware CTAs
dishit-wednesday Jul 17, 2026
edda8f9
Merge pull request #579 from off-grid-ai/fix/locket-recording-ctas
dishit-wednesday Jul 17, 2026
4743e63
Merge pull request #578 from off-grid-ai/refactor/locket-screens
dishit-wednesday Jul 17, 2026
ae962f8
feat(whisper): honest CoreML toggle + usage logs + hardened encoder d…
dishit-wednesday Jul 19, 2026
686096c
feat(whisper): per-model Neural Engine (ANE/CPU) status on the models…
dishit-wednesday Jul 19, 2026
a7a86c7
test(locket): rendered test for damaged-clip Repair banner + service
dishit-wednesday Jul 19, 2026
687cb7b
test(locket): guard speaker-turn rendering on the detail; drop dead p…
dishit-wednesday Jul 19, 2026
0b32074
feat(insights): expose hasRemoteBackend for the recorder model guard
dishit-wednesday Jul 20, 2026
2b99fe0
fix(ios): patch react-native-calendar-events for iOS 17+ Full Access
dishit-wednesday Jul 21, 2026
b90607a
feat(whisper): enforce GPU eligibility at the load site; drop flash-attn
dishit-wednesday Jul 21, 2026
85033b6
Merge pull request #581 from off-grid-ai/feat/core-round-1
dishit-wednesday Jul 22, 2026
179bbdc
fix(kb): render text-indexed docs in the preview instead of 'file not…
dishit-wednesday Jul 22, 2026
7c33831
feat(chat): scope a conversation to one recording via bounded per-tur…
dishit-wednesday Jul 23, 2026
bff2090
chore(llm): stop teeing llama.cpp native logs into the app log
dishit-wednesday Jul 23, 2026
7c71cd2
test(locket): rendered guards for pipeline %, setup focus/vocab, repa…
dishit-wednesday Jul 23, 2026
b720eb3
chore: bump pro submodule to the locket feed/detail + setup/settings …
dishit-wednesday Jul 23, 2026
b810055
chore(whisper): drop the experimental small.en-tdrz speaker-turns model
dishit-wednesday Jul 23, 2026
49a953d
fix(whisper): scroll the transcription model picker so all models are…
dishit-wednesday Jul 23, 2026
4d70880
chore: bump pro submodule to round-3 locket edits
dishit-wednesday Jul 24, 2026
6c0b44e
docs: update commit coordination
dishit-wednesday Jul 26, 2026
27ffa55
docs: record final commit plan for the transcript-visibility fix
dishit-wednesday Jul 26, 2026
02a15d5
docs: update commit coordination
dishit-wednesday Jul 26, 2026
b8ba620
docs: note branch rename and commit-1 status in coordination
dishit-wednesday Jul 26, 2026
2bbb251
docs: mark locket commits done and warn on shared-index commits
dishit-wednesday Jul 26, 2026
fdada9d
docs: log that 75c2ecc absorbed Agent A's copy-transcript hunk
dishit-wednesday Jul 26, 2026
8258aba
docs: record the coordinator sweep of remaining locket commits
dishit-wednesday Jul 26, 2026
c5a55ba
chore: bump pro submodule to round-4 locket edits
dishit-wednesday Jul 26, 2026
957cf0e
chore(deps): add cactus-react-native + react-native-nitro-modules
dishit-wednesday Jul 27, 2026
134e938
chore(android): arm64-only build (cactus ships arm64-only libs)
dishit-wednesday Jul 27, 2026
5536d71
chore: exclude poc-archive from tsc + gitignore bundled cactus models
dishit-wednesday Jul 27, 2026
cbd595d
chore: bump pro submodule to recorder-v2 STT engine commits
dishit-wednesday Jul 27, 2026
d2bb0af
chore: gitignore built app binaries
dishit-wednesday Jul 31, 2026
58b486a
chore(android): arm64-only packaging, and record why onnxruntime must…
dishit-wednesday Jul 31, 2026
922d2bf
feat(android): a releaseLocket build that installs alongside the norm…
dishit-wednesday Jul 31, 2026
5274f16
chore(android): bump to 0.0.104
dishit-wednesday Jul 31, 2026
a4b1a34
feat(ios): Live Activity and a recorder tile, as a widget extension
dishit-wednesday Jul 31, 2026
aeabfdb
chore(ios): make iOS ask for Local Network permission, and a JS refre…
dishit-wednesday Jul 31, 2026
7a79bc0
chore(ios): pod lockfile for cactus, nitro modules and ZIPFoundation
dishit-wednesday Jul 31, 2026
093badb
perf: O(1) debug-log buffer
dishit-wednesday Jul 31, 2026
6619399
test(infra): stub nitro/cactus so pro suites load, and fake the VAD/a…
dishit-wednesday Jul 31, 2026
aa778a3
feat: an STT model registry, so a pro model can be app-managed
dishit-wednesday Jul 31, 2026
bfd190c
feat: remoteOnly summarization - never fall back to on-device
dishit-wednesday Jul 31, 2026
8738b4f
fix: do not evict whisper out from under a running transcription
dishit-wednesday Jul 31, 2026
769f35e
feat: subscribable realtime transcription boundary
dishit-wednesday Jul 31, 2026
99f15a2
chore: agent definitions for the recorder work
dishit-wednesday Jul 31, 2026
9c20003
chore: bump pro submodule to the recorder-v2 work
dishit-wednesday Jul 31, 2026
dad48c8
docs(test): correct what happens to tests placed under pro/
dishit-wednesday Jul 31, 2026
029acc6
chore: bump pro submodule for the pro test runner
dishit-wednesday Jul 31, 2026
857b5b1
chore: bump pro submodule for the relocated pro tests
dishit-wednesday Jul 31, 2026
d4fe0d6
test(infra): let the locket STORES' dynamic imports run under jest too
dishit-wednesday Jul 31, 2026
7ed3148
chore: bump pro submodule for the un-parked rendered test
dishit-wednesday Jul 31, 2026
952970e
chore: bump pro submodule - pro's suite is green
dishit-wednesday Jul 31, 2026
47aae96
test(locket): repair the feed tests the unified-feed rework broke
dishit-wednesday Jul 31, 2026
caba441
chore: bump pro submodule - recorder tests moved out of core
dishit-wednesday Jul 31, 2026
c7edfe1
test: remove the locket suites that moved into pro
dishit-wednesday Aug 1, 2026
376a4c2
chore: bump pro submodule - the last locket tests moved out of core
dishit-wednesday Aug 1, 2026
51e3c60
Merge pull request #614 from off-grid-ai/fix/locket-round-3
dishit-wednesday Aug 1, 2026
d5c9f8f
Merge pull request #598 from off-grid-ai/fix/locket-round-2
dishit-wednesday Aug 1, 2026
6eb36e9
chore: bump pro submodule after the voiceprint fixture was purged
dishit-wednesday Aug 1, 2026
ef6ea21
Merge feat/locket-pro into feat/recorder-v2
dishit-wednesday Aug 1, 2026
d23ea0b
fix(download): key the staging file to the url so retries resume
dishit-wednesday Aug 3, 2026
b5e6f30
fix(downloads): keep aggregated model parts out of the manager list
dishit-wednesday Aug 3, 2026
2b1f822
feat(theme): a reading-text typography token
dishit-wednesday Aug 3, 2026
32aaf0b
chore(hooks): typecheck the pro submodule on pre-push
dishit-wednesday Aug 3, 2026
fb8e2a3
fix(rag): rename a document's name without re-embedding
dishit-wednesday Aug 3, 2026
5e431fe
fix(network): say WHY a server probe failed
dishit-wednesday Aug 3, 2026
15cfd96
fix(android): drop the redundant domain-config that forced strict TLS
dishit-wednesday Aug 3, 2026
783f446
test(models): declare the transcription veto in the residency cases
dishit-wednesday Aug 3, 2026
82f96e3
fix(litert): honor the requested token budget instead of re-clamping …
dishit-wednesday Aug 3, 2026
cceee7f
chore: bump pro to the recorder round-6 work
dishit-wednesday Aug 3, 2026
5d768f8
chore: bump pro for the speech-gating wiring
dishit-wednesday Aug 3, 2026
65ac615
Merge pull request #622 from off-grid-ai/feat/recorder-v3
dishit-wednesday Aug 3, 2026
40ab6e0
Merge pull request #619 from off-grid-ai/feat/recorder-v2
dishit-wednesday Aug 3, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions .claude/agents/device-verifier.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
---
name: device-verifier
description: Prove a change works on the physical device — build, install, pull the debug log, grep the state-machine traces. Use when a fix needs on-device evidence rather than a passing test. Runs ONE AT A TIME (there is a single device) and only in the main checkout.
tools: Bash, Read, Grep, Glob
---

You confirm behaviour on real hardware. You do not design or refactor.

## Hard constraints

- **Main checkout only.** Never run in a git worktree: a fresh worktree has no `pro/` submodule
checkout and no gradle/CocoaPods cache, so the build is cold and the artifact paths differ.
- **One instance at a time.** There is one connected device. Two of you racing `adb install`
or reading the log leaves both traces interleaved and useless.
- **Install is pre-authorized, building is not.** If `adb devices` shows a device after a build,
install it yourself with `adb install -r`. Do not kick off a build the user didn't ask for.

## The loop

1. Build only what the user asked for.
2. Install (Android: `adb install -r`; iOS: the Debug config, bundle id `ai.offgridmobile.dev`).
3. Pull the log — see the **Device Logs** section of `CLAUDE.md` for the exact
`devicectl` / `adb pull` commands. Do not re-derive them and do not hardcode a device UDID.
4. **Read only the live-session tail** — from the last `===== session start =====` marker
forward. Never dump the whole file.
5. Grep the relevant state machine (`[TTS-SM]`, `[GEN-SM]`, `[MODEL-SM]`, `[DL-SM]`,
`[ROUTE-SM]`, `[IMG-SM]`, `[MEM-SM]`, `[FAIL-SM]`) and quote the lines that decide the question.

## Reporting

Return the log lines that prove or disprove the claim, then your verdict. If the trace is absent
or ambiguous, say so — an absent line is not evidence of success. Never infer device behaviour
from source code when you were asked to verify it on the device.
42 changes: 42 additions & 0 deletions .claude/agents/pro-feature.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
---
name: pro-feature
description: Implement or change a Pro feature (locket/recorder, TTS, MCP tools, speaker ID, any paid surface). Keeps the edit inside the pro/ submodule and keeps Pro code out of the public core repo. Use for any work whose files land under pro/.
tools: Bash, Read, Edit, Write, Grep, Glob, Skill
---

You work on the Pro surface. Your job includes keeping the open-core boundary intact.

## Where code goes

Read `pro/CLAUDE.md` first — it is the contract for this submodule. Core only wires Pro in
through the slot/hook registries and never imports Pro code directly.

- Feature code, its native Kotlin/Swift, and its tests live under `pro/`.
- The only legitimate change in core is registry wiring.
- If a change seems to need core edits beyond wiring, that is a signal the seam is wrong.
Say so instead of reaching into core.

## The boundary (this is the part that gets violated)

Never let these reach the public core repo:

- `docs/plans/*.md` — architecture, handoff, and R&D docs stay out of core entirely.
- Tests that `import` from `pro/` — they belong in the pro repo.
- Any Pro implementation detail leaking upward into a core screen, store, or service.

Before you finish, `git status` core and confirm the only staged core files are registry wiring.

## Branches and PRs

`pro/` is its own git repo (`@offgrid/pro`). A Pro change is a **separate branch and separate PR
in that repo**, stacked on the relevant pro base branch — not a commit onto an existing shared
branch, and not folded into the core PR. Confirm the remote and base branch before pushing;
the pro remote is not `origin`.

## Standards

Follow the repo standards in `CLAUDE.md` — they apply inside `pro/` too. Use the `hygiene` skill
when designing a new subsystem and the `tests` skill when writing or fixing a test. Do not
restate those rules here; load the skill.

Commit each cohesive green step. Do not commit or push without explicit instruction.
12 changes: 6 additions & 6 deletions .github/workflows/uat-dev.yml
Original file line number Diff line number Diff line change
Expand Up @@ -70,12 +70,12 @@ jobs:
- uses: ruby/setup-ruby@v1
with: { ruby-version: '3.3', bundler-cache: true }
- name: pod install
# Restore Podfile.lock afterward: a CI CocoaPods version can rewrite it, which would
# dirty the tree and trip uat.sh's clean-tree pre-check. The pods are already installed,
# so the lock's content doesn't affect the build.
run: |
cd ios && pod install
cd .. && git checkout -- ios/Podfile.lock 2>/dev/null || true
# Do NOT restore Podfile.lock afterward. CI's CocoaPods can rewrite it (e.g. the fmt patch),
# and the archive's "[CP] Check Pods Manifest.lock" phase compares Podfile.lock against the
# freshly generated Pods/Manifest.lock — restoring the committed lock makes them diverge and
# fails the archive. Let the regenerated lock stand (it matches Manifest.lock); uat.sh's
# clean-tree check whitelists ios/Podfile.lock so this no longer trips the pre-check.
run: cd ios && pod install
- name: Decode signing secrets
run: |
echo "${{ secrets.ANDROID_KEYSTORE_BASE64 }}" | base64 --decode > "$RUNNER_TEMP/release.keystore"
Expand Down
22 changes: 22 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,11 @@ fastlane/*.p8
!.yarn/sdks
!.yarn/versions
docs/TRACTION_KNOWLEDGE_BASE.md
# Dev-only insights sim harness + private real recordings (never commit)
/sim/
# Debug screenshots (scratch)
/image.png
/image-*.png
# Local marketing drafts (not part of the app)
marketing/

Expand All @@ -94,3 +99,20 @@ docs/DEVICE_SESSION_COMMENTARY.md

# Device wire-capture logs (raw, large, session-specific — kept locally, not versioned)
docs/wire-captures/

# On-device diarization embedder model (test build; ~28MB)
pro/android/src/main/assets/diar/
poc-archive/
android/app/src/main/assets/cactus-models/
pro/android/src/main/jniLibs/arm64-v8a/libsherpa-onnx-jni.so

# Built app binaries. These land in the repo root from a local/BrowserStack build run
# (whisper-npu-s23.apk was 803MB, OffgridMobile.ipa 75MB) and were untracked but NOT
# ignored - one `git add .` from being committed into a repo whose .git is already ~9GB.
*.apk
*.aab
*.ipa
pro/android/libs/sherpa-onnx-static-1.13.4.jar

# Local Claude Code skill lockfile - machine state, not project config.
skills-lock.json
10 changes: 10 additions & 0 deletions .husky/pre-push
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,16 @@ if [ -n "$PUSHED_JS" ]; then
echo "▶ TypeScript type check..."
npx tsc --noEmit

# The root tsconfig EXCLUDES pro/** (the public repo's CI does not check the submodule out), so
# the check above only sees a pro file when a core file imports it - and pro screens are reached
# through the registry at runtime, not by an import. That left every pro-only screen, hook and
# service completely untypechecked on push; two real errors were sitting in the tree unseen.
# pro has its own tsconfig, so run it too whenever the submodule is actually present.
if [ -f pro/tsconfig.json ]; then
echo "▶ TypeScript type check (pro submodule)..."
npx tsc --noEmit -p pro/tsconfig.json
fi

echo "▶ JS/TS tests (related to changed files)..."
echo "$PUSHED_JS" | tr '\n' '\0' | xargs -0 npx jest --findRelatedTests --passWithNoTests

Expand Down
13 changes: 13 additions & 0 deletions App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import logger from './src/utils/logger';
import { useAppStore, useAuthStore, useRemoteServerStore, useWhisperStore } from './src/stores';
import { useDebugLogsStore } from './src/stores/debugLogsStore';
import { initDebugLogFile, appendDebugLine } from './src/utils/debugLogFile';
import { warmUpPackagerLocalNetwork } from './src/utils/packagerLocalNetworkWarmup';
import { loadProFeatures } from './src/bootstrap/loadProFeatures';
import { checkProStatus } from './src/services/proLicenseService';
import { hydrateDownloadStore } from './src/services/downloadHydration';
Expand All @@ -31,6 +32,7 @@ import { LockScreen } from './src/screens';
import { useAppState } from './src/hooks/useAppState';
import { useDownloadStore } from './src/stores/downloadStore';
import { ErrorBoundary } from './src/components/ErrorBoundary';
import { Toast } from './src/components';

LogBox.ignoreAllLogs(); // Suppress all logs

Expand Down Expand Up @@ -69,10 +71,19 @@ const ensureRemoteServerStoreHydrated = async () => {

function App() {
useDownloadListeners();
// Dev iOS only: re-issue RN's packager probe now that we have a foreground UI,
// so iOS can actually present the Local Network permission alert it refuses to
// show during didFinishLaunchingWithOptions. See packagerLocalNetworkWarmup.ts.
useEffect(() => {
void warmUpPackagerLocalNetwork();
}, []);
// Reactive: when Pro is activated at runtime (license key → loadProFeatures),
// the appRoot slot (TTS engine bridge) registers and this re-renders to mount
// it live — no restart needed.
const AppRoot = useSlot(SLOTS.appRoot);
// Root-mounted recorder prompt (the speech-model download sheet). Its own slot, because
// appRoot already holds the TTS engine bridge and a slot maps to ONE component.
const SttModelPrompt = useSlot(SLOTS.sttModelPrompt);
const [isInitializing, setIsInitializing] = useState(true);
const setDeviceInfo = useAppStore((s) => s.setDeviceInfo);
const setModelRecommendation = useAppStore((s) => s.setModelRecommendation);
Expand Down Expand Up @@ -359,6 +370,7 @@ function App() {
<SafeAreaProvider>
<SystemBars style={isDark ? 'light' : 'dark'} />
{AppRoot ? <AppRoot /> : null}
{SttModelPrompt ? <SttModelPrompt /> : null}
<NavigationContainer
theme={{
dark: isDark,
Expand Down Expand Up @@ -392,6 +404,7 @@ function App() {
>
<AppNavigator />
</NavigationContainer>
<Toast />
</SafeAreaProvider>
</GestureHandlerRootView>
);
Expand Down
107 changes: 102 additions & 5 deletions __tests__/harness/nativeBoundary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -559,6 +559,21 @@ export interface WhisperFake {
holdNextLoad(): void;
/** Release a load held via holdNextLoad(). No-op if not held. */
releaseLoad(): void;
/** HOLD the next file transcribe (context.transcribe) open until releaseTranscribe() - the
* device-shaped window a real whole-file transcribe occupies (tens of seconds), so anything
* that runs DURING an in-flight transcription (e.g. an iOS memory warning) is observable.
* One-shot. */
holdNextTranscribe(): void;
/** Resolve a transcribe held via holdNextTranscribe(). No-op if not held. */
releaseTranscribe(): void;
/** True while a held transcribe is still in flight (never resolved, never stopped). */
transcribeInFlight(): boolean;
/** Script the ggml Silero VAD (initWhisperVad → ctx.detectSpeech) the locket declutter/trim scan uses.
* Segments are VadSegment[] with t0/t1 in CENTISECONDS (vadDetect maps t*10 → ms). Default = one 0–30s
* speech run; setVadSegments([]) models silence (→ 'none'); scriptVadThrow() makes EVERY detectSpeech
* throw — the swallowed per-chunk error the fail-open path must treat as 'unknown', never as silence. */
setVadSegments(segs: Array<{ t0: number; t1: number }>): void;
scriptVadThrow(throwIt?: boolean): void;
}

function makeWhisperFake(): WhisperFake {
Expand All @@ -568,13 +583,38 @@ function makeWhisperFake(): WhisperFake {
// Load hold: opens the in-flight model-load window a real (seconds-long) ggml init has.
let loadHoldPending = false;
let loadHoldRelease: (() => void) | null = null;
let txHoldPending = false;
let txHoldResolve: ((v: unknown) => void) | null = null;
// ggml Silero VAD (initWhisperVad → ctx.detectSpeech), used by the locket declutter/trim scan.
let vadSegments: Array<{ t0: number; t1: number }> = [{ t0: 0, t1: 3000 }]; // default: 0–30s speech (centiseconds)
let vadThrows = false;
const vadContext: Record<string, jest.Mock> = {
detectSpeech: jest.fn(async () => {
if (vadThrows) throw new Error('detectSpeech failed (native VAD error)');
return vadSegments;
}),
release: jest.fn(async () => {}),
};
const context: Record<string, jest.Mock> = {
// Faithful to whisper.rn: transcribe(path, opts) returns { stop, promise }, the promise resolving to
// { result, segments } — this is the method whisperService.transcribeFile (the voice-mode file path) drives.
transcribe: jest.fn((_path: string) => ({
stop: jest.fn(async () => {}),
promise: Promise.resolve({ result: fileTranscript, segments: [{ text: fileTranscript, t0: 0, t1: 100 }] }),
})),
transcribe: jest.fn((_path: string) => {
const done = { result: fileTranscript, segments: [{ text: fileTranscript, t0: 0, t1: 100 }] };
if (!txHoldPending) {
return { stop: jest.fn(async () => {}), promise: Promise.resolve(done) };
}
txHoldPending = false;
// Parked until releaseTranscribe() - or until stop(), which is what unloadModel calls to
// cancel the native job. whisper.rn surfaces that cancellation as Code: -999.
const promise = new Promise((res) => { txHoldResolve = res; });
return {
stop: jest.fn(async () => {
const f = txHoldResolve; txHoldResolve = null;
f?.({ result: '', segments: [], isAborted: true });
}),
promise,
};
}),
transcribeFile: jest.fn(async () => ({ result: fileTranscript, segments: [{ text: fileTranscript, t0: 0, t1: 100 }] })),
transcribeRealtime: jest.fn(async () => {
rtActive = true; // native mic session starts capturing
Expand All @@ -597,11 +637,15 @@ function makeWhisperFake(): WhisperFake {
return context;
}),
releaseAllWhisper: jest.fn(async () => {}),
// Named export the locket VAD path imports: `import { initWhisperVad } from 'whisper.rn'`.
initWhisperVad: jest.fn(async () => vadContext),
// Some call sites read module-level too; mirror the context.
transcribeFile: context.transcribeFile,
};
return {
module,
setVadSegments: (segs: Array<{ t0: number; t1: number }>) => { vadSegments = segs; },
scriptVadThrow: (throwIt = true) => { vadThrows = throwIt; },
emitRealtime: ({ text, isCapturing, recordingTime, noData }) => {
if (!realtimeCb) return;
realtimeCb({
Expand All @@ -616,6 +660,12 @@ function makeWhisperFake(): WhisperFake {
realtimeActive: () => rtActive,
holdNextLoad: () => { loadHoldPending = true; },
releaseLoad: () => { const f = loadHoldRelease; loadHoldRelease = null; f?.(); },
holdNextTranscribe: () => { txHoldPending = true; },
releaseTranscribe: () => {
const f = txHoldResolve; txHoldResolve = null;
f?.({ result: fileTranscript, segments: [{ text: fileTranscript, t0: 0, t1: 100 }] });
},
transcribeInFlight: () => txHoldResolve != null,
};
}

Expand Down Expand Up @@ -704,6 +754,44 @@ function makeFsFake(): FsFake {
return { module, seedFile, seedDir, DocumentDirectoryPath };
}

// ---------------------------------------------------------------------------
// Fake: AudioNormalizer (NativeModules.AudioNormalizer) — the native WAV slicer/concat/compressor the
// locket VAD scan (extractWavSlice) and trim (concatWavSlices/compressToAac/normalizeToWav16kMono) use.
// Each op WRITES its output to the (memfs) disk so the real downstream reads (stat/exists/unlink) find a
// file — outputs are device-shaped SIZES derived from the args, so the real trim math runs on top.
// Needs the fs fake to seed; install with { fs: true, audio: true }.
// ---------------------------------------------------------------------------

export interface AudioNormalizerFake { module: Record<string, jest.Mock>; }

function makeAudioNormalizerFake(seedFile?: (path: string, sizeBytes: number) => void): AudioNormalizerFake {
// 16k mono 16-bit PCM = 32000 bytes/sec — used to size slice/concat outputs realistically.
const pcmBytes = (ms: number) => 44 + Math.max(0, Math.round((ms / 1000) * 32000));
let n = 0;
const module: Record<string, jest.Mock> = {
extractWavSlice: jest.fn(async (_src: string, _startMs: number, durationMs: number) => {
const p = `/docs/slice-${++n}.wav`;
seedFile?.(p, pcmBytes(durationMs));
return p;
}),
concatWavSlices: jest.fn(async (_src: string, ranges: Array<{ startMs: number; durationMs: number }>, outPath: string) => {
const kept = ranges.reduce((a, r) => a + (r.durationMs || 0), 0);
seedFile?.(outPath, pcmBytes(kept));
return outPath;
}),
compressToAac: jest.fn(async (_src: string, outPath: string) => {
const sizeBytes = 50_000; // a small AAC backup
seedFile?.(outPath, sizeBytes);
return { path: outPath, sizeBytes };
}),
normalizeToWav16kMono: jest.fn(async (_in: string, outPath: string) => {
seedFile?.(outPath, 5_000_000);
return outPath;
}),
};
return { module };
}

// ---------------------------------------------------------------------------
// installNativeBoundary — seed the set, then freshly require services/stores on top.
// ---------------------------------------------------------------------------
Expand All @@ -723,6 +811,9 @@ export interface InstallOpts {
download?: boolean;
/** Replace the global whisper.rn stub with a driveable STT context (boundary.whisper). */
whisper?: boolean;
/** Seed the native AudioNormalizer (WAV slice/concat/compress) — the locket VAD-scan + trim boundary.
* Needs { fs: true } (its outputs are written to the memfs disk). */
audio?: boolean;
}

export interface NativeBoundary {
Expand All @@ -739,6 +830,8 @@ export interface NativeBoundary {
download?: DownloadFake;
/** Driveable whisper STT context — present only when installed with { whisper: true }. */
whisper?: WhisperFake;
/** Native AudioNormalizer (WAV slice/concat/compress) — present only when installed with { audio: true }. */
audio?: AudioNormalizerFake;
/** Re-read RAM at the leaf mid-test (e.g. simulate OS pressure between a pre-check and the load). */
setRam(profile: RamProfile): void;
/** Fire the OS 'memoryWarning' AppState event the app's residency manager listens to (auto-eviction). */
Expand Down Expand Up @@ -787,13 +880,17 @@ export function installNativeBoundary(opts: InstallOpts = {}): NativeBoundary {
const whisperFake = opts.whisper ? makeWhisperFake() : undefined;
if (whisperFake) jest.doMock('whisper.rn', () => whisperFake.module);

// Native AudioNormalizer (WAV slice/concat/compress) — outputs written to the memfs disk.
const audioFake = opts.audio ? makeAudioNormalizerFake(fsFake?.seedFile) : undefined;


const RN = require('react-native');
RN.NativeModules.LiteRTModule = litert.module;
// Both platform names point at the same fake; localDreamGenerator's Platform.select picks one.
RN.NativeModules.LocalDreamModule = diffusion.module;
RN.NativeModules.CoreMLDiffusionModule = diffusion.module;
if (downloadFake) RN.NativeModules.DownloadManagerModule = downloadFake.module;
if (audioFake) RN.NativeModules.AudioNormalizer = audioFake.module;
// Mic permission is a device boundary: whisper STT refuses to start recording without RECORD_AUDIO
// granted (whisperService.requestPermissions → PermissionsAndroid.request). Grant it when whisper is
// installed so the real STT flow runs; the default jest PermissionsAndroid returns undefined (= denied).
Expand Down Expand Up @@ -856,5 +953,5 @@ export function installNativeBoundary(opts: InstallOpts = {}): NativeBoundary {
Object.defineProperty(RN.Platform, 'Version', { value: profile.platform === 'android' ? 34 : '17.0', configurable: true });
};

return { litert, litertEvents: handle, diffusion, fs: fsFake, llama: llamaFake, download: downloadFake, whisper: whisperFake, setRam, emitMemoryWarning: () => appState.handle.emit('memoryWarning') };
return { litert, litertEvents: handle, diffusion, fs: fsFake, llama: llamaFake, download: downloadFake, whisper: whisperFake, audio: audioFake, setRam, emitMemoryWarning: () => appState.handle.emit('memoryWarning') };
}
Loading