Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
48f1c9d
chore: ignore the loop simulator's fixtures and session screenshots
dishit-wednesday Aug 7, 2026
7b371a1
feat(slots): a home.recorder slot for a pro recorder card
dishit-wednesday Aug 7, 2026
d6d93b8
feat(home): render the recorder slot above recent conversations
dishit-wednesday Aug 7, 2026
10cf155
feat(llm): grammar, repeat-penalty and thinking overrides as one module
dishit-wednesday Aug 7, 2026
3e88c83
feat(llm): accept ConstrainedCompletionOpts in generateWithMaxTokens
dishit-wednesday Aug 7, 2026
78594cb
feat(models): skipVision, a text-only load that leaves the projector …
dishit-wednesday Aug 7, 2026
1f676d2
fix(models): a text-only load must not satisfy a caller that wants vi…
dishit-wednesday Aug 7, 2026
65d7169
fix(whisper): share one in-flight load so concurrent callers cannot l…
dishit-wednesday Aug 7, 2026
d14a4ae
feat(whisper): veto eviction while a file transcribe is running
dishit-wednesday Aug 7, 2026
8f130da
fix(whisper): pass maxThreads through transcribeFile
dishit-wednesday Aug 7, 2026
1da1e63
chore(test): run the pro submodule's own suite, and give it the root …
dishit-wednesday Aug 7, 2026
8e9d41a
chore(native): autolink pro by root only, and let pro declare its own…
dishit-wednesday Aug 7, 2026
0b35d6b
chore: bump the pro submodule pointer to the conversation loop
dishit-wednesday Aug 7, 2026
fdcf281
chore(test): run the pro submodule's suite from the root config
dishit-wednesday Aug 8, 2026
e3378b9
fix(whisper): reset the context so a wedged transcribe cannot hold it…
dishit-wednesday Aug 8, 2026
4fb8725
chore(ios): refresh Podfile.lock
dishit-wednesday Aug 8, 2026
c6f2c12
chore: bump the pro submodule to the coordinator and speech-verdict work
dishit-wednesday Aug 8, 2026
cc4c200
chore: bump the pro submodule to the sidecar pipeline
dishit-wednesday Aug 10, 2026
424e4ad
chore: bump the pro submodule to the audit fixes and the architecture…
dishit-wednesday Aug 10, 2026
d90e6df
chore: add notifee for local meeting reminders
dishit-wednesday Aug 10, 2026
c21f821
chore: bump the pro submodule to the locket product layer
dishit-wednesday Aug 10, 2026
df9e0b0
chore(ios): background audio and the pod that needs modular headers
dishit-wednesday Aug 11, 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
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -94,3 +94,9 @@ docs/DEVICE_SESSION_COMMENTARY.md

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

# Loop simulator: generated fixtures and audio (949 MB). Rebuilt by sim/loop/*.mjs.
sim/

# Screenshots pasted in during device sessions.
image*.png
3 changes: 3 additions & 0 deletions __tests__/integration/models/sttResidency.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ jest.mock('../../../src/services/whisperService', () => ({
isModelDownloaded: jest.fn(async () => true),
deleteModel: jest.fn(async () => {}),
downloadModel: jest.fn(async () => '/models/x'),
// Residency asks before evicting. Nothing is decoding in these tests, so it is always free to go;
// the veto itself is covered where transcription is actually in flight.
isFileTranscribing: () => false,
},
WHISPER_MODELS: [{ id: 'base', size: 142 }],
}));
Expand Down
8 changes: 8 additions & 0 deletions ios/OffgridMobile/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,14 @@
<array>
<string>arm64</string>
</array>
<!-- Ambient recording continues with the screen locked and the app backgrounded. Without this iOS
suspends the audio session the moment the app leaves the foreground, so the recorder stops the
instant the screen locks - which is most of when an ambient recorder is meant to work. It does
NOT survive a force-quit or an OS termination; that is a platform limit, not a configuration. -->
<key>UIBackgroundModes</key>
<array>
<string>audio</string>
</array>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
Expand Down
8 changes: 7 additions & 1 deletion ios/Podfile
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,13 @@ end

target 'OffgridMobile' do
config = use_native_modules!


# onnxruntime-objc ships no module map, and OffgridPro is a Swift pod that imports it for the
# recorder's Silero VAD. Without this, pod install fails outright with "The Swift pod `OffgridPro`
# depends upon `onnxruntime-objc`, which does not define modules" - so this line is what makes the
# ambient recorder buildable on iOS at all, not a tuning preference.
pod 'onnxruntime-objc', :modular_headers => true

use_react_native!(
:path => config[:reactNativePath],
# An absolute path to your application root.
Expand Down
58 changes: 55 additions & 3 deletions ios/Podfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,40 @@ PODS:
- MMKV (2.4.0):
- MMKVCore (~> 2.4.0)
- MMKVCore (2.4.0)
- OffgridPro (0.0.1):
- boost
- DoubleConversion
- fast_float
- fmt
- glog
- hermes-engine
- onnxruntime-objc
- RCT-Folly
- RCT-Folly/Fabric
- RCTRequired
- RCTTypeSafety
- React-Core
- React-debug
- React-Fabric
- React-featureflags
- React-graphics
- React-ImageManager
- React-jsi
- React-NativeModulesApple
- React-RCTFabric
- React-renderercss
- React-rendererdebug
- React-utils
- ReactCodegen
- ReactCommon/turbomodule/bridging
- ReactCommon/turbomodule/core
- SocketRocket
- Yoga
- onnxruntime-c (1.27.0)
- onnxruntime-objc (1.27.0):
- onnxruntime-objc/Core (= 1.27.0)
- onnxruntime-objc/Core (1.27.0):
- onnxruntime-c (= 1.27.0)
- op-sqlite (15.2.5):
- boost
- DoubleConversion
Expand Down Expand Up @@ -3091,6 +3125,11 @@ PODS:
- ReactCommon/turbomodule/core
- SocketRocket
- Yoga
- RNNotifee (9.1.8):
- React-Core
- RNNotifee/NotifeeCore (= 9.1.8)
- RNNotifee/NotifeeCore (9.1.8):
- React-Core
- RNReactNativeHapticFeedback (2.3.3):
- boost
- DoubleConversion
Expand Down Expand Up @@ -3494,6 +3533,8 @@ DEPENDENCIES:
- glog (from `../node_modules/react-native/third-party-podspecs/glog.podspec`)
- hermes-engine (from `../node_modules/react-native/sdks/hermes-engine/hermes-engine.podspec`)
- llama-rn (from `../node_modules/llama.rn`)
- OffgridPro (from `../pro`)
- onnxruntime-objc
- "op-sqlite (from `../node_modules/@op-engineering/op-sqlite`)"
- RCT-Folly (from `../node_modules/react-native/third-party-podspecs/RCT-Folly.podspec`)
- RCTDeprecation (from `../node_modules/react-native/ReactApple/Libraries/RCTFoundation/RCTDeprecation`)
Expand Down Expand Up @@ -3583,6 +3624,7 @@ DEPENDENCIES:
- RNGestureHandler (from `../node_modules/react-native-gesture-handler`)
- RNInAppBrowser (from `../node_modules/react-native-inappbrowser-reborn`)
- RNKeychain (from `../node_modules/react-native-keychain`)
- "RNNotifee (from `../node_modules/@notifee/react-native`)"
- RNReactNativeHapticFeedback (from `../node_modules/react-native-haptic-feedback`)
- RNReanimated (from `../node_modules/react-native-reanimated`)
- RNScreens (from `../node_modules/react-native-screens`)
Expand All @@ -3598,6 +3640,8 @@ SPEC REPOS:
trunk:
- MMKV
- MMKVCore
- onnxruntime-c
- onnxruntime-objc
- opencv-rne
- SocketRocket
- SSZipArchive
Expand All @@ -3620,6 +3664,8 @@ EXTERNAL SOURCES:
:tag: hermes-v0.14.0
llama-rn:
:path: "../node_modules/llama.rn"
OffgridPro:
:path: "../pro"
op-sqlite:
:path: "../node_modules/@op-engineering/op-sqlite"
RCT-Folly:
Expand Down Expand Up @@ -3796,6 +3842,8 @@ EXTERNAL SOURCES:
:path: "../node_modules/react-native-inappbrowser-reborn"
RNKeychain:
:path: "../node_modules/react-native-keychain"
RNNotifee:
:path: "../node_modules/@notifee/react-native"
RNReactNativeHapticFeedback:
:path: "../node_modules/react-native-haptic-feedback"
RNReanimated:
Expand All @@ -3822,10 +3870,13 @@ SPEC CHECKSUMS:
FBLazyVector: 309703e71d3f2f1ed7dc7889d58309c9d77a95a4
fmt: a40bb5bd0294ea969aaaba240a927bd33d878cdd
glog: 5683914934d5b6e4240e497e0f4a3b42d1854183
hermes-engine: 3de70ea2100f1780402cf146bb8110a0cdb2f34e
hermes-engine: 8c6be38f94b3bf8b864981980e64e55f08e467ec
llama-rn: e6be4084699f0237fe0156c5f826cdaeb59e399e
MMKV: 86859fdfa2b0b21db1fd6e48788474a6416a2c77
MMKVCore: 3d16ce9f7d411e135020915fde98a056859a1efa
OffgridPro: f187e1bf18351f0c92a1c101bfcfee75bb818807
onnxruntime-c: 412ab51682e622e3d77ddc9176aa92517d171820
onnxruntime-objc: f88cca350e7603f81c31b5823f3ddfaa67da9f84
op-sqlite: bafff369cecaee4fe65c89eec47deaba26f2db95
opencv-rne: 2305807573b6e29c8c87e3416ab096d09047a7a0
RCT-Folly: 846fda9475e61ec7bcbf8a3fe81edfcaeb090669
Expand Down Expand Up @@ -3867,7 +3918,7 @@ SPEC CHECKSUMS:
react-native-background-downloader: b02d12c3961322ce1c85fa0f8b3e4adb5b652106
react-native-document-picker: dc2d83366e47e89e7c51e8a41eab99c1d54e941c
react-native-document-viewer: 8c6ed07e7e27352743fa98e8dd6d288ad925b884
react-native-executorch: 9a44ee2b18773cbe5ad2e6d7376eb76f347e2935
react-native-executorch: 65df20362342afff0040d227d270a1b9a59f0c54
react-native-get-random-values: d16467cf726c618e9c7a8c3c39c31faa2244bbba
react-native-image-picker: 0314366753615115fa55c3cc937ac44cb7e75702
react-native-keyboard-controller: 7534b5a39d1e8b2b79f86e8e998ed71c7154f69f
Expand Down Expand Up @@ -3915,6 +3966,7 @@ SPEC CHECKSUMS:
RNGestureHandler: cd4be101cfa17ea6bbd438710caa02e286a84381
RNInAppBrowser: 904d24dc75e8e6c6c98a3160329192608946f9df
RNKeychain: a2c134ab796272c3d605e035ab727591000b30f3
RNNotifee: 5e3b271e8ea7456a36eec994085543c9adca9168
RNReactNativeHapticFeedback: be4f1b4bf0398c30b59b76ed92ecb0a2ff3a69c6
RNReanimated: 292cd58688552a22b3fc1cefcfbc49b336dfed68
RNScreens: 714e10b6b554f7dc7ad9f78dcf36dc8e3fc73415
Expand All @@ -3927,6 +3979,6 @@ SPEC CHECKSUMS:
whisper-rn: 7566faf9b7d78e39ab9fc634cb90fdee81177793
Yoga: 5456bb010373068fc92221140921b09d126b116e

PODFILE CHECKSUM: f66f810a788ead15881075527443239e49b50db1
PODFILE CHECKSUM: 5e9c8ded79cfe55241281a1a60dfe0b1a8404d77

COCOAPODS: 1.16.2
32 changes: 26 additions & 6 deletions jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,14 +35,34 @@ module.exports = {
preset: 'react-native',
setupFilesAfterEnv: ['<rootDir>/jest.setup.ts'],
testMatch: ['**/__tests__/**/*.test.ts', '**/__tests__/**/*.test.tsx'],
transform: {
// pro/ has its own package.json, which makes it a separate Babel package - so a test file living
// inside it resolved no Babel config at all and TypeScript syntax (`import type`) failed to parse.
// Naming the config crosses that boundary.
//
// SCOPED to pro's own tests on purpose. Setting `configFile` globally also disables per-package
// .babelrc lookup inside node_modules, which several React Native libraries rely on.
'<rootDir>/pro/__tests__/.*\\.(js|jsx|ts|tsx)$':
['babel-jest', { configFile: require.resolve('./babel.config.js') }],
'^.+\\.(js|jsx|ts|tsx|mjs)$': 'babel-jest',
// NOT optional. `transform` REPLACES the preset's map rather than merging with it, and
// react-native/jest-preset defines two entries. Overriding with only the code one silently drops
// the asset transformer, so requiring a png or svg returns its raw bytes - which surfaces as
// unrelated rendered tests failing an assertion, nowhere near the cause.
'^.+\\.(bmp|gif|jpg|jpeg|mp4|png|psd|svg|webp)$':
require.resolve('react-native/jest/assetFileTransformer.js'),
},
testPathIgnorePatterns: [
'/node_modules/', '/android/', '/ios/', '/e2e/', 'App.test.tsx',
// pro/ ships its own suite run in the pro repo's CI — never run those from here.
// Anchored to <rootDir>/pro/ so it ignores ONLY the submodule's own tests, NOT this
// repo's __tests__/pro/** pro-dependent suites (a bare '/pro/' matched both).
// The pro-DEPENDENT suites under this repo's __tests__ DO run against the real pro
// when it's checked out, and are ignored only when pro is genuinely absent.
'<rootDir>/pro/',
// Everything inside the submodule EXCEPT its own test suite.
//
// Anchored to <rootDir>/pro/ so it ignores only the submodule, NOT this repo's __tests__/pro/**
// pro-dependent suites (a bare '/pro/' matched both). The negative lookahead is the correction:
// `<rootDir>/pro/` on its own also skipped pro/__tests__, and pro has no separate runner —
// `pro`'s own `npm test` is `cd .. && npx jest`, i.e. THIS config. So a test moved into the
// submodule to keep pro source out of the public repo silently stopped executing. Two had been
// sitting there unrun. Pro-only tests belong in pro; they still have to run from here.
'<rootDir>/pro/(?!__tests__)',
...(proExists ? [] : proDependentTestPaths),
],
// Stale agent git-worktrees under .claude/worktrees/ each carry a full repo copy (incl. their own
Expand Down
10 changes: 10 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
"@dr.pogodin/react-native-fs": "^2.38.1",
"@kesha-antonov/react-native-background-downloader": "^4.5.6",
"@modelcontextprotocol/sdk": "^1.29.0",
"@notifee/react-native": "^9.1.8",
"@op-engineering/op-sqlite": "^15.2.5",
"@react-native-async-storage/async-storage": "^2.2.0",
"@react-native-community/slider": "^5.1.2",
Expand Down
2 changes: 1 addition & 1 deletion pro
Submodule pro updated from ff0d87 to f24d7b
23 changes: 23 additions & 0 deletions react-native.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
const fs = require('fs');

Check warning on line 1 in react-native.config.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `node:fs` over `fs`.

See more on https://sonarcloud.io/project/issues?id=off-grid-ai_off-grid-ai-mobile&issues=AZ_poLTnnhynfjhWFKUS&open=AZ_poLTnnhynfjhWFKUS&pullRequest=626
const path = require('path');

Check warning on line 2 in react-native.config.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `node:path` over `path`.

See more on https://sonarcloud.io/project/issues?id=off-grid-ai_off-grid-ai-mobile&issues=AZ_poLTnnhynfjhWFKUT&open=AZ_poLTnnhynfjhWFKUT&pullRequest=626

// Autolink the pro submodule's native library ONLY when it is actually on disk. Mirrors the
// fs.existsSync(pro) guard metro.config.js uses for the pro JS: a public clone without the private
// submodule sees an empty/absent pro/ dir, this entry is omitted, and the open build compiles with no
// pro native.
//
// This file says only WHERE pro is. What it contains - the Kotlin package to register, the source
// dir, the podspec - is declared by pro/react-native.config.js, which the CLI reads from this `root`
// and merges underneath. That split is the point: this repo is public, and it should not have to
// carry the private package's class names to link it.
//
// IMPORTANT: check a real file inside pro/, never just the pro/ directory - an uninitialised
// submodule leaves an empty pro/ folder behind.
const proRoot = path.resolve(__dirname, 'pro');
const proHasNative = fs.existsSync(path.join(proRoot, 'react-native.config.js'));

module.exports = {
dependencies: {
...(proHasNative ? { '@offgrid/pro': { root: proRoot } } : {}),
},
};
3 changes: 3 additions & 0 deletions src/bootstrap/slotRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,9 @@ function _clearSlotsForTesting(): void {

/** Known slot names, centralised so core and pro stay in sync. */
export const SLOTS = {
/** A card on the Home screen, above recent conversations. Pro registers the recorder /
* conversation surface here; a free build leaves it empty and Home is unchanged. */
homeRecorder: 'home.recorder',
/** Always-mounted root component(s) rendered near the app root (e.g. the TTS
* engine bridge). Mounted regardless of screen. */
appRoot: 'app.root',
Expand Down
6 changes: 6 additions & 0 deletions src/screens/HomeScreen/index.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import React from 'react';
import { View, Text, ScrollView, TouchableOpacity } from 'react-native';
import { useSlot, SLOTS } from '../../bootstrap/slotRegistry';
import { SafeAreaView } from 'react-native-safe-area-context';
import { Button, Card, CustomAlert, hideAlert } from '../../components';
import { AnimatedEntry } from '../../components/AnimatedEntry';
Expand Down Expand Up @@ -135,6 +136,8 @@ export const HomeScreen: React.FC<HomeScreenProps> = ({ navigation }) => {
action?.();
};

const HomeRecorderCard = useSlot(SLOTS.homeRecorder);

return (
<SafeAreaView style={styles.container} edges={['top']}>
<View testID="home-screen" style={styles.scrollView}>
Expand Down Expand Up @@ -200,6 +203,9 @@ export const HomeScreen: React.FC<HomeScreenProps> = ({ navigation }) => {
)
}

{/* Pro's conversation/recorder card. Renders nothing when the slot is empty (free build). */}
{HomeRecorderCard ? <HomeRecorderCard /> : null}

{/* Recent Conversations */}
{
recentConversations.length > 0 && (
Expand Down
27 changes: 19 additions & 8 deletions src/services/activeModelService/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,21 +105,31 @@ class ActiveModelService {
* below missed and re-loaded it (unload+load) every time the chat called
* loadTextModel — the "second loader" seen only for LiteRT models.
*/
private isTextModelCurrent(modelId: string): boolean {
private isTextModelCurrent(modelId: string, needsVision = false): boolean {
if (this.loadedTextModelId !== modelId) return false;
const model = useAppStore.getState().downloadedModels.find(m => m.id === modelId);
return model?.engine === 'litert'
? liteRTService.isModelLoaded()
: llmService.isModelLoaded();
if (model?.engine === 'litert') return liteRTService.isModelLoaded();
if (!llmService.isModelLoaded()) return false;
// A text-only load of a model that HAS a projector is not "current" for a caller that wants
// vision. Without this, a background load that skipped the projector to save memory would leave
// chat unable to see images and no path back: the id matches, so every later load short-circuits
// and the projector is never initialised.
return !(needsVision && model?.mmProjPath && !llmService.getMultimodalSupport()?.vision);
Comment thread
greptile-apps[bot] marked this conversation as resolved.
}

/**
* @param opts.skipVision Load WITHOUT the multimodal projector, even when the model has one.
* For background work that only needs text: a projector is hundreds of megabytes of CLIP weights
* on a device already holding a speech model. A later caller that does want vision reloads, which
* `isTextModelCurrent` now allows for.
*/
async loadTextModel(
modelId: string,
timeoutMs: number = 120000,
opts?: { override?: boolean },
opts?: { override?: boolean; skipVision?: boolean },
): Promise<void> {
// Fast path — model already loaded (no lock; just sync the store).
if (this.isTextModelCurrent(modelId)) {
if (this.isTextModelCurrent(modelId, !opts?.skipVision)) {
const store = useAppStore.getState();
if (store.activeModelId !== modelId) {
store.setActiveModelId(modelId);
Expand All @@ -135,10 +145,10 @@ class ActiveModelService {
private async doLoadTextModelLocked(
modelId: string,
timeoutMs: number,
opts?: { override?: boolean },
opts?: { override?: boolean; skipVision?: boolean },
): Promise<void> {
// Re-check after acquiring — a queued call may have loaded it already.
if (this.isTextModelCurrent(modelId)) {
if (this.isTextModelCurrent(modelId, !opts?.skipVision)) {
const store = useAppStore.getState();
if (store.activeModelId !== modelId) {
store.setActiveModelId(modelId);
Expand Down Expand Up @@ -177,6 +187,7 @@ class ActiveModelService {
store,
timeoutMs,
override: !!opts?.override || modelResidencyManager.hasSessionOverride(modelId),
skipVision: opts?.skipVision,
loadedTextModelId: this.loadedTextModelId,
onLoaded: id => {
this.setLoadedText(id);
Expand Down
Loading